More PowerShell Runspace Fun
In the last article, we began to explore how to use custom runspaces in our PowerShell scripting. Is still believe that using cmdlets like Start-Job
and Invoke-Command
are preferred. Let PowerShell do the work. But, there may be situations where advanced scripters want to have more granular control over the process.
Last time, we looked at using runspaces sequentially. Like a background job, the results remain in the runspace, and you may want to keep things separate. You can also easily re-run the code in the runspace.
Here's a simple script.
#MeasureFolder.ps1
Param([string]$Path = '.')
$stat = Get-ChildItem -Path $Path -Recurse -File | Measure-Object -Property Length -Sum -Average
[PSCustomObject]@{
PSTypeName = 'folderInfo'
Path = Convert-Path $Path
Files = $stat.Count
Size = $stat.Sum
Average = $stat.Average
Computername = [System.Environment]::MachineName
Date = Get-Date
}
I can create a set of runspaces for multiple paths to test.
$paths = "c:\scripts","c:\work",$Home
$c = Get-Content .\MeasureFolder.ps1 -Raw
#create a collection to hold the runspaces
$run = [System.Collections.Generic.List[System.Management.Automation.PowerShell]]::new()
#create a collection to hold the results
$out = [System.Collections.Generic.List[PSObject]]::new()
foreach ($path in $paths) {
$rs = [powershell]::create()
[void]$rs.AddScript($c)
[void]$rs.AddArgument($Path)
$run.Add($rs)
$out.Add($rs.Invoke())
}
The initial output is saved to $out
.
PS C:\> $out | format-Table
Path Files Size Average Computername Date
---- ----- ---- ------- ------------ ----
C:\scripts 10559 669527455.00 63408.23 PROSPERO 4/24/2024 4:09:59 PM
C:\work 619 270597236.00 437152.24 PROSPERO 4/24/2024 4:09:59 PM
C:\Users\Jeff 36602 4735498377.00 129378.13 PROSPERO 4/24/2024 4:10:01 PM
But, as long as I have the collection of runspaces, I can re-run code.
PS C:\> $run.invoke() | format-table
Path Files Size Average Computername Date
---- ----- ---- ------- ------------ ----
C:\scripts 10559 669527455.00 63408.23 PROSPERO 4/24/2024 4:30:44 PM
C:\work 619 270597236.00 437152.24 PROSPERO 4/24/2024 4:30:44 PM
C:\Users\Jeff 36602 4735498377.00 129378.13 PROSPERO 4/24/2024 4:30:46 PM
There's no real performance benefit to this approach as each folder is processed sequentially. But, let's see what else we can do.
Asynchronous Runspaces
Creating a runspace to run asynchronously isn't that much different than what we've looked at thus far.
$psAsync = [powershell]::Create()
For the sake of demonstration, I'm going to insert a sleep statement.
[void]$psAsync.AddScript({
Start-Sleep -Seconds 10
[PSCustomObject]@{
Computername = $env:COMPUTERNAME
PSVersion = $PSVersionTable.PSVersion
ProcessId = $PID
ThreadID = [AppDomain]::GetCurrentThreadId()
TotalThreads = (Get-Process -Id $PID).Threads.count
}
})
To run this asynchronously, I need to invoke it slightly different.
$Handle = $psAsync.BeginInvoke()
It is important to save the output. This is the asynchronous handle we'll need later to get the results. By the time I check the handle, I can see that the command has completed.
PS C:\> $handle
CompletedSynchronously IsCompleted AsyncState AsyncWaitHandle
---------------------- ----------- ---------- ---------------
False True System.Threading.ManualResetEvent
When I invoked the runspace asynchronously, I got my prompt back immediately, which means I could keep working.
To retrieve the results, I can use the EndInvoke()
method. This is where we need that handle.
PS C:\> $psAsync.EndInvoke($Handle)
Computername : PROSPERO
PSVersion : 7.4.2
ProcessId : 77020
ThreadID : 76
TotalThreads : 31
And when you are finished, you should clean up after yourself.
$psAsync.Dispose()
Runspace Scripting
With this in mind, let's try to incorporate this into a PowerShell script. Here's a script that uses my MeasureFolder
script.
Param ([string[]]$Path = $Home)
$c = Get-Content c:\scripts\MeasureFolder.ps1 -Raw
#create a collection to hold the runspaces
$run = [System.Collections.Generic.List[System.Management.Automation.PowerShell]]::new()
#create a collection to hold the async handles
$handles = [System.Collections.Generic.List[PSObject]]::new()
foreach ($item in $path) {
$rs = [powershell]::create()
[void]$rs.AddScript($c)
[void]$rs.AddArgument($item)
$run.Add($rs)
$handles.Add($rs.BeginInvoke())
}
#wait for everything to complete
do {
Start-Sleep -Milliseconds 100
} until ($handles.IsCompleted)
#get the results from each runspace
for ($i=0;$i -lt $handles.Count;$i++) {
$run[$i].EndInvoke($handles[$i])
}
$run.dispose()
The script creates a runspace for each folder and runs the code asynchronously. When I ran my command earlier, it took about 2.6 seconds to run sequentially. Using an asynchronous runspace took about 2 seconds. Granted, these folders are not very large.
PS C:\Scripts\> .\measure-async.ps1 -Path "c:\scripts","c:\work",$Home | Format-Table
Path Files Size Average Computername Date
---- ----- ---- ------- ------------ ----
C:\scripts 10559 669527642.00 63408.24 PROSPERO 4/25/2024 10:22:59 AM
C:\work 619 270597236.00 437152.24 PROSPERO 4/25/2024 10:22:59 AM
C:\Users\Jeff 36310 4751847069.00 130868.83 PROSPERO 4/25/2024 10:23:01 AM
However, even though I'm using asynchronous runspaces, I still have to wait for the entire script to finish before I get my prompt back.
In PowerShell 7 you could achieve similar results using
ForEach-Object
with the-Parallel
parameter. But is PS 7 is not an option, you might want to build an alternative.
WPF Runspaces
Let's bring this topic back to WPF-based scripts. As we saw when running those scripts, we don't get the prompt back until the script completes. So you might think, "I can use the asynchronous runspace Jeff just showed me."
Here's a WPF script you can test with.
#requires -version 5.1
if ($IsWindows -OR $PSEdition -eq 'desktop') {
Try {
Add-Type -AssemblyName PresentationFramework -ErrorAction Stop
Add-Type -AssemblyName PresentationCore -ErrorAction Stop
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
}
Catch {
#Failsafe error handling
Throw $_
Return
}
}
else {
Write-Warning 'This requires Windows PowerShell or PowerShell Core on Windows.'
#Bail out
Return
}
$Families = [System.Drawing.text.installedFontCollection]::new().Families
$defaultText = @'
The quick brown fox jumps over the lazy dog.
01234556789
?!@#$%^&*()-=_+<>[{}]"':;/|\`~
'@
$window = [System.Windows.Window]@{
Title = 'Font Family Preview'
Height = 325
Width = 400
WindowStartupLocation = 'CenterScreen'
icon = [System.Windows.Media.ImageSource]"c:\scripts\fonts.ico"
}
#add a handler to resize controls if the window is resized
$window.Add_SizeChanged({ $txtPreview.Height = $window.Height - 200 })
#add a handler to go to next font if the > key is pressed
$window.Add_KeyDown({
if ($_.Key -eq 'Right' -OR $_.Key -eq 'Down') {
$comboFont.SelectedIndex++
}
})
#add a handler to go to previous font if the < key is pressed
$window.Add_KeyDown({
if (($comboFont.SelectedIndex -gt 0) -AND ($_.Key -eq 'Up' -OR $_.Key -eq 'Left' )) {
$comboFont.SelectedIndex--
}
})
#add a handler to close window with Ctrl+Q
$window.Add_KeyDown({
if ($_.Key -eq 'Q' -AND $_.KeyboardDevice.Modifiers -eq 'Control') {
$window.Close()
}
})
$Stack = [System.Windows.Controls.StackPanel]@{
Orientation = 'Vertical'
Background = 'CornSilk'
}
$comboStyle = [System.Windows.Controls.ComboBox]@{
ItemsSource = 'Normal', 'Italic', 'Oblique'
SelectedIndex = 0
FontSize = 14
Height = 25
Width = 100
ToolTip = 'Select a font style'
HorizontalAlignment = 'Left'
Margin = '5,5,0,0'
}
$comboStyle.Add_SelectionChanged({
$txtPreview.FontStyle = $comboStyle.SelectedItem
})
$stack.AddChild($comboStyle)
$comboFont = [System.Windows.Controls.ComboBox]@{
ItemsSource = $Families.Name
SelectedIndex = 0
FontSize = 14
Height = 25
Width = 250
HorizontalAlignment = "left"
ToolTip = 'Select a font family'
Margin = '5,5,0,0'
}
#change the text box to use the selected font
$comboFont.Add_SelectionChanged({ $txtPreview.FontFamily = $comboFont.SelectedItem })
$Stack.AddChild($comboFont)
$grid = [System.Windows.Controls.Grid]@{
Height = 25
}
$btnPrevious = [System.Windows.Controls.Button]@{
Content = '<'
Width = 20
HorizontalAlignment = 'Left'
VerticalAlignment = 'Center'
ToolTip = 'Previous Font'
Margin = '10,5,0,0'
}
$btnPrevious.Add_Click({
if ($comboFont.SelectedIndex -gt 0) {
$comboFont.SelectedIndex--
}
})
$grid.AddChild($btnPrevious)
$btnNext = [System.Windows.Controls.Button]@{
Content = '>'
Width = 20
HorizontalAlignment = 'Left'
VerticalAlignment = 'Center'
ToolTip = 'Next Font'
Margin = '40,5,0,0'
}
$btnNext.Add_Click({ $comboFont.SelectedIndex++ })
$grid.AddChild($btnNext)
$Stack.AddChild($grid)
$txtPreview = [System.Windows.Controls.TextBox]@{
TextWrapping = 'Wrap'
AcceptsReturn = $true
VerticalScrollBarVisibility = 'Auto'
HorizontalScrollBarVisibility = 'Auto'
FontSize = 18
Height = $window.Height - 200
FontFamily = $comboFont.SelectedItem
FontStyle = 'Normal'
Text = $DefaultText
VerticalAlignment = 'Center'
Margin = '5,10,5,5'
}
$stack.AddChild($txtPreview)
$btnReset = [System.Windows.Controls.Button]@{
Content = 'Reset'
Width = 75
HorizontalAlignment = 'Left'
VerticalAlignment = 'Bottom'
Margin = '5,25,0,0'
ToolTip = 'Reset to default text and font'
}
$btnReset.Add_Click({
$txtPreview.Text = $defaultText
$comboStyle.SelectedIndex = 0
$comboFont.SelectedIndex = 0
})
$stack.addChild($btnReset)
$window.AddChild($Stack)
[void]$Window.ShowDialog()
This will create a form that allows you to preview installed fonts.
You might try code like this:
$ps = [powershell]::create()
$c = Get-Content c:\scripts\WPFFontPreview.ps1 -Raw
$ps.AddScript($c)
$handle = $ps.BeginInvoke()
But you won't get the form, even though the handle shows the script completed. This is where you can check the error stream. You'll actually see many errors, but the first one tells you all you need to know.
PS C:\> $ps.Streams.Error.Exception.Message[0]
Cannot create object of type "System.Windows.Window". The calling thread must be STA, because many UI components require this.
The error message tells you exactly what is wrong.
PS C:\> $ps.Runspace.ApartmentState
Unknown
And you can't change this value.
PS C:\> $ps.Runspace.ApartmentState = "STA"
SetValueInvocationException: Exception setting "ApartmentState": "This property cannot be changed after the runspace has been opened."
Opening a Runspace Factory
Instead, we need to create a runspace from scratch with the required settings. PowerShell has a "factory" we can use to crank it out.
$newRunspace = [RunspaceFactory]::CreateRunspace()
$newRunspace.ApartmentState = 'STA'
$newRunspace.ThreadOptions = 'ReuseThread'
$newRunspace.Open()
I can still create the PowerShell
object as I did before.
$ps = [powershell]::create()
$c = Get-Content c:\scripts\WPFFontPreview.ps1 -Raw
[void]$ps.AddScript($c)
Now for the magic part. Instead of using the default runspace, we can use the custom one created from the factory.
$ps.Runspace = $newRunspace
The WPF-based script doesn't write any output to the pipeline, so I don't need to get any results. All I need to do is launch it asynchronously.
[void]$ps.BeginInvoke()

The WPF form runs in the runspace:
PS C:\> $newRunspace
Id Name ComputerName Type State Availability
-- ---- ------------ ---- ----- ------------
15 Runspace15 localhost Local Opened Busy
And I have my prompt back. When I'm finished with the form, I can close it. The runspace remains, but I can manually remove it.
Remove-Runspace -id 15
Summary
This is running a little long so I'll wrap it up here. I have some code I use to clean up runspaces and there's one more related topic I want to show you. I'll save these items for next month. In the meantime, keep playing with my examples and let me know what you think.