adopting zio in your project#
a practical guide for swapping std.Io.Threaded for zio.ZioBackend in a
zig 0.16 application. written for any project; zlay
(an AT Protocol relay holding ~2,800 websocket subscriptions) is the worked
example throughout because it's the first real consumer.
companion docs: adoption.md (the original per-project analysis), benchmarks.md, baselines.md.
should you adopt it?#
zio pays off when you have many concurrent blocking-style io tasks —
hundreds-plus of io.concurrent calls that each spend their life in
net.Stream reads/writes. that's where thread-per-task costs you
O(conns × stacks) memory and O(conns) threads, and zio gives you fibers on
one loop thread instead (zlay: 2,800 threads → a handful; ~200× less RSS in
the bench).
you don't need it when concurrency is low (a CLI, a modest server), when
your tasks are CPU- or file-bound rather than socket-bound, or when
Io.Threaded simply isn't hurting you. threads are great until they aren't.
the swap#
zio is an ordinary package. no compiler fork, no stdlib patch, no changes to
libraries that take io as a parameter (they can't tell the difference —
that's the point of the vtable).
// build.zig.zon
.zio = .{ .url = "https://tangled.org/zzstoatzz.io/zio/archive/<ref>.tar.gz", .hash = "..." },
// before (zlay main.zig)
const Backend = Io.Threaded;
var backend: Backend = undefined;
backend = Io.Threaded.init(allocator, io_opts);
const io = backend.io();
// after
const zio = @import("zio");
var backend: zio.ZioBackend = undefined;
try backend.init(allocator, io_opts); // io_opts forwards to the inner Threaded
try backend.start(); // spawns the loop thread
const io = backend.io();
everything downstream that receives io — websocket clients, http servers,
protocol libraries — is unchanged.
what runs where#
ZioBackend is a hybrid: it wraps a real Io.Threaded and routes each
vtable call by caller domain.
| you call | from a fiber (inside io.concurrent task) |
from anywhere else |
|---|---|---|
io.concurrent |
fiber spawned directly | fiber spawned via injection |
Future.await/cancel |
parks the fiber | blocks the thread (futex) |
net connect/accept/read/write |
parks on poller readiness | delegates to Threaded |
io.sleep |
sched timer heap | delegates |
Io.Mutex/Condition/Queue |
fiber-aware futex (parks fiber) | real futex |
DNS (netLookup) |
runs on a Threaded thread; fiber parks | delegates |
| files, processes, everything else | delegates — runs on the loop thread | delegates |
that last row is the one rule you must design around.
the four rules#
-
fiber tasks should be socket-shaped. anything a fiber calls that isn't overridden runs blocking on the loop thread and stalls every other fiber. sockets, sleeps, sync, DNS: fine. file io, process spawns, database client calls: keep them off fibers (rule 2).
-
keep a second, threaded io for blocking work. the pattern zlay already runs (its
pool_io): construct a plainIo.Threadedalongside zio and hand it to components that block — database pools, disk persistence, worker pools. cross-domain handoff throughIo.Mutex/Io.Queueis safe: zio's futex ops wake both domains. (this "two-io split" is not a workaround; it's the design. blocking work should be on threads.) -
an fd belongs to the domain that created it. sockets opened by a fiber are non-blocking and parked on zio's poller; sockets opened on the threaded side are blocking. don't ship an fd across domains mid-life. (zio flips listener fds non-blocking on first fiber accept, so listen-on-main / accept-on-fiber is supported.)
-
cancellation is prompt but cooperative.
future.cancel(io)wakes the task's fiber wherever it's parked (io, futex, timer, await) and the next io/sleep call returnserror.Canceled. a fiber that never calls back intoiocan't be canceled — same contract as Threaded's checkpoints.
gotchas we hit so you don't#
readSliceShortis not a message-read. it parks until the buffer fills; short reads happen only at stream end. an echo server using it with a 32-byte buffer on 11-byte messages deadlocks — on any backend. use exact-size reads (readSliceAll) for framed protocols, or take whatReaderbuffered.- never rely on closing an fd to wake a waiter. kqueue silently drops a closed fd's filters — the wakeup is lost, the fiber parks forever. use timed waits for shutdown-observing loops; zio's engine/sched expose them, and the vtable's own ops re-check cancellation on every wake.
- shutdown order: cancel/await your fibers, then
backend.deinit(). deinit stops the loop thread; fibers still parked at that point never ran their cleanup.
rollout discipline (from zlay's ops history)#
- land the dependency +
const Backendswitch behind a build option, so-Dbackend=threadedand-Dbackend=ziobuild the same code. - green test suite on both backends.
- soak against a simulator or staging traffic — never iterate on the backend in production (zlay learned this with the previous evented attempt; see the april 2026 ops changelog).
- canary with explicit rollback triggers and post-restart checks; watch thread count and RSS — they should fall off a cliff, and if they don't, fibers aren't carrying your sockets.
current maturity (2026-07-12)#
honest status, not marketing:
- proven: the full vtable test matrix (Debug + ReleaseSafe, macos + linux, aarch64; x86_64 correctness under emulation), the fiber gauntlet, the reconnect storm, cross-domain sync, prompt cancellation.
- not yet: guard-page mmap fiber stacks (malloc stacks commit ~256 KB per fiber today), TLS-over-fibers measurement (wss:// paths — expected transparent, not yet benchmarked), multi-loop sharding (one loop thread; plenty for hundreds-of-fps workloads, measure before assuming for yours), io_uring (epoll/kqueue only), non-stream sockets on fibers (UDP delegates to threads), production soak time (zero, until zlay).