Chapter 17Lesson 03~225 minutes

Runspaces and Runspace Pools: The Mental Model

Understand runspaces and runspace pools as the execution machinery behind advanced PowerShell concurrency, with bounded pools, asynchronous invocation, stream collection, and deterministic disposal.

RunspacesRunspace poolsPowerShell SDKDisposal

Learning objectives

  • Define runspaces as PowerShell execution environments within a process.
  • Compare serial execution, process jobs, thread jobs, Parallel, and custom runspace pools.
  • Create/open a bounded RunspacePool and assign PowerShell pipelines to it.
  • Use BeginInvoke/EndInvoke and inspect per-invocation streams.
  • Dispose pools and worker pipelines reliably.
  • Decide when custom runspaces are justified versus unnecessary complexity.

1. A runspace is a PowerShell execution environment

A runspace contains the PowerShell engine state needed to run commands: session state, loaded commands/modules, variables, language mode, and pipeline execution context. It lives inside a process. Advanced hosting applications and PowerShell concurrency features use runspaces to provide independent execution contexts without launching a process per task.

A runspace is a PowerShell execution environment
flowchart LR A[One pwsh process] --> P[Runspace pool 1..N] P --> R1[Runspace] P --> R2[Runspace] P --> R3[Runspace] R1 --> T1[Task] R2 --> T2[Task] R3 --> T3[Task]

2. Choose the highest-level abstraction that solves the problem

ApproachManagement levelIsolationBuilt-in throttlingUse when
Serial pipelineHighSame runspacen/aCorrectness/simple work first
Start-JobHighSeparate processNoYou want strong local isolation
Start-ThreadJobHighSame process/runspacesYesLocal asynchronous thread jobs
ForEach-Object -ParallelHighSame process/runspace poolYesPipeline-shaped bounded parallel work
Custom RunspacePoolLow/advancedSame processPool min/maxYou need a reusable custom engine/host abstraction

3. RunspaceFactory creates and opens a pool

The .NET System.Management.Automation.Runspaces.RunspaceFactory API can create a pool with minimum and maximum runspace counts. Opening the pool prepares it for invocations. Closing and disposing it releases the resources.

$minRunspaces = 1
$maxRunspaces = 3
$pool = [runspacefactory]::CreateRunspacePool($minRunspaces,$maxRunspaces)
$pool.Open()

$pool.GetMinRunspaces()
$pool.GetMaxRunspaces()
$pool.RunspacePoolStateInfo.State

$pool.Close()
$pool.Dispose()

4. A PowerShell instance is assigned to the pool

The .NET PowerShell class represents a pipeline you build programmatically. Assign its RunspacePool property, add script/arguments, then invoke. The Runspace and RunspacePool properties are mutually exclusive: an invocation uses one execution context source.

$pool = [runspacefactory]::CreateRunspacePool(1,2)
$pool.Open()
$ps = [PowerShell]::Create()
$ps.RunspacePool = $pool
$null = $ps.AddScript('param($n) [pscustomobject]@{Number=$n;Square=$n*$n}').AddArgument(7)
$result = $ps.Invoke()
$result
$ps.Dispose(); $pool.Dispose()

5. BeginInvoke and EndInvoke separate start from collection

To use a runspace pool concurrently, start several pipelines with BeginInvoke(). Each call returns an asynchronous handle. Later, EndInvoke() collects that invocation’s success output. Errors are available through the individual PowerShell instance’s Streams.Error collection.

$pool = [runspacefactory]::CreateRunspacePool(1,3)
$pool.Open()
$work = foreach ($n in 1..5) {
    $ps = [PowerShell]::Create()
    $ps.RunspacePool = $pool
    $scriptText = 'param($x) Start-Sleep -Milliseconds 100; $x*$x'
    $null = $ps.AddScript($scriptText).AddArgument($n)
    [pscustomobject]@{ Input=$n; PowerShell=$ps; Handle=$ps.BeginInvoke() }
}

$results = foreach ($item in $work) {
    [pscustomobject]@{
        Input  = $item.Input
        Output = @($item.PowerShell.EndInvoke($item.Handle))
        Errors = @($item.PowerShell.Streams.Error).Exception.Message
    }
    $item.PowerShell.Dispose()
}
$pool.Dispose()
$results | Sort-Object Input

6. Inspect each invocation, not only the pool

The pool schedules execution; the individual PowerShell object owns its pipeline streams. A robust wrapper must collect both success output and errors and must dispose every pipeline even when one fails.

$ps = [PowerShell]::Create()
$ps.AddScript('Write-Warning "training"; Write-Error "simulated"; [pscustomobject]@{Success=$true}') | Out-Null
$output = $ps.Invoke()
[pscustomobject]@{
    OutputCount  = @($output).Count
    ErrorCount   = $ps.Streams.Error.Count
    WarningCount = $ps.Streams.Warning.Count
}
$ps.Dispose()

