Zig · Quickstart

Quickstart

const forkunion = @import("forkunion");const Capabilities = @import("forkunion").Capabilities;const Status = @import("forkunion").Status;const IndexedSplit = @import("forkunion").IndexedSplit;const TasksRange = @import("forkunion").TasksRange;const SyncConstPtr = @import("forkunion").SyncConstPtr;const SyncMutPtr = @import("forkunion").SyncMutPtr;const Pool = @import("forkunion").Pool;const PoolOptions = @import("forkunion").PoolOptions; 4.0.0-rc.1 · d927445f

ForkUnion is dead-simple to use! There is no nested parallelism, exception handling, or “future promises”; they are banned. A pool runs one dispatch at a time and refuses any other with the contended status, including one from inside its own callbacks. The thread pool itself has a few core operations:

  • spawn to initialize worker threads, and
  • for_threads to launch a blocking callback on all threads.

Higher-level APIs for index-addressable tasks are also available:

  • for_n - for individual evenly-sized tasks,
  • for_n_dynamic - for individual unevenly-sized tasks,
  • for_slices - for contiguous runs of evenly-sized tasks, one per worker.

For additional flow control and tuning, following helpers are available:

  • nap - to park workers on the CPU’s address monitor, waking with no syscall,
  • sleep(microseconds) - to block workers in the kernel for long idle periods, with 0 waiting for the next dispatch,
  • terminate - to kill the threads before the destructor is called,
  • unsafe_for_threads - to broadcast a callback without blocking, returning a generation token,
  • unsafe_join - to block until the completion of a broadcasted generation,
  • is_complete - to poll a generation token for completion without blocking.

Every dispatch is identified by a generation token. On caller_exclusive_k pools you can dispatch, overlap your own work, poll is_complete, and join - the classic poll-then-join pattern. On caller_inclusive_k pools the calling thread owes one slice of the work, which only runs inside unsafe_join, so completion can’t be reached by polling alone. The same rule shapes the RAII guard returned by for_threads and, in C++, the for_n family: the workers start at the guard’s construction on every pool, and on inclusive pools the caller’s own slice runs at join or destruction.

On caller_exclusive_k pools thread 0 may also fork from inside its own slice, at any depth: such a fork waits for every other slice of the one enclosing it, runs on all threads, and returns complete. So unsafe_for_threads can hand a whole multi-step call to thread 0, which forks the rest of the pool as it goes, while the caller only polls a status word the call stores at its end. That is how one manager thread keeps several exclusive pools busy with no queue and no extra thread. A join on such a pool waits for whatever dispatch runs, since the call may end before its submitter stores the token.

In C++ it provides NUMA extensions. That includes the colocated_pool analog of the flat_pool and the domain_allocator_t for allocating memory in a specific memory domain. Those are out-of-the-box compatible with the higher-level APIs. Most interestingly, for Big Data applications, a higher-level distributed_pool class addresses and balances the work across all compute domains.

domain_allocator_t is backed by linux_numa_allocator_t, freebsd_numa_allocator_t, or windows_numa_allocator_t.

To integrate into your Zig project, let zig fetch pin the dependency and its content hash into build.zig.zon:

zig fetch --save=forkunion https://github.com/ashvardanian/ForkUnion/archive/refs/tags/v4.0.0-rc.1.tar.gz

That records the dependency; build.zig still has to wire it, and the module carries the compiled artifact so a consumer does not relink it by hand:

