skip to content

In a recursive divide-and-conquer task that splits work in two, why is the pattern 'fork the left half, compute the right half on the current thread, then join the left' preferred over forking both halves and joining both? And what goes wrong if you join a subtask immediately after forking it?

level: middleimportance: should knowfreq 38%

answer

  1. fork one, run one - keep the calling worker busy
  2. all forks before the first join
  3. fork-then-immediately-join = sequential plus overhead
  4. halves the task count per internal node
  5. combine positionally, not by completion order

basics

~20 s

Forking both halves wastes the current thread, which then only waits; doing one half in place keeps it busy and halves the number of tasks. Forking then immediately joining is worse still - it serializes the two halves while paying full task overhead, giving sequential speed at parallel cost.

solid answer

~50 s

The calling thread is a worker too. If it forks both halves it has nothing left to do but wait, so you pay for two task submissions and get at most the same parallelism you would get by forking one and computing the other locally. The **fork-one, run-one** pattern halves task creation and keeps the current worker's cache warm on the data it just split. Forking and immediately joining is the classic bug: ``` left = fork(solve(a)); join(left) right = fork(solve(b)); join(right) ``` That runs the halves one after the other - the second is not even submitted until the first has finished - so you get sequential execution plus the full cost of task machinery. The structure looks parallel in review and profiles as slower than a loop. Order also matters for correctness of the combine step: joins return results, and if the operation is not commutative you must combine them in positional order, not completion order.

code

text · 8 lines
text
// 1. fork both: parent idles, 2 tasks per node
l = fork(solve(a)); r = fork(solve(b)); return combine(join(l), join(r))

// 2. fork one, run one: idiomatic, 1 task per node
l = fork(solve(a)); r = solve(b);       return combine(join(l), r)

// 3. fork then join immediately: SEQUENTIAL + overhead
l = join(fork(solve(a))); r = join(fork(solve(b))); return combine(l, r)

go deeper

for a junior

Know the rule of thumb: fork one half, do the other half yourself, then join - and never join a task right after forking it.

for a middle

Explain the overlap window between fork and join, why fork-then-join is sequential, and why forking one instead of both halves the task count and keeps the caller busy.

for a senior

Add the runtime perspective - a joining worker may execute the pending child inline - and the correctness point that non-commutative combines must use positional, not completion, order.

for a principal

Frame it as designing the dependency graph: forks widen the graph, joins are synchronization edges, and the achievable speedup is bounded by the longest chain you leave in place.

## The three shapes Consider a task that has split its input into halves `a` and `b`. **Shape 1 - fork both, join both** ``` left = fork(solve(a)) right = fork(solve(b)) return combine(join(left), join(right)) ``` Both halves are parallel-eligible, but the calling worker now has nothing to do except block on the first join. You have paid two task submissions to occupy at most two other workers. **Shape 2 - fork one, run one (the idiomatic form)** ``` left = fork(solve(a)) right = solve(b) // executed by this worker, right now return combine(join(left), right) ``` One submission, and the calling worker stays productive. Across the whole recursion this halves the number of task objects created, queued and possibly stolen. It also has better locality: the worker that just split the range immediately processes one of the halves, whose data it has already touched. **Shape 3 - fork then immediately join (the bug)** ``` left = fork(solve(a)); leftResult = join(left) right = fork(solve(b)); rightResult = join(right) ``` Here `solve(b)` is not even submitted until `solve(a)` has finished. The two halves cannot overlap. You have written a sequential algorithm with a task-scheduling tax on every level of the recursion - typically several times slower than simply calling `solve(a)` and `solve(b)` directly. ## Why shape 3 is so easy to write It reads naturally: create work, get its result, create the next work, get its result. The parallelism only exists in the *gap* between fork and join, and shape 3 has no gap. The rule to remember is: **everything you want to overlap must be forked before the first join.** A join is a hard boundary; nothing submitted after it can overlap with work before it. ## Why shape 2 beats shape 1 in practice Both expose the same *logical* parallelism - the two halves are independent either way. The difference is cost and scheduling: - **Task count.** Shape 2 creates one task per internal node instead of two. Over a recursion producing a million leaves that is hundreds of thousands fewer allocations, queue pushes and potential steals. - **Worker utilization.** In shape 1 the parent blocks. Good runtimes mitigate this - a joining worker will typically execute the pending child itself rather than idle - which in effect converts shape 1 into shape 2 at runtime. But relying on that mitigation is worse than writing the intent directly, and it does not help runtimes that simply park the thread. - **Locality.** The worker that split the range still has the range metadata and part of the data in cache; running one half locally exploits that. ## Join order and result combination Joins hand results back to the parent, which combines them. Two rules: 1. **Combine positionally, not by completion order.** For a sum it does not matter, because addition is commutative. For a concatenation, a merge, or building an ordered list, `combine(leftResult, rightResult)` and `combine(rightResult, leftResult)` are different answers. A subtle version of this bug is combining results as they complete - the output then depends on scheduling and varies between runs. 2. **Join in a sensible order but do not confuse order with dependency.** Joining `left` before `right` does not force `left` to finish first; both are already running. It only determines when the parent resumes. ## A related anti-pattern: joining too early in a loop When a task splits into many pieces rather than two, the same mistake appears as: ``` for part in parts: join(fork(solve(part))) // sequential ``` versus ``` handles = [fork(solve(part)) for part in parts] results = [join(h) for h in handles] // parallel ``` Fork the entire batch first, then join the batch. The shape is identical to the two-way case: no join before all the forks you want to overlap. ## What an interviewer is checking That you understand fork and join as the *boundaries of an overlap window*, not as a call-and-return pair. Candidates who see `fork` as 'run this in the background' and `join` as 'get the answer' usually write shape 3 without noticing, then conclude that parallelism 'did not help'.

  • Some runtimes make 'fork both, join both' perform almost as well as 'fork one, run one'. How?
    When a worker joins a task that has not started yet, it can simply pop that task from its own queue and execute it inline, which recreates the fork-one-run-one behaviour dynamically. If the task was already stolen, the joining worker can run other pending tasks instead of parking. This makes the difference mostly one of task-object overhead rather than of exposed parallelism, but it is runtime-dependent and not something to rely on.
  • When does combining results in completion order rather than positional order actually break a program?
    Whenever the combine operation is not commutative - concatenating text, merging sorted runs, appending to an ordered list, or folding with a non-commutative operator like subtraction or matrix multiplication. The failure is nasty because it is scheduling-dependent: the output is right on a lightly loaded machine and wrong under contention, so it looks intermittent rather than logically incorrect.

Handing a colleague one half of a stack of forms and doing the other half yourself, versus handing out both halves and then standing there watching, versus handing over half, waiting for it to come back, and only then starting the second half.

saying these in an interview costs you the question

  • Treating fork as 'start in background' and join as 'fetch result', producing a fork-immediately-join sequential loop.
  • Believing that forking both halves is required for the halves to run in parallel.
  • Thinking the join order determines which subtask runs first.
  • Assembling results in whatever order tasks complete, for a non-commutative combine.
  • Claiming the current thread cannot do real work because it is 'the coordinator'.

context