Parallel Decomposition

Fork/Join

Split the work, run the pieces, wait for all of them, combine. The split is easy and the combine is arithmetic — the join is the part that carries the correctness, because it is the only place a happens-before edge exists between the children and the parent that reads their results.

▶ Run the lab

The question this answers

The question

What exactly does the join give me, beyond "the children finished"?

The work

Summing a 100-million-element array by splitting it into four contiguous 25-million-element ranges, computing a partial sum per range, and adding the four partials.

What is shared

The input array — read-only during the parallel phase, so no synchronization is needed for it — and the results slots each child writes and the parent reads. The results array is the only mutable shared state, and it is where the join earns its keep.

The invariant — what must stay true under every interleaving

The parent reads results[i] only after child i has finished writing it; the final sum equals the sequential sum over the same input, and no element is counted twice or skipped.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

Split, run, join, combine — and why only one of those is hard

Fork/join is the simplest useful shape in parallel decomposition, and it is worth being precise about what each phase does. The split partitions the input into disjoint pieces, and disjointness is what removes the need for locks in the parallel phase: child 2 writes nothing child 3 reads. The run phase is embarrassingly parallel by construction. The combine folds the partial results. The join is the phase with no visible work at all, and it is the one that makes the whole thing correct.

The join does two distinct things and engineers usually only name the first. It waits: the parent does not proceed until every child has completed. It also publishes: joining a child establishes a happens-before edge, so everything the child wrote before finishing is guaranteed visible to the parent afterwards. Without that second property the parent could read a stale value from its own core's cache even though the child has genuinely finished — see Happens-Before: The Edge That Makes a Write Visible and Safe Publication: Handing Over a Finished Object. "I checked a flag and the child was done" is not a join.

The shape nests. Each child may itself fork, which is how recursive divide-and-conquer maps onto it, and why fork-join runtimes pair naturally with Work Stealing: a nested fork pushes a subtask onto the local deque, and an idle worker can steal it. The nesting also introduces the one structural hazard — a worker that blocks in a join while holding the only thread that could run the child it is waiting for.

  • Disjoint pieces are what make the parallel phase lock-free — the partitioning is the synchronization.
  • The join both waits and publishes; a status flag gives you the first without the second.
  • Fork/join nests, which is how recursion parallelises and why it pairs with work stealing.
  • The parent is idle during the parallel phase unless it runs one piece itself — which is the usual optimisation.
One fork/join level over four ranges
forkforkforkforkall writes now visibleParent: sum(a[0..100M))Split into 4 disjoint rangesChild 0: a[0..25M) → results[0]Child 1: a[25M..50M) → results[1]Child 2: a[50M..75M) → results[2]Child 3: a[75M..100M) → results[3]JOIN — wait + publish (happens-before)Combine: results[0]+…+results[3]Total
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The join is a barrier, so the slowest child sets the time

A join is an all-or-nothing wait: the parent proceeds when the *last* child finishes, so the phase takes as long as the slowest piece regardless of how fast the others were. Splitting an array into four equal ranges gives four equal pieces only if the per-element work is equal — which is true for summing and false for almost anything with a branch in it, like "parse each line" or "resize each image".

The timeline below shows the difference between an even split and a skewed one on the same four workers. In the skewed case, three workers finish at t=3 and then sit idle until t=10, so a nominally 4-way parallel phase delivers about 40% of the available machine. This is the single most common reason a fork/join speedup disappoints, and no amount of adding workers fixes it — the critical path is one child.

Two remedies, and they are different. Over-decompose: split into many more pieces than workers (say 64 pieces for 4 workers) so imbalance averages out and a work-stealing scheduler can rebalance — at the cost of per-task overhead, which is Parallel Overhead. Or split by cost rather than by count: if you can estimate per-item cost, partition to equalise estimated work instead of item count. The first is general and usually right; the second is better when you genuinely know the cost function.

