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

在 WASI 0.2,只要一個 component 用了 streaming 或 async API,它就無法跟任何其他 component compose——async 與「可組合」這兩件 component model 的招牌能力,過去是互斥的。0.3 把 event loop 從每個 component 手裡收回給 host,這道牆才倒。

WASI 0.3 把原生 async 帶進 component model——元件並行不再靠 hack

WASI 0.3 在 2026-06-11 釋出,按官方說法「async is now native to WebAssembly Components」,規格已 stable。具體做了一件事:把 stream<T>future<T>async 三個構造放進 Component Model 的 canonical ABI,成為一等公民。在這之前,async 不是 component model 的能力,而是每個 component 各自在內部模擬出來的東西——它有 runtime、有 event loop,但那個 loop 只屬於它自己。這篇拆的是:這三個原語在 ABI 層長什麼樣、舊的模擬法為什麼擋住了 composition、把 loop 收回 host 之後機制如何運轉,以及對寫或組合 WASM component 的人,下一週的工作會差在哪。

先把整件事的形狀擺出來。下面這個互動小工具讓你把一條「A 呼叫 B、B 呼叫 C」的 component 鏈,在 sync compose 與原生 async 兩種模式之間切換,並拖動每段 IO 等待的時間,看 end-to-end 的牆鐘時間怎麼變。重點不在數字本身,而在兩條曲線的形狀——sync 模式下總時間是各段相加,async 模式下 host 把所有在等待的 task 交錯排程,總時間趨近最慢那一段。

拖滑桿調每段 IO 等待、切換 sync / async compose · 3 段 component 鏈

80 ms / 段
牆鐘時間 → A B C 240 ms sync:每段等前一段做完,總時間 = 三段相加
sync compose 下三段相加;切到 async,host 把三段的 IO 等待交錯,總時間趨近最慢那一段。模型化的時間軸,用來凸顯 compose 形狀差異,不是 benchmark。

canonical ABI 裡的三個新原語:stream、future、async func

0.3 的全部新意可以收斂成一句:「adding stream<T>, future<T>, and async as first-class constructs to the canonical ABI」。canonical ABI 是 Component Model 定義「component 之間怎麼傳值、怎麼呼叫」的那層介面合約——把 async 放進這層,意思是 async 不再是某個語言的 runtime 自己的事,而是 component 邊界本身認得的東西。一個 Rust 寫的 component export 一個 async func,一個 JS 寫的 component import 它,兩邊不需要共享同一個 async runtime,因為 await 的語意已經寫在 ABI 裡,由 host 兜起來。

「first-class constructs to the canonical ABI」這幾個字是整件事的關鍵,值得拆開。first-class 在型別系統語境裡的意思是「跟其他基本型別平起平坐、可以被傳遞、被組合、被當成值對待」。把 async 做成 first-class,相對的是把它做成 second-class——某種得靠約定俗成的呼叫慣例、靠 guest 自己維護的狀態機才能兜出來的東西,正是 0.2 的處境。一個 second-class 的能力沒辦法被 ABI 保證,跨邊界時它的語意會散掉;first-class 的能力 ABI 認得,跨邊界時 lift / lower 規則會把它完整搬過去。0.3 把 async 從約定升格成型別,這一步才讓後面「跨 component 邊界 await」成為一個有定義的操作。

三個構造各自的角色不同,但共享一個關鍵的型別語意——它們是 owned handle。文章說得很直接:「stream<T> and future<T> function like resource types: each is an owned handle, passing one across a component boundary transfers ownership from caller to callee. Unlike resource types, they can't be borrowed.」這句話裡有三層資訊:它們像 resource type、跨邊界傳遞會轉移 ownership、但跟 resource 不同,不能 borrow。下面這個小工具把三個構造攤開,點任一個看它擁有什麼、語意上跟 resource type 差在哪。

canonical ABI 的三個新一等構造——點任一個看它擁有什麼

canonical ABI 的三個新一等構造——owned handle,跨邊界轉移 ownership,不可 borrow future<T> 未來送達一個 T 的 handle;值送達時 runtime 排程 await 它的 task,跨多層 component 邊界也成立 owned stream<T> 一連串 T 的 handle;對應 0.2 的 input-stream / output-stream,0.3 收斂成 stream<u8> owned async func component 直接 export / import;呼叫端 await 即可,邊界本身懂 async first-class future 與 stream 像 resource type,但不可 borrow——這道限制讓跨邊界排程的所有權鏈不會分岔

