close

作者 | 王鑫
策劃 |一鵬
審校 | 嘉洋

WebAssembly (簡稱 Wasm) 是目前備受關注的一門新的計算機語言,本演講從計算機語言技術的角度解析 WebAssembly 的語言特性,以及 WebAssembly 為應用提供安全沙箱機制的原理。我們將介紹 WebAssembly 在瀏覽器以外的主要應用場景和其帶來的價值,以及目前 W3C 正在定義中的一些主要特性及其對未來的影響。

本文整理自英特爾中國有限公司高級技術經理王鑫在 DIVE 全球基礎軟件創新大會 2022 的演講分享,主題為「WebAssembly 的核心語言特性與未來發展」。

分享主要分為七個部分展開:第一部分是 WebAssembly 的標準發展;第二部分和第三部分會分別介紹語言特性、字節碼與內存模型;接下來第四部分則是程序的控制流與函數調用;第五部分會帶大家了解類型系統與內存垃圾回收;第六部分會講解模塊的組件模型;最後一部分會介紹 WASI 與字節碼聯盟的情況。

以下是分享實錄:

WebAssembly 標準發展

在 2015 年,WebAssembly 第一次被對外公布。2017 年 MVP (Minimal Viable Product) 規範完成,並在 Chrome、Edge、Firefox 和 Safari 等四個主流的瀏覽器上得到支持。到了 2018 年,W3C 工作組發布了三個公開的 Drafts,包含 WebAssembly 的 Core Specification、JavaScript Interface 和 Web API。在 2019 年,WebAssembly spec 1.0 正式發布。同年 10 月份左右,Bytecode Alliance (BA) 由 Intel、 Mozilla、Fastly、Redhat 四家公司成立,主要的目標是構建與推廣基於 WebAssembly 以及 WebAssembly System Interface 的安全軟件棧。到 2021 年,BA 正式成為非盈利性的組織,微軟也加入成為協作會員,到目前已經有大概 30 多家的會員,發展情況非常良好。

我們看下兩個基於 WebAssembly 經典的使用案例。左邊是 AutoCAD 在瀏覽器上運行,能把 30 年積累的桌面應用代碼移植到到了瀏覽器上面,這主要歸功於把大量的 C/C++ 代碼轉換成 WebAssembly。2021 年另外一個標誌性的事件就是 Adobe 也把它的經典的軟件 Photoshop 搬移到了瀏覽器上面。據相關技術分析,Photoshop 的 Web 版應用主要也是基於 WebAssembly,當運行應用的時候,可能會有高達 1G 左右的本地磁盤的緩存,只需下載 10 多兆的網絡資源,啟動時間大概 3 秒鐘左右。整個應用充分地利用了 WebAssembly 和多核的技術,在運行一些操作的時候,最熱的 20 個函數裡面,SIMD 向量化計算的占有率約為 40% 左右,這說明通過類似於向量化計算的能力,WebAssembly 已經具備了支持開發強計算應用的能力。

WebAssembly 語言特性

我們這裡首先對 Wasm 語言的特性做一個總結。它包含了二進制和文本的兩種格式,它的執行模式是基於 Stack 的一種執行模式。它定義了四種基本的數據類型,就是 32 位、64 位的整數,32 位和 64 位的浮點精度。

Wasm 的內存設計也很有特色,包含了託管的內存和非託管內存類型。非託管內存也叫線性內存,一個 Wasm 實例可以有多塊的線性內存。目前線性內存的布局是由編譯器來定的。為什麼要了解這個呢?因為現在 WebAssembly 是支持多種前端語言的編譯,在每個編譯器有自己的內存布局的時候,會導致不同語言模塊之間靜態鏈接的技術挑戰。

Wasm 的流控是一個結構性的流控。它的函數調用需要使用函數表 (Function table)。如果和基於 C 語言編譯的機器指令相比,機器指令里可以直接跳到目標的物理地址,這裡是一個間接地址。Wasm 的函數調用操作碼後面都跟着一個索引號,這個索引就是目標函數在 Function table 裡面的索引值。如果是間接調用函數,則需要做函數類型檢查。

