Using Synchronized HashTables
Last month, I introduced you to the topic of creating and using PowerShell runspaces. This is definitely and advanced topic and for those special use cases where runspace-related commands like Start-Job
are lacking.
You might want to use a runspace to run continuous code in the background. With commands like Invoke-Command
or Start-Job
you can leverage runspaces to execute code in parallel. In PowerShell 7, you could also use the -Parallel
parameter with ForEach-Object
. However, in these situations, your code runs, exits, and you collect your results. You can create a runspace that doesn't end by running code in a loop. The challenge is that you can't really see into the runspace.
One intriguing possibility is the use of a special type of hashtable, called a synchronized hashtable. This is a hashtable that is shared between the runspace and the scope that launched the runspace. Because it is a hashtable, you can modify it on-the-fly. You can make any hashtable synchronized with syntax like this:
$syncHash = [hashtable]::Synchronized(@{})
I've initialized an empty hashtable but marked it as synchronized. If you pipe the object to Get-Member
you'll see the same properties and methods as a normal hashtable. But the type is slightly different.
PS C:\> $syncHash.GetType().FullName
System.Collections.Hashtable+SyncHashtable
This doesn't matter much. Instead, let's see how to use it in PowerShell.
Defining the Runspace
In order to use a synchronized hashtable, the runspace must be defined to run asynchronously. I showed this is a previous article.
$newRunspace = [RunSpaceFactory]::CreateRunspace()
$newRunspace.ApartmentState = 'STA'
$newRunspace.ThreadOptions = 'ReuseThread'
$newRunspace.Open()
I'm going to create a runspace that will run Test-Wsman
on a set of computers in a loop. To control the runspace and get results, I'll use a synchronized hashtable.
$syncHash = [hashtable]::Synchronized(@{
Results = @()
Computername = $env:computername
IsRunning = $False
LastTest = (Get-Date)
})
Here's the important bit. You need to define the hashtable in the runspace.
$newRunspace.SessionStateProxy.SetVariable('syncHash', $syncHash)
This is the equivalent of running Set-Variable
. The variable name in the runspace with be syncHash
and the value will be the synchronous hashtable object. I am using the same variable name for the sake of clarity, but it is not required.
The code I want to run in the runspace requires PowerShell 7 because I am using the ternary operator. You could revise it to use a traditional IF
statement in Windows PowerShell.
$psCmd = [PowerShell]::Create().AddScript({
$computers = 'dom1', 'srv1', 'srv2', 'srv3'
do {
$results = $computers | ForEach-Object {
[PSCustomObject]@{
Computername = $_.ToUpper()
Responding = (Test-WSMan -ComputerName $_) ? $True : $False
Date = Get-Date
}
} #foreach-object
$syncHash.Computername = $env:computername
$syncHash.Results = $results
$syncHash.IsRunning = $True
$syncHash.LastTest = (Get-Date)
Start-Sleep -Seconds 10
} while ($syncHash.IsRunning)
})
The computer names I want to test are hard-coded into the code. For each computer name, I'll create a custom object with the result of the Test-WSMan
cmdlets. I'll then update the synchronized hashtable inside the runspace.
$syncHash.computername = $env:computername
$syncHash.results = $results
$syncHash.IsRunning = $True
$syncHash.LastTest = (Get-Date)
Then the code sleeps for ten seconds before repeating the process. This will continue as long as the IsRunning
value of the synchronized hashtable is set to $True
. Putting the While()
at the end of the Do
loop ensures my code will run at least once.
I'll add the runspace to the command and start it asynchronously.
$psCmd.runspace = $NewRunspace
$data = $psCmd.BeginInvoke()
Using the HashTable
Now that the runspace is running, I can look at the synchronized hashtable in my session. To be clear, I copied and pasted the code snippets into my PowerShell session.
PS C:\> $syncHash
Name Value
---- -----
IsRunning True
Results {@{Computername=DOM1; Responding=True; Date=5/2/2024 6:24:3 …
Computername WIN10
LastTest 5/2/2024 6:24:38 AM
I can drill down to the results.
PS C:\> $syncHash.Results
Computername Responding Date
------------ ---------- ----
DOM1 True 5/2/2024 6:26:18 AM
SRV1 True 5/2/2024 6:26:18 AM
SRV2 True 5/2/2024 6:26:18 AM
SRV3 True 5/2/2024 6:26:18 AM
I can modify the hashtable in the global session and the changes will be detected in the runspace. This is how I can cleanly stop the runspace.
PS C:\> $syncHash.IsRunning = $False
I didn't show it, but when I created the new runspace it had an ID of 2. I can see that it is no longer busy.
PS C:\> Get-Runspace -id 2
Id Name ComputerName Type State Availability
-- ---- ------------ ---- ----- ------------
2 Runspace2 localhost Local Opened Available
And I'll notice that the results no longer update. If I set IsRunning
back to $True
, the runspace doesn't automatically resume. However, I can re-initiate the asynchronous code.
PS C:\ $pscmd.BeginInvoke()
This will resume the testing.
PS C:\> $synchash.Results
Computername Responding Date
------------ ---------- ----
DOM1 True 5/2/2024 6:44:29 AM
SRV1 True 5/2/2024 6:44:29 AM
SRV2 True 5/2/2024 6:44:29 AM
SRV3 True 5/2/2024 6:44:29 AM
This will work as long as you do not invoke the
ResetRunspaceState()
method on the runspace.
When I'm finished, I can set IsRunning
to $False
, and remove the runspace when it is no longer busy.
Remove-Runspace -id 2
Creating a Tool
Everything I demonstrated assumed you were creating code interactively in the console. That's a lot of typing. Instead, let's create a re-usable and flexible tool. I'm going to put the following functions into a .PS1 file that I can dot-source. This could easily be turned into a module, but let's keep it as simple as we can for now.
Scope becomes an issue here. I want to abstract the synchronized hashtable, yet still offer direct access for special situations. I also want my code to be more flexible. Instead of hard-coding the computer names into the runspace. if I make them part of the synchronized hashtable, I can modify them on-the-fly. I also want to simplify the runspace cleanup process when I'm done testing. In my function, I'll define the synchronized hashtable as a global variable.
$global:ServerWatch = [hashtable]::Synchronized(@{
Results = @()
Source = $env:computername
Computername = $PSBoundParameters.Computername
IsRunning = $False
LastTest = (Get-Date)
Runspace = $newRunspace
})
Because I'm running PowerShell 7, I can also take advantage of ForEach-Object
and run my tests in parallel. Technically, I'm nesting runspaces inside of a runspace.
$results = $syncHash.Computername | ForEach-Object -Parallel {
[PSCustomObject]@{
Computername = $_.ToUpper()
Responding = (Test-WSMan -ComputerName $_ @using:splat -OutVariable out -ErrorVariable ev) ? $True : $False
Date = Get-Date
TestDetail = $out
ErrorMessage = ([xml]$m = $ev.Exception.Message).WSManFault.Message
}
} #foreach-object
I'm also going to capture the Test-WSMan
output and any errors. Here's the complete function.
Function New-ServerWatch {
[CmdletBinding()]
Param(
[Parameter(Mandatory, HelpMessage = 'Enter the computer names to monitor')]
[string[]]$Computername,
[PSCredential]$Credential,
[Switch]$Passthru
)
$newRunspace = [RunSpaceFactory]::CreateRunspace()
$newRunspace.ApartmentState = 'STA'
$newRunspace.ThreadOptions = 'ReuseThread'
$newRunspace.Open()
$global:ServerWatch = [hashtable]::Synchronized(@{
Results = @()
Source = $env:computername
Computername = $PSBoundParameters.Computername
IsRunning = $False
LastTest = (Get-Date)
Runspace = $newRunspace
})
# I am assigning a different variable name to the synchronized hashtable
# in the runspace
$newRunspace.SessionStateProxy.SetVariable('syncHash', $global:ServerWatch)
$psCmd = [PowerShell]::Create().AddScript({
Param([PSCredential]$Credential)
$splat = @{
OutVariable = 'out'
ErrorVariable = 'ev'
}
If ($Credential) {
$splat['Credential'] = $Credential
$splat['Authentication'] = 'Default'
}
do {
$results = $syncHash.Computername | ForEach-Object -Parallel {
[PSCustomObject]@{
Computername = $_.ToUpper()
Responding = (Test-WSMan -ComputerName $_ @using:splat) ? $True : $False
Date = Get-Date
TestDetail = $out
ErrorMessage = ([xml]$m = $ev.Exception.Message).WSManFault.Message
}
} #foreach-object
#Update the synchronized hashtable
$syncHash.Results = $results
$syncHash.IsRunning = $True
$syncHash.LastTest = (Get-Date)
$syncHash.NextTest = (Get-Date).AddSeconds(30)
#the sleep interval could also be set as a parameter
#or a module variable
Start-Sleep -Seconds 30
} While ($syncHash.IsRunning)
})
if ($Credential) {
[void]$psCmd.AddParameter('Credential', $Credential)
}
$psCmd.runspace = $NewRunspace
[void]$psCmd.BeginInvoke()
If ($Passthru) {
$global:ServerWatch
}
}
I'm supporting credentials for Test-WSMan
. But I'll test without them.
PS C:\> New-ServerWatch -Computername dom1,srv1,srv2 -Passthru
NName Value
---- -----
IsRunning False
Computername {dom1, srv1, srv2}
Results {}
Source WIN10
LastTest 5/2/2024 7:12:03 AM
Runspace System.Management.Automation.Runspaces.LocalRunspace
I can manually look at the synchronized hashtable.
PS C:\> $serverwatch
Name Value
---- -----
IsRunning True
Results {@{Computername=SRV1; Responding=True; Date=5/2/202…
Runspace System.Management.Automation.Runspaces.LocalRunspace
Source WIN10
NextTest 5/2/2024 7:13:04 AM
LastTest 5/2/2024 7:12:34 AM
Computername {dom1, srv1, srv2}
PS C:\> $serverwatch.Results | Select-Object Computername,Responding,Date
Computername Responding Date
------------ ---------- ----
DOM1 True 5/2/2024 7:14:04 AM
SRV1 True 5/2/2024 7:14:04 AM
SRV2 True 5/2/2024 7:14:04 AM
But it would be easier to have a function to do this for me.
Function Get-ServerWatch {
[cmdletBinding()]
Param()
if ($global:ServerWatch.Results) {
foreach ($item in $global:ServerWatch.Results) {
$countDown = New-TimeSpan -End $global:ServerWatch.NextTest -Start (Get-Date)
#TODO: Create a custom format file for this object type
[PSCustomObject]@{
PSTypeName = 'PSServerWatch'
Computername = $item.Computername
Responding = $item.Responding
Date = $item.Date
NextTest = ($countDown.Ticks -gt 0) ? $countDown : (New-TimeSpan -Seconds 0)
ErrorMessage = $item.ErrorMessage
}
} #foreach item
}
else {
Write-Warning 'No ServerWatch object found. Run New-ServerWatch first or wait for the first test to complete.'
}
}
Ideally, I'd have custom formatting. But for now, I see everything.,
PS C:\> Get-ServerWatch
Computername : DOM1
Responding : True
Date : 5/2/2024 7:14:34 AM
NextTest : 00:00:03.9136053
ErrorMessage :
Computername : SRV1
Responding : True
Date : 5/2/2024 7:14:34 AM
NextTest : 00:00:03.9062007
ErrorMessage :
Computername : SRV2
Responding : True
Date : 5/2/2024 7:14:34 AM
NextTest : 00:00:03.9031509
ErrorMessage :
If I want to change the computers, I can manually update the hashtable as I did earlier, or use a function.
Function Set-ServerWatch {
#TODO Add support for -WhatIf and Verbose output
[cmdletBinding()]
Param(
[Parameter(Mandatory, HelpMessage = 'Enter the computer names to monitor')]
[string[]]$Computername,
[Switch]$Append
)
#TODO: Add error handling to test if the synchronized hashtable exists
if ($Append) {
$global:ServerWatch.Computername += $Computername
}
else {
$global:ServerWatch.Computername = $Computername
}
}
I can easily update the hashtable.
PS C:\> Set-ServerWatch -Computername SRV4,SRV3 -Append
At the next test, I can see the change.
PS C:\> Get-ServerWatch | Where {-Not $_.Responding}
Computername : SRV4
Responding : False
Date : 5/2/2024 7:19:42 AM
NextTest : 00:00:26.2491890
ErrorMessage : The WinRM client cannot process the request because the server name cannot be resolved.
When I'm finished, I can stop the test and cleanup the runspace, because I saved it in the hashtable.
Function Stop-ServerWatch {
[cmdletBinding()]
Param()
#TODO: Add error handling to test if the synchronized hashtable exists
$global:ServerWatch.IsRunning = $False
#close and clean up the runspace
$ServerWatch.Runspace.Close()
While ($ServerWatch.Runspace.RunspaceStateInfo.State -ne 'Closed') {
Start-Sleep -Milliseconds 100
}
Remove-Runspace $ServerWatch.Runspace
}
You can decide if you want to keep the synchronized hashtable or not.
PSClock
If you'd like to see another example, take a look at my PSClock module This module creates a transparent WPF-based clock on your desktop. It uses a synchronized hashtable to manage the clock settings.
PS C:\> $PSclockSettings
Name Value
---- -----
DateFormat F
FontWeight Normal
Runspace System.Management.Automation.Runspaces.LocalRunspace
FontSize 30
FontStyle Normal
FontFamily gadugi
Running True
Color chartreuse
CurrentPosition {1665, 1079}
StartingPosition {1665, 1079}
Started 4/29/2024 9:56:35 AM
OnTop False
You can modify settings in the hashtable, or use the associated PSClock management commands.
Set-PSClock -FontSize 35
Summary
Using a PowerShell runspace with a synchronized hashtable is not something you will use often. I tend to use them more with WPF-based scripts I'm running in an asynchronous runspace. But if you find a use for them, I'd love to hear about it.
If you want it, I've shared my ServerWatch.ps1
script on Dropbox.