Library / SDK
lalinsky/zio avatar
lalinsky/zio

zio gives Zig a coroutine runtime and a std.Io implementation

Async I/O framework for Zig

681 stars48 forksZigNOASSERTION

At a glance

What is it?
zio is an asynchronous runtime for Zig that schedules stackful coroutines across a pool of OS threads and provides a full implementation of the standard library's std.Io interface, so code written against std.Io runs unmodified. It suits a Zig developer who wants blocking-looking I/O on top of io_uring, kqueue or IOCP, and the layers are usable separately if the default scheduler is wrong.
Who is it for?
Adopt zio when you are writing networked or concurrent Zig code on the 0.17 toolchain and want coroutines with structured concurrency, cancellation and timeout support, or when you want to keep a library written against std.Io and let it run on this runtime. Do not adopt it if you are still on Zig 0.16 and intend to track main, because main targets 0.17 and the 0.16 line lives on the v0.19.x releases and the zig-0.16 branch.
Can I use it commercially?
Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly Zig, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Three layers you can take or leave

zio is described as an asynchronous runtime for Zig in the same spirit as Go's runtime or Tokio, and the README is explicit that it is built in layers with each layer usable on its own. The bottom layer is zio.ev, a cross-platform, callback-based event loop over io_uring, epoll, kqueue, iocp and poll, sitting in the same space as libuv or libxev. You can use it independently for async I/O without coroutines at all, or to embed zio's I/O into an existing callback-driven loop, and the README points at blazio, a CPython asyncio event loop built on zio.ev, as the example of that second use. Above it sits zio.coro, which holds stackful coroutine primitives, context switching, growable stacks and manual scheduling, with no I/O and no scheduler attached. That layer exists for people who want to build a different kind of scheduler rather than accept the one that ships. The top layer is zio.Runtime, the full runtime, which schedules zio.coro coroutines across executor threads, drives their I/O through zio.ev, and adds structured concurrency with task groups, cancellation, synchronization primitives and the std.Io implementation. Most programs use only this layer and never touch what is underneath.

std.Io is the reason the library matters

The feature that changes the calculus for Zig developers is that zio is a full implementation of the standard library's std.Io interface, so Zig 0.17 code written against std.Io runs on zio unmodified. That is a very different proposition from adopting a framework. A library that takes its I/O from a parameter rather than from a global runtime can be given a zio implementation and work, without the library knowing anything about zio. The README says the same thing in its usage section, where there are two main ways to use zio, the native API and the standard library's std.Io interface, and for most cases, especially if you are writing a library, it says to prefer the std.Io interface. The native API is more direct and has more features, but it ties you to the zio runtime. So the choice is not between two frameworks, it is between a portable interface with a smaller feature set and a richer one that owns the runtime. That framing also explains why the project tracks the Zig version so closely, since std.Io is itself new in the 0.17 standard library.

Work stealing versus pinned scheduling, decided at compile time

A runtime can run single-threaded, or multi-threaded in one of two modes, and the mode is chosen at compile time in your root module. With work-stealing, which is the default, idle executors steal work from busy ones, and the README gives the reason, that this keeps every thread busy when runnable work is spread unevenly to begin with. With pinned scheduling, a task stays on whichever executor it was spawned on for its entire life, with no migration and no cross-executor synchronization on the scheduling path, at the cost of no rebalancing if load is uneven. The declaration is short:

zig
pub const zio_options: zio.Options = .{ .scheduling = .pinned };

Pinned scheduling is the more interesting choice to consider, because it is a bet about the workload. If your tasks are unevenly distributed and mostly independent, stealing keeps the machine busy. If your tasks are long-lived and talk to each other, pinning removes the possibility of a task migrating mid-flight and removes the cross-executor synchronization that migration requires, at the price of leaving threads idle when the busy ones are loaded. Deciding this at compile time rather than at runtime is what keeps the scheduler path free of dynamic dispatch, and it means the choice has to be made before you know your production load distribution, which is a real constraint.

Platform coverage and the gaps in it

The support matrix is stated as support for Linux with io_uring and automatic epoll fallback, Windows with iocp, macOS, FreeBSD, NetBSD and OpenBSD with kqueue, and many other systems through poll. The I/O completeness varies by platform in ways the README is explicit about, and these are the details to read before assuming parity. Network I/O is fully asynchronous on all systems, supporting TCP, UDP, Unix sockets and raw IP sockets. File I/O is fully asynchronous on Linux, partially asynchronous for read and write on Windows, and uses blocking syscalls in a thread pool on other systems. The DNS resolver is fully asynchronous on Linux, Windows and macOS, and uses getaddrinfo in a thread pool elsewhere. So on a Linux production target you get the whole thing on the fast path, while a macOS deployment gets asynchronous networking but falls back to a thread pool for files and DNS. The context switching layer is written in user mode and covers x86_64, x86, aarch64, arm, thumb, riscv32, riscv64, loongarch64 and powerpc64. The stacks are growable, implemented by auto-extending virtual memory reservations, which is what makes a stackful coroutine practical without a fixed stack size per task.

