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.

Leave a Comment

Your email address will not be published. Required fields are marked *