Skip to main content
PowerShell · lesson 9 of 12
PowerShell advanced 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

powershell jobs parallelism performance concurrency

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.

Frequently Asked Questions

What's the difference between Start-Job and ForEach-Object -Parallel?
Start-Job runs a script block in a completely separate PowerShell process — heavier to start, but fully isolated, and the right choice for one long-running background task. ForEach-Object -Parallel (PowerShell 7+) runs each pipeline item in its own lightweight runspace within the same process, which starts faster and is built specifically for running the same block many times over a collection.
Why did a variable from outside the parallel block read as $null inside it?
Each iteration of ForEach-Object -Parallel runs in an isolated runspace that doesn't automatically see variables from the scope that launched it — $multiplier existed outside, but the parallel block had no reference to it, so PowerShell treated it as an uninitialized (null) variable rather than raising an error. $using:multiplier explicitly copies the value in.
Does $using: work with Start-Job too?
Yes. Start-Job runs the script block in a separate PowerShell process that can't see your variables, but $using:name copies the local value into that process when the job starts, just as it does for ForEach-Object -Parallel and Invoke-Command. The alternative is a param() block in the script block with the values passed through -ArgumentList.
Does -ThrottleLimit change whether the work runs in parallel, or just how much of it does at once?
It caps how many items run at the same time — the default is 5. Four one-second tasks with -ThrottleLimit 4 all start together and finish in about one second, but the same four tasks with -ThrottleLimit 2 run as two batches of two, finishing in about two seconds. The work is still parallel; the throttle just bounds the concurrency.
When is background parallelism actually worth the added complexity?
When the work is I/O-bound and mostly waiting — network calls, file operations, anything with real dead time like the Start-Sleep examples in this lesson stand in for. For CPU-bound work, PowerShell's own overhead per runspace can erase the benefit; measure with Measure-Command on the actual workload rather than assuming parallel is always faster.