7. Runspaces do not magically inherit every caller detail

Custom runspaces have their own session state. If workers require functions, modules, types, or configuration, initialize them intentionally. Hiding dependencies inside ambient global state makes custom concurrency fragile and hard to test.

# Prefer explicit arguments and module imports inside the worker script.
$worker = {
    param([int]$InputValue,[string]$Label)
    [pscustomobject]@{ Label=$Label; Value=$InputValue; Square=$InputValue*$InputValue }
}

# AddArgument() makes the dependency visible at the invocation boundary.

8. Hide low-level mechanics behind a narrow function

Callers should not manage raw async handles and disposal for ordinary use. Encapsulate the mechanics and return stable result records.

function Invoke-RunspaceMap {
    [CmdletBinding()]
    param([Parameter(Mandatory)][object[]]$InputObject,[ValidateRange(1,32)][int]$ThrottleLimit=4)

    $pool=[runspacefactory]::CreateRunspacePool(1,$ThrottleLimit)
    $pool.Open()
    $pending=[System.Collections.Generic.List[object]]::new()
    try {
        foreach($item in $InputObject){
            $ps=[PowerShell]::Create(); $ps.RunspacePool=$pool
            $scriptText='param($x) [pscustomobject]@{Input=$x;Output=$x*$x;Worker=[Environment]::CurrentManagedThreadId}'
            $null=$ps.AddScript($scriptText).AddArgument($item)
            $pending.Add([pscustomobject]@{Input=$item;PS=$ps;Handle=$ps.BeginInvoke()})
        }
        foreach($work in $pending){
            $out=@($work.PS.EndInvoke($work.Handle))
            [pscustomobject]@{Input=$work.Input;Success=($work.PS.Streams.Error.Count -eq 0);Result=$out;Errors=@($work.PS.Streams.Error)}
        }
    } finally {
        foreach($work in $pending){ if($work.PS){$work.PS.Dispose()} }
        $pool.Dispose()
    }
}
Invoke-RunspaceMap -InputObject (1..6) -ThrottleLimit 3

9. When not to build custom runspaces

Custom pools add code that you must own: initialization, cancellation, errors, stream routing, disposal, tests, and compatibility. If ForEach-Object -Parallel or Start-ThreadJob already express the workload, use them. Drop to the SDK when a reusable library/host needs custom pooling, long-lived initialization, or integration that higher-level cmdlets do not expose.

10. Advanced optional lab — compare a pool with ForEach-Object -Parallel

$inputs=1..12
$parallelTime=Measure-Command {
    $a=$inputs | ForEach-Object -Parallel { Start-Sleep -Milliseconds 60; $_*$_ } -ThrottleLimit 4
}
$poolTime=Measure-Command {
    $b=Invoke-RunspaceMap -InputObject $inputs -ThrottleLimit 4
}
[pscustomobject]@{ ParallelMs=$parallelTime.TotalMilliseconds; CustomPoolMs=$poolTime.TotalMilliseconds }
# Treat this as one machine/run result, not a universal benchmark.

11. Common runspace mistakes

MistakeConsequenceBetter pattern
Forget Dispose/CloseRunspace/pipeline resources linger.Use try/finally and dispose every PowerShell instance plus the pool.
Hide globals/modules in ambient stateWorkers fail unpredictably on clean hosts.Initialize dependencies explicitly.
Build a scheduler before needing oneComplexity exceeds value.Prefer Parallel/ThreadJob first.
Only collect success outputFailures disappear from the wrapper contract.Include Streams.Error and task identity.

12. Verification checklist

  • You can define a runspace and runspace pool.
  • You can compare high-level jobs/Parallel with custom pool management.
  • You understand PowerShell.RunspacePool assignment and BeginInvoke/EndInvoke.
  • You collect errors per invocation.
  • You dispose worker pipelines and the pool.
  • You know when custom runspaces are unnecessary complexity.

13. Knowledge check

Question 1. What problem does a runspace pool solve?

Question 2. Which object owns Streams.Error for a custom invocation?

Question 3. What separates BeginInvoke from EndInvoke?

Question 4. Why should dependencies be explicit in workers?

Question 5. When should you prefer ForEach-Object -Parallel over a custom pool?

14. Summary and next bridge

Runspaces explain how PowerShell can execute multiple pipelines inside one process, and pools add bounded reuse. With that mechanism understood, the next lesson focuses on production control: timeouts, cancellation, deterministic identity/order, synchronization, retry boundaries, and partial failure.

15. Authoritative references

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.