click a construct above

future<T> · owned handle

文件原文:each is an owned handle, passing one across a component boundary transfers ownership from caller to callee。future 代表「一個之後會出現的值」。把它傳給另一個 component,等於把「誰負責收這個值、誰被它喚醒」一併交出去。

關鍵後果:the runtime schedules whichever task is awaiting it, even if it was passed through multiple component boundaries。await 的 task 可能在三層 component 之外,runtime 照樣排得到它。

stream<T> · owned handle

stream 是 future 的「多值」版本,同屬 owned handle、跨邊界轉移 ownership。release notes 把 0.2 的 resource input-streamresource output-stream 都映射到 0.3 的 stream<u8>——兩個方向收斂成一個型別。

「不能 borrow」在這裡尤其重要:一條 stream 的消費權只能屬於一個 task,避免兩個 component 同時宣稱在讀同一條流。

async func · ABI 一等公民

原文:Components export and import async funcs directly。0.2 時,async 介面得偽裝成同步函式加一套 poll;0.3 直接讓 component 的 import / export 帶 async 語意。

這是「原生」二字的具體所指:async 寫進 canonical ABI,呼叫方只要 await,跨語言、跨 component 都認得。

「不能 borrow」這條限制值得停一下。resource type 在 component model 裡可以被 borrow——某個 component 暫時取得一個 handle 的使用權、用完歸還,所有權不轉移。stream 與 future 刻意拿掉這條路:它們只能被 own,傳出去就是轉移。理由跟跨邊界排程有關。當一個 future 的 await task 可能位在好幾層 component 之外,runtime 需要一條清楚不分岔的所有權鏈,才知道值送達時該喚醒誰。允許 borrow 等於允許「同一個 future 有多個暫時持有者」,那條鏈就會分岔,排程語意立刻變得難以定義。把它收緊成 owned-only,是讓共用 event loop 能正確運作的前提。

把這三個構造放進 canonical ABI 的意義,要對照 component model 原本的型別系統才看得清楚。canonical ABI 規定的是「跨 component 邊界時,每種型別怎麼 lower 成 linear memory 的 byte、怎麼 lift 回另一邊的型別」——record、variant、list、resource 各有自己的編碼規則。在 0.3 之前,這套規則裡沒有任何「一個值會在未來才出現」的概念,所有東西都是當下就在的。future<T>stream<T> 補的正是這個缺口:它們在 ABI 層就是一個 handle(一個整數 index,指向 host 那邊的一張表),lift / lower 的是 handle 而非值本身。值什麼時候出現、出現後喚醒誰,是 handle 背後的 host 機制負責。這也是為什麼說它們「像 resource type」——resource 在 ABI 層也是 handle。差別只在 resource 可借、future / stream 不可借。

對寫 .wit 介面定義的人,這個改變最直接的影響是介面長相。0.2 一個會回傳資料流的介面,得宣告一個 resource、給它一堆 method(readsubscribeblocking-read),再叫使用者自己把這些 method 串成一個讀取迴圈。0.3 同一個介面就是一個回傳 stream<u8> 的 function,使用者拿到 stream 直接消費。介面從「一組 method 加一份隱含的使用協定」變成「一個帶 async 語意的型別簽章」——隱含協定被 ABI 收編成顯式型別,這是 release notes 說介面「more ergonomic」的具體所指。

0.2 的三步舞與 per-component event loop

要看懂 0.3 解了什麼,得先看 0.2 怎麼模擬 async。文章的描述是:「In WASI 0.2 each component needed its own event loop/async runtime.」每個 component 自帶一套 event loop 與 async runtime。等待這件事靠 poll(list<pollable>) 完成——把一批 pollable handle 丟給 poll,問「你們誰就緒了」,這是典型的 readiness-based 介面:你先拿到一個代表「某件事將會就緒」的 token,然後反覆去 poll 它直到它說「好了」。

更麻煩的是非同步函式呼叫的形狀。0.2 沒有 async func,所以一個概念上 async 的操作得拆成三步:start-foo 啟動、finish-foo 取結果,中間用 subscribe 拿到一個可以 poll 的 pollable。文章用「the three-step start-foo / finish-foo / subscribe dance」形容它——三步舞。下面把這支舞跟 0.3 的單一 async func 簽章並排,拖中間的分隔線就能對照兩種寫法。

