This repository has no description
1# adopting zio in your project
2
3a practical guide for swapping `std.Io.Threaded` for `zio.ZioBackend` in a
4zig 0.16 application. written for any project; [zlay](https://tangled.org/zat.dev/zlay)
5(an AT Protocol relay holding ~2,800 websocket subscriptions) is the worked
6example throughout because it's the first real consumer.
7
8companion docs: [adoption.md](adoption.md) (the original per-project
9analysis), [benchmarks.md](benchmarks.md), [baselines.md](baselines.md).
10
11## should you adopt it?
12
13zio pays off when you have **many concurrent blocking-style io tasks** —
14hundreds-plus of `io.concurrent` calls that each spend their life in
15`net.Stream` reads/writes. that's where thread-per-task costs you
16O(conns × stacks) memory and O(conns) threads, and zio gives you fibers on
17one loop thread instead (zlay: 2,800 threads → a handful; ~200× less RSS in
18the bench).
19
20you don't need it when concurrency is low (a CLI, a modest server), when
21your tasks are CPU- or file-bound rather than socket-bound, or when
22`Io.Threaded` simply isn't hurting you. threads are great until they aren't.
23
24## the swap
25
26zio is an ordinary package. no compiler fork, no stdlib patch, no changes to
27libraries that take `io` as a parameter (they can't tell the difference —
28that's the point of the vtable).
29
30```zig
31// build.zig.zon
32.zio = .{ .url = "https://tangled.org/zzstoatzz.io/zio/archive/<ref>.tar.gz", .hash = "..." },
33```
34
35```zig
36// before (zlay main.zig)
37const Backend = Io.Threaded;
38var backend: Backend = undefined;
39backend = Io.Threaded.init(allocator, io_opts);
40const io = backend.io();
41
42// after
43const zio = @import("zio");
44var backend: zio.ZioBackend = undefined;
45try backend.init(allocator, io_opts); // io_opts forwards to the inner Threaded
46try backend.start(); // spawns the loop thread
47const io = backend.io();
48```
49
50everything downstream that receives `io` — websocket clients, http servers,
51protocol libraries — is unchanged.
52
53## what runs where
54
55`ZioBackend` is a hybrid: it wraps a real `Io.Threaded` and routes each
56vtable call by caller domain.
57
58| you call | from a fiber (inside `io.concurrent` task) | from anywhere else |
59|---|---|---|
60| `io.concurrent` | fiber spawned directly | fiber spawned via injection |
61| `Future.await/cancel` | parks the fiber | blocks the thread (futex) |
62| `net` connect/accept/read/write | parks on poller readiness | delegates to Threaded |
63| `io.sleep` | sched timer heap | delegates |
64| `Io.Mutex`/`Condition`/`Queue` | fiber-aware futex (parks fiber) | real futex |
65| DNS (`netLookup`) | runs on a Threaded thread; fiber parks | delegates |
66| files, processes, everything else | **delegates — runs on the loop thread** | delegates |
67
68that last row is the one rule you must design around.
69
70## the four rules
71
721. **fiber tasks should be socket-shaped.** anything a fiber calls that
73 isn't overridden runs *blocking on the loop thread* and stalls every
74 other fiber. sockets, sleeps, sync, DNS: fine. file io, process spawns,
75 database client calls: keep them off fibers (rule 2).
76
772. **keep a second, threaded io for blocking work.** the pattern zlay
78 already runs (its `pool_io`): construct a plain `Io.Threaded` alongside
79 zio and hand it to components that block — database pools, disk
80 persistence, worker pools. cross-domain handoff through `Io.Mutex` /
81 `Io.Queue` is safe: zio's futex ops wake both domains. (this "two-io
82 split" is not a workaround; it's the design. blocking work *should* be
83 on threads.)
84
853. **an fd belongs to the domain that created it.** sockets opened by a
86 fiber are non-blocking and parked on zio's poller; sockets opened on the
87 threaded side are blocking. don't ship an fd across domains mid-life.
88 (zio flips listener fds non-blocking on first fiber accept, so
89 listen-on-main / accept-on-fiber is supported.)
90
914. **cancellation is prompt but cooperative.** `future.cancel(io)` wakes the
92 task's fiber wherever it's parked (io, futex, timer, await) and the next
93 io/sleep call returns `error.Canceled`. a fiber that never calls back
94 into `io` can't be canceled — same contract as Threaded's checkpoints.
95
96## gotchas we hit so you don't
97
98- **`readSliceShort` is not a message-read.** it parks until the buffer
99 fills; short reads happen only at stream end. an echo server using it with
100 a 32-byte buffer on 11-byte messages deadlocks — on any backend. use
101 exact-size reads (`readSliceAll`) for framed protocols, or take what
102 `Reader` buffered.
103- **never rely on closing an fd to wake a waiter.** kqueue silently drops a
104 closed fd's filters — the wakeup is lost, the fiber parks forever. use
105 timed waits for shutdown-observing loops; zio's engine/sched expose them,
106 and the vtable's own ops re-check cancellation on every wake.
107- **shutdown order**: cancel/await your fibers, then `backend.deinit()`.
108 deinit stops the loop thread; fibers still parked at that point never ran
109 their cleanup.
110
111## rollout discipline (from zlay's ops history)
112
1131. land the dependency + `const Backend` switch behind a build option, so
114 `-Dbackend=threaded` and `-Dbackend=zio` build the same code.
1152. green test suite on both backends.
1163. soak against a simulator or staging traffic — never iterate on the
117 backend in production (zlay learned this with the previous evented
118 attempt; see the april 2026 ops changelog).
1194. canary with explicit rollback triggers and post-restart checks; watch
120 thread count and RSS — they should fall off a cliff, and if they don't,
121 fibers aren't carrying your sockets.
122
123## current maturity (2026-07-12)
124
125honest status, not marketing:
126
127- proven: the full vtable test matrix (Debug + ReleaseSafe, macos + linux,
128 aarch64; x86_64 correctness under emulation), the fiber gauntlet, the
129 reconnect storm, cross-domain sync, prompt cancellation.
130- not yet: guard-page mmap fiber stacks (malloc stacks commit ~256 KB per
131 fiber today), TLS-over-fibers measurement (wss:// paths — expected
132 transparent, not yet benchmarked), multi-loop sharding (one loop thread;
133 plenty for hundreds-of-fps workloads, measure before assuming for yours),
134 io_uring (epoll/kqueue only), non-stream sockets on fibers (UDP delegates
135 to threads), production soak time (zero, until zlay).