# thread-pool Design
## Overview
`thread-pool` is a fixed-capacity, shared-infrastructure thread-pool
with three concrete scheduler variants behind one public API:
- `global_queue`
- `work_stealing`
- `lane_extension`
The public API stays stable while scheduler selection, queue topology,
and instrumentation remain internal. Algorithm-level diagrams
(submit pipeline, worker loop, descriptor state, DescCache, wake
coalescing, lane routing, shutdown) are consolidated in
[ALGORITHMS.md](ALGORITHMS.md); this document covers rationale,
contracts, and memory / shutdown model.
## Public Surface
```mermaid
flowchart LR
INIT[ThreadPool.init allocator, config]
DEINIT[ThreadPool.deinit]
SUB[submit / trySubmit / submitBatch]
REG[registerSubmitter / releaseSubmitter]
WAIT[wait / waitGroup]
SHUT[shutdown mode, timeout_ns]
STAT[statsSnapshot / purgeWorkerCaches]
INIT --> SUB
SUB --> REG
SUB --> WAIT
INIT --> SHUT
INIT --> STAT
SHUT --> DEINIT
```
`ThreadPool.purgeWorkerCaches()` is quiescent-only. It returns
`error.PoolBusy` unless `active_tasks == 0 && queued_tasks == 0`.
## Scheduler Variants
### `global_queue`
- One bounded FIFO inject queue (`BoundedRing` MPMC).
- Fixed worker set.
- Workers pop directly from the shared queue.
- Included as the simplest correctness-first baseline and fallback.
- Submit-path bypasses LIFO slot and local queues entirely (comptime
elided when `-Dscheduler-variant=global_queue`).
### `work_stealing`
- One bounded central inject queue for external submitters.
- One owner-local LIFO slot (`atomic u64` with `LIFO_SLOT_EMPTY`
sentinel) plus one bounded local queue per worker.
- Local submissions from worker threads prefer the LIFO slot, then the
local queue.
- Idle workers refill from the inject queue, then attempt steals from
peer local queues using the `workers_nonempty` u64 bitmap to skip
empty victims.
- Optional per-submitter SPMC queues via `registerSubmitter()` —
cross-thread steal from submitter-owned push queues without paying
MPMC inject contention.
- Worker wake: `scanAndWake` performs a linear scan from a rotating
hint (`submitted % scan_len`), wakes the first parked worker it
finds, and exits. The scan is issued at most once per
`wake_coalesce_batch` (default 32) submissions so the futex cost
is amortised across bursts.
### `lane_extension`
- `work_stealing` plus a dedicated blocking inject queue
(`blocking_inject`) and a reserved worker range.
- Workers `[0, blocking_workers)` drain `blocking_inject`; workers
`[blocking_workers, max_threads)` drain the default lane.
- Slice L3 cross-lane rebalance: a blocking worker always helps the
default lane when its own lane is empty; a default worker helps the
blocking lane only when `Config.cpu_helps_blocking` is set.
- Slice L4 watchdog: spawns up to `Config.max_overflow_workers` when
a default-lane task runs longer than
`blocking_promotion_timeout_ns`. Overflow workers retire
themselves after sustained idleness.
## Queue Design
```mermaid
flowchart TB
subgraph MPMC
IQ[BoundedRing
Vyukov per-slot sequence
MPMC lock-free]
end
subgraph SPMC
LQ[RingQueue - default owner local
single producer, multi-consumer stealers
stealHalf batched take]
end
subgraph SPMCopt[SPMC opt-in - for testing]
CD[WorkDeque Chase-Lev
queue_kind = .chase_lev]
end
subgraph SPSC
DC[DescCache LIFO 32
zero-atomic push/pop
drainAll on release]
end
IQ ---|inject_queue + blocking_inject| Pool
LQ ---|local_queues i + submitter_queues i| Pool
CD ---|opt-in local_queues i| Pool
DC ---|workers i .desc_cache + desc_caches i| Pool
```
### Inject Queue
- Bounded ring buffer — `BoundedRing`.
- Vyukov-style per-slot `sequence` word encodes producer/consumer turn.
- Backpressure explicit: `error.QueueFull`, block with timeout, or
block indefinitely.
- `popMany` fast path: one strong head CAS reserves up to
`inject_batch_size` (default 32) entries, slot reads bounded by
per-slot sequence spin.
### Local Worker Queue
- Bounded owner-local queue with pluggable implementation via
`Config.queue_kind`.
- `queue_kind = .ring` — default SPMC modelled on Tokio/Go runq.
- `queue_kind = .chase_lev` — classic Chase-Lev deque.
- Owner pushes and pops from bottom/tail; stealers take from
head/top.
### DescCache
- 32-entry SPSC LIFO per submitter slot (`desc_caches[i]`) and per
worker (`workers[i].desc_cache`).
- Refill: `acquireDescriptorBatch` takes up to 32 indices from the
global freelist with one acquire pointer exchange; cache holds
tail, returns head to caller.
- Drain-on-release: `releaseDescriptor` pushes into the owning slot's
cache; on overflow, bounded spin-retry onto the inject queue's
freelist sibling.
- Drain-on-deinit: every cache is drained into the freelist before
`deinit` frees the array; preserves `task_desc_live == 0` invariant.
## Memory Model
- Task descriptors are preallocated from a bounded descriptor array.
- Descriptor reuse happens only after task completion and, for
waitable tasks, after `wait`.
- Worker scratch allocates one bounded buffer per worker lazily on
first use.
- On the normal owning-worker path, scratch begin/alloc/finish are
lock-free.
- Scratch buffers can be purged explicitly or after configured idle
periods.
- Explicit purge is only legal while the pool is quiescent.
- All pool-owned allocations are counted in `total_alloc_bytes`,
`total_free_bytes`, `live_bytes`, `peak_live_bytes`.
## Task Lifecycle
```mermaid
sequenceDiagram
participant App
participant Pool
participant Desc as TaskDescriptor
participant W as Worker
App->>Pool: submit(task_fn, ctx, opts)
Pool->>Desc: acquire (DescCache or freelist)
Desc->>Desc: state := reserved
Pool->>Desc: write task_fn, ctx, options, submit_ns, generation
Desc->>Desc: state.store(.queued, .release)
Pool->>Pool: push entry {gen, idx} to lifo_slot / local / submitter / inject
Pool->>W: notifySubmits + scanAndWake
W->>Desc: pop entry, filter by gen
W->>Desc: CAS state .queued -> .running
W->>App: invoke task_fn(ctx)
App-->>W: return
W->>Desc: finalizeTask: state.store(.completed or .cancelled, .release)
alt non-waitable
W->>Desc: releaseDescriptor -> DescCache.push or freelist
else waitable
Note over W,Desc: wait() returns then triggers release
end
```
1. Submission validates pool state and acquires a descriptor from the
DescCache / bounded freelist.
2. The descriptor is enqueued either to the caller worker's LIFO slot,
the caller worker's local deque, a submitter queue, or the central
inject queue.
3. A worker dequeues, steals, or refills and transitions the
descriptor to `running` via CAS.
4. The task executes exactly once.
5. The descriptor transitions to `completed` or `cancelled`.
6. Detached descriptors return to the DescCache / freelist immediately;
waitable descriptors return after `wait`.
### Task panic behaviour (REV-001)
`task_fn` **must not panic**. Zig panics are non-catchable by design —
an integer overflow, OOB access, `@panic`, or `unreachable` in user
code aborts the entire process. This is the correct outcome for a
production pool: a panicked task leaves `counters.active_tasks`
incremented, `descriptor.claimed_by` set, and the matching
`WaitGroup.pending` undecremented. `Core.runTask` cannot recover
because the control flow never returns.
Callers that install a process-wide custom panic handler (e.g. a
debugger hook or a crash reporter) must not resume execution into the
pool after catching a panic. If they do, the pool cannot self-heal;
the only observable signal is
`Stats.accounting_skew_detected = true` across multiple consecutive
snapshots. See `docs/security/REV-001.md` for the ADR and
`src/pool.zig::runTask` for the contract comment.
### Descriptor reuse and idempotent `wait()` (SEC-018)
`releaseDescriptor` is CAS-guarded on `descriptor.state` so concurrent
`wait()` callers on the same handle cannot double-push the slot onto
the freelist. Exactly one caller performs the release; others observe
the state already `.free` (with a matching generation) and return
success. A late `wait()` on a reused slot returns
`error.InvalidHandle` via the generation-tag mismatch.
### Gen-tagged queue entries
Queue entries carry `{u32 gen, u32 idx}` packed into a `u64`. On every
release, the descriptor's `generation` increments. `runTask` rejects
any entry whose embedded gen does not match the descriptor's current
gen — closes the recycled-slot double-dispatch race.
Before gen tags, the double-dequeue race fuzz gate hit 9/20; post-gen
tags it's 20/20.
## Shutdown Semantics
```mermaid
flowchart LR
Start[shutdown mode, timeout] --> Flag[state := shutting_down]
Flag --> M{mode?}
M -- drain --> Wait[Wait queued_tasks + active_tasks == 0]
M -- cancel_pending --> Cancel[cancelQueuedTasks]
Cancel --> Wait
Wait --> WD[watchdog_stop := true]
WD --> Broadcast[Broadcast park_cond all workers]
Broadcast --> Join[Join worker threads]
Join --> DrainCaches[Drain DescCaches to freelist]
DrainCaches --> DrainSubs[Drain submitter_queues + desc_caches]
DrainSubs --> Verify[Assert task_desc_live == 0]
Verify --> Free[Free owned allocations]
```
### `drain`
- Reject new submissions.
- Let queued and running work finish.
- Join workers.
- Purge worker scratch.
- Release owned memory.
### `cancel_pending`
- Reject new submissions.
- Cancel tasks still in queues (`state.cmpxchgStrong(.queued, .cancelled)`).
- Running tasks continue to completion.
- Join workers and release resources.
## Fairness and Tradeoffs
- `global_queue` is simpler and sometimes slightly smaller in RSS
because it lacks per-worker queue state.
- `work_stealing` reduces multi-producer contention and improves local
handoff, but it is not currently lock-free (it is lock-free on the
fast paths but not on the wake scan).
- Recursive submission is safe because worker-originated tasks
preferentially target the caller's local LIFO slot.
- Blocking tasks are supported through lane metadata and
`lane_extension`'s dedicated workers + overflow spawn.
## Performance-Relevant Design Choices
| Choice | Rationale | Where |
|---|---|---|
| 64-worker cap | `workers_nonempty` is a single `u64` | `Core.workers` |
| 64-submitter cap | `submitter_free_mask` is a single `u64` | `registerSubmitter` |
| Power-of-two queue sizes | mask instead of modulo | `BoundedRing`, `RingQueue`, `WorkDeque` |
| LIFO slot sentinel `0xFFFF_FFFF_FFFF_FFFF` | Gen-tagged entries never collide | `LIFO_SLOT_EMPTY` |
| `wake_coalesce_batch = 32` default | Amortises scan + futex cost | `notifySubmits` |
| `inject_batch_size` (32 default) | `popMany` amortises head-CAS | `tryDrainLaneForWorker` |
| `steal_batch_size` (8 default) | `stealHalf` copies rounded-up half | `nextTask` |
| End-of-struct `Worker.desc_cache` | Iter-11 showed layout-shift costs | `src/pool.zig::Worker` |
## Observability
`statsSnapshot()` reports:
- task lifecycle counters
- queue depth current and peak (per lane)
- steal and steal-failure counts
- wakeups and parks (aggregate + per worker)
- per-worker busy / idle / scratch / local-queue metrics
- wait and execution latency summaries (when `timestamp_mode = .full`)
- wake-latency histogram per worker
- watchdog stall counter + overflow spawn/retire counters
- Linux / macOS / Windows process memory metrics
## Comptime Elision Hooks
Three `-D` flags allow specialisation that drops branches at compile
time:
- `-Dsingle-pool=true` — elide per-pool TLS identity check
(**UNSAFE** with >1 pool per process).
- `-Dtimestamp-mode=` — pin `Config.timestamp_mode` to
`disabled`/`full`; drops histogram writes.
- `-Dscheduler-variant=` — pin `Config.scheduler_variant`; drops
alternative scheduler's branches from the hot path.
See [ALGORITHMS.md § Comptime Elision Hooks](ALGORITHMS.md#comptime-elision-hooks)
for the full map and measured gains.
## Known Gaps
- Adaptive grow/shrink is intentionally not implemented in phase 1.
The L4b `max_overflow_workers` elastic spawn is watchdog-driven, not
hysteresis-based.
- Per-task allocator-count metrics are not yet separate from byte
accounting.
- Non-x86 platforms have not been benchmarked end-to-end; the queue
primitives use `std.atomic.Value` so correctness is portable but
tuning constants are empirical for x86_64.
## Further Reading
- [ALGORITHMS.md](ALGORITHMS.md) — mermaid diagrams for every pipeline
- [architecture.md](architecture.md) — component + ownership map
- [BENCHMARKS.md](BENCHMARKS.md) — measurement protocol + current numbers
- [PERFORMANCE_TRACKING.md](PERFORMANCE_TRACKING.md) — phase-by-phase deltas
- [SCHEDULER_DECISION.md](SCHEDULER_DECISION.md) — variant-picking rubric