這支舞每一步都有它存在的理由,也正因如此才麻煩。start-foo 把操作丟出去、立刻回來,不阻塞;它回傳的不是結果,而是一個代表「這次操作」的 handle。subscribe 從這個 handle 生出一個 pollable,這是專門拿去等的 token。poll(list<pollable>) 把一批 pollable 丟給 component 自己的 event loop,loop 阻塞到至少一個就緒。就緒之後,finish-foo 才真正把結果拿出來。四個動詞、兩種 handle,描述的其實是「await 一次 async 呼叫」這一件事——只因為 ABI 不認得 async,整個流程被攤平成同步原語的組合,由 guest 自己重新組裝出 async 的語意。0.3 的 foo: async func(...) 把這四步收回一個簽章,await 的機制下沉到 host。

拖分隔線比較 0.2 三步舞與 0.3 async func · before / after

WASI 0.2 readiness-based · 三步舞 let p = start-foo(args); let pollable = p.subscribe(); poll(list[pollable]); let r = finish-foo(p); 每個 component 自帶 event loop, poll 迴圈在 component 內部跑 WASI 0.3 completion-based · 一行 foo: async func(args) -> R; let r = foo(args).await; host 管一個共用 event loop, await 交給 runtime,跨邊界排程 // most changes are entirely mechanical

互動圖表

0.2 一個 async 呼叫要拆成 start/finish/subscribe 加 poll 輪詢;0.3 收斂成一行 async func。

這支三步舞本身只是不好寫,真正的問題在它的後果。每個 component 各自跑一個 event loop,這些 loop 之間沒有共同的時間基準、沒有共享的 ready queue,彼此無法協調。文章把這句話講得毫不留情:「If a component used streaming or async APIs, it couldn't be composed with any other components.」一旦你的 component 用了 streaming 或 async,它就不能跟任何其他 component compose。component model 賣的就是「把小 component 拼成大系統」這個能力,而 async——現代 IO 幾乎所有東西——恰好把它關掉了。0.2 的 async 與 0.2 的 composition,實際上是兩個不能同時用的功能。

release notes 把這次遷移整理成一張對照表,多數是機械式替換:resource pollablefuture<T>resource input-streamoutput-stream 都變 stream<u8>poll(list<pollable>) 換成 runtime 處理的 await,subscribe() 換成從呼叫直接 return 一個 future<...>start-foo / finish-foo 配對收斂成一個 foo: async func(...)。文章對改動範圍的形容是「Most of the changes in the 0.3 interfaces are entirely mechanical.」——多數介面改動是純機械式的,這對要遷移既有 WASI 介面的人是好消息:大部分工作是把 poll pattern 換成 async func,而不是重新設計語意。

// 0.2 → 0.3 介面遷移對照(取自 release notes)
resource pollable          ──>  future<T>
resource input-stream      ──>  stream<u8>
resource output-stream     ──>  stream<u8>   // 寫入方向
poll(list<pollable>)        ──>  await on a future   // runtime 處理
subscribe() on resource    ──>  return future<...> from the call
start-foo / finish-foo     ──>  foo: async func(...)

host 收回 event loop:一個 loop 排程所有 component

0.3 的核心轉移只有一句:「WASI 0.3 makes it so the host is now the one in charge of managing the one event loop that is shared by all components.」event loop 的所有權從 component 收回到 host,而且是「一個」被所有 component 共享的 loop。這個改動很小,後果很大。當所有 component 跑在同一個 host 管理的 loop 上,async 與 composition 不再互斥——它們現在用的是同一套排程機制。

共用 loop 帶來的具體能力是跨邊界排程。文章描述:「When a value has been delivered to a future, the runtime schedules whichever task is awaiting it, even if it was passed through multiple component boundaries.」一個 future 在 component A 產生,被傳給 B,B 又傳給 C,C 裡某個 task 在 await 它。值送達時,runtime 直接排程那個位於 C 的 task——即使它在三層 component 之外。在 0.2 那是不可能的:A 的 event loop 根本不知道 C 的 event loop 存在,沒有任何共享狀態能讓「A 的值送達」喚醒「C 的 task」。下面這張圖把兩種拓樸並排——左邊 0.2 的 N 個各自為政的 loop,右邊 0.3 的單一 host loop。

