WebAssembly 工作原理淺析
- 前言
狹義上的 WebAssembly 是 W3C 標準化組織制定的一個可移植、體積小、加載快並且兼容 Web 的全新二進制格式,在前面章節中已經對此做過詳細的闡述;然而,隨着 WebAssembly 技術的演進和廣泛應用,它的內涵也在不斷的外延,廣義的 WebAssembly 可以理解爲基於 WebAssembly 演化出來的完整生態,如下圖 1 所示。
圖 1. WebAssembly 應用和基礎架構總覽
在上圖中,SAPPHIRE 作爲全球的創業投資公司,從 WebAssembly 的應用和基礎框架兩個維度描繪了 WebAssembly 的核心生態 [1],其中,應用生態依賴的 WebAssembly 基礎核心架構。WebAssembly 的基礎核心架構主要分爲三部分,首先是可移植的二進制格式本身;其次,由於手寫 WebAssembly 是一項極具挑戰和開發者不友好的方式,因此,需要提供面向 WebAssembly 的高級編程語言,以及將高級編程語言編譯爲 WebAssembly 二進制格式的編譯工具鏈和語言核心庫,我們將其稱之爲 WebAssembly 前端 (Frontend);最後,WebAssembly 的可移植性需要通過虛擬機來提供高效的運行環境;如下圖 2 所示。
圖 2. WebAssembly 基礎核心架構圖
WebAssembly 核心架構中二進制規範,語言核心庫和編譯工具鏈等內容已經在前面的章節中進行了詳細的闡述,本文將從 WebAssembly 運行時的維度來分析和闡述 WebAssembly 核心原理及基礎能力。WebAssembly 運行時由模塊加載和解析器、執行引擎以及與宿主的系統交互接口 (WASI) 等關鍵部分組成。其中,WasmLoader
主要完成 WebAssembly 標準二進制文件 [2]的加載、解碼,格式校驗,初始化和多模塊的鏈接等多個階段;當文件加載完成後,WebAssembly 運行時會爲二進制文件生成對應的 WebAssembly 模塊實例對象,並初始化運行時環境中數據區 (WasmRuntime Data Areas),包括全局數據區(global space)、方法區(function space)、間接對象引用區(table space) 以及線性內存區(memory space);完成以上兩個階段後,運行時會調度執行引擎 (Execution Engine) 來執行對應 WebAssembly 方法區中函數的字節碼,不同的執行引擎會採用不同的技術來執行字節碼,其中最典型的是解釋執行和運行期編譯執行兩種類型;此外,執行引擎還需要調度內存管理器來完成內存分配,並利用垃圾回收機制進行運行期複雜對象的管理。WebAssembly 運行時結構如下圖 3 所示。
圖 3. WebAssembly 運行時架構圖
接下來,我們將從 WebAssembly 模塊解析器出發,逐一介紹 WebAssembly 運行時的各個組成部分及其底層工作原理。
- WebAssembly 解析器
在上一小節中,我們簡單的介紹了 WebAssembly 模塊的加載和解析,它主要包含了 WebAssembly 的二進制文件的加載、驗證、實例化等階段,爲邏輯功能的執行初始化運行時環境。
2.1 模塊加載和解碼
WebAssembly 模塊的二進制格式是其抽象語法的壓縮線性編碼 [2],格式由屬性文法定義,其唯一的終結符號是字節,當且僅當它是由語法生成時,字節序列纔是模塊的格式良好的編碼;因此,屬性文法隱含地定義瞭解碼函數 (即二進制格式的解析函數)。WebAssembly 運行時首先需要加載二進制文件,按照屬性文法解析函數將字節流轉換爲內存中虛擬機可以識別和使用的數據結構。
與常用的二進制格式 (例如 ELF) 類似,WebAssembly 的二進制格式以段 (Section) 編碼爲模塊文件 (Module)。大多數段對應於模塊記錄的一個組件,此外,各段之間通過依賴關係來共享數據,例如,代碼段依賴於函數段定義的類型及函數索引,函數段依賴類型段對函數簽名的聲明,模塊的段結構如下圖 4 所示。
圖 4. WebAssembly 二進制模塊結構圖
WebAssembly 爲了減少模塊體積,無論是無符號還是有符號整數都使用 LEB128 可變長度整數編碼進行編碼。按照 WebAssembly 模塊的標準格式和編碼規則,加載和解碼階段將會把 WebAssembly 二進制文件內容轉換爲 "WasmRuntime Data Areas" 中的內部數據進行保存,其中最爲關鍵的是全局數據區 (global space)、方法區 (function space)、間接函數引用區 (table space ) 以及線性內存區 (memory space) 四大運行時區域,運行時區域內容如下圖 5 所示。
圖 5. WebAssembly 運行時狀態空間示意圖
以如下模塊中函數對象爲例,在模塊的加載過程中,模塊解析器會根據 WebAssembly 二進制格式內容在執行環境中創建對應的 "function space" 內存空間,該內存空間用於保存模塊執行時所需要的所有函數對象;解析器按函數在模塊中的定義順序爲其分配單調遞增的索引,導入函數由被導入的模塊提供,暫無法解析,需要在函數索引空間中爲其預留空間;模塊加載後索引空間如下圖 6 所示。
圖 6. 函數名字空間示意圖
2.2 模塊驗證
當完成第一階段的模塊加載後,解析器會對模塊進行校驗。模塊驗證主要驗證 WebAssembly 模塊是否格式正確,只有有效的模塊才能被實例化。有效性由類型系統在模塊及其內容的抽象語法上定義,對於每一段抽象語法,都有一個類型規則來指定適用於它的約束,它描述了有效的模塊或指令序列必須滿足的約束。Validation Algorithm[3] 提供了根據規範對指令序列進行類型檢查的完整算法的框架,該算法在二進制格式操作碼序列上進行表達,並且只對其執行一次傳遞,因此,它可以直接集成到加載器中進行模塊驗證。
圖 7. local.tee 驗證約束規則示意圖
以指令 local.tee 爲例,上圖 7 以形式化符號和文字描述兩種形式聲明瞭 local.tee 驗證約束規則;該規則的前提條件是當前上下文 (C) 定義了 local 變量 x,該變量 x 的類型爲 t(t 可以是類型 i32 | f32 | i64 | f64 | v128 | funcref | externref 中的任一類型),那麼當且僅當 local.tee 指令的輸入參數爲 t 類型並且返回 t 類型的值纔是合法的指令。當 local.tee 是合法指令,當前指令序列才能正常執行指令邏輯,進而修改程序運行狀態;否則,執行過程將會停止並拋出異常,local.tee 指令執行邏輯如下圖 8 所示。
圖 8. local.tee 指令流程示意圖
2.3 實例化
當完成了上述兩個階段,解析器的最後階段是完成 WebAssembly 模塊實例化,實例化的主要工作是根據 WebAssembly 二進制加載過程中生成的數據結構創建對象實例,並完成對象實例的符號解析和鏈接過程,如下圖 9 所示。
圖 9. WebAssembly 模塊實例化示意圖
以模塊中函數內存空間爲例,雖然在加載過程中已經完成了索引空間的創建和佈局 (如圖 6 所示),但尚未對符號完成解析和鏈接,因爲某些符號並不一定是本模塊所定義而需要從外部模塊導入,例如,"env.print"
, "share_ctx.fib"
,"share_ctx.distance"
函數。在 WebAssembly 模塊實例化過程中,解析器需要獲取被導入的模塊,並在模塊中解析需要導入的符號對象,並將導入對象保存至模塊的對應索引空間中,該過程我們稱爲 WebAssembly 模塊的動態鏈接 (動態鏈接詳細內容請參見第 9 章),如下圖 10 所示。
圖 10. WebAssembly 函數鏈接示意圖
- 執行引擎
執行引擎是一個運行環境的 "心臟",它負責目標指令的執行和運行時狀態的管理;針對 WebAssembly 基於棧的概念模型,我們從解釋器、線性內存管理和垃圾回收這三個維度對執行引擎的原理進行介紹。
3.1 棧解釋器
常用的硬件架構通常採用基於寄存器的 "三地址" (opcode dest, src1, src2) 和 "二地址"(op dest, src in x86)指令集,如 arm,x86,risc 等處理器架構。相比較而言,零地址指令的源與目標都是隱含參數,可以用更少空間存放更多指令,指令 "密度" 高、代碼的傳輸與存儲的開銷小、平臺移植性好;因此,在空間緊缺的環境中,零地址指令是一種經常被採用的設計。
WebAssembly 指令集在整體上是按照零地址形式設計的,它的指令集通過 "基於棧的架構" 來實現。棧是一種 "後進先出" 的數據結構,在基於棧的虛擬機中,大部分指令執行時都會從棧中取出操作數,然後,根據指令邏輯對這些操作數進行相應的運算,並將所得到的結果重新壓入棧中;WebAssembly 選擇使用零地址的棧虛擬機,除了實現簡單、快速外,還在於它便於高效地實現 WebAssembly 模塊的驗證;WebAssembly 模塊執行前不僅需要驗證模塊的合法性,而且需要對模塊邏輯做相應的靜態分析、安全性驗證、形式化證明等;比如,驗證模塊是否訪問了規定範圍外的內存,模塊中各個函數的返回值類型是否正確,模塊中表達式變量的作用域是否正確等,基於棧架構的模型,可以十分簡單和快速地進行這些檢查。
本文主要目的是介紹 WebAssembly 核心原理及其底層機制,因此,接下來,我們將通過一個簡短的實例來說明 WebAssembly 基於棧虛擬機執行模型。
void foo() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
return c;
}
首先,我們將上述源代碼所表示的邏輯轉換爲如下對應的 WebAssembly 字節碼。
(module
(type (;0;) (func (result i32)))
(func (;0;) (export "foo") (type 0) (result i32)
(local i32 i32 i32)
i32.const 1
local.set 0
i32.const 2
local.set 1
local.get 0
local.get 1
i32.add
i32.const 5
i32.mul
local.tee 2
return)
)
WebAssembly 函數執行需要一個調用棧,該棧以幀 (frame) 爲單位記錄函數的執行狀態,每調用一個函數就會分配一個新的棧幀壓入調用棧上,每從一個函數返回則彈出並撤銷相應的棧幀。每個棧幀包括局部變量區、操作數棧,和其它一些信息;局部變量區用於存儲方法的參數與局部變量,其中參數按源碼中從左到右順序保存在局部變量區開頭的幾個槽位中;操作數棧用於保存計算過程的中間結果和調用別的方法的參數等;每個函數所需要的局部變量區與操作數棧大小都能夠在編譯時確定,並記錄在 WebAssembly 二進制文件中;函數調用棧如下圖 11 所示。
圖 11. 函數調用棧示意圖
基於函數的棧幀結構定義,foo
函數所需要的局部變量區大小爲 3 個內存槽,分別用於存放臨時變量 a
,b
,c
;由於操作數棧的內存槽可以複用,foo
函數需要的操作數棧大小爲 2 個內存槽,用於存放表達式的臨時計算結果;如下圖 12 右側結構所示。此外,圖 12 以動畫的方式展示了 foo
函數中 WebAssembly 字節碼的完整執行過程,通過跟蹤執行過程,可以輕鬆地理解字節碼是如何指示虛擬機將數據壓入或彈出棧,以及數據是如何在棧與局部變量區之間傳遞的,例如,算數運算指令 iadd
和 imul
指令都需要從求值棧彈出兩個值運算,再把結果壓回到棧上的。
圖 12. 棧解析器執行示意圖
上圖 12 中所有數字均以十六進制表示,其中,"字節碼" 表示指令的 16 進制數值,"助記符" 則是其對應的文本描述;標記爲紅色的值是相對上一條指令的執行狀態有所更新的值;程序計數器 (PC) 用於記錄程序當前執行的位置,以字節爲單位記錄當前指令相對函數起始位置的偏移量。
至此,我們已經詳細地介紹了 WebAssembly 基於棧結構模型的指令執行的大致過程;然而,如果按照指令在虛擬機中的執行方式來區分,寄存器型虛擬機模型也會被經常使用到;關於棧式虛擬機和寄存器式虛擬機的討論在 "解釋器,樹遍歷解釋器,基於棧與基於寄存器"[4] 和 "棧式虛擬機和寄存器式虛擬機"[5][6] 話題中有很好的討論和回答;此外,基於 JIT 的高級優化虛擬機技術請參見相關的專業書籍和文檔 [7][8],不在此展開介紹。
3.2 線性內存管理
內存是執行引擎的基石,是數據的讀寫和訪問的基礎。如上圖 3 "WasmRuntime Data Areas" 所示,WebAssembly 內存包含了託管的內存和非託管內存兩種類型。託管的內存是指由虛擬機管理的內存,包括全局數據區 (global space)、方法區 (function space)、間接對象引用區 (table space),運行時棧區。非託管內存主要包括線性內存區 (memory space),它允許用戶程序進行訪問 (load, store) 和管理 (memory.grow)。
傳統的內存管理主要以進程爲單位,通過操作系統提供的 API 來申請、訪問和釋放,這種模型雖然可以提供高效的內存訪問,但帶來了比較嚴重的內存問題,例如,不同的應用共享了相同的虛擬機內存空間,越界訪問,非法內存地址的訪問,內存數據防竊取等各種內存安全問題無法從根本上解決,此外,應用間無法做到很好的隔離,惡意的軟件可以輕易的竊取用戶信息。
圖 13. WebAssembly 線性內存示意圖
如上圖 13 所示,WebAssembly 採用了線性內存的設計,線性內存是一個地址連續的、可進行字節尋址的內存結構,從偏移量 0 一直延伸到最大內存大小,最大內存大小始終是 WebAssembly 頁的的整數倍,一個 WebAssembly 內存頁被規定爲固定的 64KB 大小。每個 WebAssembly 實例都有一個專門指定的默認線性內存,WebAssembly 線性內存類型由模塊的內存段 (Memory Section) 進行描述,虛擬機通過獲取模塊的內存段類型信息來預留最大內存大小,並且讀取數據段 (Data Section) 來初始化內存區域。WebAssembly 線性內存被放置在一個完全封閉的沙箱執行環境中,這使得線性內存與進程中其他類型的內存完全分離;並且在同一個進程中和其他應用在隔離環境中共存,甚至可以在現有的 JavaScript 虛擬機中實現。在 web 環境中 [9],WebAssembly 將會嚴格遵守同源策略以及瀏覽器安全策略。總的來說,基於線性內存的沙箱環境可以讓不同應用在進程內共存,又可以在隔離環境中更加安全地使用內部的線性內存。
此外,WebAssembly 線性內存是結構化的連續內存區域,線性內存的佈局是由編譯器來定的,而且,現在 WebAssembly 支持作爲多種前端語言的編譯產物,在每個編譯器有自己的內存佈局的時候,會導致不同語言模塊之間靜態和動態鏈接的技術挑戰。當前,LLVM 作爲衆多 WebAssembly 編譯工具鏈的後端,其內存佈局的實現主要藉助鏈接器 wasm-ld 實現。wasm-ld 鏈接器將線性內存分爲 4 個區域,包括全局靜態數據區 (data area)、未初始化數據區 (bss data)、輔助棧區 (stack) 以及堆區 (heap)。wasm-ld 默認將全局靜態數據區 (data area) 作爲 WebAssembly 線性內存空間的第一個內存區域,但在提供了 "--stack-first" 鏈接選項的情況下,wasm-ld 會強制將輔助棧區 (stack) 作爲線性內存空間的第一個內存區域來佈局。wasm-ld 內存佈局主要通過 layoutMemory
函數實現,其核心邏輯核心邏輯參見如下源碼中的備註 [10]。
// Fix the memory layout of the output binary. This assigns memory offsets
// to each of the input data sections as well as the explicit stack region.
// The default memory layout is as follows, from low to high.
//
// - initialized data (starting at Config->globalBase)
// - BSS data (not currently implemented in llvm)
// - explicit stack (Config->ZStackSize)
// - heap start / unallocated
//
// The --stack-first option means that stack is placed before any static data.
// This can be useful since it means that stack overflow traps immediately
// rather than overwriting global data, but also increases code size since all
// static data loads and stores require larger offsets.
void Writer::layoutMemory() {
uint32_t memoryPtr = 0;
auto placeStack = [&]() {
/* ... skip non-critical source code */
memoryPtr += config->zStackSize;
auto *sp = cast<DefinedGlobal>(WasmSym::stackPointer);
sp->global->global.InitExpr.Value.Int32 = memoryPtr;
};
/* 1. fill the first memory region */
if (config->stackFirst) {
/* fill the stack at the first memory region
while linking with the "-Wl,--stack-first" option*/
placeStack();
} else {
memoryPtr = config->globalBase;
log("mem: global base = " + Twine(config->globalBase));
}
/* 2. set the start address of "data area" ("__global_base") */
if (WasmSym::globalBase)
WasmSym::globalBase->setVirtualAddress(memoryPtr);
if (WasmSym::definedMemoryBase)
WasmSym::definedMemoryBase->setVirtualAddress(memoryPtr);
uint32_t dataStart = memoryPtr;
/* ... skip non-critical source code */
/* 3. set the end address of "data area" ("__data_end") */
if (WasmSym::dataEnd)
WasmSym::dataEnd->setVirtualAddress(memoryPtr);
/* ... skip non-critical source code */
/* 4. fill the "stack area" in default mode
set the start address of "start area" ("stack_pointer") */
if (!config->stackFirst)
placeStack();
/* 5. set the start address of "heap area" ("__heap_base") */
// Set `__heap_base` to directly follow the end of the stack or global data.
// The fact that this comes last means that a malloc/brk implementation
// can grow the heap at runtime.
if (WasmSym::heapBase)
WasmSym::heapBase->setVirtualAddress(memoryPtr);
/* ... skip non-critical source code */
/* 6. set the "memory type" for linear memory (limits ::= {min, max}) */
out.memorySec->numMemoryPages =
alignTo(memoryPtr, WasmPageSize) / WasmPageSize;
/* ... skip non-critical source code */
// Check max if explicitly supplied or required by shared memory
if (config->maxMemory != 0 || config->sharedMemory) {
/* ... skip non-critical source code */
out.memorySec->maxMemoryPages = config->maxMemory / WasmPageSize;
}
}
基於上述實現源碼,我們通過一個簡單的示例來演示 wasm-ld 在生成 WebAssembly 模塊時是如何進行線性內存佈局的,示例代碼如下所示;其中,源代碼中通過字符數組 array 申請了 2KB 的全局靜態數據區。
/* linear-memory-layout.c */
static char array[2048];
int layout() {
int a = 1;
int b = 2;
int c = (a + b) * 5;
// avoid DCE optimization
array[1000] = 1;
return c;
}
基於上述代碼,我們通過如下的編譯命令來獲取 WebAssembly 模塊的默認線性內存佈局信息,其中,通過 "-z stack-size=1024" 鏈接選項指定棧大小。
// 編譯生成 *wasm, 設置堆棧 1K,初始內存 64K(1 page), 最大內存 128K(2 pages)
clang --target=wasm32 \
-O0 -nostdlib \
-z stack-size=1024 \
-Wl,--no-entry \
-Wl,--export-all \
-Wl,--allow-undefined \
-Wl,--initial-memory=65536 \
-Wl,--max-memory=131072 \
linear-memory-layout.c -o default-memory-layout.wasm
// 將 wasm 反彙編爲文本格式
wasm2wat default-memory-layout.wasm -o default-memory-layout.wat
Clang 默認生成的 WebAssembly 線性內存佈局如下圖 14 所示,按源碼和編譯選項中設置,線性內存初始爲 1 頁,即 64KB,最大內存爲 2 頁,即 128KB;全局數據區爲 [__global_base, __data_end] 用於 array
全局數組,總計 2KB;棧區域爲 ($**stack_pointer, **data_end) 從高地址往低地址增長,總計爲 1 KB;堆區的起始地址爲 __head_base
,緊鄰棧區,增長的方向高地址方向。
圖 14. WebAssembly 默認線性內存佈局示意圖
上圖 14 所示的線性內存佈局標識符由 WebAssembly 全局 (Global) 變量進行定義,並在 WebAssembly 代碼中進行訪問和管理。對應 WebAssembly 模塊的詳細信息可以參見如下 default-memory-layout.wat 文件所示。
;; default-memory-layout.wat ;;
(memory (;0;) 1 2)
(global $__stack_pointer (mut i32) (i32.const 4096))
(global (;1;) i32 (i32.const 1024))
(global (;2;) i32 (i32.const 3072))
(global (;3;) i32 (i32.const 1024))
(global (;4;) i32 (i32.const 4096))
(global (;5;) i32 (i32.const 0))
(global (;6;) i32 (i32.const 1))
(export "memory" (memory 0))
(export "__data_end" (global 2))
(export "__global_base" (global 3))
(export "__heap_base" (global 4))
(export "__memory_base" (global 5))
基於上述相同的源代碼,我們可以通過增加 "-Wl, --stack-first" 編譯選項來改變默認的內存佈局, 通過如下的編譯命令可以獲得棧優先的 WebAssembly 模塊線性內存佈局信息。
// 編譯生成*wasm, 設置堆棧1K,初始內存64K(1 page), 最大內存128K(2 pages)
clang --target=wasm32 \
-O0 -nostdlib \
-z stack-size=1024 \
-Wl,--no-entry \
-Wl,--export-all \
-Wl,--allow-undefined \
-Wl,--initial-memory=65536 \
-Wl,--max-memory=131072 \
-Wl,--stack-first \
linear-memory-layout.c -o stack-first-memory-layout.wasm
// 將wasm反彙編爲文本格式
wasm2wat stack-first-memory-layout.wasm -o stack-first-memory-layout.wat
Clang 採用棧優先佈局編譯選項生成的 WebAssembly 線性內存佈局如下圖 15 所示,如源碼和編譯選項中設置,線性內存初始爲 1 頁,即 64 KB,最大內存爲 2 頁,即 128 KB;棧區作爲線性內存的第一個內存區域,在 ($__stack_pointer, __memory_base
) 範圍內從高地址往低地址增長,總計爲 1 KB;全局數據區爲 [__global_base, __data_end] 用於 array 全局數組,總計 2 KB;堆區的起始地址爲 __head_base,緊鄰全局靜態數據區,增長的方向高地址方向。
圖 15. WebAssembly 棧優先線性內存佈局示意圖
上圖 15 所示的棧優先線性內存佈局標識符由 WebAssembly 全局變量 (Global) 進行定義,並在 WebAssembly 代碼中進行訪問和管理。對應 WebAssembly 模塊的詳細信息可以參見如下 stack-first-memory-layout.wat 文件所示。
;; stack-first-memory-layout.wat ;;
(memory (;0;) 1 2)
(global $__stack_pointer (mut i32) (i32.const 1024))
(global (;1;) i32 (i32.const 1024))
(global (;2;) i32 (i32.const 3072))
(global (;3;) i32 (i32.const 1024))
(global (;4;) i32 (i32.const 3072))
(global (;5;) i32 (i32.const 0))
(global (;6;) i32 (i32.const 1))
(export "__data_end" (global 2))
(export "__global_base" (global 3))
(export "__heap_base" (global 4))
(export "__memory_base" (global 5))
3.3 垃圾回收器 (GC)
對於一個成熟的虛擬機而言,內存管理是其核心功能之一;主流的內存管理方式主要包括手動內存管理 (C/C++) 和垃圾收集器內存管理 (Java, JavaScript 等) 兩大類。這兩種內存管理方式各有優劣,對他們的討論也從未停止過。
手動內管理方式在進行對象動態創建和銷燬時,需要手動分配和釋放內存,而在大型的軟件中手動調用 malloc
和 free
,很容易出現內存重複釋放導致的非法內存訪問,或者忘記釋放導致的內存泄露。
使用垃圾收集器的編程語言所分配的內存會由垃圾收集器來統一管理,每隔一段時間,垃圾回收器會對內存對象進行掃描,自動識別出來到底哪些內存區域可以被釋放。簡而言之,垃圾收集器(GC)讓開發人員無需過多考慮內存管理,他們可以管理對象引用、傳遞對象、在函數 / 變量之間共享對象,並且在不再使用這些對象時依靠 GC 來清理它們。當然,垃圾收集器的問題也是顯而易見的,垃圾收集器需要間歇性地掃描內存中可釋放的內存區域並回收垃圾,這會產生不受代碼控制的 "stop the world" 現象;由於垃圾收集器有比較大的開銷只能在特定情況下觸發,因此無法即時釋放空閒內存區域,導致內存平均水位偏高;此外,垃圾收集器需要完全掌控內存使用情況,這導致處理異構環境或語言邊界的對象回收時總是需要更多內存拷貝操作,編程語言之間交互的代碼更加繁瑣。儘管如此,現在很多編程語言仍然帶有垃圾回收器,而系統編程語言或者爲解決性能問題的編程語言或者執行環境,一般不使用垃圾回收器來進行內存管理。
對 WebAssembly 而言,初期的主要設計目標是提供一個底層的高效二進制格式及其對應的運行環境,並將靜態強類型語言 (C/C++) 直接靜態編譯到字節碼,避免在語言層面的額外開銷,從而提升性能;而目標編程語言沒有采用垃圾收集器,例如 C/C++,Rust 等,因此,WebAssembly 沒有垃圾收集器,它只提供了一塊可以按字節尋址的線性內存,而沒有任何可用於內存管理的工具。對於i32
、f32
、i64
、f64
等基礎數據類型,WebAssembly 可以在內存中高效的訪問、傳遞,而對於 Struct
、Array
等複雜的數據結構,需要手動負責對象創建和回收對象 (類似於 C/C++) 或者採用優化的內存分配器來完成內存的管理工作,例如,dlmalloc
、tcmalloc
、jemalloc
等。
作爲面向所有語言的一個底層的字節碼規範,WebAssembly 在內存管理機制上僅支持現有手動管理方式,還是增加垃圾收集器進行自動管理一直存在不同的觀點,在 Garbage collection[11] 提案的 Issue 列表中有過非常廣泛的討論 (It doesn't seem like WebAssembly should have a GC
)[12][13]。
Garbage collection 提案已經到了第三個實現階段 (Phase 3 - Implementation Phase (CG + WG)),正如提案中描述的,通過對垃圾收集器的支持可以更好的解決如下 3 方面的問題。
-
首先,採用垃圾收集器,WebAssembly 可以以更快的執行性能,更小的體積支持更廣泛的現代高級語言。
WebAssembly 目標是作爲所有高級語言的編譯目標產物,針對需要垃圾回收器的源編程語言,爲了生成可用的 WebAssembly 目標模塊,編譯器只能將垃圾收集器實現也同時編譯爲 WebAssembly 字節碼,並將其作爲二進制文件的一部分,例如 AssemblyScript 就在二進制文件中包含了一個 "makeshift GC"。但這樣會增加二進制文件的大小,同時 GC 算法的效率也會受到影響。由於 WebAssembly 缺少垃圾收集器,也成爲Scala
,Elm
,go
,Java
等語言還不支持編譯成 WebAssembly 的原因之一。因此,實現垃圾收集器及其前置提案可以支持 WebAssembly 作爲更多高級語言的高性能執行、體積小的目標產物。 -
其次,垃圾收集器 (GC) 解放了開發人員對內存管理,提高了內存的安全性。
很多現代編程語言都帶有垃圾回收器,WebAssembly 作爲可嵌入的模塊,支持快速友好的接入現有工業級的垃圾收集器,既能減輕 WebAssembly 本身的內存管理負擔,也能夠爲宿主的垃圾收集器提供一個 "同構" 的內存對象管理環境,降低異構環境或異構語言對宿主垃圾收集器的干擾,例如,基於 Garbage collection 提案標準,V8 中實現的 WebAssembly 垃圾回收實際是接入到 JavaScript 虛擬機的垃圾收集器,而並沒有實現一個自己獨立的垃圾收集器。 -
最後,WebAssembly 很多場景本質上是一個異構的多語言環境,統一的垃圾收集器可以實現多種語言之間無縫互操作。
例如,JavaScript 環境中使用 WebAssembly 作爲模塊集成,i32
,f32
,i64
,f64
等基礎數據類型可以在語言間方便的進行交互,然而,當宿主與 WebAssembly 進行復雜數據結構交互時,一個方案是將這些對象拷貝到 WebAssembly 的線性內存中進行訪問,返回時需要將數據拷貝至宿主內存中,然而,這不可避免的引入性能的開銷與複雜度,這是嚴重次優的解決方案;雖然,WebAssembly 可以通過持有宿主對象的引用來避免數據拷貝,但 WebAssembly 被賦予一個引用時,它並不總是知道它來自哪裏以及該引用背後的實際數據類型 (Opaque Type reference)。因此,WebAssembly 爲了發揮性能價值,避免這樣的開銷,必須通過進一步擴展 WebAssembly 標準來解決。
爲了表述的準確性,本文將 WebAssembly 線性內存中對象稱爲 "Guest Object",而在宿主環境中的對象稱爲 "Host Object"。在一般情況下,"Guest Object" 的生命週期可能直接取決於 "Host Object",反之亦然。解決這種生命週期相互依賴的唯一正確方法是跨宿主環境邊界擴展 WebAssembly。在 "Guest Object" 依賴於宿主環境的可 GC "Host Object" 的場景中,已經取得了很大的進步,例如,宿主對象是一個 JavaScript 對象,而 "Guest Object" 對象是一個 C++ 對象。JavaScript 的 WeakRef[15][16] 機制可以爲可 GC JavaScript 對象註冊一個 "Finalizer" 回調函數,回調函數可以在 "Host Object" 被垃圾回收的時候調用以釋放關聯的 "Guest Object"。但是,它不提供反向功能,即 "Host Object" 依賴於 WebAssembly 線性內存中的 "Guest Object" 的生命週期,無法直接在 WebAssembly 層面持有宿主環境 GC 可見的 "Host Object" 引用,那麼必須有一個對應的宿主對象的強引用來保持它的存活,這又導致內存泄漏,如下圖 16 所示。
圖 16. WebAssembly 宿主對象與線性內存對象綁定示意圖
Garbage collection[11] 提案將垃圾收集器功能帶到 WebAssembly 中,用於管理線性內存,如下圖 17 所示。
圖 17. WebAssembly 線性內存垃圾回收示意圖
由於垃圾收集器的主要職責是對可回收對象進行全生命週期的管理,包括對象的創建、對象的訪問和賦值、以及垃圾對象的銷燬。因此, WebAssembly 需要定義完整的類型系統和對象原語 (指令) 以便創建可回收對象,對象賦值的追蹤,對象屬性的訪問,內存掃描和回收等。
這導致 WebAssembly 規範增加了許多內容,包括類型的擴展,例如,在基礎數據類型之外,增加了 struct
、array
、structref
、arrayref
等高級數據類型,以及與這些數據類型對應的指令擴展,struct.new | get | set
、array.new | get | set | len
等。這個對 WebAssembly 來說是一個重大變化,因爲 WebAssembly 和傳統的彙編語言一樣是無類型的低級語言,這種變化使得 WebAssembly 逐步往類型化彙編語言的方向演進。類型化彙編語言 (Typed Assembly Language) [17][18] 通過類型註釋、內存管理原語和一套完善的類型規則擴展了傳統的非類型化彙編語言;這些類型規則保證了類型化彙編程序的內存安全、控制流安全和類型安全;此外,類型結構的表現力足以對大多數源語言編程特徵進行編碼,包括結構、數組、高階和多態函數、異常、抽象數據類型、子類型和模塊,便於實現基於垃圾收集的高級內存管理系統。
Garbage collection 提案的另一個目標是實現多種語言之間無縫互操作性。由於 Garbage collection 的複雜性,該提案被分解成更小部分以解決問題的不同組成部分,並以獨立提案發布規範。如上文所述,現有的 WebAssembly 規範無法解決 "Host Object" 依賴於 WebAssembly 線性內存中的 "Guest Object" 的生命週期問題;GC 提案假設不同的模塊可以自由共享 GC 對象的引用,這一目標是通過 Reference Types
[19] 和 Typed Function References
[20] 提案完成的;其中 Reference Types
[19] 提案增加了 externref
、funcref
用於表示和傳遞宿主中的對象引用,允許多個 table
用於保存和訪問對象引用;Typed Function References
[20] 提案擴展了引用類型的表示方法,採用 ref $t
表示對類型 $t
的引用;將函數類型作爲一等公民對待,可以通過 ref.func $f
訪問函數而不是必須放入 table
中間接訪問。Interface Types
[21] 擴展了函數類型,允許複雜數據結構作爲函數參數傳遞,例如 record
,list
等,增強了宿主與 WebAssembly 的交互性;Type Imports
[22] 提案允許模塊導入類型定義,這樣,宿主可以提供自定義類型並導入 WebAssembly 模塊中,而模塊可以對它進行類型化引用 ref $t
。在上述提案的基礎上,Garbage collection 提案進一步擴展了類型系統和指令集,使得 WebAssembly 可以通過引用類型及其子類型與宿主進行交互,同時對象之間的引用關係基於 WebAssembly 引用類型做到了無縫的連接,如下圖 18 所示。
圖 18. WebAssembly 宿主對象與線性內存對象引用管理示意圖
Garbage collection 提案還在不斷的改進中,它不斷努力嘗試找到一種 GC 解決方案,該解決方案對於 Guest 語言來說足夠好,以致於在一般情況下他們不需要自帶 GC,而是與宿主環境的 GC 集成,與宿主的 GC 集成的選項不僅可以更輕鬆地將衆多高級語言編譯爲 WebAssembly(Java
、C#
、Elm
、Scala
),還可以更輕鬆地與主機創建的對象進行互操作,從而將所有子系統收斂到一個統一的 GC,而每個子系統可以爲自身的子堆 (subheap
) 定製特定的垃圾收集算法,與此同時,GC 的使用在 WebAssembly 中是可選的,允許像 Rust 和 C++ 這樣的語言仍然使用內存分配器和線性內存。
- WebAssembly 系統接口 (WASI)
WebAssembly 是一種用於概念機器而非物理機器的低級別語言,平臺可移植性和安全性是 WebAssembly 的首要目標之一,即跨所有不同的操作系統安全運行;爲此,WebAssembly 需要一個概念操作系統的系統接口,而不是任何特定單一的操作系統,這就是 WebAssembly 平臺的系統接口 (WASI)[25][26]。
爲了保護系統資源的安全,操作系統基本上在系統資源周圍設置了一道保護屏障,內核是唯一可以訪問系統資源的特權模塊,因此, WebAssembly 系統接口的主要目的是定義一組可移植、模塊化、獨立於運行時的 WebAssembly 原生 API,WebAssembly 代碼可以使用這些 API 與外界交互,這些 API 通過基於特定功能的接口設計保留了 WebAssembly 的基本沙盒特性,並通過平臺的系統調用訪問和操作系統資源。
WebAssembly 系統接口不是單一的標準系統接口,而是標準化 API 的模塊化集合。以模塊化的方式制定標準接口,允許 WASI 從最基礎的模塊 wasi-core 開始制定最小可用標準接口,然後添加其他功能,並根據反饋和經驗確定優先級,逐步制定和實施,如下圖 19 所示。
圖 19. WebAssembly WASI 模塊示意圖
爲了支持 WebAssembly 在瀏覽器之外的環境中運行,WASI WorkGroup(WG) 以提案的形式制定了 wasi-core 標準 API[26],它提供了程序運行所需要的基本能力,涵蓋了 POSIX 能力的大部分內容,包括文件、網絡連接、時鐘和隨機數等內容。對於其中的許多能力,它將採用與 POSIX 非常相似的方法。例如,它將使用 POSIX 的面向文件的方法,您可以在其中進行系統調用,例如打開、關閉、讀取和寫入,其他所有內容基本上都提供了增強功能。針對這些能力,WASI 工作組提交和推進了多個 WASI 提案 [27],這就是模塊化方法的用武之地,這樣,我們可以獲得良好的標準化覆蓋率,同時仍然允許不同的基平臺使用對它們有意義的 WASI 部分,WASI 相關核心提案如下圖 20 所示。
圖 20. WebAssembly WASI 核心提案
wasm-core 標準接口以 wasi-libc 的形式在 wasi-sdk 庫中提供使用支持,wasi-libc 爲 WebAssembly 程序提供了廣泛的 POSIX 兼容 C API,包括對標準 I/O、文件 I/O、文件系統操作、內存管理、時間、字符串、環境變量、程序啓動和許多其他 API 的支持。儘管 wasi-libc 正在繼續發展以更好地與 WeAssembly 和 WASI 保持一致,但它依然足夠穩定並且可用於多種用途,因爲大多數 POSIX 兼容的 API 都是穩定的,如下圖 21 所示。
圖 21. WebAssembly WASI 系統架構示意圖
- 總結
至此,我們已經從模塊加載和解析、模塊執行以及與宿主的交互機制等方面對 WebAssembly 運行時原理進行了詳細的介紹;此外,基於 WebAssembly 線性內存佈局和最新提案,對 WebAssembly GC 機制進行了簡要的介紹。雖然,繼 WebAssembly 的最小可用版本 (MVP) 登陸瀏覽器之後,又發佈了 WebAssembly 規範 2.0,但並不意味着 WebAssembly 已經很完善;事實上,情況遠非如此,WebAssembly 將提供許多功能,它們將從根本上改變你可以使用 WebAssembly 來完成的工作。WebAssembly 未來所能提供的特性就如一棵技能樹 [28],我們已經獲得了初始的技能來爲我們完成工作,然而,這棵技能樹還有很多新技能待我們去解鎖,以爲我們完成很多看起來不可能完成的任務,值得我們持續的關注和投入。
- 參考文獻
[1]. What’s Up With WebAssembly: Compute’s Next Paradigm Shift: https://sapphireventures.com/blog/whats-up-with-webassembly-computes-next-paradigm-shift/
[2]. Binary Format: https://webassembly.github.io/spec/core/binary/conventions.html
[3]. Validation Algorithm: https://webassembly.github.io/spec/core/appendix/algorithm.html
[4]. 虛擬機隨談 (一):解釋器,樹遍歷解釋器,基於棧與基於寄存器,大雜燴: https://www.iteye.com/blog/rednaxelafx-492667
[5]. 棧式虛擬機和寄存器式虛擬機: https://www.zhihu.com/question/35777031/answer/64575683
[6]. Virtual Machine Showdown: Stack Versus Registers: https://www.usenix.org/legacy/events/vee05/full_papers/p153-yunhe.pdf
[7]. Principles of Just-In-Time Compilers: https://nbp.github.io/slides/GlobalScope/2021-07/#intro,0
[8]. 計算機程序的構造和解釋: https://book.douban.com/subject/1148282/
[9]. Web Embedding: http://webassembly.org.cn/docs/web/
[10]. lld::wasm::Writer: https://github.com/llvm-mirror/lld/blob/master/wasm/Writer.cpp#L190
[11]. Garbage collection(Phase 3 - Implementation Phase (CG + WG)): https://github.com/WebAssembly/gc/blob/main/proposals/gc/Overview.md
[12]. It doesn't seem like WebAssembly should have a GC: https://github.com/WebAssembly/gc/issues/36
[13]. Why does the WebAssembly GC need this enriched type system: https://github.com/WebAssembly/gc/issues/32
[14]. WebAssembly, Expanding the Pie: https://www.infoq.com/news/2020/05/webassembly-summit-2020-apie/
[15]. JavaScript Garbage Collection with WebAssembly is Possible Today: https://jott.live/markdown/js_gc_in_wasm
[16]. WeakRef: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef
[17]. Typed Assembly Language: https://en.wikipedia.org/wiki/Typed_assembly_language
[18]. Typed Assembly Language Compiler: https://www.cs.cornell.edu/talc/
[19]. Reference Types: https://github.com/WebAssembly/reference-types
[20]. Typed Function References: https://github.com/WebAssembly/function-references
[21]. Interface Types: https://github.com/WebAssembly/interface-types
[22]. Type Imports: https://github.com/WebAssembly/proposal-type-imports
[23]. The road to WebAssembly GC for OCaml: https://medium.com/@sanderspies/the-road-to-webassembly-gc-for-ocaml-bd44dc7f9a9d
[24]. AsssemblyScript Garbage Collection: https://assemblyscript.bootcss.com/garbage-collection.html#runtime-interface
[25]. Standardizing WASI: A system interface to run WebAssembly outside the web: https://hacks.mozilla.org/2019/03/standardizing-wasi-a-webassembly-system-interface/
[26]. WebAssembly System Interface: https://github.com/WebAssembly/WASI/tree/59cbe140561db52fc505555e859de884e0ee7f00
[27]. WASI proposals: https://github.com/WebAssembly/WASI/blob/59cbe140561db52fc505555e859de884e0ee7f00/Proposals.md
[28]. WebAssembly’s post-MVP future: A cartoon skill tree: https://hacks.mozilla.org/2018/10/webassemblys-post-mvp-future/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/YrR3oQrJXALapxQPBDygkQ