The Io Interface
Modern Zig has one idea running through its whole standard library: any
operation that can block, reading a file, opening a socket, sleeping, reading
the clock, takes an std.Io parameter. Nothing blocks behind your back, the
same way nothing allocates behind your back. If a function’s signature has no
Io, it cannot do I/O.
const std = @import("std");
const expect = std.testing.expect;
// A function that takes an `Io` is announcing, in its signature, that it may
// block: read a clock, touch a file, wait on the network. It does not decide
// *how* that blocking is serviced; the `Io` it is handed does.
fn elapsedNanos(io: std.Io) i96 {
const start = std.Io.Clock.Timestamp.now(io, .awake);
var acc: u64 = 0;
for (0..10_000) |i| acc +%= i;
std.mem.doNotOptimizeAway(acc);
const end = std.Io.Clock.Timestamp.now(io, .awake);
return start.durationTo(end).raw.toNanoseconds();
}
test "get an Io and pass it down" {
// In a test the instance is `std.testing.io`. In a program it is
// `init.io`, the field on the `std.process.Init` your `main` receives.
// Both are backed by `std.Io.Threaded`: a real thread pool.
const io = std.testing.io;
try expect(elapsedNanos(io) >= 0);
}
fn double(out: *u32, value: u32) void {
out.* = value * 2;
}
test "the same Io is what makes concurrency work" {
// Because blocking goes through the interface, the interface can also
// *schedule* it. `io.async` starts a task; the Threaded io runs it on
// another thread. Swap in an event-loop io and the identical call would
// suspend a coroutine instead. The calling code does not change.
const io = std.testing.io;
var a: u32 = 0;
var b: u32 = 0;
var first = io.async(double, .{ &a, 10 });
var second = io.async(double, .{ &b, 20 });
first.await(io);
second.await(io);
try expect(a == 20 and b == 40);
}What Io is
std.Io is an interface: a pointer plus a vtable, exactly the shape of
std.mem.Allocator. The vtable holds function pointers for the primitive
operations, reading, writing, waiting, scheduling, and a concrete
implementation fills them in. The standard one is std.Io.Threaded, backed by
a real thread pool. It is the same dependency-injection pattern the allocator
uses, applied to blocking instead of to memory. That is why this chapter sits
next to Allocators: learn the two together
and the rest of the library stops surprising you.
Where an Io comes from
You almost never construct one. It arrives at the top of your program and you thread it downward:
| Context | The Io you use |
|---|---|
main | init.io, a field on the std.process.Init passed to main |
| A test | std.testing.io |
| A library function | whatever the caller hands you: take io: std.Io |
A function that needs to block does not reach for a global. It accepts an Io
and passes it to the calls that block. This is why std.Io.File.readFile,
std.Io.Clock.Timestamp.now, and Io.Mutex.lock all take one as their first
argument.
Why route everything through one interface
Two payoffs justify the parameter that is now on every I/O call.
The signature tells the truth. A function that takes an Io announces that
it may block; one that does not, cannot. You can see a function’s effects from
its type, with no documentation and no surprises, the same guarantee the
allocator parameter gives you for memory.
The caller chooses the execution model. Because blocking goes through the
interface, the interface can also schedule it. io.async starts a task: on
Io.Threaded it runs on another thread; hand the same code an event-loop Io
and the identical call suspends a coroutine instead. The function being called
never changes. This is Zig’s answer to the “function coloring” problem, there
is no separate async version of readFile, only one function that does
whatever the Io it was given does.
The Concurrency chapter builds on this with
Future and Group; Readers and Writers,
Filesystem, and Time
are each one corner of the same interface.
If you are arriving from an older Zig
This is the change nicknamed “writergate” (Zig 0.15) and its follow-on work.
Code that once wrote std.fs.cwd().openFile(...) or std.time.timestamp()
with no Io in sight now routes through std.Io.Dir and
std.Io.Clock. The free-standing, globally-available I/O functions are gone;
the migration page lists the
specific renames.