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.