Parallel programs rarely create perfectly equal pieces of work. A recursive search may prune one branch immediately and spend seconds in another. A divide-and-conquer task may split unevenly because the data is skewed. A fixed global queue can distribute those tasks, but every worker then contends for one coordination point and loses useful information about which tasks and data are locally warm.

A work-stealing scheduler takes a different approach: each worker normally runs tasks from its own deque, and an idle worker obtains work from another worker only when necessary. The design is decentralized in the common case and balancing in the exceptional case. Its central promise is not that all tasks become equal. It is that idle capacity can discover available parallel work without putting every task operation through one shared bottleneck.

That promise depends on details. Which end does an owner use? Which task does a thief take? How large should tasks be? What prevents a long task or unlucky worker from starving? A useful implementation treats deque policy, task shape, locality, fairness, and instrumentation as one scheduler contract rather than assuming that the word “stealing” guarantees speedup.

Replace One Queue with Per-Worker Deques

Assume a runtime owns PP workers. Worker ii has a double-ended queue QiQ_i of runnable tasks. When a running task creates another task, it pushes the child onto its current worker’s deque. The worker obtains its next local task from one end, conventionally the bottom. When it has no local task, it chooses a victim and steals from the opposite end, conventionally the top.

text
owner worker i:                 idle worker j:
    pushBottom(Q_i, child)          victim = chooseVictim(j)
    task = popBottom(Q_i)           task = stealTop(Q_victim)
    run(task)                       if task exists: run(task)

Using opposite ends is important. The owner usually touches the bottom alone, so fast local push and pop need little coordination. Thieves contend at the top, which is accessed less often when workers remain busy. A concurrent deque still needs a carefully specified algorithm for the last-item race, resizing, and memory reclamation; “mostly owner-only” does not make an ordinary array thread-safe.

This organization makes scheduling demand-driven. A busy worker does not push tasks to selected peers or maintain a global load map. Idle workers search for supply. That avoids stale centralized estimates, although victim selection and failed probes still create overhead.

The runtime must define what counts as runnable. A task waiting for I/O, a task blocked on another task, and a task currently executing are not interchangeable deque entries. Work stealing is most natural for finite, nonblocking, CPU-oriented tasks that expose additional parallel work. Blocking a worker on an external event can strand its local deque unless the runtime compensates or allows another worker to steal those tasks.

Per-worker deques also change shutdown semantics. The runtime is not idle merely because one queue is empty. It needs a termination condition covering every deque, executing worker, in-flight spawn, and possibly suspended task. Premature termination is a distributed-state bug inside the executor.

Understand Why Owners Use LIFO and Thieves Use FIFO

An owner that pops its newest child first follows depth-first execution. Recently spawned tasks often refer to nearby data and stack context, so running them soon can preserve cache locality. Keeping older siblings in the deque also bounds the amount of exposed work in many recursive computations.

A thief takes the oldest task from the opposite end. Older tasks are often closer to the root of the task tree and therefore represent larger subtrees. Stealing one coarse subtree gives the thief enough work to remain busy and lets it generate its own local descendants. If thieves instead took the newest tiny leaf, they could spend more time coordinating than computing.

Consider a binary task tree. The owner descends the rightmost path by repeatedly taking new children from the bottom. Left siblings accumulate toward the top. A thief takes an older left sibling and receives an entire unexplored branch:

flowchart TD A[Root] --> B[Older left subtree] A --> C[Owner follows newest right child] B --> D[Thief expands locally] B --> E[More local work] C --> F[Owner continues depth first] C --> G[New child at deque bottom]

This policy is a heuristic grounded in common fork/join structure, not a universal law. A priority task, a deadline-sensitive task, or a breadth-first algorithm may need another policy. Likewise, “FIFO stealing” describes relative age in one victim deque; it does not create global FIFO order across the runtime.

The classic work/span model helps explain the goal. Let T1T_1 be total work, the time on one worker, and let TT_\infty be span, the longest dependency path even with unlimited workers. Any execution on PP workers is bounded below by

TPmax(T1/P,T).T_P \geq \max(T_1/P, T_\infty).

A good stealing runtime tries to keep workers occupied so observed time approaches the work term while respecting the span limit. It cannot parallelize a dependency chain, and it cannot erase scheduling overhead. The model guides reasoning; it does not predict an exact runtime for caches, contention, or heterogeneous tasks.

Work Through a Parallel Range Reduction

Suppose a runtime must sum a large numeric array. A task owns a half-open range [start, end). Above a threshold, it divides the range, spawns one half, computes the other half locally, and joins the result.

ts
type Task<T> = () => T | Promise<T>;

interface ForkJoinScope {
  fork<T>(task: Task<T>): Promise<T>;
}

