PowerShell: ScriptBlockと SessionStateの関係
PowerShellのScriptBlockとSessionStateの動作について解説します。
本記事の英語版はこちら。
この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と呼びましょう。
モジュールが関係せず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-ThreadJob
のScriptBlock
パラメータに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'
}
このコードを実行すると不可解なエラーが発生します。
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(スレッド)へ渡してはいけない
参考
-
Windows PowerShell Session State
https://learn.microsoft.com/ja-jp/powershell/scripting/developer/cmdlet/windows-powershell-session-state -
Invocation Operators, States and Scopes
https://seeminglyscience.github.io/powershell/2017/09/30/invocation-operators-states-and-scopes -
Runspace Affinityによるステートの破壊
https://github.com/PowerShell/PowerShell/issues/4003
Discussion