Wasm 支持一系列關鍵組成元素的 Import and Export,一個 WebAssembly 程序會定義需要外部導入什麼樣的元素,也可以定義哪些元素可以暴露給外部來訪問,為它的宿主環境,例如瀏覽器的 JavaScript,或者在獨立 Wasm 引擎的調用者,提供了一個可以通過編程的方式,來控制和訪問目標的 WebAssembly 應用對象的能力。

另外一個特性是對硬件向量化計算 SIMD 的支持,像剛才介紹到的,Photoshop 裡面大量用到 SIMD 這種能力。

最後,它是個強類型的 Type system,它也定義了 GC 和組件的模型。
字節碼與內存模型

我們使用一個例子來介紹 Wasm 的字節碼和內存模型。在最左邊是一段 C 語言的源碼,這段代碼會被編譯到屏幕中間 WebAssembly 的字節碼,右邊展示在運行態的內存布局。左邊的源碼定義了一個全局變量 count,一個函數 add,add 會把輸入參數 x 再加上 count,把結果更新到 count。

在 main 入口函數裡,調用 add(3),然後打印 count 值。中間這一段就是編譯 C 程序後生成的 WebAssembly 編碼。一開始包含幾個函數的類型(type)定義,0 和 1 是 type 的索引值,你可以把它可以看成一個表,表裡面有索引來標識了每一個類型。對應到所有引用到的函數,像代碼中的 add、main、printf 函數。

下一行表示要 import 一個 printf 函數,這個函數在字節碼裡面本身沒有提供實現,需要宿主環境來提供。接着定義了一個 Memory, 線性內存空間。再下面定義了三個 Global,Global 是 WebAssembly 定義的可以作用於全域的變量,不一定對應到源碼裡邊的全局變量,這裡可以看到源碼裡面的全局變量 count 並不是被映射到 WebAssembly 的 Global 裡面的。每個 Global 也是有編號的,同時包含其原型的定義以及初始化的值,像 9232、1040 都是它的初始化的值。

接着 Export 它的 Memory 對象,通過這樣宿主環境可以拿到 Wasm 程序的 Memory 對象進行訪問。這個 Wasm 代碼導出 (Export) 了三個對象,第一個 function #2,就是暴露給宿主環境的 main 函數。它還導出兩個 Global,一個是 data_end,一個是 heap_base。

接下來的數據 (data) 表示 Wasm 程序的靜態數據,每個數據有一個編號和線性地址位置,編號 0 就是 printf 打印的字符串的內容。第二個 2 是代表 count 的初始值,count 在 C 源碼中是一個全局變量,它實際上是在線性內存的 data 區。

下面索引號為 1 的函數是由 C 源碼 add 函數編譯過來,包含了函數類型說明,和上面函數 type #1 是對應的。下面另外一個函數 (索引 #2) 對應到 main 函數,type #0 是它的類型的定義。

右邊上部是一個線性內存,支持 Wasm 編程來任意訪問其中的任何一個位置。線性內存的讀寫訪問需要通過字節碼 iload 和 istore。iload 把線性空間中的數據移動到操作棧,其具體過程首先將偏移量設到棧裡邊,調這個指令之後它會從棧裡面取偏移量,從線性空間的偏移量去訪問拿到值,再把值壓回到棧裡面。istore 指令則是執行一套反向的流程。

LLVM 編譯 WebAssembly 的時候,有一個約定的內存布局,首先一開始是個數據(Data)區,主要是存放源碼的全局數據和靜態數據。Wasm 代碼裡面訪問這些變量的時候,是通過使用靜態的偏移量調用 iload、istore 來完成。中間的 Aux Stack 是 Wasm 程序運行中做輔助棧使用的,它與數據區的邊界是有一個 Wasm Global 來指向的,叫 data_end。data_end 是個 Global,前面我們已經看到將它 Export 出去了。Wasm 程序調用 malloc 時從其自己的 Heap 裡面分配數據,heap 區的起始位置是通過一個叫做 heap_base 的 Global 來指定的,它的初始值是編譯器在編譯時候已經計算好了,回到上面可以看到 Wasm 文件里包含其初始化的值。

