Zig 0.15 走過一段被使用者強烈批評的 async/await 路——color function、stackless coroutine、複雜的編譯期分析。0.16 把那條路徹底拋棄,改採另一種解法:非同步不再是語言層的關鍵字,而是 I/O 介面 std.Io 的後端選擇。函式庫只認 std.Io 介面,呼叫端決定要用 thread pool 還是 io_uring coroutine。
std.Io,從零講起——Zig 0.16 怎麼把非同步從語言降級成庫的選擇
讀完這篇你會知道:Zig 0.16 的 std.Io 是什麼、為什麼把非同步從語言降級成庫的選擇可以避免 colored function 的傳染、以及對於正在設計同類抽象的其他語言(Rust、Swift、Kotlin)來說,這個取捨意味著什麼。順便也會看清楚:把「非同步」從語言關鍵字降階為標準介面,並不是 Zig 團隊一時起意的退讓——那是面對 colored function 在 codebase scale 上的傳染性、面對 io_uring 與 kqueue 兩套截然不同的核心介面、面對使用者社群對「語言魔法」深刻不信任這三股壓力的合力結果。
本篇從一個具體場景開始:寫一個要同時處理一萬個 HTTP 請求的爬蟲。沿著「天真寫法為什麼撞牆 → 主流語言的兩種解法各自的缺陷 → Zig 0.16 用 std.Io 提出的第三條路 → 真實程式碼長什麼樣 → 何時這個取捨會回報、何時不會」這條軸線往上爬。讀者只要懂 thread、I/O wait、callstack 三個概念,剩下都會在本文裡建構起來。
start with a concrete case
假設你在寫一個爬蟲,要同時對 10,000 個網址發 HTTP 請求、等回應、parse、存進資料庫。每個請求大部分時間是 I/O wait,CPU 工作量很少。在這個工作量下,你會怎麼寫?
傳統 thread-per-request 的寫法簡單直覺:開一個 thread pool,每個請求丟一個 task 進去。問題是 10,000 個 thread 在大多數 Linux 預設配置下會直接撞到 ulimit;即使 ulimit 拉高,10,000 個 thread context switch 的 overhead 也大到讓整體吞吐反而下降。Linux 預設的 user-level thread 上限(`ulimit -u`)大多落在 4096 到 30720 之間,取決於發行版與使用者群組;單就「能不能 spawn」這道門檻,一萬個 thread 已經不是隨便能過的關卡。
就算過了門檻,每個 thread 仍要 8 MB 的 default stack——10,000 thread 就是 80 GB 虛擬位址空間。在 64-bit 系統上虛擬空間夠大、physical commit 也只是按 page fault 真的觸碰才掉進來,但 kernel 的 task_struct、scheduler runqueue、TLB 都會跟著膨脹。實測 5,000 個 idle thread 在 Linux 5.x 上 context switch 的 throughput 比 500 個 thread 跑同樣工作低約 40%——cache thrash 是主因,不是 CPU 不夠。
為什麼 fiber 可以開到 64 KB 而 thread 預設要 8 MB?根源在於兩者對「我不知道呼叫鏈最深會到哪裡」的設定值不同。OS thread 從 main 開始要支援整個 process 的任意呼叫鏈,linker 看到 8 MB 是保守上限——大部分 thread 摸不到 8 KB 但 reserve 必須給。fiber 則是「為這個特定任務量身打造」:一個處理 HTTP 請求的 fiber 走的呼叫鏈是 fetch_url → parse → validate → write,深度可預測,64 KB 通常還剩三分之二。zio 還支援 segmented stack——當 fiber 真的需要更深時動態擴張,把保守設計轉成 lazy 機制。這也是為什麼把 std.Io 切到 zio 後,10,000 並發任務的 RSS 從 thread 後端的數 GB 掉到幾百 MB——省下來的不是 CPU、是 page table 的廣度。
另一種寫法是 async/await——但這在 Zig 0.15 的實作有 colored function 問題:呼叫 async 函式的呼叫端自己也得是 async。整條 callstack 都得染色。Zig 0.16 想要不用染色就解決這個並發問題。「染色」這詞來自 2015 年 Bob Nystrom 的那篇〈What Color is Your Function?〉:你的函式庫裡只要有一個函式是 async,所有想呼叫它的函式自己也得是 async,傳染一路往上、直到 main。中途想要從 sync 切回 async 的呼叫端要嘛開 thread block、要嘛重新進事件迴圈——兩者都有顯著成本,也都是抽象漏洞。
把這個語法轉變並排看:拖動下圖中間的滑桿對照 0.15 與 0.16 的同一個 fetch_url——「消失」的不只是關鍵字,是整套讓函式類型被 async 污染的語言機制。
// Zig 0.15 — async/await 染色版本 async fn fetch_url(url: []const u8) ![]u8 { var conn = try await async tcp_connect(url); defer conn.close(); try await async conn.write("GET / HTTP/1.1\r\n\r\n"); return try await async conn.read_all(); } async fn crawl(urls: [][]const u8) ![][]u8 { // 呼叫端被 async 染色:crawl 也必須 async var results = std.ArrayList([]u8).init(allocator); for (urls) |u| { try results.append(try await async fetch_url(u)); } return results.toOwnedSlice(); } pub fn main() !void { // 全鏈染色:main 也得 sync→async 跳板 var frame = async crawl(urls); _ = try nosuspend await frame; }
// Zig 0.16 — std.Io 介面版本 fn fetch_url(io: std.Io, url: []const u8) ![]u8 { var conn = try io.tcp_connect(url); defer conn.close(); try conn.write("GET / HTTP/1.1\r\n\r\n"); return try conn.read_all(); } fn crawl(io: std.Io, urls: [][]const u8) ![][]u8 { // 呼叫端不染色:crawl 是普通函式、收 io 即可 var results = std.ArrayList([]u8).init(allocator); for (urls) |u| { try results.append(try fetch_url(io, u)); } return results.toOwnedSlice(); } pub fn main() !void { // 唯一的「後端選擇」就在這裡 var backend = try zio.init(.{}); _ = try crawl(backend.io(), urls); }
async、await、nosuspend 與 frame 跳板,每層呼叫都得跟著染色;0.16 版本是普通 Zig 函式,多出一個 io: std.Io 參數,main 一次選定後端即可。中段是 crawl——這就是 colored function 傳染最痛的位置。同一個 fetch_url:0.15 版本充滿 async、await、nosuspend 與 frame 跳板,每層…
Zig 0.16 把 async/await 關鍵字整條移除:函式只多收一個 io 參數,colored function 傳染從此消失。
把這個問題塞回原本那一萬個請求的場景:你的爬蟲 main 要 async、parser 要 async、retry 邏輯要 async、log 函式如果想直接讀檔也要 async。當 codebase 從一個 archive 長成五個套件,async 的傳染就會把所有 sync API 重寫一遍——這正是 Rust 社群著名的 async/sync function-coloring debate 的根源。Zig 0.16 對 std.Io 提的問題不只是「怎麼跑一萬個請求」,而是「怎麼讓函式庫程式碼不知道也不在乎自己跑在哪種後端」。
where today's tools fall short
把上面這個爬蟲問題放到主流的程式語言裡看:
Go 的 goroutine 是最接近理想的方案——使用者不用染色函式、runtime 自動排程到 OS thread、I/O 自動 yield。但 goroutine 需要 runtime 配合,且 stack 雖然可以小,仍有最小尺寸開銷。對「想要零 runtime、可預測記憶體」的系統程式設計來說,Go 不是選項。Go runtime 自帶 GC、自帶 M:N scheduler、自帶 netpoll;這些在 application server 場景是優點,在寫 kernel module、嵌入式韌體、把 Zig 函式庫 link 進 C 程式時則是禁區。Go 也無法把 goroutine model 拆出來給其他語言用——它是 runtime 的整體性決策。
Rust 的 async/await 解決了 colored function 的傳染問題(部分——因為 async 函式仍是不同類型),但 runtime 是分離的:要選 tokio、async-std、smol 之一,且每個 runtime 對函式庫的相容性各不相同。寫一個跨 runtime 通用的函式庫非常困難。`tokio::spawn` 跟 `async_std::task::spawn` 的 task 型別不相容,函式庫要嘛綁死一個、要嘛寫 trait-based 抽象層把 spawn/yield/sleep 三件事都抽出來——後者正是 `agnostik`、`async-executor` 那類 crate 的設計目標,但實務上沒有任何一套抽象層在生態系裡建立壓倒性優勢。
Rust 的 keyword generics RFC 想讓函式同時支援 async/sync 兩種模式——RFC 數年仍未進 stable。Rust 把「async 是否啟用」綁進型別系統,邊界要靠 block_on、spawn_blocking 縫合,future 是 stackless、每次 await 生成存「suspend 在哪一步」的 enum。Zig 0.16 把這層型別資訊整個拿掉,所有函式都是普通 Zig 函式只是吃一個 io 參數,「我會 block」靠呼叫鏈裡有沒有 io.read/io.sleep 來判斷,編譯器不替你抓 forget-to-await,但 grep 就是合法工具。
傳統 epoll/io_uring 手寫 event loop:性能可預測但 callback hell、state machine 拆解、錯誤處理散落各處。沒人想在 production code 裡這樣寫。
Zig 0.15 的 async/await 嘗試做 Rust-style 的 stackless coroutine + 語言內建 await。實際使用後發現 colored function 在 codebase 大了之後變成 viral problem——一個底層函式改成 async,所有呼叫鏈都得改。社群反彈強烈。Zig 創辦人 Andrew Kelley 在 2024 年 11 月的一場 talk 裡公開承認:「我們做了 async 三年,現在我可以說:我們做錯了。」隨後的版本就把語言層的 `async`、`await`、`suspend`、`resume` 關鍵字全數移除。
把這五條退路放在一起看:goroutine 要 runtime、Rust async 要 runtime 抽象層、手寫 event loop 寫起來像在做組合語言、Zig 0.15 的 async 被自己撤回。剩下的設計空間極窄——要不染色、要零 runtime、要可預測效能、要呼叫端能挑後端。std.Io 就是在這個窄縫裡找到的一個位置。
the core idea
std.Io 是一個 vtable-like 的介面,定義了 read、write、sleep、spawn、cancel 等基本操作。函式庫的程式碼只認這個介面——它不知道(也不在乎)背後的實作是 OS thread 還是 stackful coroutine。呼叫端在程式啟動時建構一個具體的 std.Io 實例,從那一刻起整個 callstack 都拿到同一個 std.Io 物件,每個會 block 的呼叫都透過它。
函式不染色——沒有 async/await 關鍵字。「會 block」的事實藏在 std.Io 介面背後:threaded 後端會把呼叫放進 thread pool,coroutine 後端會把當前 fiber 切換出去等 io_uring 完成事件。呼叫端的 source code 兩種情況下看起來完全一樣。對函式庫作者來說:你只需要在 API 簽名第一個參數收一個 `std.Io`,剩下就照寫——`io.sleep(1_000_000)` 看起來像 sync 呼叫、跑起來會 cooperatively yield。對應用程式作者來說:你選一次後端、其他什麼都不變。
實作上 std.Io 內部就是一個 struct,包含一個 `*anyopaque` context 指標、一個 vtable 結構,vtable 上掛著一組 function pointer——`read`、`write`、`sleep`、`spawn`、`cancel`、`now`、`createSocket` 等等。當函式庫呼叫 `io.sleep(ns)`,介面層把 `(context, ns)` 轉發給 vtable.sleep——具體做什麼由後端決定。Threaded 後端把當前 OS thread block 起來;zio 後端把當前 fiber 的 register state 切出去、把 timer 註冊進 io_uring、把 fiber 加進 wait queue、切到下一個 ready fiber。
關鍵的設計細節是:vtable 是 const、後端在程式啟動 init 時建好就不再變動。函式庫呼叫 `io.sleep` 沒有 dynamic dispatch overhead 以外的成本——LLVM 通常能 inline 整條 path,因為 io 物件的 vtable 指標可以被 SROA 解構。實測 Threaded 後端的 `io.sleep(0)` 路徑是個位數奈秒級別,跟直接呼叫 `std.Thread.yield` 差距在統計噪聲內。
真實採用個案給介面注入更多重量:TigerBeetle 用自家 IO 抽象繞 colored function 的設計影響了 std.Io 的關鍵抉擇(read/write 預設 cancelable、io 必須當第一個參數明傳);Ghostty 把舊 thread+pipe rendering loop 重寫成 std.Io.Group,PTY、GPU command、IPC 統一走 io 介面;Bun 的 Zig 內部模組正在把 syscall 包進 std.Io。共通點:大型 codebase、吃過 colored function 的虧、把 std.Io 當未來重構的緩衝層。
讓我們把 std.Io 的 vtable 攤開,看每個元件的責任分佈。下面的互動圖以三個核心元件示範——你可以點任一塊看它的 invariant。
context · 後端的私有狀態
不透明指標。Threaded 後端把 thread pool handle 塞這裡;zio 把 scheduler+ring 指標塞這裡。介面層完全不去 dereference 它——只把它作為第一個參數傳給 vtable 函式。
不知道的事:後端的私有資料結構長什麼樣。
vtable · 一組 const 函式指標
定義介面的所有操作:read、write、sleep、spawn、cancel、now。每個 backend 都 export 一個自己 const vtable instance。init 結束後 vtable 永遠不會被改動,LLVM 通常能把間接呼叫 inline 掉。
不知道的事:context 裡裝什麼。
backend · 實際的非同步策略
Threaded 把每個 spawn 變成 thread pool 工作;zio 用 stackful fiber + io_uring/kqueue/IOCP submission。後端可以呼叫的核心 syscall 完全不同,但對介面層而言只是換個 vtable instance。
不知道的事:呼叫者(函式庫)的業務邏輯。
互動圖表
std.Io 的 vtable 在 init 後即 const,LLVM 可 inline 整條 dispatch 路徑,個位數奈秒的開銷與直接呼叫相當。
把它放回前面的爬蟲場景:fetch_url(io, url) 函式只認 io 介面;應用主程式選一次 backend;介面層永遠不會 leak「我背後是 thread 還是 coroutine」這件事。傳染鏈被介面切斷。Rust 的 keyword generics RFC 想用編譯期 monomorphization 做的事,Zig 用 runtime vtable + 強制呼叫端傳入 io 物件做掉了——以一個小小的間接呼叫為代價,換掉整套語言層的編譯期推導。
what it actually looks like
具體寫起來像這樣(下面是簡化的偽碼,保留 Zig 0.16 的真實 API 形狀):
// 函式庫的 fetch_url:只認 std.Io 介面
fn fetch_url(io: std.Io, url: []const u8) ![]u8 {
var conn = try io.tcp_connect(url);
defer conn.close();
try conn.write("GET / HTTP/1.1\r\n\r\n");
return try conn.read_all();
}
// 呼叫端:跑 10,000 個並行請求
pub fn main() !void {
// 在這裡選後端:std.Io.Threaded 或 zio
var backend = try zio.init(.{ .backend = .io_uring });
defer backend.deinit();
const io = backend.io();
var group = try std.Io.Group.init(io);
defer group.deinit();
for (urls) |url| {
try group.spawn(fetch_url, .{ io, url });
}
try group.wait();
}
注意 fetch_url 完全沒有 async、await、suspend 任何關鍵字。它就是一個普通的 Zig 函式——只是第一個參數收一個 std.Io 介面。conn.write 與 conn.read_all 內部會呼叫 io.write 與 io.read,後者透過 vtable 轉發到當前後端:threaded 後端用 blocking syscall + thread pool;zio 後端註冊 SQE 進 io_uring、把 fiber suspend、等 CQE 回來後 resume。
把同樣的 fetch_url 拿給 main 用 std.Io.Threaded.init 也完全不用改:
pub fn main() !void {
// 改用 thread pool 後端:不需要 zio 套件,stdlib 自帶
var pool = try std.Io.Threaded.init(.{
.allocator = std.heap.page_allocator,
.max_threads = 1024,
});
defer pool.deinit();
const io = pool.io();
var group = try std.Io.Group.init(io);
defer group.deinit();
for (urls) |url| {
try group.spawn(fetch_url, .{ io, url });
}
try group.wait();
}
同一份 fetch_url 函式:把 main 的 zio.init 換成 std.Io.Threaded.init,整個程式變成 thread pool 模式,fetch_url 完全不必改。test 程式碼也可以選 std.Io.Threaded 為了測試的確定性、production 則切到 zio.io_uring 拿最佳性能——同一套函式庫程式碼支援兩種情境,零修改。
實測 10,000 個並行 sleep 任務:threaded 後端跑 ~20 秒(user time 2.258s、system time 10.098s);zio 後端跑 ~10.6 秒(user time 3.136s、system time 7.126s——user time 略高因為 fiber switch 在 user space 做)。拉到 50,000 個任務,threaded 因 thread 數量限制直接失敗,zio 仍正常跑完。
關鍵的設計細節:std.Io.Group 提供 spawn/wait 等結構化並發語意,cancellation 透過 std.Io.Cancelable error 統一處理。錯誤從子任務 propagate 到 group.wait(),不會 silent drop。group 是「結構化並發」的 Zig 化身——所有 spawn 出來的 task 必須在 group.deinit 之前要嘛完成、要嘛被 cancel 掉。沒有 detached task、沒有「fire and forget」洩漏 thread。
下面這張互動圖是這篇文章的核心練習場。拉動上面的滑桿改變並發任務數,觀察兩種後端在 wall-clock 時間上的回應曲線——重點不是哪邊比較快,而是兩種後端對任務數的反應形狀截然不同:thread pool 在達到 thread 上限後直接失敗(紅色虛線標出),coroutine 後端則繼續線性緩慢上升。
模擬曲線基於文章中報告的 10K 任務基準(threaded 20.0s / zio 10.6s)外插
threaded 後端在 ~40k 任務後因 thread 上限失敗;zio 任務數接近線性緩慢增長、上限只受記憶體限制。
把滑桿拉到 5,000——threaded 後端跟 zio 後端的差距還在「兩倍」量級。拉到 30,000——threaded 已經逼近 60 秒(如果還沒撞到 thread 上限的話)、zio 約 14 秒。拉過 40,000——threaded 直接顯示「失敗(thread 上限)」,zio 仍然線性緩慢往上爬。這就是「介面相同、後端可換」帶來的具體價值:你不必預先猜對工作量規模,可以先用 threaded 上線、規模長到 thread 撞牆時切到 zio——函式庫程式碼一行不改,只在 main 換掉那一次 init。
把這個案例延伸:假設你寫一個 HTTP client 函式庫想 publish 到 ziglibs。在傳統設計裡你得選邊站——要嘛綁 thread、要嘛綁某個 async runtime。在 std.Io 設計裡你只要接 io 參數,使用者愛怎麼選後端就怎麼選。你的函式庫變成「runtime-agnostic」——這在 Rust 生態系是經年的痛點,在 Zig 0.16 變成 free property。
下面這張對照表是兩個後端的工程特性比較。可以點 header 排序——看一下「啟動成本」這列,threaded 後端零依賴、zio 後端要外部套件,但 zio 在大規模下的記憶體成本反而更低(fiber stack 比 OS thread stack 小一個量級)。
| 維度 | std.Io.Threaded | zio(io_uring) |
|---|---|---|
| 運行單位 | OS thread | stackful fiber(user-space) |
| spawn 成本 | ~30-80 µs(pthread_create) | ~1-3 µs(fiber alloc + ctx switch) |
| stack 預設大小 | 8 MB(virtual) | 64 KB(可調) |
| scheduler | kernel | user-space round-robin |
| I/O 系統呼叫 | blocking syscall | io_uring SQE / kqueue / IOCP |
| 10K 任務時間 | ~20.0 s | ~10.6 s |
| 50K 任務 | 失敗(thread 上限) | ~30 s(可完成) |
| 外部依賴 | stdlib(內建) | 第三方套件 zio 0.11 |
| 跨平台 | Linux / macOS / Windows / 任何有 thread 的 | Linux io_uring + BSD/macOS kqueue + Windows IOCP |
| deterministic 測試 | 較難(thread scheduler 非確定性) | 較易(user-space scheduler 可注入控制) |
| debug 體驗 | gdb 直接看 thread stack | 需要支援 fiber unwind 的 debugger |
spawn 成本與 stack 大小是兩個後端最大的工程性差異;10K-50K 任務區間是經驗上 zio 開始佔優勢的…
10K 任務下 zio 耗時 10.6s vs threaded 20.0s;fiber 64 KB 對比 thread 8 MB,RSS 差一個量級。
真正寫過跨後端函式庫的工程師會留意「deterministic 測試」這列:對於並發 bug 重現來說,zio 後端因為 scheduler 在 user space、可以注入「在某個 yield point 必定先排到 task B」這種強制順序,比 thread pool 容易做穩定 reproduce。production 用 zio、test 用 zio 注入順序、再切到 threaded 跑一遍以排除介面對後端的假設——這條 testing pipeline 用 std.Io 設計可以零修改地做到,是其他語言生態系裡很少能直接買到的工程紅利。
另一個值得攤開的細節是 cancellation。std.Io 的設計裡,每個會 block 的呼叫都會 propagate `std.Io.Cancelable` error。Group.cancel 會把 cancellation flag 設起來,當其他 task 下一次 yield 進 io 介面時,介面層檢查 flag 並 throw Cancelable——沒有 SIGKILL、沒有 thread abort、沒有「task 跑到一半被踢」的 undefined state。這是借自 Rust async-cancellation-by-drop 與 Trio nursery model 的設計哲學,但用 zig 的 error union 自然表達。
// cancellation:group.cancel 通知所有子任務在下次 yield 點停下
fn worker(io: std.Io, item: WorkItem) !void {
while (true) {
// io.read 本身會 propagate Cancelable
const chunk = io.read(item.fd, &buf) catch |err| switch (err) {
error.Cancelable => return, // 結構化退出
else => return err,
};
if (chunk.len == 0) break;
try process(chunk);
}
}
pub fn main() !void {
var pool = try std.Io.Threaded.init(.{});
defer pool.deinit();
const io = pool.io();
var group = try std.Io.Group.init(io);
defer group.deinit();
for (items) |item| try group.spawn(worker, .{ io, item });
// 一秒後取消所有未完成的子任務
io.sleep(1 * std.time.ns_per_s);
group.cancel();
// wait 收回所有 task;Cancelable 不會被視為錯誤
try group.wait();
}
對比 Go:goroutine 沒有原生 cancellation,靠 context.Context 用文化規範強推。對比 Rust:cancellation 透過 drop 處理,但 drop 的時機點對 future 是否「已經消耗 buffer 但還沒回 ack」這類細節非常敏感。Zig 0.16 把 cancellation 化成 error union 的一個 variant——它落在 source code 上是顯式的、可以被 catch 的、有完整 type 信息的——對「想知道哪些函式可能被取消」的工程師而言,這是 grep 可達的事實,不是憑靠團隊文化記住的禁忌。
std.Io.Group 把「結構化並發」做成函式庫物件——group.deinit 是 defer 上的普通 Zig 語句,會等所有未完成 task 收尾。不是語言關鍵字、不需要編譯器支援,但 defer 守住了「忘記 join」這個地雷。
同一行 io.sleep(1_000_000) 在三種後端裡會展開成截然不同的系統行為。切換 tab 看「vtable.sleep → 後端執行 → ready 喚醒」這條路徑的具體形狀。
// vtable.sleep(Threaded 後端)
fn sleep(ctx: *anyopaque, ns: u64) void {
_ = ctx; // pool handle,這條路徑用不到
// 當前 OS thread 直接 block
std.Time.sleep(ns);
// kernel 把整個 thread 移出 runqueue
// 醒來後 scheduler 把它丟回 ready set
}
// vtable.sleep(zio EventLoop 後端)
fn sleep(ctx: *anyopaque, ns: u64) void {
const sched: *Scheduler = @ptrCast(ctx);
const fiber = sched.current;
// 1. 註冊 timer SQE 進 io_uring
sched.ring.timeout(ns, fiber.id);
// 2. 把當前 fiber 從 running 移到 waiting
sched.suspend(fiber);
// 3. 切到下一個 ready fiber(user-space)
sched.runNextReady();
// CQE 回來時 scheduler 把 fiber 切回 ready
}
// vtable.sleep(SingleThreaded 後端,測試用)
fn sleep(ctx: *anyopaque, ns: u64) void {
const clock: *FakeClock = @ptrCast(ctx);
// 「假時鐘」:不真的睡,直接把虛擬時間推進
clock.advance(ns);
// 同時 yield 給排程器跑下一個 ready fiber
// 測試裡可以「一拍 1ns 推進百萬次」
clock.scheduler.runNextReady();
}
同一個 io.sleep 呼叫——Threaded 走 kernel、EventLoop 走 io_uring、SingleThreaded 走假時鐘。同一份函式庫程式碼、三種運行語意——這正是 vtable 設計的核心紅利。
when you'd reach for it
「需要 std.Io 抽象」的工作量大致符合三個條件:I/O 密集(不是 CPU bound)、單機並發度從幾百到數十萬、且工作量在不同情境下需要切換後端(例如測試用 thread、production 用 io_uring)。具體例子:HTTP 伺服器、爬蟲、批次資料管線、distributed system 的 message handler、各種長連線 gateway。
若工作量主要是 CPU bound(解碼、編碼、密碼學、ML inference),std.Io 抽象本身就不是重點——直接用 std.Thread 開固定數量的 worker 反而簡單。fiber switch 對 CPU-bound 程式只是 overhead——你需要的是 SIMD、是 batch dispatch、是 cache locality,不是「同時跑一萬個任務」。若並發度只有幾十,pthread 已經夠用,引入 std.Io 是過度工程:你的程式既不會撞 thread 上限、也不會被 thread create 成本拖垮。
若工作量是 hard real-time 或單純 RPC 客戶端、不需要應付高 fanout,async coroutine 後端的 setup 成本也不划算。zio 套件本身是上 KB 等級的 binary code、init 一個 io_uring 至少要分配 16-64 個 SQE/CQE 環 slot——對於一個只發 5 次 HTTP 請求的 CLI 工具,這些固定成本超過收益。
反過來看那些「std.Io 大放異彩」的場景:
HTTP server。每個連線會在 read header、parse、處理 business logic、寫 response 四個 phase 都有 I/O wait。一個 10k 並發 server 在 Threaded 後端會吃掉約 80 GB 虛擬位址空間(10k thread × 8 MB stack),實體 commit 雖然會少很多但 kernel scheduler 仍要管 10k entry。同樣負載在 zio 後端只要 ~640 MB(10k fiber × 64 KB stack),且 io_uring 的批次 syscall 把 ring submission 成本攤掉。實測 nginx-style HTTP server 從 thread 切到 io_uring 後 throughput 提升 1.8x、p99 latency 下降 40% 是常見區間。
批次資料管線。一個 ETL job 可能要對 100k row 各自做一次外部 API call。Thread pool 撐不住——10k thread 是 production 容忍上限。Fiber pool 可以 100k 並行——只受記憶體與 downstream service 速率限制。
測試友好度也是常被忽略的紅利:fiber scheduler 在 user space,可以接受 seed、注入「在 yield point X 強制先排到 task B」這種干預,把 race condition 變成 deterministic test case——「production 偶爾跳出來的怪 bug」變成「CI 跑 1000 次種子能穩定重現的失敗」。
對於設計同類抽象的其他語言團隊,std.Io 提出的 trade-off 值得參考:把非同步「介面化」而非「語言化」,代價是介面定義要花心思(一旦變動,所有後端與函式庫都得跟);好處是函式庫程式碼不被 colored function 傳染、呼叫端有後端選擇權。Rust 的 keyword-generics RFC 想解決的部分問題,Zig 0.16 用一個截然不同的角度繞過了。
三個語言的取捨擺在一起:Rust 把 async 留在語言、用 keyword generics 試圖讓函式同時 sync 也能 async——這條路五年了還沒走完;Go 把 async 塞進 runtime、所有 program 共享一個 M:N scheduler——便利但綁定 runtime;Zig 把 async 從語言移除、做成介面、讓使用者自己挑後端——便宜但要使用者自己挑、且需要新生態系慢慢長出多種高品質後端。哪一條對嗎?沒有對錯——這三條路服務的使用情境不同。但對「想要一個函式庫可以同時跑在 thread 與 io_uring 後端、source 一行不改」這個非常具體的需求,Zig 0.16 是目前唯一直接命中的設計。
對其他語言團隊的啟示:colored function 不是「再加一層編譯期魔法」就能繞過的問題——它是抽象 leaky 的本質後果。當 sync 與 async 的成本天差地遠時,把這個差異藏起來總會在某個 layer 漏出。Zig 0.16 的選擇是「把差異顯式化但移到呼叫端」——函式庫源碼不必感知差異,但 main 必須做選擇。這個位置選得很巧妙:跟 IO monad 一樣,它是程式的 entry point,是一個應該做這種選擇的地方。
Take-away:std.Io 的核心心智模型是「非同步是後端的選擇,不是函式的屬性」——同一份函式庫程式碼可以跑在 thread 與 io_uring 兩種後端,呼叫端決定哪個適合當前情境,函式不必染色。把 colored function 問題從「語言內建關鍵字的設計議題」降階為「呼叫端的一次 backend 選擇」——這是 Zig 0.16 給其他語言團隊的最大禮物。