@Rays
2023-03-23T08:58:54.000000Z
字数 8787
阅读 577
摘要: WebAssembly作为一种仍在发展中的字节码格式,意在发展成为各种语言的编译目标,wasm运行时的许多提议和WASI标准正在完善和实现。Java-on-wasm依然是一个新生事物,Java极客应该以开放的心态关注wasm,把握机会参与其中。
作者: EDOARDO VACCHI
正文:
不少Java开发人员在面对WebAssembly一词时,首先会想到这是一种“浏览器技术”,之后可能会认为“还是归结为JVM”。毕竟浏览器内应用对他们而言是一种“史前生物”。
最近数周内,围绕WebAssembly,多项技术呈密集发布,例如Docker+wasm技术预览等。作为一名Java极客,我认为不应视WebAssembly为一时风尚而置若罔闻。
文如其名,WebAssembly(wasm)的确可称为“一种用于Web的字节码”。Java和wasm二者间的相似性也仅限于此。这里“wasm”是小写的,表示它是一个缩略词,而非首字母缩略语。
如果有兴趣了解我们如何定义了WebAssembly标准,欢迎翻阅我写过的一篇博文,其中解释了来龙去脉。本文阐述的重点是,为什么说WebAssembly并不仅仅局限于Web。
首要一点,WebAssembly运行时仅是貌似JVM。其中一点,WebAssembly的长远目标,是成为适合各种编程语言的编译目标。但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项目大行其道。该项目中包括针对例常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定义为一种结构化栈机使用的虚拟指令集架构(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”。
经过上文的铺垫,下面给出本文的重点,就是阐明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与运行时的底层功能协同工作,可简化平台的迁移。
尽管存在各种挑战,但WebAssembly依然是首个有潜力成为真正的多供应商、多平台、安全和多语言的编程平台。我认为各位Java极客应把握机会参与其中。
WebAssembly规范和WASI工作仍在不断地发展变化。点滴汇成江海,这些工作铺就了通往简化任意编程语言的移植之路,且不仅局限于支持手动内存管理的语言。
事实上,部分使用垃圾回收的语言已实现移植,尽管它们所采用的方式方法各有千秋。例如,Go采取了编译为wasm(虽然存在部分限制);Python移植采取了解释器的移植,即将CPython解释器编译为wasm,之后和传统的执行环境一样去执行Python脚本。
当然,实现向Java的移植依然面对很多问题,内存管理只是其中之一。我们当然可以为可执行文件中添加GC,这实际上也正是GraalVM原生镜像目前的工作方式。但在我看来,更难之处在于对其它一些CPU功能或系统调用的支持。这些功能目前仍然不稳定,或尚未得到广泛支持,诸如:
简而言之,无论对于浏览器之内还是之外的WebAssembly平台,移植Java依然存在诸多挑战。
当前,已有一些面向WebAssembly和Java的项目和软件库。下面将列出我在网上发现的一些资源,虽然其中很多只能称为兴趣爱好项目。
一些项目针对将Java转换为WebAssembly。但其中大多数项目生成的代码是不兼容更精简的wasm运行时的,通常只适用于浏览器中运行。
一些项目也值得关注,它们针对浏览器运行时,部分提供实验性wasm支持:
前面一直讨论的是如何让Java程序运行在wasm运行时上,我们当然也希望能反其道而行之。平心而论,JVM编程语言已颇具规模的,并且当前大多数wasm运行时所提供的编程模型(使用手动内存管理)在JVM上运行也有些别扭。为周全起见,在此仍有必要提及,当然这些项目也值得介绍。
希望本文能激发大家对WebAssembly的兴趣。Java-on-wasm依然是一个新生事物,欢迎大家以开放心态去探索这一全新的世界,并从中收获惊喜。
Edoardo Vacchi,博士毕业于米兰大学,研究方向是编程语言设计与实现。在UniCredit银行研发部门工作三年后,加入Red Hat公司,先后参与 Drools规则引擎、jBPM工作流引擎和Kogito云原生业务自动化平台项目。关注WebAssembly等新语言技术,并在KIE organization和个人博客上撰写文章。