線性內存之外的其餘 Wasm 內存是受管內存 (Managed Memory),這些對象的目標地址不是用戶完全來控制的。第一種是 Globals,可以把它看成一個一維的數組,這裡 data_end 是索引值為 1 的 Global,heap_base 是第二個 Global,還會有其他的一些變量按順序依次排下去。它的訪問有專門的字節碼,叫做 global.get,或者 global.set,這些字節碼後面會跟隨目標 Global 的索引值。

還有一種受管內存叫做 Locals,Locals 對應的字節碼叫做 local.get、local.set,以及 tee。Locals 它是以當前的棧為基準的,在執行指令的時候,缺省就是當前的棧作為基礎來進行訪問和定位的。源碼中基本類型的函數局部變量,可以使用 Locals 來映射,其他類型局部變量則會使用線性內存中 Aux Stack 來管理。和線性內存操作相比,Global 和 Local 操作目標的索引值是固定在 Wasm 文件中,說明其在編譯時刻決定。而線性內存的訪問地址是由 Wasm 程序邏輯本身在運行時來決定。

最後還有一種叫做操作棧 (Operation Stack),Wasm 許多操作碼裡邊隱含操作棧訪問,但沒有任何操作碼可以顯式控制操作棧。比如說前面看到的 add 操作,它會自動在棧裡面取兩個數,把計算的結果再返回到棧裡面去。

控制流與函數調用

下面介紹一下 WebAssembly 程序的控制流,以及函數調用。

WebAssembly 設計一種結構化的控制流,它定義了幾類相關操作碼,第一類就是定義一個 Label,比如 block 是定義一個塊,loop 定義一個循環塊。第二類包含 if、else 和 end 三種操作指令,這種方式就更接近於源碼的語義了,而非像 C 語言編譯生成的條件跳轉目標指令。第三類指令表示跳轉,這種指令比較接近編譯後的跳轉機器指令。另外一類指令是 Branch,如函數的 call、call_indirect 和 return 返回。

在屏幕右邊是一個 Wasm 文本方式表示的簡單例子,它是一個循環,循環有個標識為 my_loop,對應到 Wasm 二進制裡邊是個索引號。代碼中壓一個常數 0 到棧裡邊,作為後面比較的條件。if 操作碼後面跟隨一組滿足條件下執行的操作碼,之後可能會跟隨 else 操作碼,else 後面會有一系列指令,else 結束後會有個叫 end 的指令。代碼後面有一個 br 操作碼,執行跳轉到 my_loop。

Wasm 結構化跳轉機制有幾個特點,首先它的 Labels 是類型化的,它具有輸入和輸出的參數。所以在右邊進 if 之前會壓棧,進入這種 Labels 之後,會有它的一個獨立的操作棧,它可以保證在裡面有 pop 類的操作,不會把上級的棧破壞掉,而且退出棧的時候,它很容易回溯到上級棧的位置。

另外它的棧的跳轉不能像 C 語言跳到一個任意的 Label,它只能向上返回跳轉,它通過一個 Index 向上返回一級或者若干級,這種方式它很有效地避免了 Buffer Overflow 的可能性。

函數調用操作碼後面帶的是一個索引值,索引代表函數在 Table 裡邊的位置,Table 會真正指向函數它的物理位置。indirect call 會像 C 語言定義的函數指針的調用,它的索引不跟隨在指令碼裡面,而是從棧裡面取,這樣可以通過壓棧,相當於把函數指針傳進來,再調用它。

