⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

Concurrency

Zig’s concurrency story now runs through std.Io. Press Run: this executes in your browser, on a target with no threads at all.

const std = @import("std");

fn slowDouble(out: *u32, value: u32) void {
    out.* = value * 2;
}

fn accumulate(total: *u32, mutex: *std.Io.Mutex, io: std.Io, amount: u32) void {
    // `Io.Mutex.lock` takes the io instance, like everything else that can
    // block. It returns an error only because it can be cancelled.
    mutex.lock(io) catch return;
    defer mutex.unlock(io);
    total.* += amount;
}

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var buf: [256]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
    const out = &file_writer.interface;

    // `io.async` starts a task and hands back a Future to await.
    var a: u32 = 0;
    var b: u32 = 0;
    var first = io.async(slowDouble, .{ &a, 10 });
    var second = io.async(slowDouble, .{ &b, 20 });
    first.await(io);
    second.await(io);
    try out.print("doubled: {d} {d}\n", .{ a, b });

    // A Group awaits many tasks at once, so nothing outlives the scope.
    var total: u32 = 0;
    var mutex: std.Io.Mutex = .init;
    var group: std.Io.Group = .init;
    for (0..8) |_| {
        group.async(io, accumulate, .{ &total, &mutex, io, 1 });
    }
    try group.await(io);
    try out.print("total: {d}\n", .{total});

    try out.flush();
}

io.async returns a Future

var first = io.async(slowDouble, .{ &a, 10 });
var second = io.async(slowDouble, .{ &b, 20 });
first.await(io);
second.await(io);

The Io implementation decides what “async” means. A threaded implementation runs these in parallel; the single-threaded WASI one runs them inline. Your code does not change, which is exactly why this page is runnable and the Threads page is not.

Group for many tasks

var group: std.Io.Group = .init;
for (0..8) |_| group.async(io, work, .{ ... });
try group.await(io);

A Group owns its tasks. Awaiting it waits for all of them, and cancelling it cancels all of them, so a task cannot outlive the scope that started it. That property is what “structured” means here, and it is the thing raw Thread.spawn cannot give you.

Shared state still needs a lock

Concurrency is not parallelism, but as soon as an implementation is parallel, unsynchronised shared mutation is a data race. std.Io.Mutex is the tool, and like everything else it takes the io instance:

try mutex.lock(io);
defer mutex.unlock(io);

The bigger picture

This is the same idea as Allocator, applied to time instead of memory. A function that takes an Io announces that it may block, and the caller chooses the execution strategy. Libraries written this way work unchanged on a thread pool, an event loop, or a single-threaded wasm runtime.