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 regressed out of the language with 0.11.0 and are not coming back. Instead, Zig 0.16 exposes concurrency through an interface — std.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.
What You'll Learn
By the end of this lesson you will fan out four independent tasks with std.Io.Group, have each write its answer into a slice you own, and await the whole group with one call — then find out what happens when two of those tasks write to the same place instead of their own. That second half is the point: task-level concurrency is easy to launch and easy to get quietly wrong.
This is an optional extension, not a capstone milestone — the record analyzer never launches a task. What this lesson is for is the idea that a capability can be a parameter rather than an ambient power, which is the same idea allocators taught, applied to time instead of memory. You arrive here able to thread an Allocator through a call chain and to pair every acquisition with its release (Memory and Allocators); a Future is that discipline again, with await or cancel in place of free.
A word on version stability before you invest in the details. Everything in this track is pinned to Zig 0.16.0, and std.Io is the least settled surface in the standard library — more so than anything else you have learned here. It is new in 0.16: the 0.15.1 release reworked the writer and reader interfaces, and 0.16 then made I/O take an Io instance, which is the model this lesson teaches. Expect the spelling to move again at 0.17: names, the shape of std.process.Init, and which operations are cancellation points are all fair game. What is unlikely to move is the design — that concurrency is a value you pass rather than a runtime you switch on. Learn the model here; check the release notes before you copy a signature into production code.
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.
The word "launches" is doing some work there, and it is worth pinning down what it does and does not promise. Predict the exact order of these four lines.
Predict
The task prints from inside its own body. Predict the order of the four printed lines on the default Io backend.
const std = @import("std");
fn label(n: u32) u32 {
std.debug.print(" running task {d}\n", .{n});
return n * 10;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
std.debug.print("before launch\n", .{});
var f = io.async(label, .{1});
std.debug.print("after launch\n", .{});
const r = f.await(io);
std.debug.print("awaited {d}\n", .{r});
}The three lines in main are ordinary sequential statements, so before launch, after launch, awaited 10 appear in that order, and await cannot return before the task finishes. Where the task's own line falls between them is the part io.async does not promise: on the default threaded backend this run prints it after after launch, but the contract permits running the work eagerly, inline, or out of order. The only guarantee you may rely on is that it completes before await returns.
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.
Make that difference concrete by enacting it, in two steps, predicting each before you run:
- Downgrade the promise but keep the error handling. Change
io.concurrent(sumTo, .{1000})toio.async(sumTo, .{1000}), leaving thecatch |err| switch (err)exactly as it is. Predict whether it compiles before running. It does not. The compiler stops witherror: expected error union type, found 'Io.Future(u64)', pointing at thecatch. This is the type system stating the difference between the two calls:io.concurrentcan fail to deliver real concurrency, so it hands back an error union you must unwrap, whileio.asyncpromises so little that there is nothing to fail — it returns theFuturedirectly. The error case you were handling does not exist any more. - Now remove the handling too. Delete the whole
catch |err| switch (err) { ... }clause so the line readsvar future = io.async(sumTo, .{1000});. Predict the output before running. It compiles and printssum = 499500, the same answer as before. The observable result is identical — which is the honest summary of the trade. You gave up a guarantee that this particular program never needed, and in exchange the error branch disappeared. The lesson is that the fallback path in the original fence is not boilerplate: it exists precisely becauseio.concurrentasked for something a backend might not be able to give.
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.
Before debugging one, assemble one. The lines below are the body of a runAll(io: std.Io, out: []u64) that fans tasks over a group and reports what they wrote, given a worker that sleeps 50 ms and then writes (id + 1) * 10 into out[id]. Only one arrangement both compiles and reports every result — and the sleep is what makes the difference visible.
Arrange the code
These lines are the body of a function that fans four tasks out over a group, waits for them, and reports what they wrote. Each task sleeps briefly before writing (id + 1) * 10 into its own slot, so the finished array is { 10, 20, 30, 40 } and the total is 100. Reorder them so it prints exactly that — then answer what the order is really testing: which two lines read the out array, and what does each of them see if it runs before group.await has returned?
for (0..out.len) |id| {var total: u64 = 0;var group: std.Io.Group = .init;try group.await(io);}for (out) |v| total += v;std.debug.print("results: {any} total={d}\n", .{ out, total });group.async(io, worker, .{ io, id, out });
Writing into shared memory is where fan-out goes wrong, and it rarely goes wrong loudly. The program below splits work across four tasks and reports a total that is close enough to look plausible. Commit a hypothesis before you change anything.
Debug
This counts the words across eight lines by handing two lines to each of four tasks. Each task owns its own results index, so nothing is racing, and the eight lines hold 33 words between them. It compiles, runs, exits cleanly — and prints a total that is not 33. Commit a hypothesis about which lines each task is actually being given, then fix it so the program prints 'total words = 33'.
const std = @import("std");
// Each task counts the words in its own chunk and reports through the results
// slice: every task owns one index, so no two tasks write to the same place.
fn countChunk(io: std.Io, id: usize, chunk: []const []const u8, results: []u64) std.Io.Cancelable!void {
_ = io;
var count: u64 = 0;
for (chunk) |line| {
var it = std.mem.tokenizeScalar(u8, line, ' ');
while (it.next()) |_| count += 1;
}
results[id] = count;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const lines = [_][]const u8{
"the quick brown fox", "jumps over the lazy dog",
"pack my box with", "five dozen liquor jugs",
"how vexingly quick daft", "zebras jump but not",
"the five boxing wizards", "jump quickly at dawn",
};
const task_count = 4;
const per_task = lines.len / task_count;
var results: [task_count]u64 = @splat(0);
var group: std.Io.Group = .init;
var id: usize = 0;
while (id < task_count) : (id += 1) {
const chunk = lines[id..][0..per_task];
group.async(io, countChunk, .{ io, id, chunk, &results });
}
try group.await(io);
var total: u64 = 0;
for (results) |r| total += r;
std.debug.print("total words = {d}\n", .{total});
}
Expected output:
total words = 33
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 the 0.16 release notes call it "work-in-progress, experimental, serving to inform the evolution of the interface" — while Io.Threaded is the one they describe as feature-complete and well-tested. 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.
Try It Yourself
Reading about fan-out is not the same as wiring one up. This is a build task: a small program that reports its own pass/fail. Three pieces are stubbed out — the body of a group task, the function that divides the work and launches the group, and the fold that adds the subtotals up. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error. Note that main here takes a std.process.Init so it can hand you an io — the group needs one, and so does every task you add to it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each does its job. Run it as-is to see which check fails first, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Watch the offset arithmetic in TODO 2: the checks pin down which readings each task must receive, not merely that the grand total comes out right.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
const Reading = struct {
sensor: []const u8,
value: u32,
};
// TODO 1: fill in this task body. It runs as one member of a group, so it
// reports by WRITING into out rather than by returning a value. Sum the
// value field of every reading in batch and store the total at out[id].
// batch = { .value = 5 }, { .value = 7 }, id = 2 -> out[2] becomes 12
fn sumBatch(io: std.Io, id: usize, batch: []const Reading, out: []u64) std.Io.Cancelable!void {
_ = io;
_ = batch;
out[id] = 0;
}
// TODO 2: split readings into task_count contiguous batches and launch one
// sumBatch task per batch on the group, then wait for all of them.
// readings.len is always an exact multiple of task_count.
// Remember the batch for task id starts at id * batch_len, not at id.
// Every reading must land in exactly one batch.
fn fanOutSums(io: std.Io, readings: []const Reading, out: []u64, task_count: usize) !void {
_ = io;
_ = readings;
_ = out;
_ = task_count;
}
// TODO 3: add up the per-task subtotals into one grand total. This runs after
// the group has been awaited, so every slot is populated.
// totalOf(&.{ 12, 30 }) -> 42
fn totalOf(subtotals: []const u64) u64 {
_ = subtotals;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main(init: std.process.Init) !void {
const io = init.io;
const readings = [_]Reading{
.{ .sensor = "inlet", .value = 10 }, .{ .sensor = "inlet", .value = 20 },
.{ .sensor = "core", .value = 30 }, .{ .sensor = "core", .value = 40 },
.{ .sensor = "outlet", .value = 50 }, .{ .sensor = "outlet", .value = 60 },
.{ .sensor = "vent", .value = 70 }, .{ .sensor = "vent", .value = 80 },
};
var subtotals: [4]u64 = @splat(0);
try fanOutSums(io, &readings, &subtotals, 4);
try check(subtotals[0] == 30, "task 0 should sum the FIRST two readings: 10 + 20 = 30");
try check(subtotals[1] == 70, "task 1 should sum the THIRD and FOURTH readings: 30 + 40 = 70");
try check(subtotals[3] == 150, "task 3 should sum the LAST two readings: 70 + 80 = 150");
try check(totalOf(&subtotals) == 360, "every reading must be counted exactly once, so the grand total is 360");
try check(totalOf(&.{}) == 0, "the total of no subtotals at all is 0, not a crash");
try check(totalOf(&.{ 12, 30 }) == 42, "totalOf should add its input, not just return the first element");
var two: [2]u64 = @splat(0);
try fanOutSums(io, &readings, &two, 2);
try check(two[0] == 100, "with 2 tasks the first batch is four readings: 10 + 20 + 30 + 40 = 100");
try check(totalOf(&two) == 360, "the grand total does not depend on how many tasks you split across");
std.debug.print("All checks passed.\n", .{});
std.debug.print("subtotals: {any}\n", .{subtotals});
std.debug.print("grand total: {d}\n", .{totalOf(&subtotals)});
}
Expected output:
All checks passed.
subtotals: { 30, 70, 110, 150 }
grand total: 360Once it passes, try two variations and predict each before running:
- Drop the stride multiply. In
fanOutSums, change the slice expression fromreadings[id * batch_len ..][0..batch_len]toreadings[id..][0..batch_len]. Predict which check fails before running. It compiles and runs. Thesubtotals[0] == 30check still passes — task 0 is unaffected, because0 * batch_lenand0are the same — butsubtotals[1] == 70fails, because task 1 now receives readings 1 and 2 (20 + 30 = 50) instead of readings 2 and 3. This is the same defect as the debug block above, met from the writing side rather than the reading side. - Await inside the loop. Move
try group.await(io);from after thewhileloop to the last statement inside it. Predict what happens before running. Every check still passes and the output is identical: the work was correct either way. What changed is invisible in the result — each task is now awaited before the next is launched, so the four tasks run one after another instead of together, and the fan-out has become a sequential loop with extra ceremony. It is a useful reminder that a concurrency bug does not have to produce a wrong answer to be a bug.
Two more questions before the summary — one reaching back to the lesson this whole model was built on, and one carrying the idea into another language.
Recall
No notes. This lesson opened by comparing Io to something you met in Memory and Allocators, thirteen lessons back: a capability Zig refuses to make ambient. In that lesson, a function that needed to allocate had to take an std.mem.Allocator parameter rather than reaching for a global heap. What did that design buy the caller, and how does the same reasoning apply to a function's Io parameter?
Transfer
In Zig, the ability to do I/O arrives as an Io value that a function must accept as a parameter, and a function without one cannot do I/O. In Rust, an async function is written with no such parameter — it returns a Future that does nothing until some executor, chosen elsewhere in the program, polls it. Which statement identifies what the two designs genuinely share, rather than a surface resemblance?
Key Takeaways
- Zig has no
async/awaitkeywords; concurrency is thestd.Iointerface, passed explicitly like anAllocator main(init: std.process.Init)hands you a readyiovia Zig 0.16's process initializationio.asynclaunches a task and returns aFuture;future.await(io)waits and yields the resultio.concurrentpromises real parallelism and returnserror.ConcurrencyUnavailablewhen the backend cannot provide itstd.Io.Groupfans out N tasks and awaits or cancels them as one unit; group tasks returnCancelable!voidand report via shared memory- A
Futureis a resource — alwaysawaitorcancelit, and never ignoreerror.Canceled - The default backend
Io.Threadedruns tasks on OS threads;Io.Evented(event loops) exists but is experimental and out of scope
Pro Tip: Prefer
io.asyncwhen overlap is optional andio.concurrentonly 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 theIointerface, 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 -- parallelism at the task level. The final extension goes one level down, to parallelism inside a single instruction: @Vector, element-wise arithmetic, and @reduce.