Skip to content
v1.0.0-zig0.15.2

Choosing a Primitive

Volt provides six sync primitives and four channel types. Picking the wrong one leads to unnecessary contention, complexity, or subtle bugs. This guide helps you choose.

Start here and follow the arrows:

Do you need to pass DATA between tasks?
├── YES --> Is it a single value (one producer, one consumer)?
│ ├── YES --> Oneshot
│ └── NO --> Do ALL consumers need every message?
│ ├── YES --> BroadcastChannel
│ └── NO --> Do you only care about the LATEST value?
│ ├── YES --> Watch
│ └── NO --> Channel (bounded MPMC)
└── NO --> Do you need to PROTECT shared state?
├── YES --> Are reads much more frequent than writes?
│ ├── YES --> RwLock
│ └── NO --> Is it a counter / resource pool?
│ ├── YES --> Semaphore
│ └── NO --> Mutex
└── NO --> Do you need to COORDINATE timing?
├── YES --> Is it one-time initialization?
│ ├── YES --> OnceCell
│ └── NO --> Is it a rendezvous point (N tasks must arrive)?
│ ├── YES --> Barrier
│ └── NO --> Notify
└── NO --> You probably don't need a primitive.
PrimitivePurposeAllocationFairness
MutexExclusive access to shared stateNoneFIFO
RwLockMany readers OR one writerNoneWriter priority
SemaphoreLimit concurrent access (N permits)NoneFIFO
NotifyWake one or all waiting tasksNoneFIFO (one) / All
BarrierWait until N tasks arriveNoneAll released together
OnceCellLazy one-time initializationNoneFirst caller initializes
ChannelPatternAllocationBlocking Behavior
Channel(T)MPMC bounded queueHeap (ring buffer)Senders wait when full
Oneshot(T)Single value deliveryNoneReceiver waits for value
BroadcastChannel(T)Fan-out to all receiversHeap (ring buffer)Lagging receivers skip
Watch(T)Latest value broadcastNoneReceivers watch for changes

Use Notify when:

  • You need to signal “something happened” without data.
  • Multiple tasks wait for a condition to become true.
  • You want notifyOne() (wake one) or notifyAll() (wake all) semantics.
var notify = volt.sync.Notify.init();
// Producer signals that data is ready
notify.notifyOne();
// Consumer waits for signal
var waiter = volt.sync.notify.Waiter.init();
notify.waitWith(&waiter);

Use Channel when:

  • You need to transfer actual data values.
  • You want backpressure (bounded queue).
  • Order of messages matters.
var ch = try volt.channel.bounded(Task, allocator, 100);
// Producer sends work
_ = ch.trySend(work_item);
// Consumer receives work
switch (ch.tryRecv()) {
.value => |task| process(task),
.empty => {},
.closed => break,
}

These three are the “specialized channels” for specific patterns.

Exactly one send, exactly one receive. Zero allocation.

fn requestResponse(io: volt.Io, request: Request) !HttpResponse {
var os = volt.channel.oneshot(HttpResponse);
// Spawn a task that computes the response
var f = try io.@"async"(handleRequest, .{request, &os.sender});
_ = f;
// Wait for the response
var waiter = @import("volt").channel.oneshot_mod.Oneshot(HttpResponse).RecvWaiter.init();
if (!os.receiver.recvWait(&waiter)) {
// yield until value arrives
}
return waiter.value.?;
}

Single value that changes over time. Receivers only see the latest value. No history.

var config = volt.channel.watch(AppConfig, default_config);
// Updater thread
config.send(new_config);
// Multiple readers
var rx = config.subscribe();
if (rx.hasChanged()) {
const cfg = rx.borrow();
applyConfig(cfg);
rx.markSeen();
}

Every receiver gets every message. Ring buffer with configurable capacity.

