fork of gitlab.com/devnw/zig/thread-pool for zig 0.17

README.md

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.

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 #

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 for the full walkthrough, docs/api-reference.md for every public symbol, and docs/examples.md for batch submission, blocking lanes, and cancellation.

Architecture At A Glance #

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<br/>Vyukov MPMC inject]
        LQ[LocalQueue<br/>RingQueue or WorkDeque]
        SQ[submitter_queues<br/>RingQueue per submitter]
    end
    subgraph Caches[src/pool/desc_cache.zig]
        DC[DescCache<br/>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 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 #

flowchart LR
    C[Config.scheduler_variant] --> GQ[global_queue]
    C --> WS[work_stealing]
    C --> LE[lane_extension]
    GQ --> GQD[One BoundedRing inject<br/>workers pop directly]
    WS --> WSD[Per-worker LIFO slot + local ring<br/>+ inject + submitter_queues<br/>+ workers_nonempty u64 bitmap]
    LE --> LED[work_stealing + dedicated<br/>blocking_inject + blocking workers<br/>+ 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.

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 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 #

make build
make test              # unit + integration
make test-stress
make ci                # everything

Or directly with Zig:

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:

zig build test-all -Dtsan    # stress suite auto-downgrades to Debug under TSan
zig build fuzz -Dtsan

Benchmark Commands #

# 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 #

// 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=<m> runtime Pin Config.timestamp_mode at build time. Always safe.
-Dscheduler-variant=<v> 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 below.

Public API #

Stable surface from 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) #

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 #

Deep dives #

Security #

Performance archive #

License #

Apache-2.0. See LICENSE.