⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

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:

ContextThe Io you use
maininit.io, a field on the std.process.Init passed to main
A teststd.testing.io
A library functionwhatever 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.