PowerShell · lesson 9 of 12
Background Jobs and ForEach-Object -Parallel
Time four one-second tasks run in sequence and in parallel, see an outer variable turn into $null in the parallel block, and pass values in with $using:.
By SkillByExample · Updated
Some things are genuinely worth measuring rather than asserting — how much faster parallel execution actually is, exactly.
Sequential, measured
$sequential = Measure-Command {
1..4 | ForEach-Object { Start-Sleep -Seconds 1 }
}
"Sequential: $($sequential.TotalSeconds) seconds"
Sequential: 4.0746114 seconds
Four one-second tasks, one after another — a little over four seconds, exactly as expected.
Parallel, measured
$parallel = Measure-Command {
1..4 | ForEach-Object -Parallel { Start-Sleep -Seconds 1 } -ThrottleLimit 4
}
"Parallel: $($parallel.TotalSeconds) seconds"
Parallel: 1.1064056 seconds
Same four tasks, same total work, about a quarter of the wall-clock time. -Parallel runs
each pipeline item in its own lightweight runspace instead of waiting for the previous one to
finish — with -ThrottleLimit 4, all four start together.
The bug: an outer variable reads as $null
$multiplier = 10
1..3 | ForEach-Object -Parallel {
$_ * $multiplier
}
0
0
0
$multiplier is clearly 10 one line above — inside the parallel block, it acts like it’s
0 (or rather, $_ * $null, which is 0). Each parallel iteration runs in an isolated
runspace that doesn’t automatically inherit variables from the scope that launched it, so
$multiplier inside the block refers to nothing at all, silently.
The fix: $using:
$multiplier = 10
1..3 | ForEach-Object -Parallel {
$_ * $using:multiplier
}
10
20
30
$using:multiplier explicitly copies the outer variable’s value into the runspace before the
block runs. Without it, a parallel block can only see what’s defined inside itself — nothing
from the calling scope crosses that boundary implicitly.
Background jobs: a separate process, not a runspace
# start-job.ps1 - a background job runs while the script carries on
$job = Start-Job -ScriptBlock { Start-Sleep -Seconds 2; "job finished" }
Get-Job
$result = Receive-Job -Job $job -Wait
$result
Remove-Job -Job $job
$ pwsh start-job.ps1
Id Name PSJobTypeName State HasMoreData Location
-- ---- ------------- ----- ----------- --------
1 Job1 BackgroundJob Running True localhost
job finished
Get-Job ran while the job was still sleeping, so its State is Running, and Receive-Job -Wait
then blocked until the result arrived. A wider terminal adds a Command column to that table.
Start-Job launches a whole separate PowerShell process, so the job can’t see your variables
either. $using: still works here: PowerShell copies the value into the new process when it
starts the job.
# using-job.ps1 - $using: copies a local value into a Start-Job process too
$title = "Dune"
$job = Start-Job -ScriptBlock { "plain: [$title], using: [$using:title], pid differs: $($PID -ne $using:PID)" }
Receive-Job -Job $job -Wait
Remove-Job -Job $job
$ pwsh using-job.ps1
plain: [], using: [Dune], pid differs: True
The plain $title was empty in the job, because the job’s process never had that variable, while
$using:title arrived with the value. The different $PID confirms the job ran in another
process. You can also pass values in as arguments, the way you’d call a function:
# arg-job.ps1 - the other way in: -ArgumentList and a param() block
$job = Start-Job -ScriptBlock {
param($Name)
"Hello from job, $Name"
} -ArgumentList "Dune"
Receive-Job -Job $job -Wait
Remove-Job -Job $job
$ pwsh arg-job.ps1
Hello from job, Dune
Use $using: for a value or two, and -ArgumentList with param() when the script block is long
enough to read like a function.
Practice
1. Measure the same four tasks with a lower throttle limit and predict the result before running it.
$throttled = Measure-Command {
1..4 | ForEach-Object -Parallel { Start-Sleep -Seconds 1 } -ThrottleLimit 2
}
"Throttled to 2: $($throttled.TotalSeconds) seconds"
Throttled to 2: 2.099378 seconds
With only 2 concurrent slots for 4 one-second tasks, they run as two batches of two — about two seconds, not one and not four. The throttle limit bounds concurrency; it doesn’t change whether the work is parallel at all.
2. Check a job's State without blocking on Receive-Job -Wait.
$job = Start-Job -ScriptBlock { Start-Sleep -Seconds 3; "done" }
Start-Sleep -Milliseconds 200
$job.State
Running
Reading .State directly is non-blocking — useful for a script that wants to check progress
on several jobs periodically instead of waiting on each one in turn.
3. Pass two arguments into a background job with -ArgumentList.
$job = Start-Job -ScriptBlock {
param($Title, $Year)
"$Title was published in $Year"
} -ArgumentList "Dune", 1965
Receive-Job -Job $job -Wait
Remove-Job -Job $job
Dune was published in 1965
-ArgumentList maps positionally onto the script block’s param() list, the same calling
convention as any other PowerShell function.
Next: pulling everything in this track together into a system inventory report.