concurrency
when your program needs to do multiple things at once - handle many connections, run background tasks, update stats while processing requests. zig gives you threads, mutexes, and atomics. no async/await.
these notes focus on design decisions, not syntax. for api details, see std.Thread docs.
when to use atomics vs mutex
you often need to share state between threads. the question is how to protect it.
atomics are for simple counters - things where each operation is independent:
posts_checked: std.atomic.Value(u64) = .init(0),
// in some thread:
_ = self.posts_checked.fetchAdd(1, .monotonic);
mutex is for complex data or multi-step operations:
bufo_matches: std.StringHashMap(MatchInfo),
bufo_mutex: Thread.Mutex = .{},
// in some thread:
self.bufo_mutex.lock();
defer self.bufo_mutex.unlock();
try self.bufo_matches.put(name, info);
the pattern in find-bufo/bot/src/stats.zig: five simple counters use atomics (posts_checked, matches_found, etc.), but the hashmap of per-bufo match data uses a mutex.
rule of thumb: single integer that threads increment independently? atomic. anything else? mutex.
memory ordering
you'll see .monotonic everywhere for counters - it's the weakest ordering, just means "this operation is atomic, but i don't care about ordering relative to other operations."
use .acquire/.release when signaling between threads - one thread's write must be visible before another proceeds:
running: std.atomic.Value(bool) = .init(true),
// main thread: signal shutdown
self.running.store(false, .release); // release: writes before this are visible
if (self.thread) |t| t.join();
// background thread: check for shutdown
fn loop(self: *Self) void {
while (self.running.load(.acquire)) { // acquire: sees writes before store
std.Thread.sleep(interval);
self.doWork();
}
}
rule of thumb:
- independent counters →
.monotonic - signaling (shutdown flags, "data ready" flags) →
.releasefor store,.acquirefor load
see: logfire-zig/root.zig - background flush thread
callback pattern
the jetstream client doesn't use channels or complicated message passing. it just takes a function pointer and calls it when a message arrives:
callback: *const fn (Post) void,
// when a message comes in:
self.callback(.{
.uri = uri,
.text = text,
});
simpler than channels when you have one producer and one consumer. the callback runs on the producer's thread, so keep it fast.
see: find-bufo/bot/src/jetstream.zig#L18
reconnection with backoff
network connections fail. when they do, don't hammer the server - back off exponentially:
var backoff: u64 = 1;
const max_backoff: u64 = 60;
while (true) {
connect() catch {};
posix.nanosleep(backoff, 0);
backoff = @min(backoff * 2, max_backoff);
}
starts at 1 second, doubles each failure, caps at 60 seconds. simple and effective.
ring buffer for metrics
when tracking recent activity (request latencies, event counts), use a fixed-size circular buffer:
const SAMPLE_COUNT = 1000;
const LatencyBuffer = struct {
samples: [SAMPLE_COUNT]u32 = .{0} ** SAMPLE_COUNT,
count: usize = 0,
head: usize = 0,
fn record(self: *LatencyBuffer, value: u32) void {
self.samples[self.head] = value;
self.head = (self.head + 1) % SAMPLE_COUNT;
if (self.count < SAMPLE_COUNT) self.count += 1;
}
};
var buffer: LatencyBuffer = .{};
var mutex: std.Thread.Mutex = .{};
pub fn record(value: u32) void {
mutex.lock();
defer mutex.unlock();
buffer.record(value);
}
for percentiles, copy and sort:
pub fn getP95(self: *const LatencyBuffer) u32 {
if (self.count == 0) return 0;
var sorted: [SAMPLE_COUNT]u32 = undefined;
@memcpy(sorted[0..self.count], self.samples[0..self.count]);
std.mem.sort(u32, sorted[0..self.count], {}, std.sort.asc(u32));
return sorted[(self.count * 95) / 100];
}
this pattern works for ephemeral stats that reset on restart. for persistent counters, use the database.