Fork/Join Framework

Divide-and-conquer parallelism: split a task until it is small, run the halves on a work-stealing pool, and join the results; with Java's ForkJoinPool, a traced sum, and why a plain thread pool deadlocks on the same code.

Overview

Fork/join is a way to run a divide-and-conquer algorithm on every core. A task that is too big splits itself into subtasks (the fork), runs them in parallel, waits for their results (the join) and combines them. A task that is small enough just does the work in a loop.

Java ships it as ForkJoinPool with RecursiveTask and RecursiveAction. Its scheduler comes from Doug Lea's 2000 paper, which adapted the work-stealing scheduler of Cilk: each worker thread keeps its own queue of tasks, and idle workers steal from busy ones. The interview questions are about that scheduler, about choosing the size at which a task stops splitting, and about what must never happen inside a task.

Fork, compute, join

Every fork/join task has the same shape. The idiom that matters is fork one half, compute the other half in the current thread, then join the forked half. The current thread stays busy instead of forking both halves and sitting idle.

A trace summing [3, 1, 4, 1, 5, 9, 2, 6] with a threshold of 2 elements.

  • 1 — [0, 8) — too big: fork [0, 4), compute [4, 8)
  • 2 — [4, 8) — too big: fork [4, 6), compute [6, 8)
  • 3 — [6, 8) — small: 2 + 6 — 8
  • 4 — [4, 8) — join [4, 6): 5 + 9 = 14, add 8 — 22
  • 5 — [0, 4) — joined by [0, 8); too big: fork [0, 2), compute [2, 4)
  • 6 — [2, 4) — small: 4 + 1 — 5
  • 7 — [0, 4) — join [0, 2): 3 + 1 = 4, add 5 — 9
  • 8 — [0, 8) — 9 + 22 — 31

On one worker the steps run in this order. With more workers, an idle worker steals [0, 4) at step 1 and runs steps 5 to 7 while the first worker runs steps 2 to 4.

Work stealing

A shared task queue would make every worker fight over one lock. Instead each worker owns a double-ended queue (a deque). The rules from Lea's paper:

  • The owner, pushing forked subtasks — Its own deque, top — Subtasks stay with the thread that made them, so their data is likely still in its cache
  • The owner, taking work — Its own deque, top — LIFO: youngest first — Finishes the small, recent task it is about to join
  • An idle worker (a thief) — A randomly chosen victim's deque, bottom — FIFO: oldest first — The oldest task was split off first, so it is the biggest piece of work, and the thief rarely touches the end the owner is using
  • A worker that reaches a join — Runs other available tasks until the joined task is done, instead of blocking

The last row of the table is the important one. A join does not park the thread; the worker keeps executing tasks. Java's ForkJoinTask.join works this way, and tryUnfork lets a thread take back the task it forked last if nobody has stolen it.

Why a plain thread pool is not enough

Run the same recursive code on an ordinary fixed-size pool and it can deadlock. A pool thread submits a subtask and then blocks waiting for it, but the subtask sits in the queue behind it and every worker is blocked the same way. Python's concurrent.futures docs show this exact deadlock with one worker. The program below reproduces it (with a timeout, so it ends) and then applies the fork/join fix: at the join, if nobody has started the child, cancel it in the queue and run it in this thread.

Future.cancel() returns True only when the call has not started, which is the same test tryUnfork makes. A thread that fails to cancel waits only on a task another worker is already running, so the waits form a tree toward the leaves and can never form a cycle.

> [!IMPORTANT] > This shows the scheduling idea, not a speedup. In standard CPython only one thread runs Python code at a time (the global interpreter lock), so CPU-bound Python work goes to ProcessPoolExecutor or multiprocessing. Free-threaded builds since Python 3.13 can disable the lock, but they are not the default.

The threshold

Splitting costs something: each task is an object, a deque push and possibly a steal. Too few tasks and some cores sit idle; too many and the bookkeeping outweighs the work. The ForkJoinTask docs give a very rough rule: a task should do more than 100 and fewer than 10,000 basic steps. Halving 1,000,000 elements until a piece is at most the threshold gives these counts.

  • 1 — 1,000,000 — 999,999 — 1
  • 100 — 16,384 — 16,383 — about 61
  • 1,000 — 1,024 — 1,023 — about 977
  • 10,000 — 128 — 127 — about 7,813
  • 100,000 — 16 — 15 — 62,500

A threshold of 1 creates a million task objects to add a million numbers. At 100,000 there are only 16 leaves, so on a 16-core machine one slow leaf leaves the other cores idle with nothing to steal. The right value is found by measuring, and it moves with the machine and the cost of each element.

What you will be able to do

  • Write a RecursiveTask or RecursiveAction with the fork-one, compute-one, join idiom.
  • Explain work stealing: per-worker deques, LIFO for the owner, FIFO for thieves, and why joins help instead of block.
  • Explain why the same recursion deadlocks on a fixed thread pool and how fork/join avoids it.
  • Choose a threshold and say what goes wrong when it is too small or too large.
  • Say what must not run inside a fork/join task and where the common pool is used.

Interview questions

  • What is the Fork/Join framework and when do you use it?
  • How does work stealing work, and why do thieves take from the other end of the deque?
  • What is the difference between RecursiveTask and RecursiveAction?
  • Why fork one half and compute the other, instead of forking both and joining both?
  • Where else is ForkJoinPool used in Java, and what can go wrong with the common pool?

Sign up free to read the full topic

More Low-Level Design topics

Start free on Cariara · Coding problems · System design studio