PowerShell Classes and SessionState

PowerShell Classes and SessionState

Multithreading caveats

ยท

7 min read

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.

๐Ÿ’ก
If you are not familiar with what the SessionState is or how it works with ScriptBlocks, please refer to my previous article: https://mdgrs.hashnode.dev/scriptblock-and-sessionstate-in-powershell

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".

๐Ÿ“
In PowerShell 5.1, the static method[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

  • NoRunspaceAffinityattribute can be used to make all methods unbound in PowerShell 7.4 preview or later

References

ย