This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

zio / docs / adoption-guide.md
6.3 kB

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#

  1. 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).

  2. keep a second, threaded io for blocking work. the pattern zlay already runs (its pool_io): construct a plain Io.Threaded alongside zio and hand it to components that block — database pools, disk persistence, worker pools. cross-domain handoff through Io.Mutex / Io.Queue is 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.)

  3. 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.)

  4. 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 returns error.Canceled. a fiber that never calls back into io can't be canceled — same contract as Threaded's checkpoints.

gotchas we hit so you don't#

  • readSliceShort is 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 what Reader buffered.
  • 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)#

  1. land the dependency + const Backend switch behind a build option, so -Dbackend=threaded and -Dbackend=zio build the same code.
  2. green test suite on both backends.
  3. 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).
  4. 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).