JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

WebAssembly 與瀏覽器之外的 Wasm 執行階段

你已經知道機器碼、執行階段(runtime)與行程是什麼。WebAssembly 把這些想法重新洗牌:一套小巧、可攜的指令集,在一個你能放心交付不受信任程式碼的沙盒裡,以接近原生的速度執行。這篇導引從位元組層往上把這個想法建起來,並說明它為何正逃出瀏覽器、進入伺服器、外掛與邊緣運算。

WebAssembly 究竟是什麼:一套可攜的指令集,而不是一種語言

這個名字一開始幾乎會誤導每一個人。WebAssembly——通常縮寫為 Wasm——不是一種你拿來寫的語言,而且儘管帶著「Web」,它早已不再綁在瀏覽器上。它是一種精簡、低階的二進位指令格式:一套虛擬的指令集架構,設計來作為 C、C++、Rust 等語言的編譯目標。你把你的原始碼往下編譯成一個位元組碼的 `.wasm` 檔,就像你會編譯成 x86-64 一樣;差別在於,這套指令集是從頭設計來追求可攜、緊湊,以及安全地執行你不信任的程式碼。把它想成一種新的可執行檔,只是它的 CPU 是軟體做的。

這套指令集長什麼樣?Wasm 是一台堆疊機(stack machine),在組合語言那一級之後這應該很熟悉。它沒有 rax、rsp 這類具名暫存器,而是把運算元推入與彈出一個隱含的運算元堆疊。`i32.const 7` 推入 32 位元整數 7;接著的 `i32.const 5` 推入 5;然後 `i32.add` 把兩者都彈出、相加、再把 12 推回去。型別刻意精簡——`i32`、`i64`、`f32`、`f64`(32 與 64 位元的整數與浮點數),再加上少數幾個參考型別。沒有 `unsigned long` 或 `struct` 這種指令;一個較高階的型別只是這些原語在記憶體裡擺出來的一個模式,正是你早已握住的那種位元組對上詮釋的觀點。

WAT (the text form of Wasm) for add(a, b):

  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a      ;; push a
    local.get $b      ;; push b
    i32.add)          ;; pop both, push a+b

source (C):  int add(int a, int b) { return a + b; }
compiled .wasm bytes:  20 00 20 01 6a   (local.get/local.get/i32.add)
同一個簡單函式,分別以 C 原始碼、Wasm 文字形式(WAT)、以及執行階段實際看到的原始位元組呈現。一台堆疊機:運算元被推入,運算子把它們彈出。沒有暫存器,也沒有把任何特定平台的呼叫慣例烤死在裡面。

沙盒:線性記憶體,以及它為何逃不出去

以下這個想法,讓 Wasm 成為一道前沿,而不只是又一種位元組碼。一個 Wasm 模組全部的可變記憶體,是一個單一、連續的位元組陣列,叫做它的線性記憶體(linear memory)——字面上就是一個扁平的位元組陣列,把指標那一級的圖像具體化了。Wasm 模組內部的一個指標,不是宿主機的位址;它是進入那個陣列的一個索引,從 0 開始。當模組去載入「位址」0x1F 處的值時,執行階段把它算成 `base + 0x1F`,其中 `base` 是宿主恰好把那個陣列放置的位置,而關鍵在於:在碰任何東西之前,它會檢查那個索引是否在界限之內

追一下這替你換來了什麼。在原生程式碼裡,一個寫過陣列尾端的緩衝區溢位會一路撞進作業系統擺在後面的任何東西——另一個物件、一個返回位址、配置器中介資料——而從安全那一級你知道,那是漏洞利用的種子。在 Wasm 裡,同一個越界寫入無法觸及線性記憶體陣列以外的任何東西,因為每一次存取都做了界限檢查,而那個陣列就是全部了。一個有臭蟲或惡意的模組,最壞能做的就是損毀它自己的沙盒。它看不見宿主的位址空間、沒有辦法偽造一個指向宿主的指標、根本沒有任何能命名宿主位址的指令。不安全的程式碼仍然不安全——但它只對它自己不安全。

執行階段:一個 .wasm 檔究竟如何被執行

一個 `.wasm` 檔是資料,不是原生可執行檔;必須有某個東西來執行它。那個東西就是一個 Wasm 執行階段——宿主上的一支程式(wasmtime、Wasmer、WAMR、V8 等),它載入模組、把它的位元組碼轉成真實的 CPU 指令。在執行任何一條指令之前,它先做驗證(validation):一道快速的靜態檢查,確認位元組碼型別正確、結構良好——每個堆疊操作的運算元型別都對、每個跳躍都落在界限內、沒有函式回傳一個它從未產生過的值。這比 C 的未定義行為更接近提前(ahead-of-time)驗證:一個通過驗證的模組不可能處於那些讓原生臭蟲變得無界的畸形狀態。沒通過驗證的程式碼根本不會被執行。

