作者 | EDOARDO VACCHI
譯者 | 蓋磊
策劃 | 冬雨
不少 Java 開發人員在面對 WebAssembly 一詞時,首先會想到這是一種“浏覽器技術”,之後可能會認為“還是歸結為 JVM”。畢竟浏覽器内應用對他們而言是一種“史前生物”。
最近數周内,圍繞 WebAssembly,多項技術呈密集釋出,例如Docker+wasm技術預覽等。作為一名 Java 極客,我認為不應視WebAssembly為一時風尚而置若罔聞。
文如其名,WebAssembly(wasm)的确可稱為“一種用于 Web 的位元組碼”。Java 和 wasm 二者間的相似性也僅限于此。這裡“wasm”是小寫的,表示它是一個縮略詞,而非首字母縮略語。
如果有興趣了解我們如何定義了 WebAssembly 标準,歡迎翻閱我寫過的一篇博文,其中解釋了來龍去脈。本文闡述的重點是,為什麼說 WebAssembly 并不僅僅局限于 Web。
首要一點,WebAssembly 運作時僅是貌似 JVM。其中一點,WebAssembly 的長遠目标,是成為适合各種程式設計語言的編譯目标。但 JVM 并非如此,至少最初沒有做如此考慮。
第一個神話:JVM 是一種多語言編譯目标
必須承認,JVM 是最為豐富的、可互操作的語言生态系統之一。Java 生态還包括了 Scala,Jython,JRuby,Clojure,Groovy,Kotlin 等程式設計語言。
但現實非常可悲,Java 位元組碼從未真正地成為一種通用的編譯目标。不少文獻資料都對此做了清晰的闡述。例如,John Rose 在“位元組碼與組合選擇的結合:JVM中的invokedynamic”一文中寫道:
Java 虛拟機(JVM)被廣泛采用,可部分歸因于 class 檔案格式是可移植的、緊湊的、子產品化的和可驗證的,并且非常易于使用。然而,class 檔案在設計上僅針對 Java 這一種語言,用于表達其它語言編寫的程式時,常常出現一些阻礙開發和執行的“痛點”。
這篇文章闡釋了invokedynamic操作碼引入 JVM 中的原因和方式。事實上,引入該操作碼就是專為支援使用 JVM 運作時的動态語言。雖然 JRuby,Jython,Groovy 等一些語言在運作時中添加了該操作碼,并不是 JVM 在設計中考慮了如何支援這些語言,而是因為這些語言已經這樣做了。木已成舟,隻能去認可它!
換句話說,時過境遷,JVM 依然未成為這些動态語言合适的編譯目标。甚至可以說,以 JVM 為編譯目标并非因為它是最好的,而是考慮到 JVM 的采納度和支援情況,人們希望能與 JVM 互操作。正如 JavaScript 那樣!
GraalVM:一統各方的虛拟機
最近GraalVM項目大行其道。該項目中包括針對例常 Java 位元組碼的 JIT 編譯器,以及用于建構高效語言解釋器的 API,還新添加了原生鏡像編譯器。
成為“一統所有VM的虛拟機”,是 GraalVM 的最初目标之一,也就是說成為一種多語言運作時。
但 Truffle 并未定義多語言編譯目标,而是通過 Truffle API 實作一種極高層級表示,進而建構基于 AST 的高效 JIT 解釋器。對感興趣的讀者,可自行去深入了解“抽象文法樹”(AST)。
“緻程式設計大神”:漫遊程式設計語言的奇境,所有一切都變“神奇”。使用 Truffle,的确可以為其它“适當”的位元組碼格式編寫 JIT 解釋器。
事實上,已有用于 LLVM(Sulong)的 Truffle 解釋器。當然,LLVM 位碼也的确是多平台/多目标編譯目标。依此類推,是否可以說 GraalVM/Truffle 同樣支援多平台編譯目标?
從技術角度看,可以這麼說,甚至可以說是“完全正确的”。但依然存在不少可商榷之處,對此本文不一一展開讨論。簡而言之,LLVM 位碼隻是作為一種編譯目标,并未完全考慮作為一種跨平台的運作時語言。例如,針對不同的 CPU 和作業系統,LLVM 可能必須要調用不同的指令。此外,不同于作為多廠商标準的 WebAssembly,GraalVM 和 Truffle 目前為止仍然是開源的、社群驅動的、單廠商實作的項目。但将GrallvVM納入OpenJDK的工作近期已經啟動,并可能進入Java語言規範。
畢竟,WebAssembly 隻是一種得到 GraalVM/Truffle 支援的語言。如果要使用 GraalVM,甚至可以考慮 wasm!
第二個神話:WebAssembly 隻是另一種 Stack-based VM(棧機)
WebAssembly 定義為一種結構化棧機使用的虛拟指令集架構(ISA)。
上述定義中,關鍵在于“結構化”(structured)一詞,它表明 WebAssembly 與 JVM 的工作方式大相徑庭。結構化棧機在實際運作中,大部分計算使用值棧,控制流卻使用塊、if 和循環等結構化結構表示。WebAssembly 語言則更進一步,一些指令可同時使用“簡單”和“嵌套”表示。
下面給出一個例子。wasm 棧機中有如下表達式:
( x + 2 ) * 3
int exp(int);
Code:
0: iload_1
1: iconst_2
2: iadd
3: iconst_3
4: imul
5: ireturn
該表達式可被翻譯為下述一系列指令:
(local.get $x)
(i32.const 2)
i32.add
(i32.const 3)
i32.mul
其中:* local.get在棧中加入本地變量$x;* 然後i32.const将 32 位整數(i32)常量2推送入棧;* i32.add從棧中彈出兩個值,并将$x+2結果推送入棧;* 整數常量3被推送入棧;* i32.mul彈出兩個整數值,并将($x+2)*3的i32乘法結果推送入棧。
大家應該能注意到,用括号括起來的,是含有一個以上參數的指令。上面給出的“線性化”版本的 WebAssembly,在.wasm 檔案中直接轉換為二進制表示。此外還有在語義上等效的另一種“嵌套”表示:
(i32.mul
(i32.add
(local.get $x)
(i32.const 2))
(i32.const 3))
嵌套表示别具特色。操作的嵌套和編寫有别于 JVM 等位元組碼類型,而是類似于一種“傳統”程式設計語言。這裡所說的“傳統”,就是指操作讀起來類似于 LISP 家族中的 Scheme 語言。顯而易見,其中使用的括号約定,就是在向 Scheme 緻敬。當然,事出必有因。對 JavaScript 的神奇起源稍有了解,就一定知道 JavaScript 最初是在 10 天内寫成的,而且 Brendan Eich 一開始的任務是去開發另一種 Scheme 方言。
至少對我而言,嵌套序列更有趣的細節在于,它能自然地線性化為其它版本。事實上,遵循括号表達式的優先規則,須從最内層的括号開始。例如:
(i32.add
(local.get $x)
(i32.const 2))
上面的例子首先擷取$x,然後為常量指派2,進而将二者相加。此後,再去處理外層的表達式:
(i32.mul
(i32.add ...)
(i32.const 3))
對其中的i32.add求值,需對常量指派3,并将二者相乘。這與棧機的操作順序相同。
這裡提出結構化控制流,同樣是考慮了安全性,以及簡單性:
WebAssembly 棧機僅限于結構化控制流和結構化棧的使用。這一方面極大地簡化了“一次通過”(one pass)驗證,避免了 JVM(棧映射推出前)等棧機的固定點(fixpoint)計算;另一方面,也簡化了其他工具編譯和操作 WebAssembly 代碼。
下面看一個例子:
void print(boolean x) {
if (x) {
System.out.println(1);
} else {
System.out.println(0);
}
}
上述代碼翻譯為如下位元組碼:
void print(boolean);
Code:
0: iload_1
1: ifeq 14
4: getstatic #7 // java/lang/System.out:Ljava/io/PrintStream;
7: iconst_1
8: invokevirtual #13 // java/io/PrintStream.println:(I)V
11: goto 21
14: getstatic #7 // java/lang/System.out:Ljava/io/PrintStream;
17: iconst_0
18: invokevirtual #13 // java/io/PrintStream.println:(I)V
21: return
在如上等價的 WebAssembly 定義中可看到,非結構化跳轉指令ifeq和goto并未出現,而是恰如其分地被語句塊if...then...else所替代。
(module
;; 導入浏覽器控制台對象,需要将此從JavaScript傳遞進來。
(import "console" "log" (func $log (param i32)))
(func
;; 如運作if代碼塊,更改為True。
(i32.const 0)
(call 0))
(func (param i32)
local.get 0
(if
(then
i32.const 1
call $log ;; 應該記錄'1'
)
(else
i32.const 0
call $log ;; 應該記錄'0'
)))
(start 1) ;; 自動運作第一個func。
)
原例可在Mozilla Developer Network上檢視和運作。
當然,上述例子也可線性化,形成如下的非嵌套版本:
(module
(type (;0;) (func (param i32)))
(type (;1;) (func))
(import "console" "log" (func (;0;) (type 0)))
(func (;1;) (type 1)
i32.const 1
call 0)
(func (;2;) (type 0) (param i32)
local.get 0
if ;; label = @1
i32.const 1
call 0
else
i32.const 0
call 0
end)
(start 1))
更多差異:記憶體管理
另一處 WebAssembly 虛拟機和 JVM 大相徑庭,在于各自的記憶體管理,雖然難以評價孰優孰劣。大家應該知道,Java 不需要開發人員顯式地配置設定和釋放記憶體,也無需去操心棧和堆的配置設定。但開發人員通常需要了解一些記憶體管理知識,在真正需要時能使用一些方法做顯式處理,雖然現實中很少有人這麼做。
事實上該特性并非語言層級上的,而是 VM 的工作機制。在 VM 層級,并沒有記憶體處理的原語操作。堆配置設定原語是以 JDK API 的方式提供。開發人員無法預設禁用記憶體管理,不能說“我不需要 GC Heap,我将自己實作記憶體管理”。
目前,WebAssembly 做法恰恰相反。大多數語言在以 WebAssembly 為編譯目标時,的确是自行管理記憶體,這并非巧合。有些語言的确能做 GC,但其 VM 并不提供 GC 功能,是以自身的例程在 GC 時必須復原。
WebAssembly 的做法是,為使用者配置設定一小片支援配置設定、釋放甚至是随意移動等操作的“線性記憶體”(linear memory)。雖然在一定程度上要比 JVM 提供的功能更強大,但在使用中也需謹慎。
例如,JVM 不需要開發人員顯式指定對象的記憶體布局,結構體打包(structure packing)、位元組對齊(word alignment)等記憶體空間優化工作已交由 VM 處理。但在 WebAssembly 中,這些工作需要開發人員處理。
這在一方面,使得 WebAssembly 成為手動管理記憶體的程式設計語言的理想編譯目标。因為這類語言需要并期望對記憶體更高程度上的控制。但在另一方面,增加了語言間互操作的難度。
目前,結構和對象布局是 ABI(Application Binary Interface,應用二進制接口)關注的問題。但ABI對JVM開發人員都已成為昨日黃花,除了一些極為有限和需注意的例外情況。
值得關注的是,最近WebAssembly垃圾回收規範草案已向前推進。規範草案中不僅聲明了 GC,而且有效地描述了結構體,以及與原始語言無關的結構體間互操作方式。盡管該草案尚未準備好,但事情是在不斷發展的,多個關注問題正得到解決。
并非局限于 Web
看到大家應該注意到,本文至此還從未提起過“Web”。
經過上文的鋪墊,下面給出本文的重點,就是闡明 Java 極客應該關注 WebAssembly。
即使你不關注前端技術,也不應将 WebAssembly 純粹視為前端技術。在 WebAssembly 的設計和規範中,沒有任何一處規定其是專門綁定到前端的。事實上,目前的大多數主流的 JavaScript 運作時,都能夠加載和連結 WebAssembly 二進制檔案,甚至在浏覽器之外。是以,可在 Node.js 運作時中運作 wasm 可執行檔案,并且使用薄薄一層 JS 膠水代碼,就能與平台其它部分互動。
但目前也存在一些純 WebAssembly 運作時,例如wasmtime、wasmEdge、wasmCloud、Wazero等。純運作時完全脫離開 JavaScript 主機,并且比成熟的 JavaScript 引擎更輕量級,更易于嵌入到更大型的項目中。
事實上,許多項目正開始采納 WebAssembly,将其作為托管擴充和插件的多語言平台。
Envoy proxy正是其中一個著名項目。其代碼庫以 C++為主,雖然支援插件,但存在和浏覽器插件一樣的問題,即必須做編譯、必須做釋出、插件可能無法以正确的權限級别運作,甚至在發生嚴重故障時可能破壞整個過程。現在,開發人員可以通過嵌入 Lua 或 JS 解釋器,支援使用者通過編寫腳本方式成功運作。解釋器更為安全,因為它與主要業務邏輯隔離,并且僅采用安全方式與主機環境互動。但不足之處是必須為使用者選擇一種語言。
另一種做法是嵌入 WebAssembly 運作時,讓使用者自己選擇語言,然後編譯成 wasm。該做法可實作同樣的安全保證,使用者也更樂意為之。
純 WebAssembly 運作時不僅用于實作擴充。一些項目正在建立 wasm 原生 API 薄層,以提供獨立的平台。
例如,Fastly開發了邊端的無伺服器計算平台。其中,無伺服器功能由使用者提供的 WebAssembly 可執行檔案實作。
初創公司Fermyon正開發一個豐富的生态,實作僅使用 wasm 編寫 Web 應用。該生态由各種工具和基于 Web 的 API 組成。最新釋出的産品是Fermyon Cloud。
這些解決方案已為特定用例提供定制的即席 API,這确實是 WebAssembly 的一類使用方式。不止于此,Docker 創始人 Solomon Hykes 在 2019 年就寫道:
如果 wasm+WASI 在 2008 年就出現了,那麼我們就不需要去建立 Docker。這足以說明其重要性。伺服器端 WebAssembly 是計算的未來,但标準化的系統接口是缺失的一環。希望 WASI 能夠勝任這項任務!
— Solomon Hykes (@solomonstre) March 27, 2019
抛開具體場景,人們的第一反應不免是“wasm 到底與 Docker 有什麼關系?”。當然也會想,“WASI 是什麼鬼?”
WASI 指“WebAssembly System Interface”,可視其為支援 wasm 運作時與作業系統互動的一組(類 POSIX)API 集合。WASI 是否類似于 JDK 類庫?并不完全如此。WASI 是薄薄一層面向功能的 API,用于與作業系統互動,詳見Mozilla公告部落格。簡而言之,WASI 補上了缺失的一環。WASI 允許定義與作業系統直接互動的後端應用,無需任何額外的層,也無需即席 API。目前 WASI 的工作是推進其被廣泛采納,能在某種程度上成為後端開發的事實标準。
WASI API 包括檔案系統通路、網絡乃至線程 API 等。這些 API 與運作時的底層功能協同工作,可簡化平台的遷移。
移植 Java
盡管存在各種挑戰,但 WebAssembly 依然是首個有潛力成為真正的多供應商、多平台、安全和多語言的程式設計平台。我認為各位 Java 極客應把握機會參與其中。
WebAssembly 規範和 WASI 工作仍在不斷地發展變化。點滴彙成江海,這些工作鋪就了通往簡化任意程式設計語言的移植之路,且不僅局限于支援手動記憶體管理的語言。
事實上,部分使用垃圾回收的語言已實作移植,盡管它們所采用的方式方法各有千秋。例如,Go 采取了編譯為 wasm(雖然存在部分限制);Python 移植采取了解釋器的移植,即将 CPython 解釋器編譯為 wasm,之後和傳統的執行環境一樣去執行 Python 腳本。
當然,實作向 Java 的移植依然面對很多問題,記憶體管理隻是其中之一。我們當然可以為可執行檔案中添加 GC,這實際上也正是 GraalVM 原生鏡像目前的工作方式。但在我看來,更難之處在于對其它一些 CPU 功能或系統調用的支援。這些功能目前仍然不穩定,或尚未得到廣泛支援,諸如:
- 大多數獨立 wasm 運作時依然缺乏對線程的支援,或是仍是實驗性的;
- 甚至浏覽器支援也是實驗性的,是通過 WebWorkers 模拟的;
- 套接字通路缺少标準化支援:所有支援編寫自定義 HTTP handler 的服務,通常都提供了預先配置的套接字,對低層級通路是受限的;
- 更難模拟的實驗性功能是異常處理,因為 wasm 位元組碼中缺乏非結構化跳轉。實作該功能,應需在 wasm VM 中提供适當的支援。
- 每種語言對記憶體布局和對象形狀都有自己的限制。是以,各語言更難跨邊界共享資料,這阻礙了不同語言間的相容性,限制了 wasm 作為多語言平台的适用性(但該問題已列入 GC 規範本身之中)。
簡而言之,無論對于浏覽器之内還是之外的 WebAssembly 平台,移植 Java 依然存在諸多挑戰。
WebAssembly 對 Java 的支援
目前,已有一些面向 WebAssembly 和 Java 的項目和軟體庫。下面将列出我在網上發現的一些資源,雖然其中很多隻能稱為興趣愛好項目。
浏覽器中運作 Java
一些項目針對将 Java 轉換為 WebAssembly。但其中大多數項目生成的代碼是不相容更精簡的 wasm 運作時的,通常隻适用于浏覽器中運作。
- Bytecoder、JWebAssembly和TeaVM等轉換器項目,都是将 Java 位元組碼轉換為 WebAssembly,但在将 Java 位元組碼轉換為浏覽器友好代碼的技術上略有差異。其中,TeaVM項目相對而言更具前景。我們看到在Fermyon分支中,包括了對 WASI Bytecoder 的初步支援。
- CheerpJ是一個非常有前途的專有軟體項目。CheerpJ 意在提供對全部 Java 特性的支援,甚至包括 Swing。還有Chrome擴充使用 Web 技術運作很贊的 applet。
一些項目也值得關注,它們針對浏覽器運作時,部分提供實驗性 wasm 支援:
- J2CL(GWT的後續)是 Java 到 JavaScript 的源到源編譯器,即 transpiler,最近提供了對 wasm 的支援。該編譯器支援最新的 GC 規範。
- Bck2Brwsr也是針對 JavaScript 和浏覽器的位元組碼編譯器。
- Kotlin/Native也支援通過 LLVM 編譯為 wasm。它也繼承了 Kotlin/Native 存在的所有問題,例如并非支援所有的 Java 庫。
- DoppioJVM項目值得一提。它采用了完全不同的技術路徑,類似Python,不是将位元組碼編譯為 wasm,而是提供一種用 JavaScript 編寫的浏覽器内 VM,去解釋 JVM 位元組碼。但不幸的是,該項目目前不再維護。
在 JVM 上運作 WebAssembly
前面一直讨論的是如何讓 Java 程式運作在 wasm 運作時上,我們當然也希望能反其道而行之。平心而論,JVM 程式設計語言已頗具規模的,并且目前大多數 wasm 運作時所提供的程式設計模型(使用手動記憶體管理)在 JVM 上運作也有些别扭。為周全起見,在此仍有必要提及,當然這些項目也值得介紹。
- 首選顯然是前文提及的GraalVM Truffle實作的WebAssembly解釋器。GraalVM/Truffle 平台博采衆 JIT 之長,具有多語言互操作性。
- asmble提供了一組工具,包括将 wasm 編譯為位元組碼的編譯器、wasm 解釋器等。
- Happy New Moon With Report (JVM)是 WebAssembly 的 JVM 運作時。列舉于此,僅是因為我喜歡這個憨憨的命名。
- 原生 wasm 運作時綁定,例如kawamuray/wasmtime-java。
- 最近推出的Extism項目跨多種宿主語言,提供原生 WebAssembly 運作時 wasmtime 接口統一的 API。
- Katai WebAssembly是一個由我維護的 wasm 解析器項目,它使用Katai Struct二進制解析生成器編寫,歡迎大家回報問題請求(PR)。項目設計上并非針對在 JVM 上運作 wasm,而是針對使用者操作或查詢 wasm 可執行檔案資訊的需求。事實上,Kaitai 文法支援對所有受支援語言生成二進制解析器,不局限于 Java,還包括 Python、Ruby、Go 和 C++等。
結束語
希望本文能激發大家對 WebAssembly 的興趣。Java-on-wasm 依然是一個新生事物,歡迎大家以開放心态去探索這一全新的世界,并從中收獲驚喜。
作者簡介
Edoardo Vacchi,博士畢業于米蘭大學,研究方向是程式設計語言設計與實作。在 UniCredit 銀行研發部門工作三年後,加入 Red Hat 公司,先後參與 Drools規則引擎、jBPM工作流引擎和Kogito雲原生業務自動化平台項目。關注 WebAssembly 等新語言技術,并在KIE organization和個人部落格上撰寫文章。
原文連結: WEBASSEMBLY FOR THE JAVA GEEK