Skip to editor content
learningzig.orglesson 11 of 25

Async I/O with std.Io

If you came here expecting async and await keywords, there is one thing to clear up first: Zig does not have them. They were removed from the language in 0.14 and are not coming back. Instead, Zig 0.16 exposes concurrency through an interfacestd.Io — that you thread through your program the same way you already thread an allocator. There is no hidden runtime, no green-thread magic, and no global event loop switched on behind your back. You ask for concurrency explicitly, and you can see exactly where it happens.

Io Is Passed Explicitly, Like an Allocator

In Memory and Allocators you saw that Zig refuses to hide allocation behind a global. Functions that allocate take an Allocator parameter, so the caller always chooses the strategy. std.Io follows the same design: functions that do I/O or spawn concurrent work take an Io parameter.

This is a deliberate trade. It costs you a parameter on every I/O-facing function, but in return the concurrency model is a value you hold, not an ambient capability. A function that has no Io parameter cannot secretly open a socket or launch a thread — the type signature tells you its reach.

Getting an Io in main

Your main receives an Io through Zig 0.16's process-initialization mechanism. Declare main to take a std.process.Init parameter and the runtime hands you a ready-to-use io (along with an allocator and the environment), backed by a sensible default implementation for your target.

const std = @import("std");

fn double(x: u32) u32 {
    return x * 2;
}

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

    // io.async launches `double(21)` as a task and hands back a Future.
    var future = io.async(double, .{21});

    // await blocks until the task finishes, then yields its return value.
    const result = future.await(io);

    std.debug.print("double(21) = {d}\n", .{result});
}

io.async takes the function and a tuple of its arguments, and returns a Future holding the eventual result. future.await(io) waits for completion and returns the value. That pair — launch, then await — is the smallest unit of concurrency in the whole model.

io.async vs io.concurrent

There are two ways to launch a task, and the difference is a promise about parallelism.

io.async is permissive: it launches the task, but the implementation is free to run it eagerly, inline, or out of order. On a single-threaded blocking backend it may simply run the function right there. That is fine when you want the option of overlap but do not depend on it.

io.concurrent is stronger: it promises the task can make progress independently while you do other work. Not every backend can honor that, so it returns an error union — error.ConcurrencyUnavailable when the implementation cannot provide real concurrency. You must handle that case, which keeps the failure honest instead of silently degrading.

const std = @import("std");

fn sumTo(n: u64) u64 {
    var total: u64 = 0;
    var i: u64 = 0;
    while (i < n) : (i += 1) total += i;
    return total;
}

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

    // Ask for genuine concurrency; fall back to running inline if unavailable.
    var future = io.concurrent(sumTo, .{1000}) catch |err| switch (err) {
        error.ConcurrencyUnavailable => {
            std.debug.print("concurrency unavailable; running inline\n", .{});
            std.debug.print("sum = {d}\n", .{sumTo(1000)});
            return;
        },
    };
    const result = future.await(io);
    std.debug.print("sum = {d}\n", .{result});
}

Reach for io.concurrent when overlap is the point — two network requests that should be in flight at once. Reach for io.async when overlap is merely welcome. And remember: with io.async, ordering is not guaranteed, so never write code that assumes one task ran before another.

Fanning Out with std.Io.Group

Awaiting futures one at a time is tedious when you have N independent tasks. std.Io.Group collects a set of tasks and lets you await — or cancel — them as a unit. You add tasks with group.async (or group.concurrent), then call group.await once to wait for all of them.

const std = @import("std");

// A group task must return `Cancelable!void`; it reports its result by writing
// into shared storage rather than returning a value.
fn worker(io: std.Io, id: u32, results: []u64) std.Io.Cancelable!void {
    _ = io;
    var sum: u64 = 0;
    var i: u64 = 0;
    while (i <= id) : (i += 1) sum += i;
    results[id] = sum;
}

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

    var results: [4]u64 = undefined;
    var group: std.Io.Group = .init;

    var id: u32 = 0;
    while (id < 4) : (id += 1) {
        group.async(io, worker, .{ io, id, &results });
    }
    // One await drains the whole group.
    try group.await(io);

    for (results, 0..) |r, i| {
        std.debug.print("task {d} -> {d}\n", .{ i, r });
    }
}

