Lukáš Lalinský

Categories

Tags

Zig 0.16 shipped last month with std.Io, a cross-platform interface for I/O and concurrency. This is a big step for the ecosystem. Libraries can now be written against a standard I/O abstraction, independent of the runtime, and application developers can plug in whatever implementation they want.

The only usable implementation shipped with 0.16 is std.Io.Threaded, which uses a thread pool. When you spawn concurrent tasks, it creates OS threads to run them. Let’s see how it works with a simple example:

const std = @import("std");

const num_tasks = 10_000;

fn task(io: std.Io) std.Io.Cancelable!void {
    try io.sleep(.fromSeconds(10), .awake);
}

pub fn main(init: std.process.Init) !void {
    var group: std.Io.Group = .init;
    for (0..num_tasks) |_| {
        try group.concurrent(init.io, task, .{init.io});
    }
    try group.await(init.io);
}

This spawns 10,000 concurrent tasks, each sleeping for 10 seconds. On my machine, it completes in about 20 seconds:

$ time ./std_demo

real    0m20.158s
user    0m2.258s
sys     0m10.098s

The overhead comes from spawning OS threads. If you try increasing this to 50,000 tasks, it will likely fail on most systems due to thread limits (ulimit -u on Linux).

This isn’t just an arbitrary benchmark. Asynchronous I/O exists to solve a real problem: network servers with many connected clients. You don’t want to spawn an OS thread for every client connection. That’s why we have event loops, coroutines, and async I/O.

There is std.Io.Evented in the standard library, which is meant to use io_uring on Linux and kqueue on BSD/macOS. It’s still a work in progress though, missing many functions and doesn’t currently compile.

I’ve written about zio before, and I’ve just released version 0.11 with a full std.Io implementation. It uses stackful coroutines and asynchronous OS-level I/O APIs (io_uring or epoll on Linux, kqueue on BSD/macOS, IOCP on Windows). Here’s the same example using zio:

const std = @import("std");
const zio = @import("zio");

const num_tasks = 10_000;

fn task(io: std.Io) std.Io.Cancelable!void {
    try io.sleep(.fromSeconds(10), .awake);
}

pub fn main(init: std.process.Init) !void {
    const rt = try zio.Runtime.init(init.gpa, .{});
    defer rt.deinit();

    const io = rt.io();

    var group: std.Io.Group = .init;
    for (0..num_tasks) |_| {
        try group.concurrent(io, task, .{io});
    }
    try group.await(io);
}

The code is almost identical. You just initialize a zio runtime and use its io() method to get the std.Io interface. With zio, the same 10,000 tasks complete in about 10 seconds:

$ time ./zio_demo

real    0m10.606s
user    0m3.136s
sys     0m7.126s

That’s the expected time, since all tasks run truly concurrently. You can increase this to 50,000 or more tasks and it will continue to work, limited only by available memory.

You can use this io instance for anything you’d use std.Io.Threaded for. To write an HTTP server with std.http.Server, for example, just pass zio’s io and it will work the same way.

If you want to use async I/O in Zig 0.16 with the standard APIs, you don’t need to wait for std.Io.Evented to be ready. Zio’s implementation is still new, so if you hit any problems, please reach out on GitHub and I’ll be happy to help.