系統類型與內存垃圾回收
剛才在字節碼裡面已經看到類型信息,它所有的函數都是有一個類型的定義,而且類型是有從 0 開始的索引編號。垃圾回收特性 (GC) 目前進入了文本可用的提案 2 的階段,它包含着一系列的所依賴的 Spec,如引用類型 Reference Type,還有就是類型化函數引用 Typed Function Reference,類型導入這些提案等。

WASM GC 同時又引入了一些新的類型結構,在右下角可以看到,引入了類似於像函數和數組的類型結構。Wasm 的類型體系的特點是,它主要目標是描述低級的數據布局,並不有源碼層面的信息,它的子類型,比如說 A 是 B 的子類型,它主要是指內存布局有覆蓋關係,而不是類似於 C、C++ 或者是 Java 里語言層面的顯式繼承的定義。在語言層面可以沒有任何的繼承的描述,只要它符合子類型的條件,它類型就會類似一個父子關係。在生成的目標的指令裡面,操作碼後面會帶操作對象的類型索引號。

如果大家熟悉 C 的編譯的話,就知道 C 的生成目標裡面是沒有類型的,所有的類型都是在編譯的時候,編譯器知道所有的類型信息,但是不會在生成目標的機器碼里提供類型信息。WebAssembly 把類型信息放到目標文件中,因為它要提供一種中間層,接近於底層的機器但是又不是真正機器底層的這麼一種技術。

我們認為 Wasm 這麼設計的確是有一些好處的。把類型信息直接傳送到發行的模塊里,這樣有利於脫離編譯器語言的依賴來實現模塊的連接,即便都是從 C 語言源碼編譯過來,不同的編譯器對於類型的定義有可能是不一樣的,但是類似於結構還有數組,各自的理解或者約定是不一樣的,更別提不同的語言它們之間的約定,語言層面也很難保證一致。所以我們都把目標的類型放到二進制的模塊裡面,這樣不同的語言、不同的編譯器它們之間的連接就會更加容易一些。

這樣也會減少運行時刻對隱式類型的依賴,很多的類型都顯式地告訴了 Runtime,Runtime 不再需要去做水面以下的部分。另外它會有利於在加載時刻(Loading Time)的類型驗證,因為類型信息已經在字節碼裡邊了,它很容易去做類型的推導和驗證,看壓棧或者是傳參是不是符合它的目標類型,這樣的話可以儘可能減少在運行刻的類型的驗證,因為在運行刻做類型驗證是非常耗 CPU 的,這樣可以讓它的執行效率大量的提升。

下面介紹在 GC 特性中引入的一些新的一些元素,首先是結構。結構的成員變量還是用索引號來訪問的,比如說你 new 一個對象之後,get 或者 set 一個成員變量,成員變量通過 index 來訪問。數組就是同樣類型的多個元素的聚合,每個數組也是透過一個索引來進行訪問的。另外函數就引入了一個類型化函數指針,叫 ref.func 和 ref.call_ref,可以讓你直接傳函數指針,而不是傳一個索引。它的好處就是能夠極大地加快函數指針調用的速度,對比目前因為要做大量的 Type Check,所以通過 indirect 效能不是很好。運行刻的類型引用,它可以從一個 Type 去生成一個 Type 的引用,Type 引用可以通過變量或者傳參來進行傳遞。另外還有未裝箱的標量,還有類型的測試和等價,子類型化,還有運行刻的類型強轉等等這些元素。(註:最新的 GC 提案在此基礎上有進一步改動,請讀者以 w3c 的官方提案文本為準)

右邊是一個關於結構的例子,首先下面它定義了 time、point 結構,time 包含一個 32 位整型和 64 位的精度的浮點成員。point 包含 3 個 64 位的浮點組成的 xyz 成員。下面是一個函數,它的傳參是 point 結構的對象,因為 p 是它的傳入的參數,get $p 就是把 p 它的指針放到棧裡面,按它的 point 類型取 x 字段,x 最後是一個索引號,取到之後,把它放到棧裡邊。把 x 值從棧裡面取出來,再賦到 y field 裡邊,這就是這個函數做的事情。再下面使用 struct.new 來對結構進行分配,struct.new 後面跟的是一個類型,後面會跟着每一個成員初始化的值,它會返回一個結構的引用對象。

