Since PowerShell class methods are implemented as ScriptBlocks internally, the concept of the Bound and Unbound ScriptBlocks that I talked about in my previous article also applies to them.
Usually, class methods don't refer to variables or functions outside the class so which SessionState they use doesn't matter so much. However, there is an important caveat that relates to the SessionState if we use classes with multithreading.
In this post, we'll first look at how classes are bound to a SessionState. We'll see an example of the multithreading issue next, and learn how we can avoid the issue with NoRunspaceAffinity
attribute that is introduced in PowerShell 7.4 preview.
Which SessionState are they bound to?
The rule can be described as follows:
Class methods are bound to the SessionState where the class is lastly defined in the caller's Runspace.
Instance methods are bound when the constructor of the class is invoked. Static methods are bound when they are invoked.
Let's look at the simplest case first. Here, we define a class that writes a script scope variable $script:var
to the host to see which SessionState it's bound to.
If you define a class on the terminal or in a script, the class methods see the variable in the global (default) SessionState where the class is defined.
class TestClass {
[void] InstanceMethod() {
$script:var | Write-Host
}
static [void] StaticMethod() {
$script:var | Write-Host
}
}
$var = 'Global Var'
[TestClass]::new().InstanceMethod() # Global Var
[TestClass]::StaticMethod() # Global Var
Modules have their own SessionState so if you define the class in a module:
# Module.psm1
$var = 'Module Var'
class TestClass {
[void] InstanceMethod() {
$script:var | Write-Host
}
static [void] StaticMethod() {
$script:var | Write-Host
}
}
The class methods see the variable in the module SessionState, not in the global SessionState.
using module .\Module.psm1
$var = 'Global Var'
[TestClass]::new().InstanceMethod() # Module Var
[TestClass]::StaticMethod() # Module Var
These examples show that both instance and static methods are bound to the SessionState where the class is defined. Note that the location where the class instance is created does not matter in this single Runspace case.
What does "lastly defined" mean?
I wrote the rule like this:
Class methods are bound to the SessionState where the class is lastly defined in the caller's Runspace.
In a Runspace, a class can be defined multiple times (by dot-sourcing for example). In that case, class methods are bound to the SessionState in which the class is most recently defined.
Let's move the class definition to Class.ps1
:
# Class.ps1
class TestClass {
[void] InstanceMethod() {
$script:var | Write-Host
}
static [void] StaticMethod() {
$script:var | Write-Host
}
}
Then, dot-source it in a module.
# ModuleExternalClass.psm1
$var = 'ModuleVar'
. $PSScriptRoot\Class.ps1
function Get-ClassInstance() {
[TestClass]::new()
}
Export-ModuleMember -Function *
On the terminal, import the module first and dot-source the same Class.ps1
.
# On the terminal
$var = 'Global Var'
# TestClass is defined in a module first
Import-Module ./ModuleExternalClass.psm1
$instance1 = Get-ClassInstance # ..[1]
$instance1.GetType().Assembly.GetName() # 1.0.0.1
$instance1.InstanceMethod() # Module Var
$instance1.GetType()::StaticMethod() # Module Var
# TestClass is defined here again
. ./Class.ps1
$instance2 = Get-ClassInstance
$instance2.GetType().Assembly.GetName() # 1.0.0.1
$instance2.InstanceMethod() # Global Var
$instance2.GetType()::StaticMethod() # Global Var
$instance1.InstanceMethod() # Module Var ..[2]
$instance1.GetType()::StaticMethod() # Global Var
$instance1
sees the module $var
, but after dot-sourcing the class, $instance2
sees the global $var
. The assembly names of $instance1
and $instance2
are the same so they are the same classes. However, the bound SessionState changes if you define the class multiple times.
Also, note that at [2]
, the instance method still uses the module SessionState. This is because instance methods are bound to a SessionState at the time when the instance is created (at [1]
).
Multi-Runspace Scenario
Let me repeat the rule:
Class methods are bound to the SessionState where the class is lastly defined in the caller's Runspace.
A Runspace has its own SessionState, and class methods look for the SessionState in the Runspace where the constructor (for instance methods) or the static method is called.
The next example uses Start-ThreadJob
to create a new Runspace. We define the same class both in the default Runspace and the new Runspace on the thread.
$var = 'Runspace 1'
. .\Class.ps1
$semaphore = [System.Threading.Semaphore]::new(0, 1)
$arguments = @{
root = $PSScriptRoot
semaphore = $semaphore
}
$job = Start-ThreadJob {
$root = $args[0].root
$semaphore = $args[0].semaphore
$var = 'Runspace 2'
. "$root\Class.ps1"
[TestClass].Assembly.GetName() | Write-Host # 1.0.0.1
[TestClass]::new().InstanceMethod() | Write-Host # Runspace 2
[TestClass]::StaticMethod() | Write-Host # Runspace 2
$semaphore.Release()
while ($true) { Start-Sleep -Seconds 1 }
} -ArgumentList $arguments -StreamingHost $host
$semaphore.WaitOne() | Out-Null
[TestClass].Assembly.GetName() # 1.0.0.1
[TestClass]::new().InstanceMethod() # Runspace 1 ..[3]
[TestClass]::StaticMethod() # Runspace 1 ..[4]
You can see that the methods refer to the variable in the same Runspace as the one that the method calls belong to. In other words, the SessionState that a class is bound to is managed per Runspace. If not so, the last method calls [3] and [4]
should have shown "Runspace 2"
.
[4]
shows "Runspace 2"
because the binding of static methods is managed per PowerShell session, not per Runspace.What happens if we call class methods in a Runspace where the class is not defined? Let's see this example:
$var = 'Runspace 1'
. .\Class.ps1
# Unsafe!
# Passing a class defined in Runspace1 to Runspace2
$job = Start-ThreadJob {
$class = $args[0]
$var = 'Runspace 2'
$class::new().InstanceMethod() | Write-Host # Runspace 2 ..[5]
$class::StaticMethod() | Write-Host # Runspace 1 ..[6]
} -ArgumentList ([TestClass]) -StreamingHost $host
By passing a class to another Runspace, you can call its methods in a Runspace where the class is never defined. In that case, instance methods of a class that is created in the Runspace become unbound ScriptBlocks which grab the SessionState where they are invoked [5]
. Static methods are bound to the SessionState where the class is lastly defined even if it's on another Runspace [6]
.
This behavior is hard to remember and confusing so it's better to just dot-source the class in Runspace 2
like in the previous example. Also, the static method call [6]
is unsafe which we'll look at in the next section.
Unsafe Class Usage in Multithreading
We've finally come to the main topic ๐!
Calling class methods that are bound to a SessionState from another Runspace (Thread) sometimes causes state corruption. Even if the methods are thread-safe, the SessionState they are bound to is not thread-safe.
In the next example, a class Amplifier
is defined in the default Runspace. ForEach-Object -Parallel
creates multiple Runspaces to process a ScriptBlock on background threads, and the class instance is passed to those threads with the using
scope modifier.
class Amplifier {
[Double]$scale = 1
Amplifier($scale) {
$this.scale = $scale
}
[Double] GetValue([Double]$value) {
foreach ($i in 1..1000) {
$value *= $this.scale
}
return $value
}
}
$amplifier = [Amplifier]::new(1.01) # ..[7]
1..4 | ForEach-Object -Parallel {
$amplifier = $using:amplifier
while ($true) {
$amplifier.GetValue($_) | Out-Null # ..[8]
}
} -ThrottleLimit 4 -AsJob
# ..[9]
while ($true) {
Start-Sleep -Milliseconds 500
Write-Host 'Hi'
}
The instance method is bound to the global SessionState at [7]
and modifies the SessionState at the method call [8]
on background threads. At the same time, the main thread modifies the global SessionState in the while loop [9]
which leads to state corruption. Running this code causes a strange error though the method itself is thread-safe.
NoRunspaceAffinity Attribute
To avoid this multithreading issue, NoRunspaceAffinity
attribute was introduced in PowerShell 7.4 preview. If you add the attribute right before the class definition, both instance methods and static methods become unbound ScriptBlocks which grab the SessionState where they are invoked.
In the previous example, we can fix the error just by adding [NoRunspaceAffinity()]
. The method call [8]
now uses the SessionState in each Runspace that ForEach-Object
creates.
[NoRunspaceAffinity()]
class Amplifier {
#...
}
Conclusion
What we learned in this post:
Class methods are bound to a SessionState which sometimes causes an issue in multithreading
Do not pass classes or class instances to another Runspace unless necessary
NoRunspaceAffinity
attribute can be used to make all methods unbound in PowerShell 7.4 preview or later
References
ScriptBlock and SessionState in PowerShell
https://mdgrs.hashnode.dev/scriptblock-and-sessionstate-in-powershellForEach-Object -Parallel situationally drops pipeline input
https://github.com/PowerShell/PowerShell/issues/12801PowerShell class static method has inconsistent behavior when the .ps1 file is dot sourced into multiple Runspace's
https://github.com/PowerShell/PowerShell/issues/4001Need to add doc for the new
NoRunspaceAffinity
attribute introduced to PowerShell class
https://github.com/MicrosoftDocs/PowerShell-Docs/issues/9231