Even split versus skewed split, same four workers, same join. Modelled to show straggler behaviour.SIMULATED
Even · W0
range 0
joined
Even · W1
range 1
joined
Even · W2
range 2
joined
Even · W3
range 3
combine
Skew · W0
range 0 — all the heavy rows
combine
Skew · W1
range 1
blocked in join — cannot help
Skew · W2
range 2
blocked in join
Skew · W3
range 3
blocked in join
↑ Even: join satisfied↑ Skew: join satisfied — one child set the whole time
runningreadywaitingblockedidle1 tick ≈ one chunk of elementary work

The schedule where the join is missing

The bug below is not exotic; it is what happens when someone replaces a join with a completion counter, or forgets one child in a hand-written list of joins, or joins three of four because the fourth was added later. The parent reads a results slot the child has not written, gets whatever was there — zero, in most initialisations — and produces an answer that is *plausibly wrong*: a sum that is 25% low, with no exception, no warning and full test coverage on the single-threaded path.

Read the schedule for the second, subtler failure too. Even after child 3 sets a done flag, the parent reading results[3] has no ordering guarantee unless the flag write and the results write are ordered with respect to each other and the parent's reads. A plain boolean flag does not create that edge; a join, a lock, or a properly ordered atomic does. This is exactly the family of bug in Safe Publication: Handing Over a Finished Object and Reordering: The Compiler and the CPU Both Do It, and it is why "check a flag in a loop" is not a substitute for a join even when the flag is eventually correct.

The practical defences are structural rather than vigilant. Fork and join in the same lexical scope so a missing join is a syntax-visible omission; use a construct that joins all children on scope exit (Structured Concurrency); and prefer an API that returns the results rather than one that writes into a shared array, because a returned value cannot be read before it exists.

