📙

PowerShell: ScriptBlockと SessionStateの関係

2023/05/14に公開

PowerShellのScriptBlockとSessionStateの動作について解説します。

本記事の英語版はこちら。
https://mdgrs.hashnode.dev/scriptblock-and-sessionstate-in-powershell


この2つのScriptBlockの違いをご存じでしょうか。ひとつはスクリプトの中で直接定義されたもの、もうひとつは文字列から作成されたものです。

$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')

この記事で話題にする違いは以下のようになります。

$sb1は作成された場所のSessionStateにバインドされる。$sb2は作成時にはSessionStateにバインドされず、Invokeされるときにその場所のSessionStateにバインドされる。

これが何を意味するか確認していきましょう。

SessionStateとは

PowerShellのプロセスは、ホストから送られるコマンドの実行環境であるデフォルトのRunspaceを1つ持ちます。そしてRunspaceには、1つのグローバルSessionStateが存在します。このグローバルSessionStateにモジュールがインポートされると、それらのモジュール1つ1つに対して新たなSessionStateが作成されます。

SessionStateはPowerShellのセッションもしくはモジュールの状態を保持します。特に重要な機能として、SessionStateはスコープのスタックを保持することで変数、関数、コマンドレットを管理しています。ScriptBlockはこのSessionStateオブジェクトを使用し、スコープルールに従って変数や関数にアクセスしています。

つまり、ScriptBlcokにはSessionStateが必要ですが、PowerShellセッションには複数のSessionStateが存在します。いくつかのケースにおいて、ScriptBlockがどのSessionStateを使用しているかが重要になることがあります。

バウンドScriptBlockとアンバウンドScriptBlock

例として、スクリプトスコープの変数$varと、ScriptBlockを実行するだけの関数を持ったモジュールを作成します。

# MyModule.psm1
$var = 'Module Var'
function Run($scriptBlock) {
    $scriptBlock.Invoke()
}

ターミナル側では変数$varを返すだけの2種類のScriptBlockを作成し、それらをモジュールのRun関数へ渡します。

# ターミナル上で
Import-Module .\MyModule.psm1

$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')

$var = 'Global Var'
Run $sb1 # Global Var
Run $sb2 # Module Var

$sb1はグローバルの$varを参照しますが、$sb2はモジュールの中の変数を参照しています。

ScriptBlockを波括弧で直接定義した場合、そのScriptBlockは定義された場所のSessionStateにバインドされます(このケースではグローバルのSessionState)。これをバウンドScriptBlockと呼ぶことにしましょう。バウンドScriptBlockはそれがInvokeされる場所によらず、常にバインドされたSessionStateを使用します。それが$sb1がグローバルの$varを参照する理由です。

ScriptBlockを文字列から生成した場合は、Invokeさせるタイミングでその場のSessionStateを取得します。モジュールは独自のSessionStateを持ち、$sb2はInvokeされるモジュールのSessionStateを使用するため、モジュール内部の変数を参照することになります。これをアンバウンドScriptBlockと呼びましょう。

Bound Unbound ScriptBlocks

モジュールが関係せずScriptBlockが常に1つのSessionState上で動作する場合は、バウンドとアンバウンドの動作上の違いはありません。

ドットソースとSessionState

スコープに着目すると、ScriptBlockを呼び出す方法は2種類存在します。呼び出しオペレータ(&)とドットソース(.)です。呼び出しオペレータは新しいスコープを作成しますが、ドットソースはスコープを作成しません。

# 新しくスコープを作成する
& $scriptBlock
$scriptBlock.Invoke()
Invoke-Command -ScriptBlock $scriptBlock

# スコープを作成しない
. $scriptBlock
Invoke-Command -ScriptBlock $scriptBlock -NoNewScope

先のバウンド・アンバウンドの例ではスコープを作成するScriptBlock.Invoke()を使用しましたが、バウンドかどうかはドットソースの挙動にも影響します。ScriptBlockをドットソースすると、そのScriptBlockは、バインドされているSessionStateの現在のスコープで実行されます。ScriptBlockが呼び出される場所のSessionStateではありません。

ドットソースを使用するように先ほどのモジュールを変更しましょう。

# MyModule.psm1
function Run($scriptBlock) {
    . $scriptBlock # [1]
    if ($var) {
        "MyModule: $var"
    }
}

