Chapter 17Lesson 02~210 minutes

Thread Jobs and ForEach-Object -Parallel

Compare process jobs with thread jobs and ForEach-Object -Parallel, including current version support, throttling, $Using: references, thread safety, ordering, errors, and measured overhead.

ThreadJobParallelThrottleThread safety

Learning objectives

  • Differentiate process-isolated jobs from same-process thread jobs.
  • Use Start-ThreadJob and ForEach-Object -Parallel with bounded concurrency.
  • Explain current runspace-pool reuse behavior and version history.
  • Use $Using: safely and recognize shared mutable-state hazards.
  • Handle per-iteration failures without losing target identity.
  • Measure when parallel execution helps or hurts.

1. Thread jobs trade isolation for lower overhead

A thread job runs in another thread inside the same PowerShell process. That means it avoids the process/remoting serialization overhead of Start-Job. Returned objects can remain live references. The tradeoff is weaker isolation: a process-wide failure can terminate every thread job in that process, and unsafe shared mutation can corrupt state.

MechanismBoundarySerializationIsolationTypical fit
Start-JobSeparate processYesStrongLonger tasks where isolation matters
Start-ThreadJobThread/runspace in same processNo remoting serializationLowerMany local waiting/compute tasks
ForEach-Object -ParallelPool of runspaces/threadsNo remoting serializationLowerPipeline-oriented bounded parallel work

2. Start-ThreadJob and Parallel availability

The ThreadJob module first shipped with PowerShell 6 and ships with PowerShell 7. ForEach-Object -Parallel was introduced in PowerShell 7.0. On the course baseline, inspect capability rather than assuming a legacy host.

$capability = [pscustomobject]@{
    Version          = $PSVersionTable.PSVersion.ToString()
    StartThreadJob   = [bool](Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)
    ParallelParamSet = [bool]((Get-Command ForEach-Object).ParameterSets.Name -contains 'ParallelParameterSet')
}
$capability

3. Start-ThreadJob uses the familiar job lifecycle

Thread jobs still integrate with Get-Job, Wait-Job, Receive-Job, and Remove-Job. The difference is where execution occurs and how objects cross the boundary.

$jobs = 1..3 | ForEach-Object {
    Start-ThreadJob -ArgumentList $_ -ScriptBlock {
        param($n)
        Start-Sleep -Milliseconds (120*$n)
        [pscustomobject]@{ Item=$n; WorkerThread=[Environment]::CurrentManagedThreadId; Square=$n*$n }
    }
}
$jobs | Wait-Job | Out-Null
$jobs | Receive-Job | Sort-Object Item
$jobs | Remove-Job

4. ThreadJob has a throttle; use it deliberately

Current Start-ThreadJob exposes -ThrottleLimit; the documented default is 5 threads. The underlying thread pool is shared within the session, so choose a limit based on workload and host capacity rather than copying a high number.

$jobs = foreach ($n in 1..8) {
    Start-ThreadJob -ThrottleLimit 3 -ArgumentList $n -ScriptBlock {
        param($i)
        Start-Sleep -Milliseconds 150
        [pscustomobject]@{ Item=$i; Finished=[datetime]::UtcNow }
    }
}
$jobs | Wait-Job | Receive-Job | Sort-Object Item
$jobs | Remove-Job

5. ForEach-Object -Parallel is pipeline-oriented concurrency

ForEach-Object -Parallel accepts pipeline input and executes a script block concurrently. The default throttle is 5. PowerShell 7.1 and later reuse runspaces from a runspace pool by default; -UseNewRunspace forces a new runspace per iteration and is usually more expensive.

1..6 | ForEach-Object -Parallel {
    Start-Sleep -Milliseconds (80 * (7-$_))
    [pscustomobject]@{
        Item   = $_
        Square = $_ * $_
        Thread = [Environment]::CurrentManagedThreadId
    }
} -ThrottleLimit 3

Notice that completion order is not guaranteed. If business semantics require input order, carry an index and sort after collection rather than assuming parallel output order.

6. Using: passes references into thread-based work

With thread-based parallelism, $Using: does not imply remoting serialization. Reference-type objects can be shared with the running threads. Reading immutable data is usually safe; mutating a non-thread-safe object is not.

$prefix = 'node'
1..4 | ForEach-Object -Parallel {
    [pscustomobject]@{ Name = "$Using:prefix-$_"; Input=$_ }
} -ThrottleLimit 2