var events = try volt.channel.broadcast(Event, allocator, 256);
defer events.deinit();
var rx1 = events.subscribe();
var rx2 = events.subscribe();
_ = events.send(.{ .kind = .user_login, .user_id = 42 });
// Both rx1 and rx2 receive the event

A common question: should I use a Mutex-protected struct or a Channel?

  • Data flows in one direction (producer to consumer).
  • You want backpressure (bounded capacity).
  • Tasks don’t need to read the same data simultaneously.
  • You want to decouple producers from consumers.
  • Multiple tasks need to read AND write the same data.
  • You need atomic read-modify-write operations.
  • The data is too large or complex to copy through a channel.
  • Access patterns are mostly reads (RwLock).

Use both: channel for commands, shared state for the data.

const Command = union(enum) {
get: struct { key: []const u8, reply: *volt.channel.oneshot_mod.Oneshot(Value).Sender },
put: struct { key: []const u8, value: Value },
};
// Single owner task manages the map
fn mapOwner(ch: *volt.channel.Channel(Command)) void {
var map = std.AutoHashMap([]const u8, Value).init(allocator);
while (true) {
switch (ch.tryRecv()) {
.value => |cmd| switch (cmd) {
.get => |g| _ = g.reply.send(map.get(g.key).?),
.put => |p| map.put(p.key, p.value) catch {},
},
.empty => {},
.closed => return,
}
}
}

Based on benchmark results (M3 Mac, 8 workers). Note: these numbers use 8 worker threads for contended benchmarks. The Tokio comparison uses 4 workers to match Tokio’s default configuration, so numbers there will differ.

PrimitiveUncontended8-Thread Contended
Mutex10.2ns99.1ns
RwLock (read)11.0ns94.4ns
RwLock (write)10.2ns94.4ns
Semaphore9.7ns162.5ns
Notify9.9ns
Barrier15.3ns
OnceCell (get)0.4ns
Channel send5.3ns190.8ns (MPMC)
Channel recv3.1ns
Oneshot4.0ns
Broadcast23.4ns
Watch13.0ns

Key takeaways:

  • OnceCell is nearly free after initialization (0.4ns read).
  • Channel has the fastest uncontended send/recv (lock-free ring buffer).
  • Mutex/RwLock/Semaphore are similar uncontended (~10ns). Under contention, RwLock matches Mutex for read-heavy workloads.
  • MPMC Channel under heavy contention (190ns) is the most expensive. Consider partitioning or using MPSC mode.
// BAD: Mutex for one-time init
var mutex = volt.sync.Mutex.init();
var initialized = false;
var resource: ?Resource = null;
fn getResource() *Resource {
if (mutex.tryLock()) {
defer mutex.unlock();
if (!initialized) {
resource = initResource();
initialized = true;
}
}
return &resource.?;
}
// GOOD: OnceCell
var cell = volt.sync.OnceCell(Resource).init();
fn getResource() *Resource {
return cell.getOrInit(initResource);
}
// BAD: Channel for single-value delivery
var ch = try volt.channel.bounded(Result, allocator, 1);
defer ch.deinit(); // unnecessary allocation
// GOOD: Oneshot (zero allocation)
var os = volt.channel.oneshot(Result);
// BAD: Mutex around a counter
var mutex = volt.sync.Mutex.init();
var count: usize = 0;
fn increment() void {
if (mutex.tryLock()) {
defer mutex.unlock();
count += 1;
}
}
// GOOD: Semaphore (designed for counting)
var sem = volt.sync.Semaphore.init(0);
fn increment() void { sem.release(1); }
fn getCount() usize { return sem.availablePermits(); }

4. Broadcasting When Only Latest Value Matters

Section titled “4. Broadcasting When Only Latest Value Matters”
// BAD: Broadcast channel for config updates
// Receivers process every intermediate value, even stale ones.
var bc = try volt.channel.broadcast(Config, allocator, 16);
// GOOD: Watch channel
// Receivers only see the latest config.
var watch = volt.channel.watch(Config, default_config);