WebAssembly 並沒有真正定義 GC 本身實現,而是定義了完善的 GC 系統所需要的工具,它更想定義了一個工具箱,每個 Runtime 可以通過使用這個工具箱去實現自己的 GC。

模塊的組件模型

模塊鏈接與組件模型,目前這塊的規範也在制定之中。首先需要標準化模塊之間的鏈接,module-linking 的 spec 定義了一些典型的鏈接的模式。屏幕右邊是兩種比較典型的鏈接模式,上面這種叫 Link-time Virtualization,描述了所有 WASM 模塊之間的靜態依賴,比如說 parent,它也會去訪問 WASI 文件系統,child 也會訪問文件系統,virtualize 模塊也會訪問。我們並不想用 child 能夠真的去訪問物理的內存,可以引入 Virtualization 模塊,它會向 child 提供 WASI Interface,所以 child 說需要去訪問 WASI Interface,實際上是讓 Virtualization 去給它導出一個讓它使用,Virtualization 模塊中間會做一些轉換或者一些檢查,它會去實際訪問真正的 File System 模塊,這樣對於 child instance 它所看到是個虛擬化的接口,這叫 Link-time Virtualization。

還有一種模式叫 Shared-Everything C-family Dynamic Linking,可以在動態的過程中,由不同的模塊組成不同的實例。比如說 zipper,它也引用了 libc,它可以構建一個單獨的一個實例,img 它可能也引入了一系列,包括 libc、libzip,但是它們可以組織成一個 instance,它有它自己的內存空間和一個實例所需要的元素,既具有很好的隔離性,又具有很好的靈活性。

組件模型一個組件會包含一系列的模塊,它現在定義了一個組件有若干個組成的部分,一個是它的模塊數的定義,主要就是靜態的模塊,它的實例定義。它會定義好有哪些實例,實例需要導入的是什麼東西。然後是類型的定義,類型定義包括靜態的類型定義,還有實例化時刻的類型的定義,實例化時刻的類型定義有點像如果用 Linux 系統的鏈接器,當你編譯的時候,你只需要知道鏈接對象的引入符號就可以了,它引入什麼你並不關心。但是當你實際加載,開始運行鏈接的時候,每一個被鏈接的鏈接庫文件,它所依賴的這些符號也需要被解決,它要像一個鏈式一樣去找它所有被鏈接的符號,最終程序才能跑起來,這個時候就引入了很多的不確定性。在這把後面那部分鏈接的過程,把整個鏈接鏈條都會定義到這個組件裡面去。這樣在實例化的時候,在定義的時候,就把部分在將來做的事前置。函數的定義目前主要還是基於一個線性內存的新的 ABI,但是它已經有一部分可配置的能力。目前還有一個叫 InterfaceType,這樣給程序提供了更多的自定義這種接口的能力,

以上就是 GC 現在的一些大概情況。從上述的信息來看,基於 Wasm 的強的類型系統它有很大的靈活性,它就像一個積木式的系統,可以從不同的語言搭建出很多的模塊,這些模塊又可以搭建很多的組件,這樣,在未來它會有非常好的潛力,去構建一個跨越語言的應用生態系統。如果一切像預期一樣發展,我們可以預見在未來,也許 WebAssembly 是一個遠遠比現在要更加廣泛的一種語言生態。

WASI 與字節碼聯盟

字節碼聯盟是一個以 WebAssembly 技術為中心的開源實現的非盈利組織,目前有非常多的程序員加入進來,而且加入的速度也是非常快的。目前在組織裡面主要在做包含開源的 Runtime 項目,還有 WebAssembly System Interface (WASI),以及一些工具和組件生態的方案。最近字節碼聯盟 TSC 的章程發布了,本人作為技術委員會創始的成員參與了全程的章程的定義,這個章程兼顧了多方面的考慮。大概花了接近兩個季度的時間才完成,也參考了很多目前比較成熟的社區的一些章程,大家有興趣可以去看看。