バウンドとアンバウンドScriptBlockを作成してモジュールへ渡します。

Import-Module .\MyModule.psm1

$sb1 = {$var = 'var from [Bound] ScriptBlock'}
$sb2 = [ScriptBlock]::Create('$var = "var from [Unbound] ScriptBlock"')

function CallModule($scriptBlock) {
    Run $scriptBlock # [2]
    if ($var) {
        "Global: $var"
    }
}

CallModule $sb1 # Global: var from [Bound] ScriptBlock
CallModule $sb2 # MyModule: var from [Unbound] ScriptBlock

ドットソースは常にそれが書かれた場所[1]に変数を取り込むようなイメージがあるかもしれませんが、それは正しくありません。$sb1はバインドされたSessionStateの現在のスコープ、つまりグローバルSessionState[2]で実行されます。$sb2はドットソースされた場所のSessionState(モジュールのSessionState)を取得するので、ドットソースオペレータを書いた場所のスコープ[1]で実行されます。

GetNewClosureの場合は?

ScriptBlock.GetNewClosure()はそれを呼び出した時点のローカル変数をScriptBlockへコピーしますが、もう一つあまり知られていない動作があります。GetNewClosure()はそのScriptBlockのためのダイナミックモジュールを作成します。

GetNewClosure()によって作成されたScriptBlockは、それ専用に作成されたモジュールのSessionStateにバインドされます。バウンドScriptBlockの一種ですが、そのSessionStateは他とは分離されています。そのため、実行される際にはそのScriptBlockの外のグローバル変数以外の変数は参照することができません。ドットソースを使ったとしてもそのダイナミックモジュールの外のステートを変更することはできません。

# Script.ps1
$sb = {
    $var
    $var = 'Modified'
}.GetNewClosure()

$var = 'Script Var'
& $sb
. $sb
& $sb # Modified
$var # Script Var

Runspaceが複数の場合

バウンド・アンバンドに注意すべきケースがもう一つあります。複数のRunspaceを使ったマルチスレッディングを行う場合です。ThreadJobを例として取り上げます。

ThreadJobはスレッドで処理を行うために新しいRunspaceを作成します。そしてRunspaceは固有のSessionStateを持ちます。Start-ThreadJobScriptBlockパラメータにScriptBlockを渡すと、そのScriptBlockは内部的にToString()関数を使用して文字列に変換され、新たにアンバウンドScriptBlockが作成されます。それはThreadJobのSessionStateで実行されるため、問題にはなりません。

ところが、ArgumentListパラメータやusingスコープ修飾子によってScriptBlockを渡した場合、別のスレッドでそのままそれを起動できてしまいます。もしバウンドScriptBlockだった場合は、呼び出し元のSessionStateが同時に複数のスレッドから変更される可能性があります。これはステートの破壊につながるため危険です。

$sb = {
    $var = 1
    foreach ($i in 1..10000) {
        $var = [Math]::Pow($var, 3);
    }
}

# 危険!
# バウンドScriptBlockをArgumentListで別スレッドへ渡す
$job = Start-ThreadJob -ScriptBlock {
    while ($true) {
        $args[0].Invoke()
    }
} -ArgumentList $sb

while ($true) {
    Start-Sleep -Milliseconds 500
    Write-Host 'Hi'
}

Multi Runspace

このコードを実行すると不可解なエラーが発生します。

State Corruption

ScriptBlockのアンバウンド化

マルチスレッドのケースのように、バウンドScriptBlockをアンバンドに変換したい場合があります。そのような時はScriptBlock.Ast.GetScriptBlock()を使うのが簡単です。

$bound = {$var}
$unbound = $bound.Ast.GetScriptBlock()

ScriptBlock.ToString()を使用して、その文字列からアンバンドScriptBlockを作成する方法もありますが、Ast.GetScriptBlock()を使用する方が効率的なはずです。

まとめ

この記事では、ScriptBlockとSessionStateがどのように動くのかを解説しました。大事な点をまとめます。

  • バウンドScriptBlockとアンバウンドScriptBlockは、モジュール内で起動されると異なる挙動をとる
  • ドットソースとGetNewClosure()は、SessionStateの仕組みを把握していると挙動が理解しやすい
  • 1つのRunspaceにバインドされたScriptBlockを別のRunspace(スレッド)へ渡してはいけない

参考

Discussion