vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

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;
}
0.15 · async/await
// 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);
}
0.16 · std.Io
同一個 fetch_url:0.15 版本充滿 asyncawaitnosuspend 與 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 介面,不直接呼叫 thread 或 io_uring std.Io(介面:read/write/sleep/spawn/cancel) std.Io.Threaded OS thread pool 後端(內建) zio io_uring/kqueue + 堆疊式 coroutine 介面層 後端層 應用層
std.Io 是一個 vtable-like 介面,函式庫程式碼只認介面、不認後端。呼叫端在程式啟動時挑後端,整個 codebase 都不必感知是 thread 還是 coroutine。

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 *anyopaque vtable const fn pointers backend Threaded / zio 介面層只認 (context, vtable) 這兩個欄位; 後端可以是 thread pool、coroutine scheduler、任何符合介面的東西。 點任一塊讀它的不變式

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 後端則繼續線性緩慢上升。

tasks = 10,000
~20.0 s std.Io.Threaded(thread pool)
~10.6 s zio(io_uring + coroutine)
並發任務數(log scale, 100 → 100k) wall-clock(秒) 0 15 30 45 60+ 100 1k 10k 100k thread 上限 std.Io.Threaded zio (io_uring)
模擬曲線基於文章中報告的 10K 任務基準(threaded 20.0s / zio 10.6s)外插。threaded 在 ~40K 任務後撞到 thread 上限失敗;zio 對任務數的反應接近線性緩慢上升、上限只受記憶體限制。實測會因 kernel 版本、ulimit 設定、io_uring 環大小而浮動。

模擬曲線基於文章中報告的 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 threadstackful fiber(user-space)
spawn 成本~30-80 µs(pthread_create)~1-3 µs(fiber alloc + ctx switch)
stack 預設大小8 MB(virtual)64 KB(可調)
schedulerkerneluser-space round-robin
I/O 系統呼叫blocking syscallio_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 開始佔優勢的轉折帶。點欄位 header 排序。

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
}
誰被 block整個 OS thread
切換成本~5-15 µs(kernel context switch)
並發上限thread pool 大小(通常 < 4096)
需要的 syscallnanosleep / FUTEX_WAIT
需要的環境無,stdlib 內建
// 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
}
誰被 block單一 fiber(OS thread 繼續跑別的 fiber)
切換成本~100-300 ns(純 user-space)
並發上限受 RAM 限制,10⁵ 起跳
需要的 syscallio_uring_enter(批次攤平)
需要的環境Linux 5.x+ io_uring 或 BSD kqueue
// 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();
}
誰被 block沒人,clock 直接跳
切換成本~10-50 ns
並發上限單執行緒、deterministic 順序
需要的 syscall
需要的環境測試專用,所有 io 操作走假時鐘

同一個 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 給其他語言團隊的最大禮物。