字節碼聯盟目前 Runtime 開源項目主要有兩個,一個是 WASMTIME,另外一個是 WebAssembly Micro Runtime,WebAssembly Micro Runtime 最早是由 Intel 開發的,在 2019 年貢獻給字節碼聯盟了。目前除了 Intel 持續在上面開發之外,有很多企業如亞馬遜、索尼、螞蟻、小米、阿里巴巴在上面也貢獻了很多特性和功能。

WASI 是什麼?WASI 是標準化 WASM 的模塊和 Native 宿主環境之間的一個調用接口,這個接口和上層的編程語言是無關的。其中的 wasi-libc 提供了 libc 的支持,把原來的像底層和 Kernel 對接 syscall 調用接口換成了 WASI 的 Interface,這樣大家可以在 WebAssembly 裡面繼續調用類似於 FileOpen 這樣的系統調用,可以在所有的 Runtime 上運行,達到一個很好的跨平台特性。

另外它定義了一個 Capability-based Security,很簡單的說,啟動一個實例的時候,可以給它指定一個目錄,在實例裡面 Wasm 應用無論怎麼訪問目錄,它看到的根目錄就是你指定的物理的目錄,所以所有的一切操作都是在本機一個子目錄裡面運作,這樣它就沒有能力去訪問整個磁盤上的其他的一些它不應該訪問的文件系統。WASI 目前的發展是非常好的,有很多標準都在進行之中,大家有興趣可以在 W3C 的網站上了解一下。

最後給大家快速介紹一下 WebAssembly Micro Runtime (WAMR) 開源項目,因為這個項目是我們團隊從頭到現在一直在參與的,也希望大家能更多了解。WAMR 是基於 C 語言實現的,它有兩個解釋器的實現,一個叫 Fast,一個 Classic,Fast 比 Classic 要快一倍左右。關於它的一些實現,我們之前也輸出過一些文章,大家有興趣可以去了解一下。

WAMR 支持 JIT 和 AoT,JIT 和 AoT 目前是基於 LLVM 框架來實現的,整個 Runtime 的特點就是說它的 VMCore 很小,在 100K 以內,但同時它的性能又非常好。一方面藉助 LLVM 這個非常好的編譯框架,它的性能和 GCC 相比,根據不同的 Workload,從 60%、70%、80%、90%,甚至還有快過 GCC 原生編譯的。另外它的 AoT 也是個很特色的設計,因為它有個完全自定義的 AoT 的加載機制,不依賴於系統的 Loader,它可以在很多的平台上都可以用,像 Linux 或者 SGX 環境,甚至像一些 MCU 上的嵌入式操作系統,也可以使用 AoT 的 Loader。另外它支持向量化計算,對於 Intel SGX 和 TDX 這種安全的執行環境有非常良好的支持。它還支持多線程、pthread、Reference type 和 Multi-modules 等豐富的特性,歡迎大家能花點時間了解體驗下。

活動推薦

將於 11 月 21-22 日舉辦的 GMTC 全球大前端技術大會(北京站)上,來自阿里的前端技術專家光弘老師將分享《基於 LowCodeEngine 的阿里低代碼組件體系的建設和實踐》,帶你了解低代碼組件為組件研發領域帶來的變化以及機會點。此外,本次 GMTC 北京站還設置了 TypeScript、跨端技術選型、前端 DevOps 實踐、IoT 動態應用開發、大前端監控、移動端性能與效率優化等共 12 個專題,50+ 大廠技術專家現場分享,點擊底部【閱讀原文】查看更多精彩內容,感興趣的同學聯繫票務經理:+86 18514549229
本周薦文

玉伯:聊聊我在阿里做前端的這 12 年

你不需要Next.js(和SSR)

React:我愛你,但是你越來越讓我失望了

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()