WASI 0.2 —— 每個 component 自帶 event loop,彼此無法協調 A loop B loop C loop 三個 loop 沒有共享 ready queue —— A 的值送達無法喚醒 C 的 task ⇒ 用了 async / streaming 的 component 不能 compose WASI 0.3 —— host 管一個共用 loop,跨 component 邊界排程 host event loop(共用) A B C future 穿過 A→B→C 三層邊界, 值送達時 runtime 直接排程 C 裡 正在 await 的 task。
左:0.2 的 N 個 event loop 各自為政,沒有共享狀態能跨 component 喚醒 task。右:0.3 把 loop 收回 host,一個共用 loop 讓 future 的喚醒穿過多層邊界。

把 loop 收回 host 還有一個不那麼顯眼但實際的好處:host 才是真正貼著 OS 的那一層。真正的 IO multiplexing 原語——epoll、kqueue、io_uring、IOCP——都在 host 端。0.2 讓每個 component 自帶 loop,等於要求每個 guest runtime 各自去逼近這些原語、各跑一套排程,彼此還對不上。把唯一一個 loop 放在 host,guest 只負責產生與 await futures,底下要對接哪個 OS 的哪套 IO 介面,是 host 的事。對寫 host integration 的人來說,這把「我要實作的 async 介面」從「每個 guest runtime 一套」收斂成「host 一套」。

這裡有個值得辨明的對照:0.2 的「每個 component 自帶 event loop」不是哪個實作偷懶,而是 ABI 沒有 async 概念時的必然結果。當邊界只認得同步呼叫,guest 要做 async,唯一的辦法就是在自己內部跑一個 loop、把同步的 poll 原語包成 async 的假象——Rust 的 component 用 tokio 或某個 reactor,JS 的用它自己的 microtask queue,各搭各的台。問題不在任何一個 loop 寫得好不好,而在它們之間沒有共同語言:A 的 reactor 不知道 B 的 reactor 正在等什麼,兩個 loop 各自空轉或各自阻塞,無從合併成一次等待。0.3 不是把這些 loop 寫得更好,而是把「需要 N 個 loop」這個前提拿掉——async 進了 ABI,guest 就不需要自帶 loop 了。

共用 loop 也改變了「阻塞」這件事在 component 組合裡的傳染方式。0.2 裡,一個 component 在它自己的 loop 上阻塞等 IO,這個阻塞對外面是不透明的——對 caller 來說它就是一個還沒回來的同步呼叫,caller 的 loop 只能跟著卡住,或者 caller 得自己也走一套 poll。阻塞會沿著呼叫鏈往上傳,每一層都得各自處理。0.3 裡,當 C 裡的 task await 一個還沒送達的 future,它讓出的是同一個 host loop,host 可以馬上去跑別的就緒 task——包括完全無關的另一條 component 鏈。讓出與被排程都發生在同一個 loop 上,這正是本文開頭那個互動圖想讓你體感的事:sync 模式下阻塞逐層相加,async 模式下所有等待被同一個 loop 交錯吃掉。

completion-based,不是 readiness-based

0.3 的 async 還換了執行模型。文章說:「The async model is completion-based, not readiness-based. This is similar to the ultra-efficient Linux io_uring and Windows' IOCP/IoRing APIs.」這兩種模型的差別是 IO 介面設計裡的老問題。readiness-based——epoll、select、0.2 的 poll(list<pollable>)——的流程是:你問 kernel「這個 fd 可讀了嗎」,kernel 說「可讀了」,然後你自己去 read。它通知的是「就緒」,實際的搬運還是你做。completion-based——io_uring、IOCP——反過來:你提交一個操作(「幫我讀這個 fd」),kernel 把整件事做完,再通知你「做完了,結果在這」。通知的是「完成」,搬運由底層代勞。

為什麼這對 component model 是對的選擇?因為跨 component 邊界傳遞「就緒訊號再讓對方自己搬」會把 IO 的細節漏進 component 邊界。readiness 模型要求等待方持有 fd、自己發 read。但在 component 組合裡,發起 IO 的 component 跟消費結果的 component 可能隔著好幾層邊界,中間那些 component 不該、也不能碰底層 fd。completion 模型剛好對上 future 的語意:你拿到的是一個 future<T>,它 await 出來就是「完成的結果 T」,而不是「現在可以去搬了」的訊號。誰來搬、怎麼搬,封在 host 那一個 loop 裡。把它跟 io_uring 並列不是修辭——它們解的是同一類問題:用 completion 把搬運從呼叫方手裡拿走。