通過驗證之後,執行階段以兩種方式之一執行模組。一個簡單的執行階段直譯(interpret)位元組碼,就像基礎那一級的直譯器想法——容易、可攜,但較慢。一個認真的執行階段則對它做 JIT 編譯:一個即時(just-in-time)編譯器在每個 Wasm 函式第一次被呼叫時,把它翻成原生的 x86-64 或 ARM 指令,於是你寫的那個迴圈跑成的是真實機器碼,而不是一步一步被走過的位元組碼。因為 Wasm 的指令與硬體指令對映得很貼近、型別又是具體的,這種程式碼產生很快,結果以接近原生的速度執行——通常落在「同一份 C 直接編成宿主碼」的一個小百分比之內。「可攜但慢」與「原生但不安全」過去是個被迫的二選一;這正是鬆開它的那道前沿。

WASI:給沙盒一條碰觸世界的路

一個完美密封的沙盒也完美地沒用:純 Wasm 沒有任何指令能開檔、讀通訊端、或取得時間,因為這些沒有一個住在線性記憶體陣列裡。每一個真實的作用都必須往外呼叫宿主提供的某個函式——一個匯入(import)——以跨過那道邊界。這就是 WASI(WebAssembly System Interface,WebAssembly 系統介面)登場之處。WASI 是一組標準化的被匯入函式,長得很像你已經熟悉的 POSIX 呼叫——有一個 WASI 版本的 open()/read()/write(),運作在檔案描述符之上——但一個模組只能呼叫執行階段選擇交給它的那些。

WASI 底下的設計抉擇才是深刻的那個,而它應該讓你想起這一級本身講過的東西:WASI 是以能力為基礎的(capability-based)。在一般的 POSIX 裡,任何行程都能對它的身分憑證所允許的任何路徑呼叫 open()——權限是環境性的,附著在整個行程上。WASI 把這顛倒過來:一個模組無法命名一條它沒被給予的路徑。宿主在模組執行之前授予它一個指向某個特定目錄或檔案的把手(handle),而模組只能觸及它握有把手的東西。這正是以能力為基礎的安全——權限以一個不可偽造的把手在流動,而不是程式碼能去要求的一個全域許可。一個只被交給 `/tmp/work` 把手的 Wasm 模組,實體上無法讀 `/etc/passwd`;沒有任何它能命名的路徑可以到那裡。

  1. 把你的 C 或 Rust 編譯成一個宣告了其匯入的 .wasm 模組——例如它匯入一個它期望宿主提供的 WASI fd_read。
  2. 執行階段載入模組並驗證位元組碼;畸形或型別錯誤的程式碼在任何執行之前就被拒絕。
  3. 宿主決定授予哪些能力——比方說,一個指向某個目錄的把手與 stdout——並只提供那些匯入;其餘一切就是單純地不存在。
  4. 執行階段對函式做 JIT 編譯並執行它們;每一次記憶體存取都做界限檢查,而每一個碰觸世界的呼叫都走過一個被授予的匯入。

為何這是一道前沿:Wasm 正往瀏覽器之外去哪裡

把這些片段拼起來,你就看出人們為何興奮。Wasm 給你接近原生的速度、真正的可攜性(同一個 `.wasm` 能在任何裝了執行階段的宿主上跑,不管作業系統或 CPU 是什麼)、一個由結構保證的強沙盒、以及對世界的能力範圍化存取。這個組合悄悄地同時與兩種老工具競爭。比起一個容器,一個 Wasm 模組小得多,而且以微秒級啟動,而不是一個行程與它的命名空間所需的數十毫秒——沒有作業系統映像、沒有 forkexec,只有「驗證並 JIT」。比起一個用 dlopen 載入的原生外掛,它能安全地執行你不信任的第三方程式碼,因為一個行為失當的模組無法損毀宿主。

這些性質正把 Wasm 拉進真正的系統工作裡。在邊緣與無伺服器(serverless)運算上,執行階段能以微秒為每個請求拉起一個全新的沙盒化實例,閃開啟動一個容器的冷啟動成本——你的函式是一個 `.wasm`,不是一台虛擬機。在資料庫與代理伺服器裡,Wasm 正成為「在行程內安全地執行使用者提供的擴充」的方式,而換成原生外掛則會冒著弄垮整台伺服器的風險。而它也是一條乾淨的路,把一個編譯好的產物送出去、讓它在各種架構上一模一樣地執行——那個可攜通用語(lingua franca)的夢,只是由沙盒、而非由祈禱來強制。一個正在浮現的標準「元件模型(Component Model)」推得更遠:讓以不同語言寫成的 Wasm 模組以帶型別的介面彼此連結,於是一個 Rust 模組與一個 C 模組無須共用一個脆弱的 ABI 就能組合。