Concurrency Roadmap/Parallel Algorithm Patterns

Fork-Join & Divide-and-Conquer Parallelism

Recursively split a problem into independent subproblems, solve them on separate cores, and combine the results — with Amdahl's Law setting the hard ceiling on how much that can ever help.

!3/5Theory: 35m1 problems
Language-specific mechanics: Concurrency Language Manual

A different problem than everything before it

Every earlier topic on this roadmap — locks, semaphores, condition variables, latches, thread pools — solves some version of the same problem: multiple independent activities need to share a machine safely and efficiently. Fork-join is not that. Fork-join takes a single computation — sort this array, sum these numbers, render this frame — and asks: given N cores instead of 1, how much faster can this one thing finish? That's a genuinely different question, with a different toolkit and a hard mathematical ceiling (Amdahl's Law, below) that no amount of clever engineering can push past.

The pattern itself is exactly what its name says:

  1. Fork — if the problem is small enough, solve it directly (the base case). Otherwise, split it into two or more independent subproblems and hand them off to run in parallel (typically by recursing).
  2. Join — wait for the subproblems' results and combine them into the answer for the current level.

This is just divide-and-conquer recursion (the same shape as merge sort or quicksort) with one twist: the recursive calls that used to run one after another now run at the same time, on different cores. Java's ForkJoinPool/RecursiveTask, Cilk's spawn/sync, and OpenMP's task construct are all implementations of this exact idea.

Mechanics and template

The canonical skeleton, independent of language:

function solve(problem): if problem.size <= THRESHOLD: return solveSequentially(problem) # base case left, right = split(problem) leftResult = fork(solve, left) # runs in parallel with... rightResult = solve(right) # ...this, executed on the current thread join(leftResult) # wait for the forked half return combine(leftResult, rightResult)

Notice the asymmetry: only one of the two halves is actually handed off (forked); the other is computed directly on the calling thread before joining. This is the standard idiom in every real fork-join framework — it avoids a thread going idle immediately after forking when there's already more work (the other half) sitting right there to do.