Group tasks return Cancelable!void rather than a value — they communicate results by writing into memory you provide, like the results slice above. That slice must outlive the group, which is why it is declared in main and passed by pointer.

Cancellation and Cleanup Rules

A Future is a resource. Once you launch a task you are responsible for reclaiming it, and there are exactly two ways to do that: await it (wait for the result) or cancel it (request it stop, then reclaim). Dropping a Future on the floor without doing either can leak the task's resources — the same discipline as pairing every alloc with a free.

cancel requests that the task stop at its next cancellation point — a call into Io that can return error.Canceled, such as io.sleep or a blocking read. The task receives error.Canceled there and unwinds. cancel still returns the result slot, so you observe either the value the task managed to produce or the error.Canceled it unwound with.

const std = @import("std");

fn slowCount(io: std.Io, limit: u64) std.Io.Cancelable!u64 {
    var i: u64 = 0;
    while (i < limit) : (i += 1) {
        // io.sleep is a cancellation point: after cancel is requested it
        // returns error.Canceled instead of sleeping again.
        try io.sleep(std.Io.Duration.fromMilliseconds(1), .awake);
    }
    return i;
}

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

    var future = io.async(slowCount, .{ io, 1_000_000 });

    // We no longer need the result. cancel requests a stop and reclaims the
    // Future, returning whatever the task unwound with.
    const result = future.cancel(io);
    if (result) |count| {
        std.debug.print("finished early with {d}\n", .{count});
    } else |err| {
        std.debug.print("cancelled: {s}\n", .{@errorName(err)});
    }
}

Because a cancelled task usually returns error.Canceled, it is almost always a bug to ignore that error — swallowing it hides the fact that the work did not complete. The same rule applies to a Group: group.cancel requests cancellation on every member and reclaims the group's resources in one call.

What Io.Threaded Actually Is

The default Io you get in main is backed by Io.Threaded. Say plainly what it is: it runs your tasks on a pool of operating-system threads. io.async and io.concurrent hand work to those threads; await joins on them. There is no user-space scheduler and no event loop — the "async" is task-level concurrency over real threads, presented through a uniform interface so your code does not hard-code that choice. This is the production-ready backend, and for the overwhelming majority of programs it is the right one.

Because it is threads underneath, the usual rules apply: tasks that touch shared data need synchronization, and spawning millions of tiny tasks costs real thread resources — which is why the examples above use small task counts.

An Honest Word on Event Loops

Zig does have a second backend, Io.Evented, built on an event loop and stackful coroutines — the "green threads" story people associate with async. It is real, but as of 0.16 its maintainers flag it experimental and explicitly not intended for use by non-contributors. It is not something you should build on today. The value of the std.Io interface is exactly that when Io.Evented matures, code written against Io can adopt it without a rewrite — but for now, Io.Threaded is the backend you use, and event loops are an outlook, not a tool.

Key Takeaways

  • Zig has no async/await keywords; concurrency is the std.Io interface, passed explicitly like an Allocator
  • main(init: std.process.Init) hands you a ready io via Zig 0.16's process initialization
  • io.async launches a task and returns a Future; future.await(io) waits and yields the result
  • io.concurrent promises real parallelism and returns error.ConcurrencyUnavailable when the backend cannot provide it
  • std.Io.Group fans out N tasks and awaits or cancels them as one unit; group tasks return Cancelable!void and report via shared memory
  • A Future is a resource — always await or cancel it, and never ignore error.Canceled
  • The default backend Io.Threaded runs tasks on OS threads; Io.Evented (event loops) exists but is experimental and out of scope

Pro Tip: Prefer io.async when overlap is optional and io.concurrent only when two operations genuinely need to be in flight at once — asking for concurrency you do not need just adds an error case to handle. Let the Io interface, not your call sites, decide how tasks are scheduled.

Next Steps

You can now launch, await, and cancel concurrent work through Zig's Io interface. But how do you know your tasks actually do what you intend? Next, you'll learn Zig's built-in testing framework, which lets you write and run tests right alongside your source code.

Next lesson

Testing

Write Zig unit tests using built-in test blocks and std.testing — run tests with zig test, assert values, and detect memory leaks automatically.

22 min