const forkunion = b.dependency("forkunion", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("forkunion", forkunion.module("forkunion"));

Then import and use in your code:

const std = @import("std");
const fu = @import("forkunion");

pub fn main() !void {
    const topology = try fu.Topology.init();
    defer topology.deinit();
    const pool = try fu.Pool.init(topology, .{ .threads = 4 });
    defer pool.deinit();

    // Execute work on each thread (OpenMP-style parallel)
    try pool.forThreads({}, struct {
        fn work(thread_index: usize, compute_domain: fu.ComputeDomain) void {
            _ = compute_domain;
            std.debug.print("Thread {}\n", .{thread_index});
        }
    }.work);

    // Distribute 1000 tasks across threads (OpenMP-style parallel for)
    var results = [_]i32{0} ** 1000;
    try pool.forN(1000, &results, struct {
        fn process(slots: *[1000]i32, task: usize, at: fu.ThreadInDomain) void {
            _ = at;
            slots[task] = @intCast(task * 2);
        }
    }.process);

    // Or let the pool hand each thread a disjoint sub-slice, with no index arithmetic
    try pool.forSlicesMut(i32, &results, {}, struct {
        fn scale(chunk: []i32, at: fu.ThreadInDomain) void {
            _ = at;
            for (chunk) |*slot| slot.* *= 2;
        }
    }.scale);
}

The context comes before the callback and travels as a caller-owned pointer, so the callback receives exactly the qualifiers the caller chose — the same shape std.sort uses, and the reason a std.atomic.Value in a context can never be silently copied per task. Pass {} when a kernel needs no state at all.

The Topology handle reports the hardware topology, to size and place work:

const topology = try fu.Topology.init();
defer topology.deinit();
for (0..try topology.computeDomainsCount()) |index| {
    const domain = fu.ComputeDomain.at(index);
    std.debug.print("domain {d}: {d} cores, level {d}, allocate on memory domain {d}\n", .{
        index, try topology.logicalCoresCountIn(domain), try topology.computeLevelIn(domain), (try topology.localMemoryOf(domain)).index(),
    });
}

A compute domain, a memory domain, and a memory domain’s OS id are three distinct types — ComputeDomain, MemoryDomain, and MemoryDomainId — exactly as in Rust and Mojo, so passing one where another belongs is a compile error rather than a silent read of the wrong node.

The Zig standard library no longer ships a fork-join pool at all; its replacement, std.Io.Group, allocates per task, grows its thread set lazily, runs work inline on the caller once saturated, and knows nothing about NUMA.

API

Types

CallerExclusivityDefines whether the calling thread participates in task execution
pub const CallerExclusivity = enum(c_int)

Values

inclusive0Calling thread participates in workload, spawning N-1 workers.
exclusive1Calling thread only coordinates, spawning N workers.
ErrorOne error per status, so @errorName carries as much as the status itself does.
pub const Error = error

Zig error values hold no payload, so the symbol name that named which call failed cannot travel with the error. Reach for Status where that detail matters.

Values

Unknown
BadAlloc
CapacityExhausted
InvalidArgument
ConfigMismatch
AlreadySpawned
NotSpawned
ThreadRefused
TopologyUnavailable
PermissionDenied
Unsupported
Contended
StructCapabilitiesEverything the library can do, whether decided when it was compiled or found on this machine.1
topology.zig:74 ↗
pub const Capabilities = packed struct(u32)

One bit per facility, and two accessors ask two questions of the same bit. comptimeCapabilities reports whether the code was built: a set place_huge_pages_on_domain means we compiled the path that asks for them. runtimeCapabilities reports whether the machine offers it now.

Neither implies the other. A binary that built place_memory_on_domain runs perfectly well on a single-node box, where the runtime accessor never sets that bit; and a machine with four NUMA nodes reports none of them to a build that left the topology out.

Fields

x86_pauseboolx86 pause instruction
x86_tpauseboolx86-64 tpause instruction, with WAITPKG support
arm64_yieldboolArm yield instruction
arm64_wfetboolAArch64 wfet instruction, with FEAT_WFxT support
riscv64_pauseboolRISC-V pause instruction
riscv64_wrsboolRISC-V WRS.STO monitored wait, from the Zawrs extension
os_threadsboolOwn the raw OS thread handle instead of a std::thread
topologyboolEnumerate this machine's cores, compute domains, and memory domains
place_threads_by_affinityboolBind a thread to a set of cores, choosing where it runs
place_threads_by_core_classboolSteer a thread onto a class of core at creation, choosing where it runs
reschedule_threads_by_classboolReclass a thread's scheduler to sleep or wake it, choosing when it runs
place_memory_on_domainboolPlace a buffer's pages on a chosen memory domain
place_huge_pages_on_domainboolPlace larger-than-base pages on a chosen memory domain
huge_transparent_pagesboolThe kernel promotes base pages to huge pages on its own
colocate_pools_on_domainboolThe domain-aware colocated_pool and distributed_pool are compiled in
x86_cldemoteboolCLDEMOTE moves a just-written line toward the shared LLC and retains it. Reporting-only: the emitter is chosen at compile time by FORKUNION_WITH_DEMOTE_CACHE_LINES, never dispatched.
arm64_dc_cvacboolDC CVAC cleans a dirty line to the coherency point - AArch64's nearest demote. Set where EL0 execution is known-legal, i.e. Linux, which sets SCTLR_EL1.UCI.
riscv64_zicbomboolThe kernel enabled user-mode Zicbom cache-block management, attested through hwprobe - the bit the C ABI's cascade selects cbo.clean on.
x86_cmpccxaddboolCMPCCXADD, the conditional atomic add, admitting x86_cmpccxadd_atomic_ref.
x86_raointboolRAO-INT aadd and its family, executed at the shared cache.
arm64_lseboolArmv8.1 FEAT_LSE: one instruction per read-modify-write.
arm64_rcpcboolArmv8.3 FEAT_LRCPC: LDAPR acquires, cheaper than LDAR's.
riscv64_zacasboolRISC-V Zacas, the amocas compare-and-swap.
riscv64_atomicboolThe RISC-V A extension's lr, sc and amo words.
arm64_prfmboolPRFM, AArch64's prefetch hint.
riscv64_zicbopboolRISC-V Zicbop, the prefetch.w hint.
riscv64_zabhaboolRISC-V Zabha, byte and halfword atomics.
_unusedu5
allAll-ones allow-mask: pass to a pool constructor to disable capability filtering.
topology.zig:139 ↗
pub fn all() Capabilities
EnumStatusWhy a call into the C core failed, mirroring fu_status_t value-for-value.1
types.zig:21 ↗
pub const Status = enum(c_int)

Non-exhaustive, so a status a newer core reports arrives intact rather than coerced.

Values

success0
unknown-1
bad_alloc-2
capacity_exhausted-3
invalid_argument-4
config_mismatch-5
already_spawned-6
not_spawned-7
thread_refused-8
topology_unavailable-9
permission_denied-10
unsupported-11
contended-14
describeStatic, English description; mirrors fu_status_to_string.
types.zig:38 ↗
pub fn describe(self: Status) [:0]const u8
StructIndexedSplitSplits a range of tasks into fair-sized runs for parallel distribution.2
types.zig:194 ↗
pub const IndexedSplit = struct

The first tasks % threads runs get ceil(tasks / threads) tasks; the rest get floor(tasks / threads). This minimizes size variance across threads. Mirrors the C++ indexed_split and Lemire's fair-run scheme. See: https://lemire.me/blog/2025/05/22/dividing-an-array-into-fair-sized-chunks/

Fields

quotientusize
remainderusize
initBuilds a split of tasks_count tasks across threads_count nonzero threads.
types.zig:199 ↗
pub fn init(tasks_count: usize, threads_count: usize) IndexedSplit
getReturns the { first, count } run owned by thread thread_index.
types.zig:208 ↗
pub fn get(self: IndexedSplit, thread_index: usize) TasksRange
StructTasksRangeA half-open [first, first + count) run of task indices - the "what work" of a slice dispatch.3
types.zig:166 ↗
pub const TasksRange = struct

Zig has no first-class range value, so first and count feed the language's own for syntax: for (range.first..range.end()) |task|. An idle thread receives count == 0, and every dispatch still calls it exactly once.

Fields

firstusizeThe first task index in the run.
countusizeHow many tasks the run covers; zero means an idle thread.
endOne past the last task index, so first..end() is the half-open span.
types.zig:173 ↗
pub fn end(self: TasksRange) usize
isEmptyWhether the run covers no tasks at all.
types.zig:178 ↗
pub fn isEmpty(self: TasksRange) bool
ofThe run's slice of data, which must be at least end() long.
types.zig:183 ↗
pub fn of(self: TasksRange, comptime T: type, data: []T) []T
StructSyncConstPtrA Send + Sync-style read-only raw-pointer view, letting one immutable buffer be read by every worker across the FFI callback boundary.3
types.zig:230 ↗
pub fn SyncConstPtr(comptime T: type) type

The caller guarantees the pointee outlives the parallel region and is not mutated while shared.

Fields

ptr[*]const T
initWraps a raw pointer to the start of the shared buffer.
types.zig:237 ↗
pub fn init(ptr: [*]const T) Self
getA reference to the element at index; the caller owns bounds checking.
types.zig:242 ↗
pub fn get(self: Self, index: usize) *const T
as*× 2The underlying raw pointer.
types.zig:247 ↗
pub fn asPtr(self: Self) [*]const T
StructSyncMutPtrA Send + Sync-style mutable raw-pointer view, letting workers write disjoint slots of one buffer across the FFI callback boundary.2
types.zig:257 ↗
pub fn SyncMutPtr(comptime T: type) type

The caller guarantees each worker touches disjoint indices and the pointee outlives the region.

Fields

ptr[*]T
initWraps a raw pointer to the start of the shared buffer.
types.zig:264 ↗
pub fn init(ptr: [*]T) Self
getA mutable pointer to the element at index; the caller owns disjointness and bounds.
types.zig:269 ↗
pub fn get(self: Self, index: usize) *T
StructPoolThread pool for fork-join parallelism19
scheduling.zig:151 ↗
pub const Pool = struct

Fields

handle*anyopaque
initSpawns a pool onto topo, per options.
scheduling.zig:158 ↗
pub fn init(topo: Topology, options: PoolOptions) Error!Pool

Spawn one pool per compute domain with .placement = .{ .on_compute_domain = … } and coordinate them from a single thread with the generation-token API.

deinitDestroys the thread pool
scheduling.zig:185 ↗
pub fn deinit(self: Pool) void
threadsCountReturns the number of threads in the pool
scheduling.zig:190 ↗
pub fn threadsCount(self: Pool) Error!usize
callerExclusivityReturns whether the calling thread participates in the workload.
scheduling.zig:200 ↗
pub fn callerExclusivity(self: Pool) Error!CallerExclusivity

Queries the pool directly rather than caching, so it stays correct across terminate and re-spawning with a different exclusivity.

capabilitiesReturns the capabilities this pool actually spawned with - the allow-mask intersected with what the build compiled and the machine offers, so a NUMA-less box reports the flat pool.
scheduling.zig:208 ↗
pub fn capabilities(self: Pool) Error!Capabilities
computeDomainsCountReturns the number of compute domains the pool spans
scheduling.zig:215 ↗
pub fn computeDomainsCount(self: Pool) Error!usize
threadsCountInReturns the number of threads in a specific compute domain
scheduling.zig:222 ↗
pub fn threadsCountIn(
    self: Pool,
    compute_domain: ComputeDomain,
) Error!usize
locateThreadInConverts a global thread index to its local index within a compute domain
scheduling.zig:229 ↗
pub fn locateThreadIn(
    self: Pool,
    global_thread_index: usize,
    compute_domain: ComputeDomain,
) Error!usize
terminateTerminates all worker threads; the pool can be respawned.
scheduling.zig:237 ↗
pub fn terminate(self: Pool) void

Not thread-safe; call only when no dispatch is in flight, never as a sync point.

napParks worker threads on the CPU's address monitor, staying on their cores.
scheduling.zig:243 ↗
pub fn nap(self: Pool) void

Not thread-safe; call between task batches, and the next dispatch wakes the workers.

sleepBlocks worker threads in the kernel, freeing their cores, for at most microseconds between re-checks, or until the next dispatch when 0.
scheduling.zig:250 ↗
pub fn sleep(self: Pool, microseconds: usize) void

Not thread-safe; call between task batches, and the next dispatch wakes the workers.

forThreadsExecutes a callback on all threads, blocking.
scheduling.zig:260 ↗
pub fn forThreads(
    self: Pool,
    context: anytype,
    comptime func: anytype,
) Error!void

The context is a single-item pointer the caller owns, forwarded to the callback verbatim, so its const-ness carries through:

  • If context is void: fn (usize, ComputeDomain) void
  • Otherwise: fn (@TypeOf(context), usize, ComputeDomain) void
forNDistributes N tasks across threads with static scheduling, blocking.
scheduling.zig:285 ↗
pub fn forN(
    self: Pool,
    n: usize,
    context: anytype,
    comptime func: anytype,
) Error!void
  • If context is void: fn (usize, ThreadInDomain) void
  • Otherwise: fn (@TypeOf(context), usize, ThreadInDomain) void
forNDynamicDistributes N tasks with dynamic work-stealing, blocking.
scheduling.zig:319 ↗
pub fn forNDynamic(
    self: Pool,
    n: usize,
    context: anytype,
    comptime func: anytype,
) Error!void
  • If context is void: fn (usize, ThreadInDomain) void
  • Otherwise: fn (@TypeOf(context), usize, ThreadInDomain) void
forSlicesDistributes N tasks as slices, blocking.
scheduling.zig:354 ↗
pub fn forSlices(
    self: Pool,
    n: usize,
    context: anytype,
    comptime func: anytype,
) Error!void

The TasksRange names the half-open run of task indices this worker drew.

  • If context is void: fn (TasksRange, ThreadInDomain) void
  • Otherwise: fn (@TypeOf(context), TasksRange, ThreadInDomain) void
forSlicesMutSplits data into one contiguous chunk per thread and runs func on each, blocking.
scheduling.zig:392 ↗
pub fn forSlicesMut(
    self: Pool,
    comptime T: type,
    data: []T,
    context: anytype,
    comptime func: anytype,
) Error!void

The chunks partition data, so no two threads observe overlapping elements and the callback can write its own slice with no atomics and no index arithmetic.

  • If context is void: fn ([]T, ThreadInDomain) void
  • Otherwise: fn (@TypeOf(context), []T, ThreadInDomain) void
unsafeForThreadsExecutes callback on all threads without blocking; unsafe.
scheduling.zig:423 ↗
pub fn unsafeForThreads(
    self: Pool,
    context: anytype,
    comptime func: anytype,
) Error!usize

Returns a generation token to pass to isComplete or unsafeJoin.

  • If context is void: fn (usize, ComputeDomain) void
  • Otherwise: fn (@TypeOf(context), usize, ComputeDomain) void

Unlike the blocking dispatches, this one returns while worker threads may still be running, so the pointee must outlive unsafeJoin rather than merely the call.

isCompleteReturns true if the given generation has completed.
scheduling.zig:451 ↗
pub fn isComplete(self: Pool, generation: usize) Error!bool

A true result also guarantees visibility of every contributor's writes. On caller-inclusive pools this can only turn true once unsafeJoin contributes the calling thread's slice, so poll-then-join is reserved for exclusive pools.

unsafeJoinBlocks until the given generation completes; unsafe.
scheduling.zig:460 ↗
pub fn unsafeJoin(self: Pool, generation: usize) void

On caller-inclusive pools this also executes the calling thread's slice. Idempotent: joining an already-joined generation returns immediately.

StructPoolOptionsHow a pool is spawned: width, placement, and the facilities it may use.1
scheduling.zig:127 ↗
pub const PoolOptions = struct

One options struct rather than a family of constructors, so a call site names only what it changes and a new knob costs no new overload.

Fields

threadsusizeThreads to run, the calling thread included when exclusivity is .inclusive.
placementPlacementWhich cores the pool may use: every compute domain, or exactly one.
name?[]const u8Optional thread name, clipped by the core to what the platform's thread naming accepts.
exclusivityCallerExclusivityWhether the calling thread runs work or only coordinates.
allowedCapabilitiesAllow-mask: clear a waiter bit to force a lower-priority busy-wait, or clear place_memory_on_domain to force the flat, non-NUMA pool.
PlacementBoth placements are named, so "every domain" is a state rather than a missing index.
scheduling.zig:141 ↗
pub const Placement = union(enum)

Fields

everywherevoidSpread across every compute domain the topology reports.
on_compute_domainComputeDomainPin threads and domain-local allocations to one compute domain. On builds without NUMA only compute domain 0 is valid.

Functions

versionReturns the library version as a struct
pub fn version() struct { major: u32, minor: u32, patch: u32 }
comptimeCapabilitiesWhich kernel facilities this build of ForkUnion was compiled to use.
pub fn comptimeCapabilities() Capabilities
comptimeCapabilitiesStringThe set comptimeCapabilities bits, comma-separated, like "threads,topology".
pub fn comptimeCapabilitiesString(buf: []u8) Error![]const u8

POLISH: writes into a caller-provided buffer so the returned slice borrows buf; the buffer must outlive the slice. Callers pass their own stack buffer.

runtimeCapabilitiesWhich features this machine turned out to offer, probing the CPU and the memory system.
pub fn runtimeCapabilities() Capabilities
runtimeCapabilitiesStringThe set runtimeCapabilities bits, comma-separated: "arm64_yield,place_memory_on_domain".
pub fn runtimeCapabilitiesString(buf: []u8) Error![]const u8

POLISH: writes into a caller-provided buffer so the returned slice borrows buf; the buffer must outlive the slice. Callers pass their own stack buffer.

Constants

default_alignmentThe width the padding wrapper aligns to: two cache lines, because most x86 parts prefetch in pairs, so one line of separation still leaves two accumulators sharing a prefetch unit.
pub const default_alignment: usize

The core spells this default_alignment_k and deliberately does not use std::hardware_destructive_interference_size, which trips GCC ABI warnings and picks worse.

Derived from the target the same way the header and build.rs derive it, so all three agree without copying a number between them. Only padding rests on it - a pool strides its cells by the width the machine reports at spawn.