Async I/O in Zig 0.16, today

birdculture1 pts0 comments

Async I/O in Zig 0.16, today | Lukáš Lalinský

Menu

Lukáš Lalinský

2026-05-11

Categories

programming

Tags

zig<br>networking<br>async

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

The only usable implementation shipped with 0.16 is std.Io.Threaded,<br>which uses a thread pool. When you spawn concurrent tasks, it creates<br>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 {<br>try io.sleep(.fromSeconds(10), .awake);

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

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

$ time ./std_demo

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

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

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

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

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

const std = @import("std");<br>const zio = @import("zio");

const num_tasks = 10_000;

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

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

const io = rt.io();

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

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

$ time ./zio_demo

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

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

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

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

init group const tasks async implementation

Related Articles