completion-based 還改變了 host 與 guest 之間誰主動的關係。readiness 模型裡,guest 是主動方:它持有 fd、自己發 read、自己決定什麼時候搬。host 只負責回答「就緒了沒」。completion 模型反過來把 guest 變成被動方:guest 提交一個操作就交出去,接下來是 host 把事做完、主動把結果送回那個 await 點。對 sandbox 安全模型這是個順手的對齊——WASM 的整個賣點就是 guest 不該直接碰宿主資源,把 IO 的搬運封進 host、guest 只看得到 future 的結果,本來就比「讓 guest 持有 fd 自己 poll」更貼合 WASM 的隔離邊界。0.3 選 completion,等於讓 async 模型跟 WASM 原本的信任邊界長在一起。

這也讓前面「不可 borrow」那條限制更合理。completion-based 下,一個 future 代表「一份正在被 host 處理、完成後會送達的結果」。如果它能被 borrow,等於多個持有者同時宣稱在等同一份結果,而 host 只會把它送達一個 await 點。owned-only 保證了「一個 future、一個 await、一個喚醒」的乾淨對應——這正是讓 host 那個共用 loop 能簡單正確地排程的條件。三個設計選擇——canonical ABI、共用 host loop、completion 模型——是互相咬合的,不是三件獨立的事:把 async 升格進 ABI,才談得上把 loop 收回 host;loop 收回 host,才需要一個能跨邊界送達的 completion 模型;completion 模型要乾淨,才需要 future 不可 borrow。一拉一整串。

直接 compose:從毫秒到奈秒

機制講完,落到使用上最大的改變是 component 之間可以直接 compose。文章對效果的描述是:「For most microservices this will reduce the time for calling other microservices from milliseconds to nanoseconds: six orders of magnitude.」注意這句帶 For most microserviceswill——是條件式加未來式,不是已測得的普適結果。它的理由很實在:當兩個 component 可以直接 compose,呼叫對方就不必走網路;省掉的是序列化、socket、TCP/HTTP 來回那整串開銷。一個本地函式呼叫是奈秒級,一次網路 RPC 是毫秒級——六個數量級的差距來自「拿掉網路」這個結構性改變,而 0.3 讓用了 async 的 component 終於能被這樣 compose。

這個奈秒數字要小心讀。它不是說「同一段 component 邏輯在 0.3 跑快了六個數量級」,而是說「原本得拆成兩個 microservice、靠網路互呼的東西,現在可以 compose 成同一個 process 裡兩個直接互呼的 component」。省下的是網路那一跳,不是運算本身。能不能吃到這個好處,取決於你的兩個 component 是否真的能放在同一個 host 裡 compose——對很多既有架構,跨服務邊界是組織邊界、部署邊界,不是隨手就能合併的。文章自己也用 For most microserviceswill 框住了這個 claim,把它當成「結構上現在可行」而非「你的系統明天就會快六個數量級」來讀,才是誠實的。

對不同角色,下一步的判斷不一樣。如果你在寫 WASM component——edge runtime 上的函式、plugin 系統的模組——0.3 之後 async 介面才真正能跟其他 component 拼起來,過去因為「用了 async 就不能 compose」而被迫合成一個大 component 的設計,現在可以拆開。如果你在做 host integration,要實作的 async 介面從「每個 guest runtime 各一套」收斂成「host 一套共用 loop」,對接 io_uring / IOCP 的工作集中到一個地方。而要遷移既有 0.2 介面的人,release notes 那張對照表是權威依據,多數改動是機械式的 poll-換-async-func;真正需要動腦的少數,是那些原本就刻意用 0.2 的同步攤平來規避 composition 限制的介面——它們的設計假設在 0.3 失效,得重想。

時程是這篇唯一會過期的部分。poll(list<pollable>) 變成 await、三步舞變成一行,這些語意是 durable 的;但「現在能不能用」要看 runtime 與語言支援。Wasmtime 45 跑的是 release candidate,按文章說法「Wasmtime 46 will ship WASI 0.3.0 with Component Model Async enabled by default」——預設開啟要等 46。guest 端的語言 binding 也還沒到齊:「Async support for guest binding generators is also in-progress for many languages」,多數語言的 async guest 支援仍 in-progress,官方用未來式說「you'll be able to write WASI 0.3 components in Rust, Go, JavaScript, Python, and more」。規格 stable 與工具鏈到位之間,這段差距是現在要評估遷移時必須算進去的。

What this enables:把 event loop 從每個 component 收回給 host、配上 completion-based 的 owned future,WASI 0.3 讓「用了 async 的 component」與「可被 compose 的 component」第一次成為同一種 component——這是 0.2 結構上做不到的事。