Cancellation, timeouts and structured concurrency

The concurrency features are the ones that determine whether a runtime is usable in production, and the list here is specific. There is fast and safe cancellation support for all operations, and full timeout support, both for individual I/O operations and for arbitrary user code, with proper cleanup on either a timeout or an external cancel. That last clause is the one that matters, because a timeout that leaves the underlying operation dangling is a resource leak, and the guarantee that cleanup happens on either path is what allows timeouts to be used freely rather than carefully. Structured concurrency comes in the form of task groups, so a group of tasks is cancelled as a unit when its parent finishes or is cancelled, which is visible in the example code where a zio.Group is initialised, deferred to cancel, and then used to spawn the per-client handler. Beyond that there is the ability to wait on a mix of different operations at once, tasks, channels, timeouts, and anything else implementing the wait protocol, so a single completion point can cover several kinds of event. Synchronization primitives are included, including more advanced ones such as channels. There is also an integration with std.log and std.debug.print through a custom debug_io, so that logging and printing do not block the event loop, which is the kind of detail that separates a runtime used in production from one used in demos.

Getting it into a Zig project

Installation is two steps. First, add zio as a dependency in build.zig.zon:

bash
zig fetch --save "git+https://github.com/lalinsky/zio#v0.19.0"

Then in your build.zig, add the zio module as a dependency to your program:

zig
const zio = b.dependency("zio", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("zio", zio.module("zio"));

The version note in the fetch command matters because the branch layout is split by toolchain. The main branch is for Zig 0.17, and for Zig 0.16 the README points at the v0.19.x releases or the zig-0.16 branch. The library is a Zig package, and the top level of the repository shows the shape of the project, with build.zig and build.zig.zon at the root, src/ for the implementation, examples/ with eighteen example programs covering the layers and the primitives, docs/ with a mkdocs.yml for the site, a DEVELOPMENT.md, a check.sh script and test_runner.zig for the test harness. The examples are worth more than a typical gallery of samples because each one isolates a mechanism, with coro_demo.zig and ev_demo.zig for the two lower layers, mutex_demo.zig, producer_consumer.zig and signal_demo.zig for the primitives, and parallel_grep.zig, http_server.zig and ntp_client.zig for realistic programs.

The native API in practice

The README's minimal TCP echo server shows what the native API looks like at runtime. The shape is worth reading because it is the same shape as any structured concurrency program. A runtime is initialised with an allocator and deinitialised on a defer, the listening address is parsed and listened on, a task group is initialised and cancelled on a defer, and the accept loop spawns a handler per connection into that group. The handler closes the stream on a defer, reads lines with a delimiter-aware reader, writes them back and flushes, and the error handling distinguishes error.EndOfStream, which breaks the loop, from any other error, which is returned. The interesting detail is the writer flush, because a buffered writer that is never flushed is a common way to lose the last response in a TCP server, and the explicit flush after every write is the pattern the example teaches. The std.Io version of the same server is given alongside it, with the same structure but the Io value passed in as a parameter, which is the difference that keeps a library portable.

Editorial conclusion

Adopt zio when you are writing networked or concurrent Zig code on the 0.17 toolchain and want coroutines with structured concurrency, cancellation and timeout support, or when you want to keep a library written against std.Io and let it run on this runtime. Do not adopt it if you are still on Zig 0.16 and intend to track main, because main targets 0.17 and the 0.16 line lives on the v0.19.x releases and the zig-0.16 branch. Verify first which Zig version your project pins, whether you want the work-stealing scheduler or the pinned one, and which of the two API styles you are committing to, since the native API ties you to the zio runtime while std.Io does not.

Frequently asked questions

How do I add zio to a Zig project?

Run zig fetch --save "git+https://github.com/lalinsky/zio#v0.19.0" to add it to build.zig.zon, then in build.zig create the dependency with b.dependency and add it to the program with exe.root_module.addImport("zio", zio.module("zio")). The library is a Zig package with build.zig and build.zig.zon at the repository root.

Which Zig version does zio support?

The main branch is for Zig 0.17, and the badge in the README names Zig 0.17.0. For Zig 0.16 the README directs you to the v0.19.x releases or the zig-0.16 branch.

What is the difference between work stealing and pinned scheduling in zio?

With work stealing, which is the default, idle executors steal work from busy ones, keeping every thread busy when runnable work is spread unevenly. With pinned scheduling a task stays on the executor it was spawned on for its whole life, with no migration and no cross-executor synchronization on the scheduling path, but no rebalancing when load is uneven. The mode is chosen at compile time with zio_options.

Can I use zio without coroutines?

Yes. zio.ev is a cross-platform, callback-based event loop over io_uring, epoll, kqueue, iocp and poll that can be used independently for async I/O without coroutines, or embedded into an existing callback-driven loop. zio.coro is the other separable layer, holding stackful coroutine primitives with no I/O or scheduler attached, for building a different kind of scheduler.

Official sources

  1. Issues
  2. lalinsky/zio on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/lalinsky-zio.svg)](https://hysenlabs.com/projects/lalinsky-zio)

Was this useful?