# thread-pool
`thread-pool` is a bounded Zig 0.16.0 thread-pool library built for the
`devnw/zig` ecosystem. One public API, three scheduler variants behind
it, and a hardened performance / security track-record documented in
`docs/`.
- **Bounded by construction.** Fixed descriptor pool, fixed worker cap,
bounded per-worker queues, bounded inject queue.
- **Lock-free hot paths.** Chase-Lev / SPMC ring / Vyukov MPMC queues;
SPSC LIFO descriptor caches; single-owner LIFO slot.
- **Two scheduler defaults + a blocking-lane extension.** `global_queue`,
`work_stealing`, `lane_extension` — picked via one `Config` field.
- **Runtime parity harness.** Direct side-by-side benchmarks against
vendored Tokio and Go source trees.
- **Memory-safety hardened.** 1 Critical + 2 High AppSec findings fixed;
TSan + fuzz clean; see
[`docs/performance/appsec-fixes-2026-04-15/FINAL.md`](docs/performance/appsec-fixes-2026-04-15/FINAL.md).
## Status
| Field | Value |
|---|---|
| Zig target | `0.16.0` |
| Scheduler variants | `global_queue`, `work_stealing`, `lane_extension` |
| Default scheduler | `global_queue` |
| Tests passing | 84 / 85 (1 skipped, runtime-safety-gated) |
| TSan baseline | Zero warnings across unit + integration + stress |
| Fuzz baseline | Clean across 32–256 ops × ≥100 iterations |
| Current HEAD | see `git log -1` |
## Getting Started
```zig
const std = @import("std");
const thread_pool = @import("thread_pool");
fn task(ctx: *anyopaque, runtime: *thread_pool.TaskContext) void {
_ = ctx;
_ = runtime;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var pool = try thread_pool.ThreadPool.init(gpa.allocator(), .{
.min_threads = 2,
.max_threads = 4,
.queue_capacity = 1024,
.local_queue_capacity = 128,
.scheduler_variant = .work_stealing,
});
defer pool.deinit();
var wg = pool.waitGroup();
_ = try pool.submit(task, undefined, .{ .wait_group = &wg });
wg.wait();
}
```
See [docs/getting-started.md](docs/getting-started.md) for the full
walkthrough, [docs/api-reference.md](docs/api-reference.md) for every
public symbol, and [docs/examples.md](docs/examples.md) for batch
submission, blocking lanes, and cancellation.
## Architecture At A Glance
```mermaid
flowchart LR
subgraph Public[Public API src/root.zig]
TP[ThreadPool.init / submit / submitBatch / wait / shutdown / statsSnapshot]
end
subgraph Core[Core src/pool.zig]
EN[enqueuePush]
NT[notifySubmits]
WM[workerMain → nextTask → runTask]
WD[watchdog + overflow spawn]
end
subgraph Queues[src/queue]
IQ[BoundedRing
Vyukov MPMC inject]
LQ[LocalQueue
RingQueue or WorkDeque]
SQ[submitter_queues
RingQueue per submitter]
end
subgraph Caches[src/pool/desc_cache.zig]
DC[DescCache
SPSC LIFO 32-entry]
end
TP --> EN
EN --> LQ
EN --> SQ
EN --> IQ
EN --> DC
EN --> NT
NT --> WM
WM --> LQ
WM --> IQ
WM --> SQ
WD --> WM
```
Full-depth algorithms reference: [docs/ALGORITHMS.md](docs/ALGORITHMS.md)
includes mermaid diagrams for each pipeline (task submission, worker
loop, descriptor lifecycle, DescCache flow, Chase-Lev deque, Vyukov
MPMC, wake coalescing, lane routing, shutdown).
## Scheduler Variants
```mermaid
flowchart LR
C[Config.scheduler_variant] --> GQ[global_queue]
C --> WS[work_stealing]
C --> LE[lane_extension]
GQ --> GQD[One BoundedRing inject
workers pop directly]
WS --> WSD[Per-worker LIFO slot + local ring
+ inject + submitter_queues
+ workers_nonempty u64 bitmap]
LE --> LED[work_stealing + dedicated
blocking_inject + blocking workers
+ watchdog overflow spawn]
```
| Variant | When to use |
|---|---|
| `global_queue` | Simplest correctness baseline; low per-worker overhead. |
| `work_stealing` | General-purpose; best throughput in most mixed workloads. |
| `lane_extension` | Mixing long-running (blocking) and short-latency tasks. |
## Performance Snapshot
Same-host `bench-runtime-compare` at HEAD = `2c99e2b` (2026-04-17, Linux
x86_64, 56 workers, 6 retained samples after 1 warmup discard). Full
tables and methodology in [docs/BENCHMARKS.md](docs/BENCHMARKS.md).
### Throughput (ops/sec)
| Scenario | Zig thread-pool | Tokio | Go | Zig vs Go |
|---|---:|---:|---:|---:|
| `flood_1m` | 860,073 | 343,795 | 679,626 | **1.27x ahead** |
| `contended_spawn_8x` | 1,141,570 | 607,149 | 3,042,676 | 2.66x behind |
| `cpu_work_10us` | 1,070,750 | 449,018 | 554,179 | **1.93x ahead** |
| `cpu_work_100us` | 480,783 | 309,121 | 416,573 | **1.15x ahead** |
| `contended_cpu_10us` | 1,244,836 | 621,083 | 1,783,701 | 1.43x behind |
| `nested_fanout_4x` | 1,127,801 | 1,392,766 | 2,954,402 | 2.62x behind |
| `burst_drain_burst` | 160,994 | 142,510 | 141,912 | **1.13x ahead** |
| `mixed_fast_slow` | 258,541 | 231,506 | 252,541 | **1.02x ahead** |
**Wins:** 5/8 vs Go, 7/8 vs Tokio. See
[docs/RUNTIME_COMPARISON.md](docs/RUNTIME_COMPARISON.md) for latency
tables and per-scenario analysis.
### Scheduling Techniques Wired In
| Feature | Where | Measured gain |
|---|---|---|
| Wake coalescing (`wake_coalesce_batch=32`) | `notifySubmits` → `scanAndWake` | `flood_1m +183%` |
| Per-submitter SPMC queues (`registerSubmitter`) | `enqueuePush` fallback | `contended_spawn_8x +17.2%` |
| Per-worker + per-submitter `DescCache` (SPSC LIFO 32) | hot-path descriptor acquire | `nested_fanout_4x +81.26%` paired |
| `workers_nonempty` u64 steal bitmap | `nextTask` victim selection | `contended_spawn_8x +17%` |
| Inject `popMany` batched drain | `tryDrainLaneForWorker` | `flood_1m_batched +14%` |
| Gen-tagged queue entries (u32→u64) | `runTask` stale-entry filter | 20/20 reorder+sentinel stress |
| Comptime elision (`-Dsingle-pool`, `-Dtimestamp-mode`, `-Dscheduler-variant`) | `build.zig` | 6–17% on single-pool hosts |
## Build & Test
```bash
make build
make test # unit + integration
make test-stress
make ci # everything
```
Or directly with Zig:
```bash
zig build build
zig build test --summary all
zig build test-integration --summary all
zig build test-stress --summary all
zig build test-all --summary all
```
ThreadSanitizer sweep:
```bash
zig build test-all -Dtsan # stress suite auto-downgrades to Debug under TSan
zig build fuzz -Dtsan
```
## Benchmark Commands
```bash
# CI-safe suite
zig build bench -- --output benchmarks/results/latest.json
# Scale suite (longer scenarios)
zig build scale-bench -- --output benchmarks/results/scale-latest.json
# Zig-only latency suite (cpu_work, contended, nested, burst, mixed,
# lane isolation)
zig build bench-realistic -Doptimize=ReleaseFast
# Direct comparison against vendored Tokio and Go
git submodule update --init --recursive
zig build bench-runtime-compare -Doptimize=ReleaseFast -- \
--output benchmarks/results/runtime-comparison-latest.json
# Compare two baselines
zig build compare-bench -- \
--baseline benchmarks/results/baseline.json \
--current benchmarks/results/latest.json
```
The comparison binary matches rows by scenario, scheduler, worker count,
and submitter count, so the output is directly diffable.
## Configuration Highlights
```zig
// Most-used config knobs (see src/types.zig for the full set).
.scheduler_variant = .work_stealing, // .global_queue / .work_stealing / .lane_extension
.min_threads = 2, // lower bound on spawned workers
.max_threads = 8, // hard cap (≤ 64, bounded by workers_nonempty u64)
.queue_capacity = 1024, // inject queue + bounded descriptor pool
.local_queue_capacity = 256, // per-worker ring capacity (power of two)
.queue_kind = .ring, // .ring (default) / .chase_lev
.inject_batch_size = 32, // popMany drain batch
.steal_batch_size = 16, // max entries per stealHalf call
.wake_coalesce_batch = 32, // wake scan + signal every N submits
.max_submitter_queues = 0, // >0 enables registerSubmitter API
.allow_blocking_lane = false, // enable blocking-lane routing
.blocking_workers = 0, // workers reserved for .blocking lane
.cpu_helps_blocking = false, // default workers help blocking lane (L3)
.watchdog_interval_ns = 0, // L4 stall detection (0 disables)
.max_overflow_workers = 0, // L4b elastic spawn cap
.timestamp_mode = .full, // .disabled / .completion_only / .full
```
Build-time comptime flags:
| Flag | Default | Effect |
|---|---|---|
| `-Dsingle-pool=true` | `false` | Elide per-pool TLS identity check. **UNSAFE** with >1 pool per process — restores the pre-F-001 OOB hazard. +6–17% on submit-heavy scenarios. |
| `-Dtimestamp-mode=` | runtime | Pin `Config.timestamp_mode` at build time. Always safe. |
| `-Dscheduler-variant=` | runtime | Pin `Config.scheduler_variant` at build time. Always safe. |
| `-Dtsan` | `false` | Enable ThreadSanitizer; auto-downgrades stress suite to Debug. |
| `-Dwith_otel=true` | `false` | Compile in OpenTelemetry instrumentation. Off by default; the otel dependency is `lazy = true` and only fetched when this flag is on. Verified zero-overhead when off (no `otel` / `observability` symbols leak into the disabled build). See [Observability](#observability) below. |
## Public API
Stable surface from [src/root.zig](src/root.zig):
- `ThreadPool.init(allocator, config) !ThreadPool`
- `ThreadPool.deinit()`
- `ThreadPool.submit(task_fn, ctx, options) !TaskHandle`
- `ThreadPool.trySubmit(task_fn, ctx, options) !TaskHandle`
- `ThreadPool.submitBatch(tasks, options) !SubmitBatchResult`
- `ThreadPool.wait(handle) !void`
- `ThreadPool.waitGroup() WaitGroup`
- `ThreadPool.shutdown(mode, timeout_ns) !void` — `.drain` / `.cancel_pending`
- `ThreadPool.statsSnapshot() Stats`
- `ThreadPool.purgeWorkerCaches() !void`
- `ThreadPool.registerSubmitter() !usize` / `releaseSubmitter(idx)`
Exported queue primitives for low-level experimentation:
- `thread_pool.BoundedRing(T)` — Vyukov MPMC
- `thread_pool.RingQueue(T)` — SPMC
- `thread_pool.WorkDeque(T)` — Chase-Lev
- `thread_pool.DescCache` — SPSC LIFO 32
## Observability
`thread-pool` exposes an opt-in OpenTelemetry seam via the
`thread_pool.observability` namespace. The seam is **off by default**;
turn it on with `-Dwith_otel=true`.
When disabled (the default), every helper collapses to a comptime-known
no-op, the otel dependency is never resolved, and
`otel/scripts/verify-consumer-noop.sh` confirms zero `otel` /
`observability` symbols leak into the produced artifacts.
### What gets instrumented
The seam wires one **producer**-kind span per public-API submit at
`ThreadPool.submit`, `ThreadPool.trySubmit`, and `ThreadPool.submitBatch`
(one span covers the whole batch — per-task spans would explode volume
under flood workloads).
| Span field | Value |
|---|---|
| `name` | `thread_pool.submit` |
| `kind` | `producer` |
| `messaging.system` | `thread_pool` |
| `messaging.operation` | `publish` |
Worker-side task pickup is intentionally **not** instrumented — that
path runs at very high frequency and per-pop spans would distort the
latency budgets the scheduler is tuned against.
### Caller bootstrap (when `-Dwith_otel=true`)
```zig
const std = @import("std");
const thread_pool = @import("thread_pool");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Build providers from OTEL_* env vars (defaults: OTLP/HTTP to
// http://localhost:4318, parentbased_traceidratio @ 0.05).
var otel_state = try thread_pool.observability_init.Otel.init(
allocator,
"my-service",
);
defer otel_state.deinit();
// installGlobals must run against the value at its STABLE address
// (not a moved-from copy) so the published Tracer/Meter pointers
// outlive every span site.
otel_state.installGlobals();
// ... use thread_pool as usual ...
}
```
`OTEL_SERVICE_NAME`, `OTEL_TRACES_SAMPLER`, `OTEL_EXPORTER_OTLP_*`, and
the rest of the OTEL spec env vars work as documented in the
OpenTelemetry environment variable reference.
### Dependency cycle handling
`otel` itself depends on `thread-pool` transitively under
`-Dotel_use_devnw_pool=true`, so a naive eager dependency would form a
build-graph cycle. The cycle is broken at compile time by:
1. `lazy = true` on the `.otel` entry in `build.zig.zon` — Zig only
fetches when `b.lazyDependency("otel", ...)` is reached.
2. `b.lazyDependency` is gated behind `if (with_otel)` in `build.zig`,
so the disabled build never enters the otel-resolving branch.
3. otel's default build does NOT set `-Dotel_use_devnw_pool=true`, so
even when otel is built it does not force-fetch thread-pool.
4. Comptime guards in `src/observability.zig`
(`const otel = if (enabled) @import("otel") else struct {};`) keep
the otel namespace out of the disabled-mode AST entirely.
### See also
- Integration recipe (canonical): `otel/docs/integration/RECIPE.md`
- Verifier: `otel/scripts/verify-consumer-noop.sh thread-pool`
## Documentation Map
### Start here
- [Getting Started](docs/getting-started.md)
- [API Reference](docs/api-reference.md)
- [Examples](docs/examples.md)
- [FAQ](docs/faq.md)
### Deep dives
- [Architecture](docs/architecture.md) — component layout + ownership
- [Design](docs/DESIGN.md) — rationale, queue design, memory model
- [Algorithms](docs/ALGORITHMS.md) — mermaid diagrams for every pipeline
- [Benchmarks](docs/BENCHMARKS.md) — scenario matrix, A/B protocol
- [Runtime Comparison](docs/RUNTIME_COMPARISON.md) — Zig vs Tokio vs Go
- [Testing](docs/TESTING.md) — validation matrix, TSan, fuzz
- [Scheduler Decision](docs/SCHEDULER_DECISION.md)
- [Performance Tracking](docs/PERFORMANCE_TRACKING.md)
### Security
- [AppSec Review (2026-04-15)](docs/APPSEC_REVIEW_2026-04-15_src.md)
- [AppSec Remediation](docs/performance/appsec-fixes-2026-04-15/FINAL.md)
- [TSan Baseline](docs/performance/appsec-fixes-2026-04-15/TSAN-findings.md)
- [SEC-018 — `wait()` idempotency](docs/security/SEC-018.md)
- [REV-001 — `task_fn` panic contract](docs/security/REV-001.md)
### Performance archive
- [2026-04-17 Go-gap round 2 (FAILED)](docs/performance/2026-04-17-go-gap-round2/FINAL.md) — closed planes reference
- [2026-04-16 Go-gap contended (DescCache)](docs/performance/2026-04-16-go-gap-contended/FINAL.md)
- [2026-04-15 AppSec remediation perf ledger](docs/performance/appsec-fixes-2026-04-15/README.md)
- [2026-04-15 Direct runtime comparison](docs/performance/2026-04-15-runtime-comparison/README.md)
## License
Apache-2.0. See [LICENSE](LICENSE).