For this to be correct, the two subproblems must be genuinely independent — no shared mutable state between them during the fork. For it to be fast, combine has to be cheap relative to the work being combined (merging two sorted halves is O(n); if your combine step were itself O(n²), parallelizing the split-and-solve part wouldn't help much).

Amdahl's Law: the ceiling on speedup

This is one of the most interview-tested quantitative ideas in this entire roadmap, and it's worth being able to derive on a whiteboard. Say a fraction (p) of a program's execution time is parallelizable, and the remaining ((1-p)) is inherently sequential (I/O setup, the final combine step, whatever cannot be split). With (s) processors working on the parallel portion, Amdahl's Law gives the maximum possible speedup:

[ \text{Speedup}(s) = \frac{1}{(1 - p) + \dfrac{p}{s}} ]

Worked example: suppose 90% of a computation can be parallelized ((p = 0.9)) and you throw 10 cores at it ((s = 10)):

[ \text{Speedup}(10) = \frac{1}{0.1 + \dfrac{0.9}{10}} = \frac{1}{0.1 + 0.09} = \frac{1}{0.19} \approx 5.26\times ]

Ten cores, but only ~5.3x faster — not 10x. Now push (s \to \infty): the parallel term (p/s) vanishes entirely, and speedup approaches a hard limit of (\frac{1}{1-p} = \frac{1}{0.1} = 10\times). No number of cores can ever get you more than 10x, because that last 10% is stuck running on one core no matter what. This is the number interviewers want: identify (p), and you can immediately state the theoretical ceiling, before writing a single line of parallel code. It's also the standard argument for why profiling to find and shrink the sequential portion is usually a better investment than adding more cores.

(A related, less commonly asked idea worth knowing exists: Gustafson's Law reframes the same trade-off for a fixed parallel runtime with growing problem size rather than a fixed problem size — useful context if an interviewer pushes on "but what if the sequential part doesn't stay fixed as the problem grows?")

The sequential-fallback threshold

Forking isn't free — scheduling a task (even a lightweight one, not a full OS thread) costs real time: allocating the task object, pushing it onto a queue, potentially waking another worker. If you recursively fork all the way down to single-element base cases, that overhead can easily dwarf the actual work being done, making your "parallel" algorithm slower than the sequential version it's replacing.

The fix is the same one you've likely seen in a completely different context: introsort's fallback to insertion sort for small subarrays. Introsort doesn't recurse quicksort all the way to arrays of size 1 — below some small threshold (often ~16 elements), it switches to insertion sort, because insertion sort's low constant-factor overhead wins for tiny inputs even though it's asymptotically worse. Fork-join uses the identical idea in the parallelism dimension instead of the algorithm-choice dimension: below a size/work threshold, stop forking and just run sequentially in the current task.

Picking the threshold is an empirical tuning problem, not something you derive from first principles — too low, and scheduling overhead dominates; too high, and you leave cores idle because there isn't enough parallel work generated. "Measure a sequential baseline, then tune the threshold against it" is the honest answer if asked; a good starting point is a threshold large enough to guarantee at least one worker's worth of real work per leaf task, then adjust from measurements.

Pitfalls and interview gotchas

  • Assuming subproblems are independent when they're not. If left and right touch shared mutable state (a running counter, a shared collection being mutated in place), you've silently reintroduced the race conditions from earlier in this roadmap — fork-join doesn't grant you safety, it just gives you a structured way to get unsafe code to run in parallel too.
  • Forking too eagerly. Recursing to the base case of size 1 (no threshold at all) turns scheduling overhead into the dominant cost. This is the single most common performance bug in real fork-join code.
  • Computing both halves via fork() instead of computing one directly. If you fork both subtasks and then join both, you've forced the current thread to sit idle waiting instead of doing useful work — always compute one half inline and only fork the other.
  • Ignoring Amdahl's Law when promising a speedup number. "We'll just throw more cores at it" has a mathematically provable ceiling the moment there's any unavoidable sequential portion — including the final combine/join step itself, which is why an expensive combine step can quietly cap your whole algorithm's scalability.
  • Confusing "many small forks" with "good parallelism." Parallelism is bounded by the number of independent forked tasks that can run simultaneously relative to available cores — flooding the scheduler with thousands of tiny tasks below the useful threshold doesn't increase real throughput, it just increases overhead.

How this differs from what came before

Coordinating independent work (earlier topics)Fork-join (this topic)
GoalCorrectness + fairness while multiple unrelated tasks share resourcesMinimum wall-clock time for one computation
Unit of workIndependent threads/requests that may run indefinitelySubproblems of a single recursive decomposition, expected to terminate and combine
Success metricNo races, no deadlock, acceptable latency/throughput under contentionSpeedup relative to the sequential baseline, bounded by Amdahl's Law
Failure modeRace condition, deadlock, priority inversionOver-forking overhead, poor load balance, an uncombinable/non-associative combine step

The next subtopic, Map-Reduce & Data Parallelism, is really a specialization of this same fork-join idea for the common case where the "subproblems" are just chunks of the same dataset undergoing the same operation. The one after that, Work Stealing, answers the practical scheduling question this section has been quietly assuming away: which idle core actually picks up a forked task, and how does that decision stay cheap even with thousands of forks in flight?

Reference implementations in:

Parallel Sum via Fork-Join (with a Sequential Threshold)

Same shape works for merge sort, reductions, or any divide-and-conquer computation: split until the subproblem is small enough that forking isn't worth the overhead, then fall back to sequential.

func parallelSum(arr []int64, lo, hi, threshold int) int64 { if hi-lo <= threshold { var sum int64 for i := lo; i < hi; i++ { sum += arr[i] } return sum // base case: sequential } mid := lo + (hi-lo)/2 leftCh := make(chan int64, 1) go func() { leftCh <- parallelSum(arr, lo, mid, threshold) }() // fork right := parallelSum(arr, mid, hi, threshold) // compute here return <-leftCh + right // join }

No ForkJoinPool and no RecursiveTask — you just spawn a goroutine. The runtime scheduler already work-steals across Ps, so userland fork-join is 'go + receive'. The sequential threshold still matters: spawning a goroutine per handful of adds loses to a tight loop. errgroup is the same shape with error plumbing.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked

Optional Practice (Extra Reps)

For once you've cleared the main set above and want more reps on this pattern. These don't count toward the roadmap's progress stats — solve them purely for your own benefit.