Do you know the difference between these two ScriptBlocks? One is directly defined in a script, and the other is created from a string.
$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')
The difference I talk about here can be described like this:
$sb1
is bound to the SessionState that the ScriptBlock is created in.$sb2
is not bound to any SessionState when it's created and is bound to the SessionState where it's invoked.
Let's see what this means.
What is SessionState?
A PowerShell process has the default Runspace which is the operating environment for the commands invoked by a host. In a Runspace, there is the global SessionState object. When modules are imported to the global SessionState, a new SessionState object is created for each module.
A SessionState object holds the state of a PowerShell session or a module. Most importantly, the SessionState manages variables, functions and cmdlets by holding a stack of Scopes. ScriptBlocks use the SessionState object to look for variables and functions based on the scoping rule.
The point is that a ScriptBlock needs a SessionState object to run, and there are multiple SessionState objects in a PowerShell session. In some cases, we need to be careful about which SessionState a ScriptBlock uses.
Bound and Unbound ScriptBlocks
Let's assume that we create a module that has a script scope variable $var
and a function to invoke a ScriptBlock.
# MyModule.psm1
$var = 'Module Var'
function Run($scriptBlock) {
$scriptBlock.Invoke()
}
In the terminal, we create the two types of ScriptBlocks that just return a variable $var
, and we pass them to the Run
function of the module.
# On the terminal
Import-Module .\MyModule.psm1
$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')
$var = 'Global Var'
Run $sb1 # Global Var
Run $sb2 # Module Var
$sb1
sees the global $var
, while $sb2
sees the script variable in the module.
When you define a ScriptBlock by writing braces directly, the ScriptBlock is bound to the SessionState where it's defined, the global SessionState in this case. Let's call this a Bound ScriptBlock. Bound ScriptBlock always uses the bound SessionState no matter where it's invoked. That's why $sb1
sees the global $var
.
If you create a ScriptBlock from a string, it doesn't hold any SessionState. It grabs the SessionState when it's invoked. Because modules have their own SessionState, and $sb2
uses the module's SessionState where it's invoked, it sees the variable inside the module. Let's call this an Unbound ScriptBlock.
Note that if modules are not involved and the ScriptBlocks run in a single SessionState, you don't see any difference between bound and unbound ScriptBlocks.
Dot Sourcing and SessionState
In terms of scoping, there are two ways to invoke a ScriptBlock: the Call operator and Dot Sourcing. The call operator creates a new scope, while dot sourcing does not create a new one.
# Create a new scope.
& $scriptBlock
$scriptBlock.Invoke()
Invoke-Command -ScriptBlock $scriptBlock
# Don't create a new scope.
. $scriptBlock
Invoke-Command -ScriptBlock $scriptBlock -NoNewScope
In the previous example for the Bound and Unbound ScriptBlocks, I used ScriptBlock.Invoke()
that created a new scope, but Bound/Unbound also makes a difference in the dot sourcing case. When you dot source a ScriptBlock, it runs in the current scope of the SessionState that the ScriptBlock is bound to, not the SessionState where the ScriptBlock is invoked.
Let's modify the previous module so that it uses dot sourcing.
# MyModule.psm1
function Run($scriptBlock) {
. $scriptBlock # [1]
if ($var) {
"MyModule: $var"
}
}
Outside the module, we define a bound and an unbound ScriptBlock and pass them to the module.
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
You might have an impression that dot sourcing always injects variables into the place where we write the dot source operator (line [1]), but it is not correct. $sb1
runs in the current scope of the bound SessionState, the global SessionState in this case (line [2]). $sb2
grabs the SessionState when it's dot sourced, the module's SessionState in this case, so it runs in the scope where we wrote the dot source operator (line [1]).
What about GetNewClosure?
I think you know that ScriptBlock.GetNewClosure()
copies local variables to the ScriptBlock at the time when GetNewClosure()
is called, but there is another not well-known behavior. It creates a dynamic module for the ScriptBlock.
What it means is that the ScriptBlock created by GetNewClosure()
is bound to the SessionState of the module that is created for the ScriptBlock. It is a kind of Bound ScriptBlocks but its SessionState is isolated from others. Therefore, when it's invoked, it does not see any variables outside of the ScriptBlock except the global variables and even dot sourcing cannot change the state outside of the dynamic module.
# Script.ps1
$sb = {
$var
$var = 'Modified'
}.GetNewClosure()
$var = 'Script Var'
& $sb
. $sb
& $sb # Modified
$var # Script Var
Multi-Runspace Scenario
There is another case where you need to be aware of the bound and unbound ScriptBlocks: multi-threading using multiple Runspaces. We'll take ThreadJob as an example.
ThreadJob creates a new Runspace to process commands on another thread, and the Runspace has its own SessionState. When you pass a ScriptBlock to the ScriptBlock
parameter of the Start-ThreadJob
function, it is converted to a string internally using ToString()
, and a new unbound ScriptBlock is created. It's invoked with the ThreadJob's SessionState, so this one has no problem.
However, if you pass a ScriptBlock by the ArgumentList
parameter or the using
scope modifier, you can invoke the ScriptBlock directly on the thread. If it's a bound ScriptBlock, there is a possibility that the caller's SessionState is modified at the same time from multiple threads (Not always though). This is not safe and leads to state corruption.
$sb = {
$var = 1
foreach ($i in 1..10000) {
$var = [Math]::Pow($var, 3);
}
}
# Unsafe!
# Passing a bound ScriptBlock to another thread as an argument
$job = Start-ThreadJob -ScriptBlock {
while ($true) {
$args[0].Invoke()
}
} -ArgumentList $sb
while ($true) {
Start-Sleep -Milliseconds 500
Write-Host 'Hi'
}
Running this code throws a strange error:
Passing an unbound ScriptBlock is safe on the other hand, so remember, you should never pass a bound ScriptBlock to a ThreadJob.
Making ScriptBlock Unbound
Like in the case of multi-threading, there might be a situation where you want to get an unbound ScriptBlock from a bound ScriptBlock. One easy way is to use ScriptBlock.Ast.GetScriptBlock()
.
$bound = {$var}
$unbound = $bound.Ast.GetScriptBlock()
You can also use ScriptBlock.ToString()
and create an unbound ScriptBlock from that string, but Ast.GetScriptBlock()
should be more efficient.
Conclusion
In this article, I explained how ScriptBlocks and SessionStates work together. I would summarize the key points below:
Bound and Unbound ScriptBlocks behave differently when they are invoked in a module.
Dot Sourcing and
GetNewClosure()
sometimes behave confusingly, but knowing the underlying SessionState mechanism helps you understand the behavior.Do not pass a ScriptBlock that is bound to a Runspace to another Runspace (Thread).
References
Windows PowerShell Session State
https://learn.microsoft.com/en-us/powershell/scripting/developer/cmdlet/windows-powershell-session-stateInvocation Operators, States and Scopes
https://seeminglyscience.github.io/powershell/2017/09/30/invocation-operators-states-and-scopesState corruption due to Runspace affinity
https://github.com/PowerShell/PowerShell/issues/4003