PowerShell By Example: Threading

Threading allows you to run multiple pieces of work at the same time, making scripts faster when tasks are independent of each other. PowerShell offers several ways to achieve this, from simple background jobs to low-level runspaces.


Background Jobs

The simplest way to run code in the background is Start-Job. Each job runs in a separate PowerShell process.

$job = Start-Job -ScriptBlock {
    Start-Sleep -Seconds 2
    "Job completed"
}

# Do other work here while the job runs...
Write-Host "Waiting for job..."

# Block until the job finishes and retrieve output
Wait-Job -Job $job
Receive-Job -Job $job
Remove-Job -Job $job

Result:

Waiting for job...
Job completed

Multiple Jobs

$jobs = 1..5 | ForEach-Object {
    $number = $_
    Start-Job -ScriptBlock {
        Start-Sleep -Seconds (Get-Random -Minimum 1 -Maximum 3)
        "Job $using:number done"
    }
}

# Wait for all jobs to finish
$jobs | Wait-Job | Receive-Job
$jobs | Remove-Job

Result (order may vary):

Job 3 done
Job 1 done
Job 5 done
Job 2 done
Job 4 done

Note: Start-Job spawns a new process for each job, which has significant overhead. Use Thread Jobs or Runspaces for better performance.

Language Mode Error

If you see this error:

Start-Job: Cannot start job. The language mode for this session is incompatible with the system-wide language mode.

Your system enforces Constrained Language Mode (CLM) via AppLocker or Windows Defender Application Control (WDAC). Because Start-Job launches a new PowerShell process, that child process inherits the system-wide CLM policy and refuses to accept script blocks from a FullLanguage session.

You can check the current language mode with:

$ExecutionContext.SessionState.LanguageMode

Workarounds:

The most practical alternative is Start-ThreadJob or ForEach-Object -Parallel, which run on threads inside the same process and are not subject to this restriction:

# Use Start-ThreadJob instead
$job = Start-ThreadJob -ScriptBlock {
    Start-Sleep -Seconds 2
    "Job completed"
}

Wait-Job -Job $job
Receive-Job -Job $job
Remove-Job -Job $job

If you must use Start-Job in a CLM environment or on PowerShell 5.1, save the script to a .ps1 file that is trusted under your AppLocker/WDAC policy and pass the file path with -FilePath instead of a script block:

# myjob.ps1 must be in a path allowed by AppLocker/WDAC
Start-Job -FilePath "C:\Scripts\myjob.ps1"

Thread Jobs

Start-ThreadJob (available in the ThreadJob module, included with PowerShell 7) runs jobs as threads instead of processes - much faster to start and with less memory overhead.

# Install if not already available (PowerShell 5.1)
# Install-Module -Name ThreadJob

$jobs = 1..5 | ForEach-Object {
    $number = $_
    Start-ThreadJob -ScriptBlock {
        Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 500)
        "Thread job $using:number done"
    }
}

$jobs | Wait-Job | Receive-Job
$jobs | Remove-Job

Result (order may vary):

Thread job 2 done
Thread job 4 done
Thread job 1 done
Thread job 5 done
Thread job 3 done

ForEach-Object -Parallel (PowerShell 7+)

PowerShell 7 introduced the -Parallel parameter on ForEach-Object, which is the most concise way to parallelise a pipeline.

1..5 | ForEach-Object -Parallel {
    Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 500)
    "Item $_ processed"
} -ThrottleLimit 3

Result (order may vary):

Item 2 processed
Item 1 processed
Item 3 processed
Item 5 processed
Item 4 processed

The -ThrottleLimit controls how many threads run simultaneously (default is 5).

Passing Variables into Parallel Blocks

Variables from the outer scope are not automatically available inside -Parallel. Use the $using: scope modifier.

$prefix = "Result"
$multiplier = 10

1..5 | ForEach-Object -Parallel {
    "$using:prefix: $($_ * $using:multiplier)"
} -ThrottleLimit 5

Result:

Result: 10
Result: 20
Result: 30
Result: 40
Result: 50

Runspaces

Runspaces are the lowest-level threading primitive in PowerShell. They are faster than jobs and give you the most control, but require more setup.

$runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$runspace.Open()

$ps = [System.Management.Automation.PowerShell]::Create()
$ps.Runspace = $runspace

$ps.AddScript({
    Start-Sleep -Milliseconds 500
    "Runspace completed"
}) | Out-Null

# Start asynchronously
$asyncResult = $ps.BeginInvoke()

# Do other work here...
Write-Host "Runspace is running asynchronously..."

# Wait and collect output
$output = $ps.EndInvoke($asyncResult)
$output

$ps.Dispose()
$runspace.Close()

Result:

Runspace is running asynchronously...
Runspace completed

Runspace Pools (Advanced)

A RunspacePool limits the number of concurrent threads, preventing resource exhaustion when processing large collections.

$maxThreads = 4
$pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $maxThreads)
$pool.Open()

$jobs = [System.Collections.Generic.List[hashtable]]::new()

foreach ($i in 1..10) {
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.RunspacePool = $pool

    $ps.AddScript({
        param($number)
        Start-Sleep -Milliseconds (Get-Random -Minimum 100 -Maximum 400)
        "Item $number processed on thread $(
            [System.Threading.Thread]::CurrentThread.ManagedThreadId
        )"
    }).AddArgument($i) | Out-Null

    $jobs.Add(@{
        PowerShell  = $ps
        AsyncResult = $ps.BeginInvoke()
    })
}

# Collect all results
foreach ($job in $jobs) {
    $job.PowerShell.EndInvoke($job.AsyncResult)
    $job.PowerShell.Dispose()
}

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

Result (order may vary; notice thread IDs are reused):

Item 2 processed on thread 12
Item 1 processed on thread 9
Item 4 processed on thread 11
Item 3 processed on thread 10
Item 6 processed on thread 12
Item 5 processed on thread 9
...

Thread-Safe Collections

When multiple threads write to a shared collection, race conditions can occur. Use thread-safe types from System.Collections.Concurrent.

$results = [System.Collections.Concurrent.ConcurrentBag[string]]::new()

1..10 | ForEach-Object -Parallel {
    $bag = $using:results
    $bag.Add("Item $_ finished")
} -ThrottleLimit 5

$results | Sort-Object

Result:

Item 1 finished
Item 2 finished
Item 3 finished
...
Item 10 finished

Avoid writing to a plain [System.Collections.Generic.List[T]] or @() array from parallel threads - use ConcurrentBag, ConcurrentQueue, or ConcurrentDictionary instead.


Summary

ApproachIsolationSpeedBest for
Start-JobProcessSlowIsolation, compatibility with PS 5.1
Start-ThreadJobThreadFastGeneral parallel tasks
ForEach-Object -ParallelThreadFastSimple pipeline parallelism (PS 7+)
RunspaceThreadFastestFine-grained control, high volume
RunspacePoolThreadFastestThrottled high-volume processing