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; Choosing an Io
covers the others and when you would swap. 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. std.Io.Dir.readFile,
std.Io.Clock.Timestamp.now and Io.Mutex.lock all take one as their first
argument for that reason.
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 async and Group, and
Cancellation with stopping a task
once it is running. 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.