Zig Async and Concurrency
Concurrency lets a program make progress on multiple tasks at the same time. Zig approaches concurrency through threads for true parallel execution and through its standard library's event loop and I/O abstractions for asynchronous tasks. Note that Zig's async/await syntax is under active redesign as of 2024 — this topic covers threads and the practical concurrency tools available in stable Zig today.
Threads — True Parallel Execution
A thread is an independent sequence of instructions that the operating system can run in parallel with other threads, using multiple CPU cores.
Single thread: Core 0: [Task A ─────────────────────] [Task B ───────────] Two threads: Core 0: [Task A ───────────] Core 1: [Task B ───────────] Total time: roughly half
Spawning a Thread
const std = @import("std");
fn worker(id: usize) void {
std.debug.print("Thread {d} started\n", .{id});
// simulate work
std.time.sleep(100 * std.time.ns_per_ms);
std.debug.print("Thread {d} done\n", .{id});
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, worker, .{1});
const t2 = try std.Thread.spawn(.{}, worker, .{2});
const t3 = try std.Thread.spawn(.{}, worker, .{3});
t1.join();
t2.join();
t3.join();
std.debug.print("All threads finished\n", .{});
}
main thread
│
├─── spawn t1 (worker, id=1)
├─── spawn t2 (worker, id=2)
├─── spawn t3 (worker, id=3)
│ │ │ │
│ [running] [running] [running]
│ │ │ │
│ t1.join() t2.join() t3.join()
│ │ │ │
└─────────┴──────────┴──────────┘
│
"All threads finished"
Thread Safety — The Problem
When multiple threads access the same memory, they can conflict. Thread 1 reads a value, Thread 2 reads the same value, both modify it, and one modification overwrites the other.
var counter: u32 = 0; Thread 1: Thread 2: read counter (0) read counter (0) add 1 → 1 add 1 → 1 write 1 write 1 ← counter = 1, not 2!
This is a race condition. Zig does not hide race conditions — it gives you tools to prevent them explicitly.
Mutex — Mutual Exclusion Lock
A mutex allows only one thread to access a shared resource at a time. Other threads wait until the lock is released.
const std = @import("std");
var counter: u32 = 0;
var mutex = std.Thread.Mutex{};
fn increment(times: usize) void {
for (0..times) |_| {
mutex.lock();
defer mutex.unlock();
counter += 1;
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, increment, .{1000});
const t2 = try std.Thread.spawn(.{}, increment, .{1000});
t1.join();
t2.join();
std.debug.print("Counter: {d}\n", .{counter}); // always 2000
}
mutex.lock(): Thread 1 holds lock → Thread 2 waits Thread 1 unlocks → Thread 2 proceeds Thread 2 holds lock → Thread 1 waits ...no conflict, counter is always correct
Atomic Operations
For simple counters and flags, atomic operations are faster than mutexes. An atomic operation completes in one CPU instruction — no other thread can see it halfway done.
const std = @import("std");
var atomic_counter = std.atomic.Value(u32).init(0);
fn atomicIncrement(times: usize) void {
for (0..times) |_| {
_ = atomic_counter.fetchAdd(1, .seq_cst);
}
}
pub fn main() !void {
const t1 = try std.Thread.spawn(.{}, atomicIncrement, .{1000});
const t2 = try std.Thread.spawn(.{}, atomicIncrement, .{1000});
t1.join();
t2.join();
std.debug.print("Counter: {d}\n", .{atomic_counter.load(.seq_cst)});
}
Atomic increment: ┌─────────────────────────────────────────┐ │ Read + Add + Write in ONE CPU operation │ │ No thread can interrupt in between │ └─────────────────────────────────────────┘
Thread-Local Storage
A thread-local variable gives each thread its own private copy. No sharing, no conflict:
threadlocal var thread_id: u32 = 0;
fn setAndPrint(id: u32) void {
thread_id = id;
std.time.sleep(10 * std.time.ns_per_ms);
std.debug.print("My ID: {d}\n", .{thread_id});
// Each thread sees its own value — no conflict
}
Semaphore — Limiting Concurrent Access
var semaphore = std.Thread.Semaphore{ .permits = 3 };
fn accessResource(id: usize) void {
semaphore.wait();
defer semaphore.post();
std.debug.print("Thread {d} using resource\n", .{id});
std.time.sleep(50 * std.time.ns_per_ms);
}
Semaphore with 3 permits: Thread 1 → wait → permit 1 taken (enters) Thread 2 → wait → permit 2 taken (enters) Thread 3 → wait → permit 3 taken (enters) Thread 4 → wait → no permits left → BLOCKS Thread 1 → post → permit released → Thread 4 enters
Practical Example: Parallel File Processing
const std = @import("std");
const WorkItem = struct { id: usize, value: u64 };
fn processItem(item: WorkItem) u64 {
// Simulate expensive computation
var result: u64 = item.value;
for (0..1000) |_| result = (result *% 6364136223846793005) +% 1442695040888963407;
return result;
}
pub fn main() !void {
const items = [_]WorkItem{
.{ .id = 1, .value = 100 },
.{ .id = 2, .value = 200 },
.{ .id = 3, .value = 300 },
.{ .id = 4, .value = 400 },
};
var results: [4]u64 = undefined;
var threads: [4]std.Thread = undefined;
// Spawn one thread per item
for (items, 0..) |item, i| {
threads[i] = try std.Thread.spawn(.{}, struct {
fn run(it: WorkItem, out: *u64) void {
out.* = processItem(it);
}
}.run, .{item, &results[i]});
}
// Wait for all threads
for (threads) |t| t.join();
for (results, 0..) |r, i| {
std.debug.print("Result {d}: {d}\n", .{i + 1, r});
}
}
Concurrency Model Comparison
Approach | When to use ──────────────────┼────────────────────────────────── Threads + Mutex | CPU-intensive parallel work Atomic values | Simple counters, flags, signals Thread-local data | Per-thread state with no sharing Semaphore | Limiting access to a pool of N resources Single thread | I/O-bound or simple sequential programs
Zig gives you the building blocks and trusts you to choose the right tool. There is no hidden runtime, no garbage-collected green thread system — just OS threads, atomics, and synchronization primitives that map directly to hardware capabilities. This transparency makes Zig programs predictable in both performance and behavior.