async function parallelSum(
  values: ReadonlyArray<number>,
  start: number,
  end: number,
  threshold: number,
  scope: ForkJoinScope,
): Promise<number> {
  if (end - start <= threshold) {
    let total = 0;
    for (let index = start; index < end; index += 1) {
      total += values[index]!;
    }
    return total;
  }

  const middle = start + Math.floor((end - start) / 2);
  const left = scope.fork(() => parallelSum(values, start, middle, threshold, scope));
  const right = await parallelSum(values, middle, end, threshold, scope);
  return (await left) + right;
}

Imagine four workers and a root range [0, 16), with a leaf threshold of two. Worker 0 forks [0, 8) onto its deque and immediately descends through [8, 16). Worker 1, initially idle, steals the older [0, 8) task. Both workers recursively create local work. Workers 2 and 3 can steal older subranges from either worker as they appear.

Computing one branch inline matters. If every split enqueued both children and then blocked waiting, the runtime would create more queue traffic and could deadlock in an executor that treats joins as ordinary blocking. Fork/join runtimes normally let a waiting worker execute other available tasks, or represent the continuation itself as schedulable work.

The reduction is deterministic only if the operation is mathematically associative under its actual representation. Floating-point addition is not perfectly associative, so different split trees can produce slightly different rounding. Work stealing may change execution order while preserving the dependency graph. If bitwise reproducibility is required, use a fixed reduction tree or a numerically stable, explicitly reproducible scheme rather than relying on incidental steal order.

The threshold controls task granularity. At one extreme, a task per element overwhelms useful arithmetic with allocation, deque operations, joins, and stealing. At the other, one task for the complete array exposes no parallelism. A suitable threshold makes leaf work substantially larger than scheduling overhead while leaving enough independent leaves to cover imbalance.

Choose Granularity with Work and Span in Mind

Task count should exceed worker count enough to absorb irregularity, but more tasks are not free. Each spawn may allocate metadata, update counters, publish memory, and create a future or continuation. Each join adds dependency bookkeeping. Stolen tasks can move data access to another cache domain.

A rough engineering model is

Tobserved=Tuseful+Tspawn+Tdeque+Tsteal+Tjoin+Tlocality.T_{observed} = T_{useful} + T_{spawn} + T_{deque} + T_{steal} + T_{join} + T_{locality}.

The terms are workload-dependent; the equation is an accounting checklist, not a claim that they can always be measured independently. Optimize granularity by comparing useful work per task with scheduling cost and by observing whether workers run out of parallel work.

Recursive cutoffs can be based on input size, estimated cost, depth, or current scheduler pressure. Input size works when cost per item is regular. Search and parsing often need a cost estimate because equal-size regions can require unequal work. Scheduler-aware cutoffs can stop spawning when local queues are already deep, but feedback must be stable: reacting to every momentary queue change can make runs unpredictable.

Nested parallelism needs a shared runtime or explicit limits. If every task creates a private worker pool, thread count and memory grow multiplicatively. A composable fork/join API lets nested work contribute tasks to the same bounded worker set. The runtime should also avoid oversubscribing when external code already uses parallel native libraries.

Granularity affects cancellation. Large leaves check cancellation less often and hold a worker longer; tiny leaves amplify scheduler overhead. Put cooperative checks inside genuinely long leaf loops, not only between tasks, and define whether queued cancelled tasks are removed eagerly or discarded when popped.

Balance Stealing, Locality, and Fairness

Victim selection determines how quickly idle workers find work and how much contention they create. Randomized victim choice spreads probes without maintaining a global load table. Round-robin probing is simple but can synchronize idle workers into the same pattern. Hierarchical selection can prefer nearby workers before crossing a memory or machine boundary, preserving locality at the cost of slower global balance.

Stealing one task minimizes transfer but can be inefficient when victims hold deep queues. Batch stealing amortizes synchronization and seeds the thief’s deque, but it may take too much work, damage locality, or leave the victim idle soon afterward. Adaptive batching should be based on observed queue depth and demonstrated benefit, with a clear maximum.

Pure owner LIFO can delay old local tasks if a stream of new children keeps arriving. Thieves may eventually take those old tasks, but a saturated runtime with no idle thieves can still exhibit unfairness. Options include periodically running from the top, aging tasks, separate queues for latency-sensitive work, or limiting spawn depth. Each weakens the simple locality policy, so fairness requirements should be explicit.

Priority complicates work stealing. Per-worker priority deques make victim selection and global priority approximate. A high-priority task on one worker can wait while another worker runs lower-priority local work. If strict priority or deadlines are contractual, a general-purpose work-stealing deque may be the wrong abstraction. Approximate priority can be acceptable for throughput work if bounded inversion is measured.

Affinity matters when a task owns thread-local state, a non-transferable resource, or data tied to one accelerator. Such a task is not freely stealable. Keep it in an affinity queue or split movable computation from pinned operations. Marking too much work pinned defeats balancing; ignoring affinity can make execution invalid.

Handle Blocking, Dependencies, and Scheduler Failure Modes