Parent combines before child 3 has published. Illustrative trace of a possible interleaving.ILLUSTRATIVE
Invariant · results[i] is read by the parent only after child i has written it; the total equals the sequential sum.
#ParentChild 3State
1allocate results[4] = {0,0,0,0}; fork children 0–3·results=0,0,0,0 children running=4
2join child 0, 1, 2 (child 3's join was never written)·results=250,250,250,0 joined=3
3·still summing a[75M..100M)child 3 progress=60%
4read results[3] → 0·parent sees results[3]=0
✕ Parent read a slot no child has published; the value is the initialiser, not a partial sum.
5combine → 750; return·returned total=750 true total=1000
✕ A silently wrong answer: 25% low, no exception, no log line, deterministic under light load.
6·write results[3] = 250results=250,250,250,250
7·set done[3] = truedone[3]=true
A missing join is not a crash, it is a wrong number. The failure is worst under light load, where children finish quickly and the bug hides — and appears in production when one range is slow. Join structurally, or return values instead of writing slots.

Key points

  • Fork/join = disjoint split, independent execution, join, combine. Disjointness is what removes the need for locks.
  • The join waits *and* publishes: it is the happens-before edge that makes the children's writes visible to the parent.
  • A completion flag gives you waiting without publishing, and that difference is a real bug on weakly ordered hardware.
  • The join is a barrier, so the phase costs the slowest child — an even split by count is not an even split by work.
  • Over-decompose (more pieces than workers) so a stealing scheduler can rebalance; that is the general fix for skew.
  • A missing join produces a plausibly wrong answer with no error, and it hides under light load.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Partition the input into disjoint pieces, so no piece's writes are another piece's reads.
  • Fork one task per piece; the runtime may execute them on any worker, or the parent may run one piece itself to avoid idling.
  • Each child computes into its own result location — ideally a returned value, otherwise a slot no other child touches.
  • The parent joins every child. Each join blocks until that child completes and establishes a happens-before edge from the child's writes to the parent's subsequent reads.
  • After all joins, the parent combines the partial results — sequentially, or with another parallel reduction if there are many.
  • Recursively: a child may fork its own children, with the same rule at every level, down to a sequential cutoff.
Interleavings that matter
  • Parent forks 4, joins 4, combines — every partial is published before it is read, and the total matches the sequential result on every run.
  • Parent joins 3 of 4 and combines: results[3] reads its initialiser and the total is silently low. Under light load child 3 usually finishes first, so the bug appears only when that range is slow.
  • Child sets a done flag after writing its result; parent spins on the flag then reads the result. On a weakly ordered architecture the parent may observe the flag set and the result stale — waiting without publishing.
  • Three children finish at t=3, the fourth at t=10; the join is satisfied at t=10 and three workers were idle for 70% of the phase.
  • A child throws; without an explicit policy the parent's join either rethrows (and the other three children keep running unobserved — Orphaned Tasks) or the exception is swallowed and the parent combines a garbage slot.
  • The parent blocks in a join on a pool worker while its child is queued behind other tasks in the same pool: no worker is free to run the child, and the join never returns (Deadlock).
What it guarantees — and does not
  • Guaranteed: after a join returns, that child has completed and everything it wrote before completing is visible to the joining task.
  • Guaranteed: with a disjoint partition, no synchronization is needed between children — they touch no common mutable state.
  • NOT guaranteed: any ordering *between* children. They may run in any order, on any worker, or all on one worker if the runtime decides so.
  • NOT guaranteed: that fork means parallel. A fork is a scheduling request; on a busy or single-threaded runtime the children run sequentially and everything remains correct but not faster (Async Is Not Parallelism).
  • NOT guaranteed: that a child's failure stops its siblings. Cancellation on failure is a policy you configure, not a property of the shape.
  • NOT guaranteed: even completion times. The join is a barrier and inherits the tail of the slowest child.
  • NOT guaranteed: deterministic combine order. If the combine is not associative, the answer can vary between runs (Parallel Reduce, Reduction Ordering: The Sum Changed When the Worker Count Did).
Where contention appears
  • Almost none during the parallel phase, by design — disjoint ranges over a read-only input contend only on memory bandwidth (Memory Bandwidth: More Cores, Same Bus).
  • Adjacent result slots in one array share a cache line, so four children writing results[0..3] repeatedly can produce False Sharing: Different Variables, Same Cache Line that costs more than the computation; accumulate locally and write once.
  • The join point itself is a synchronization site: N children signalling one parent is a hot spot at high fan-out, and deep recursion makes it many small hot spots.
  • Workers blocked in a join are occupied but not working — in a pool, that is capacity removed from every other task.
How it fails
  • Missing join: reading a result before it is published — a wrong answer, not a crash.
  • Publication race: a completion flag without an ordering edge, so the parent sees "done" and a stale value.
  • Straggler-dominated phase: an even split by count over uneven work, leaving most workers idle.
  • Join deadlock: the parent occupies the only worker that could run the child it is waiting for.
  • Swallowed child exceptions producing a partial result presented as complete.
  • False sharing on the results array turning a memory-bound win into a coherence-traffic loss.
  • Orphaned siblings when the first failure propagates out of the join and nothing cancels the rest.
When it helps
  • Genuinely independent work over a partitionable input, where each piece is large enough to dwarf task overhead.
  • Recursive divide-and-conquer algorithms, which fork/join expresses directly (Parallel Algorithms).
  • CPU-bound phases inside an otherwise sequential program, where you want parallelism for a bounded region and a clean re-entry into sequential code.
  • When you need a clear structural boundary: everything the children did is done at the join, which makes reasoning about state after the phase trivial.
When it hurts
  • When pieces are small: fork and join overhead per piece exceeds the work, and the parallel version is slower (Parallel Overhead).
  • When the split is badly skewed and cannot be over-decomposed — one long-pole child makes the join a sequential wait.
  • When children need to communicate. Fork/join assumes independence; children that share mutable state need locks and lose the shape's main benefit.
  • When the work is I/O-bound: you do not need parallel *execution* to overlap waiting, and an async gather expresses it with far less machinery (Promise.all & gather).
  • When the combine is order-sensitive and results must be bitwise reproducible run to run.
How you would know
  • Wall time of the phase against the sum of child times divided by worker count — the gap is imbalance plus overhead.
  • Per-child duration distribution. A long tail is skew, and it identifies which piece to split further.
  • Worker idle time inside the phase, which is the direct cost of the straggler.
  • Speedup against the sequential version at several input sizes — the crossover tells you the right sequential cutoff.
  • Cache-miss and coherence-traffic counters on the results array if the child count is high and each child writes often.
  • A determinism check: run the parallel version many times on one input and compare results exactly. Any variation means the combine is order-sensitive or a join is missing.
Complexity it introduces
  • The partitioning becomes part of the algorithm and has to handle uneven sizes, empty pieces and the boundary elements correctly.
  • Exception policy must be decided explicitly: propagate the first, collect all, cancel siblings, or continue — each is a different contract for the caller.
  • A sequential cutoff constant enters the code and needs justification and re-measurement.
  • Debugging moves from a single stack to N stacks plus a join point, and the causal link between the failing child and the caller is only in the code, not in the trace.
  • The parallel phase must not assume thread affinity or thread-local state, since any worker may run any child.
Simpler alternatives
  • Sequential iteration, when the input is small or the per-element work is trivial — always the baseline to beat, and it wins more often than expected.
  • A parallel map over a work-stealing pool without explicit forking, when the language offers it (rayon, parallel streams) — same shape, less to get wrong.
  • An async gather, when the pieces are I/O-bound rather than CPU-bound (Promise.all & gather).
  • A pipeline, when pieces are dependent stages rather than independent chunks — fork/join cannot express a dependency between siblings (Pipeline Parallelism: Different Items, Different Stages).
  • SIMD within one thread, when the operation is uniform over adjacent data: often several times faster than a thread-level split with no coordination at all (SIMD: One Instruction, Many Elements).

Fork/join and the split threshold

Fork/join — splitting is not free
4,096 elements, 0.002 ms of work each, 8 workers. Each split costs 0.05 ms to create and join.
L0
1 × 4,096
L1
2 × 2,048
L2
4 × 1,024
L3
8 × 512
L4
16 × 256
sub-tasks created
30
useful work
8.2 ms
split + join overhead
1.5 ms
speedup on 8 workers
6.76×
fork(lo, hi):
  if (hi - lo <= 64)  return sequential(lo, hi)      // the base case is the tuning knob
  mid = (lo + hi) / 2
  left = spawn fork(lo, mid)                                // +0.05 ms
  right =       fork(mid, hi)                               // run one half on THIS thread
  return left.join() + right                                // join is where the parallelism ends

levels requested 4 → 4 actually taken
leaves 16 × 256 elements   span 0.91 ms   total 1.21 ms
16 leaves of 256 elements → 6.76× on 8 workers. Overhead is 1.5 ms against 8.2 ms of work, which is the range where splitting pays. Note the shape of the ceiling: you need at least 8 leaves to keep 8 workers busy, and past roughly 32 leaves you are buying load balance, not parallelism. Drag the threshold down to 1 and watch the overhead column overtake the work column.
1/9 · fork · level 0SIMULATED

Work stealing between deques

Work stealing — an idle worker is a bug, not a rest
Four deques start uneven: 11 / 6 / 2 / 1 tasks. Static partitioning finishes when the unluckiest worker finishes.
Worker 1
own work
stolen work
Worker 2
own work
Worker 3
own work
stolen work
Worker 4
own work
stolen work
runningreadywaitingblockedidleticks (1 task each)
makespan, no stealing
11 ticks
makespan, stealing
5 ticks
perfect balance
5.00 ticks
steal operations
4
Steal log
t=1 W4 idle → steals 5 from the tail of W1's deque
t=2 W3 idle → steals 2 from the tail of W1's deque
t=4 W1 idle → steals 1 from the tail of W2's deque
t=4 W3 idle → steals 1 from the tail of W4's deque
Own work  → popped from the HEAD of my deque (LIFO: hottest in cache)
Stolen    → taken from the TAIL of a victim's deque (oldest, biggest sub-task)
Two ends  → the owner and the thief rarely touch the same slot, so the
            common case is uncontended and needs no lock at all.
5 ticks instead of 11, from 4 steal operations. The total work never changed — 20 tasks either way. What changed is that the last 6 ticks are no longer three workers watching one worker finish. Stealing is not free: each steal is a synchronised hand-off, and the stolen sub-task arrives cold in the thief's cache. It pays when task durations are unpredictable, which is exactly when static partitioning fails.
1/5 · tick 1SIMULATED

Barrier vs latch

Barrier vs latch
Two phases of work across N workers, where phase 2 reads what phase 1 wrote. The question is who has to wait for whom, and whether the primitive can be used twice.
Coordination
Worker 1
phase 1 (68)
at barrier
phase 2 (41)
at barrier
Worker 2
phase 1 (50)
at barrier
phase 2 (39)
at barrier
Worker 3
phase 1 (95)
phase 2 (47)
at barrier
Worker 4
phase 1 (66)
at barrier
phase 2 (44)
at barrier
Worker 5
phase 1 (77)
at barrier
phase 2 (57)
↑ barrier 1↑ barrier 2
runningreadywaitingblockedidlems
wall clock
152 ms
worker time spent blocked
176 ms
phase 2 sees phase 1
guaranteed
reusable
yes, every round
barrier  every worker blocks at await(); the last arrival releases all N, and the barrier resets
         wall = max(phase1) + max(phase2) = 95 + 57 = 152 ms
latch    workers count down and carry on; a separate waiter is released when the count hits 0
         wall = max(phase1) + finalise    = 95 + 20 = 115 ms   — and the latch cannot count up again
none     wall = max over workers of (phase1 + phase2) = 142 ms, with no happens-before edge at all
152 ms, of which 176 ms is workers blocked at a barrier. A barrier converts N independent workers into one worker running at the speed of the slowest, once per round — the cost is the variance, not the mean, so a single straggler taxes every round for everybody. It buys you the thing phase 2 needs: a happens-before edge, so every write from phase 1 is visible to every reader in phase 2. If your phases are unbalanced, the fix is not a faster barrier; it is fewer barriers, or work-stealing inside a phase so the stragglers stop existing.
SIMULATEDdurations are deterministic per worker count

What people believe, and what is true

Claim

The join just waits for the children to finish.

Reality

It also publishes. The happens-before edge is why the parent sees the children's writes, and a flag-based wait does not provide it.

Claim

Forking four tasks means four things run in parallel.

Reality

Fork is a scheduling request. Cores, pool size and other work decide what actually runs simultaneously — the program is correct either way and fast only sometimes.

Claim

An even split of the array is an even split of the work.

Reality

Only when per-element cost is uniform. For anything with a branch, equal ranges give unequal work and the join waits for the worst one.

Go deeper

Overview

Cut the work into independent pieces, run them, wait for all of them, put the answers together. The waiting step is called the join.

Practical

Join every child in the same scope you forked it, return values rather than writing into a shared array, decide the exception policy, and over-decompose so imbalance averages out.

Advanced

The join is a memory-ordering event as much as a scheduling one. Watch for false sharing on adjacent result slots, and never block in a join on a worker whose pool must run the child.

Internals

In a work-stealing runtime, fork pushes onto the local deque and join often *helps* — the joining worker executes the pending child itself rather than parking, which is what keeps fork/join from deadlocking on pool exhaustion.

Apply it