7. Shared mutable state needs thread-safe types

If threads must update one shared collection, use a type designed for concurrency. PowerShell documentation demonstrates ConcurrentDictionary. A normal generic Dictionary or ArrayList is not automatically safe just because PowerShell created it.

$map = [Collections.Concurrent.ConcurrentDictionary[int,string]]::new()
1..10 | ForEach-Object -Parallel {
    $d = $Using:map
    $null = $d.TryAdd($_, "item-$_")
} -ThrottleLimit 4

$map.GetEnumerator() | Sort-Object Key

8. Parallel errors are per-iteration and ordering is nondeterministic

A terminating error in one parallel iteration normally terminates that iteration, not every sibling. Other iterations can continue. Non-terminating errors and other streams may arrive in nondeterministic order, so correlate every record with its input identity.

$results = 1..5 | ForEach-Object -Parallel {
    try {
        if ($_ -eq 3) { throw 'simulated failure' }
        [pscustomobject]@{ Item=$_; Success=$true; Error=$null }
    } catch {
        [pscustomobject]@{ Item=$_; Success=$false; Error=$_.Exception.Message }
    }
} -ThrottleLimit 2
$results | Sort-Object Item

9. Parallelism can make small work slower

Runspace scheduling, initialization, synchronization, and result collection all cost time. A trivial arithmetic operation often runs faster serially. Parallelism becomes attractive when each task does enough CPU work or spends meaningful time waiting independently.

$serial = Measure-Command {
    $null = 1..200 | ForEach-Object { $_ * $_ }
}
$parallel = Measure-Command {
    $null = 1..200 | ForEach-Object -Parallel { $_ * $_ } -ThrottleLimit 4
}
[pscustomobject]@{
    SerialMs   = [math]::Round($serial.TotalMilliseconds,2)
    ParallelMs = [math]::Round($parallel.TotalMilliseconds,2)
}

10. Lab — compare serial and bounded parallel waiting work

The lab uses only local sleeps to simulate independent I/O waits, so timing differences are observable without depending on the network.

$items = 1..8
$serialTime = Measure-Command {
    $serialResult = $items | ForEach-Object {
        Start-Sleep -Milliseconds 150
        [pscustomobject]@{ Item=$_; Mode='Serial' }
    }
}
$parallelTime = Measure-Command {
    $parallelResult = $items | ForEach-Object -Parallel {
        Start-Sleep -Milliseconds 150
        [pscustomobject]@{ Item=$_; Mode='Parallel' }
    } -ThrottleLimit 4
}
[pscustomobject]@{ SerialMs=$serialTime.TotalMilliseconds; ParallelMs=$parallelTime.TotalMilliseconds }
$parallelResult | Sort-Object Item

11. Common thread/parallel mistakes

MistakeWhy it failsBetter pattern
Mutate a normal shared dictionary/listConcurrent writes are not guaranteed safe.Return independent results or use concurrent collections.
Set ThrottleLimit extremely highScheduler/resource contention can erase gains or destabilize the host.Measure and bound concurrency.
Assume output order equals input orderTasks finish nondeterministically.Carry identity/index and sort if order is required.
Parallelize tiny workStartup/scheduling overhead dominates.Measure serial first.

12. Verification checklist

  • You can explain process jobs versus thread jobs.
  • You know ForEach-Object -Parallel was introduced in PowerShell 7.0 and reuses a runspace pool by default in 7.1+.
  • You use explicit throttle limits.
  • You understand $Using: reference-sharing risk for mutable objects.
  • You preserve task identity because output and errors can arrive out of order.
  • You measure whether parallelism actually improves the workload.

13. Knowledge check

Question 1. Why are thread jobs lighter than Start-Job?

Question 2. What is the default ForEach-Object -Parallel throttle?

Question 3. Since which version are runspaces reused by ForEach-Object -Parallel by default?

Question 4. Is it safe to mutate any object passed with $Using:?

Question 5. Why can parallel errors appear in a surprising order?

14. Summary and next bridge

Thread jobs and ForEach-Object -Parallel reduce process/serialization overhead, but they expose shared-process and thread-safety concerns. The next lesson opens the abstraction underneath them: a runspace is a PowerShell execution environment, and a runspace pool is the reusable worker pool advanced tooling manages directly.

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.