A work-stealing executor makes progress only if runnable work remains accessible and workers do not all block outside the scheduler. A task that waits synchronously for a child can consume one worker while the child sits queued. If every worker does the same, available tasks exist but no worker runs them. Fork/join joins should suspend the continuation or help execute other tasks rather than occupy a worker passively.

External blocking is similar. A filesystem call or mutex wait can stop a worker the runtime believes is active. Possible designs include prohibiting blocking in compute tasks, moving blocking calls to a separate bounded executor, or adding compensating workers under strict limits. Unlimited compensation converts one blocked dependency into uncontrolled thread growth.

Deque correctness has sharp races. Owner pop and thief steal can target the final element simultaneously. Exactly one must win, and the task must be neither lost nor executed twice. Resizing must publish the new buffer safely while thieves may still read the old one. Indices can wrap after long execution. These are reasons to use a proven concurrent deque algorithm and memory-model-specific implementation rather than adapting a sequential container.

Other failure modes include:

  • Repeatedly probing empty victims and burning CPU while the runtime is idle.
  • A hot victim attracting every thief and turning its deque top into a bottleneck.
  • Tiny stolen tasks that finish before the steal cost is recovered.
  • Unbounded task creation exhausting memory before workers catch up.
  • Exceptions leaving join counters unresolved and parents waiting forever.
  • Cancellation dropping a task without completing its future.
  • Shutdown declaring quiescence while a running task is about to spawn work.

Backpressure belongs at task admission and spawn boundaries. A runtime can cap outstanding tasks, inline work when a local queue is deep, or require producers to join before spawning further. The chosen rule must not block all workers while waiting for queue capacity.

Instrument the Scheduler Instead of Guessing

Application throughput alone cannot explain scheduler behavior. Collect per-worker and aggregate signals that preserve enough shape to distinguish insufficient parallelism from excessive scheduling.

Useful metrics include tasks executed locally, tasks stolen, steal attempts, successful-steal ratio, tasks per steal batch, deque depth distribution, task execution duration, spawn-to-start delay, join wait, idle time, and time spent probing. Track these by task class with bounded labels, not by individual task identifiers.

Interpret combinations:

Observation Plausible explanation Next check
High idle time, shallow queues Insufficient exposed parallelism Span and cutoff depth
Many failed steals, little useful work Idle probing policy is too aggressive Victim choice and park behavior
High queue delay, few idle workers Too much admitted work or long tasks Granularity and backpressure
Frequent steals, poor speedup Tasks too small or locality loss Leaf duration and cache behavior
One deep deque, idle peers Victim selection or non-stealable affinity Per-worker attempts and task flags
Long join waits with queued tasks Blocking join implementation Worker states and continuation handling

Tracing every task can alter the workload dramatically. Sample long tasks and steals, aggregate counters locally, and merge them periodically. Include worker identity and parent task class in diagnostic traces while avoiding payload data. A scheduler profile should make work, span symptoms, queueing, and steal overhead separable.

Park idle workers rather than spinning forever, but choose a wake-up protocol that cannot miss newly published work. A short adaptive spin may help bursty workloads; longer spinning consumes CPU and competes with useful workers. Record park and wake latency before tuning.

Verify Safety, Progress, and Performance

Deque tests need deterministic race control. Force owner pop and thief steal to meet at a one-element deque, enumerate both linearization orders, and assert exactly one receives the task. Exercise resize while thieves read, index-boundary conditions, cancellation, and shutdown concurrent with spawning. Model checking or a controlled scheduler is more informative than adding sleeps.

At the executor level, assign every test task a unique ID and record starts and completions. Assert each accepted task reaches exactly one terminal outcome: success, failure, or cancellation. Assert joins settle when children fail, task counts return to zero, and no deque retains work after quiescence. Inject exceptions at spawn, execution, and completion publication boundaries.

Progress tests should create deliberately skewed trees. Put most work under one old branch and verify idle workers steal it. Mix one long leaf with many short leaves and check that short work is not indefinitely delayed. Include nested fork/join and bounded blocking scenarios. Use barriers to make the schedule reproducible rather than relying on machine timing.

Performance tests need curves across worker counts and cutoffs. Record total work, approximate span or dependency depth, tasks created, steal cost, idle fraction, and useful throughput. A slowdown at high worker counts may reflect memory bandwidth or locality, not merely a bad victim algorithm. Label all measurements with hardware, runtime, affinity, and workload shape; scheduler conclusions do not transfer automatically between them.

Work stealing is a runtime strategy for irregular parallel tasks, not a guarantee that every workload scales. It works when tasks expose movable parallelism, leaves are large enough to repay scheduling, joins do not pin workers, and idle workers can find coarse work cheaply. Per-worker deques make local execution inexpensive; stealing repairs imbalance; instrumentation reveals whether that exchange is paying off. The scheduler is successful when its policies preserve correctness and locality while turning available parallel work into useful execution, not when its steal counter is merely high.