pub const CallerExclusivity = enum(c_int) Values
inclusive | 0 | Calling thread participates in workload, spawning N-1 workers. |
|---|---|---|
exclusive | 1 | Calling thread only coordinates, spawning N workers. |
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, andfor_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_tis backed bylinux_numa_allocator_t,freebsd_numa_allocator_t, orwindows_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.gzThat 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.
CallerExclusivityDefines whether the calling thread participates in task executionpub const CallerExclusivity = enum(c_int) inclusive | 0 | Calling thread participates in workload, spawning N-1 workers. |
|---|---|---|
exclusive | 1 | Calling 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.
Unknown | ||
|---|---|---|
BadAlloc | ||
CapacityExhausted | ||
InvalidArgument | ||
ConfigMismatch | ||
AlreadySpawned | ||
NotSpawned | ||
ThreadRefused | ||
TopologyUnavailable | ||
PermissionDenied | ||
Unsupported | ||
Contended |
CapabilitiesEverything the library can do, whether decided when it was compiled or found on this machine.1pub 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.
x86_pause | bool | x86 pause instruction |
|---|---|---|
x86_tpause | bool | x86-64 tpause instruction, with WAITPKG support |
arm64_yield | bool | Arm yield instruction |
arm64_wfet | bool | AArch64 wfet instruction, with FEAT_WFxT support |
riscv64_pause | bool | RISC-V pause instruction |
riscv64_wrs | bool | RISC-V WRS.STO monitored wait, from the Zawrs extension |
os_threads | bool | Own the raw OS thread handle instead of a std::thread |
topology | bool | Enumerate this machine's cores, compute domains, and memory domains |
place_threads_by_affinity | bool | Bind a thread to a set of cores, choosing where it runs |
place_threads_by_core_class | bool | Steer a thread onto a class of core at creation, choosing where it runs |
reschedule_threads_by_class | bool | Reclass a thread's scheduler to sleep or wake it, choosing when it runs |
place_memory_on_domain | bool | Place a buffer's pages on a chosen memory domain |
place_huge_pages_on_domain | bool | Place larger-than-base pages on a chosen memory domain |
huge_transparent_pages | bool | The kernel promotes base pages to huge pages on its own |
colocate_pools_on_domain | bool | The domain-aware colocated_pool and distributed_pool are compiled in |
x86_cldemote | bool | CLDEMOTE 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_cvac | bool | DC 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_zicbom | bool | The kernel enabled user-mode Zicbom cache-block management, attested through hwprobe - the bit the C ABI's cascade selects cbo.clean on. |
x86_cmpccxadd | bool | CMPCCXADD, the conditional atomic add, admitting x86_cmpccxadd_atomic_ref. |
x86_raoint | bool | RAO-INT aadd and its family, executed at the shared cache. |
arm64_lse | bool | Armv8.1 FEAT_LSE: one instruction per read-modify-write. |
arm64_rcpc | bool | Armv8.3 FEAT_LRCPC: LDAPR acquires, cheaper than LDAR's. |
riscv64_zacas | bool | RISC-V Zacas, the amocas compare-and-swap. |
riscv64_atomic | bool | The RISC-V A extension's lr, sc and amo words. |
arm64_prfm | bool | PRFM, AArch64's prefetch hint. |
riscv64_zicbop | bool | RISC-V Zicbop, the prefetch.w hint. |
riscv64_zabha | bool | RISC-V Zabha, byte and halfword atomics. |
_unused | u5 |
allAll-ones allow-mask: pass to a pool constructor to disable capability filtering.pub fn all() Capabilities StatusWhy a call into the C core failed, mirroring fu_status_t value-for-value.1pub const Status = enum(c_int) Non-exhaustive, so a status a newer core reports arrives intact rather than coerced.
success | 0 | |
|---|---|---|
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.pub fn describe(self: Status) [:0]const u8 IndexedSplitSplits a range of tasks into fair-sized runs for parallel distribution.2pub 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/
quotient | usize | |
|---|---|---|
remainder | usize |
initBuilds a split of tasks_count tasks across threads_count nonzero threads.pub fn init(tasks_count: usize, threads_count: usize) IndexedSplit getReturns the { first, count } run owned by thread thread_index.pub fn get(self: IndexedSplit, thread_index: usize) TasksRange TasksRangeA half-open [first, first + count) run of task indices - the "what work" of a slice dispatch.3pub 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.
first | usize | The first task index in the run. |
|---|---|---|
count | usize | How many tasks the run covers; zero means an idle thread. |
endOne past the last task index, so first..end() is the half-open span.pub fn end(self: TasksRange) usize isEmptyWhether the run covers no tasks at all.pub fn isEmpty(self: TasksRange) bool ofThe run's slice of data, which must be at least end() long.pub fn of(self: TasksRange, comptime T: type, data: []T) []T SyncConstPtrA Send + Sync-style read-only raw-pointer view, letting one immutable buffer be read by every worker across the FFI callback boundary.3pub fn SyncConstPtr(comptime T: type) type The caller guarantees the pointee outlives the parallel region and is not mutated while shared.
ptr | [*]const T |
|---|
initWraps a raw pointer to the start of the shared buffer.pub fn init(ptr: [*]const T) Self getA reference to the element at index; the caller owns bounds checking.pub fn get(self: Self, index: usize) *const T as*× 2The underlying raw pointer.pub fn asPtr(self: Self) [*]const T SyncMutPtrA Send + Sync-style mutable raw-pointer view, letting workers write disjoint slots of one buffer across the FFI callback boundary.2pub fn SyncMutPtr(comptime T: type) type The caller guarantees each worker touches disjoint indices and the pointee outlives the region.
ptr | [*]T |
|---|
initWraps a raw pointer to the start of the shared buffer.pub fn init(ptr: [*]T) Self getA mutable pointer to the element at index; the caller owns disjointness and bounds.pub fn get(self: Self, index: usize) *T PoolThread pool for fork-join parallelism19initSpawns a pool onto topo, per options.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 poolpub fn deinit(self: Pool) void threadsCountReturns the number of threads in the poolpub fn threadsCount(self: Pool) Error!usize callerExclusivityReturns whether the calling thread participates in the workload.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.pub fn capabilities(self: Pool) Error!Capabilities computeDomainsCountReturns the number of compute domains the pool spanspub fn computeDomainsCount(self: Pool) Error!usize threadsCountInReturns the number of threads in a specific compute domainpub fn threadsCountIn(
self: Pool,
compute_domain: ComputeDomain,
) Error!usize locateThreadInConverts a global thread index to its local index within a compute domainpub fn locateThreadIn(
self: Pool,
global_thread_index: usize,
compute_domain: ComputeDomain,
) Error!usize terminateTerminates all worker threads; the pool can be respawned.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.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.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.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:
void: fn (usize, ComputeDomain) voidfn (@TypeOf(context), usize, ComputeDomain) voidforNDistributes N tasks across threads with static scheduling, blocking.pub fn forN(
self: Pool,
n: usize,
context: anytype,
comptime func: anytype,
) Error!void void: fn (usize, ThreadInDomain) voidfn (@TypeOf(context), usize, ThreadInDomain) voidforNDynamicDistributes N tasks with dynamic work-stealing, blocking.pub fn forNDynamic(
self: Pool,
n: usize,
context: anytype,
comptime func: anytype,
) Error!void void: fn (usize, ThreadInDomain) voidfn (@TypeOf(context), usize, ThreadInDomain) voidforSlicesDistributes N tasks as slices, blocking.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.
void: fn (TasksRange, ThreadInDomain) voidfn (@TypeOf(context), TasksRange, ThreadInDomain) voidforSlicesMutSplits data into one contiguous chunk per thread and runs func on each, blocking.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.
void: fn ([]T, ThreadInDomain) voidfn (@TypeOf(context), []T, ThreadInDomain) voidunsafeForThreadsExecutes callback on all threads without blocking; unsafe.pub fn unsafeForThreads(
self: Pool,
context: anytype,
comptime func: anytype,
) Error!usize Returns a generation token to pass to isComplete or unsafeJoin.
void: fn (usize, ComputeDomain) voidfn (@TypeOf(context), usize, ComputeDomain) voidUnlike 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.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.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.
PoolOptionsHow a pool is spawned: width, placement, and the facilities it may use.1pub 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.
threads | usize | Threads to run, the calling thread included when exclusivity is .inclusive. |
|---|---|---|
placement | Placement | Which cores the pool may use: every compute domain, or exactly one. |
name | ?[]const u8 | Optional thread name, clipped by the core to what the platform's thread naming accepts. |
exclusivity | CallerExclusivity | Whether the calling thread runs work or only coordinates. |
allowed | Capabilities | Allow-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.pub const Placement = union(enum) everywhere | void | Spread across every compute domain the topology reports. |
|---|---|---|
on_compute_domain | ComputeDomain | Pin threads and domain-local allocations to one compute domain. On builds without NUMA only compute domain 0 is valid. |
versionReturns the library version as a structpub 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.
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.