你好,我是于航。

在开始真正学习 Wasm 这门技术之前,我想先来问你一个问题:你有没有思考过,在 Web 技术的历史发展长河中,为什么会出现 Wasm 这样一门技术?现有的这些 Web 技术,又存在着哪些无法解决的问题?

要知道,所有新兴技术的诞生都一定有它存在的意义,或者要去解决的问题。比如 jQuery 之于浏览器的兼容性、Vue.js / React.js 之于 Web 应用的构建模式。

虽然用前端框架和库来类比 Wasm 不算十分合适,但我想阐述的是,Wasm 的出现也并非偶然。在这节课的内容中,我们就来一起看看 Wasm 诞生背后的那些故事。相信在学习完本课程后,你会对 Wasm 有了一些新的了解。而这些了解有时可能比一项技术本身更加重要。

JavaScript 的发展和困境

1995 年末,Brendan Eich 仅用了 10 天时间便发明出了 JavaScript 编程语言,而在随后的二十多年中,JavaScript 已经成为了不可动摇的,用于开发 Web 前端应用的必备编程语言之一。

不仅如此,随着后来诸如 React Native、Electron 以及 Vue.js 等各类框架的不断涌现,JavaScript 曾经一度成为 GitHub 语言排行榜的年度冠军。JavaScript 也因此被广泛应用到了各行各业、各个领域的各类项目中。

虽说 JavaScript 的应用场景如此广泛,但也会有它自己的烦恼。下面我们就从 Web 应用层面以及 JavaScript 语言本身,来看看它究竟在愁些什么。

Web 应用规模的急速增长

随着移动互联网的发展和各种形式经济活动的不断展开,运行在浏览器中的各类 Web 应用,它们的体积与复杂性随着时间的推移在不断发展。为了能够在浏览器中高效运行这些不断“变大”的 Web 应用,浏览器厂商们也在不断地寻求着各种“黑科技”来优化浏览器的性能。

但与日益庞大和复杂化的 Web 应用相比,浏览器对自身性能的优化可谓是举步维艰。不难预见,当“复杂化”与“性能优化”的速度之比不断变大时,迟早有一天,浏览器会再也无法支撑起这些庞大 Web 应用的运行。

据相关数据统计,截止 2019 年底,全世界一共有约 16 亿个可索引网页,而其中的 95% 都在使用 JavaScript。在这些网页中,大约有 45% 的网页创建于最近 5 年。而 2015 年,ECMAScript 2015 (ES6) 诞生,也标志着 JavaScript 开始进入了标准一年一更新的节奏中。

现代的大多数网页,都会使用较新的 JavaScript 语法标准进行开发,然后在发布时使用诸如 Babel 等工具,将这些新的 JavaScript 语法转换为对应的 ES5 旧版本语法,来兼容旧版本浏览器。但这样做,产生的各类 Polyfill 代码,会极大地增加整个 Web 应用的体积。

同时,在 Web 应用的实际运行过程中,大量的 JavaScript 代码也会降低应用的整体运行效率。Twitter 曾尝试直接以 ES6+ 版本代码的形式,来发布整个 Web 应用。通过这种方式所减少的 Polyfill Bundle 文件的大小,竟然可以达到应用所使用的全部 JavaScript 代码的 83%。

JavaScript 的弱类型之殇

除了上面我们讲到的,浏览器性能优化与 Web 应用规模日益增大,这两者行进速度的“不协调”所可能带来的问题之外,JavaScript 语言本身也有着其自身的“弱点”。而由于这些“弱点”所带来的妥协,使得浏览器在面对庞大的 Web 应用时,也会显得力不从心。

可以说,JavaScript 是一个“动态类型”的编程语言。在实际编码过程中,我们不需要为每一个变量指定对应类型。变量具体类型的推导过程,会被推迟到代码的实际运行时再进行。JavaScript 这种动态类型语言所独有的特性,在某种程度上相较于静态类型语言而言,会带来额外的运行时性能开销。

下面我们来一起想象一下,JavaScript 引擎在执行表达式“x + y”时的具体流程。这里 x 与 y 分别是在一段 JavaScript 代码中定义的两个变量,当引擎执行到“x + y”时,对于运算符“+”来说,位于其左右两侧的操作数可以是 JavaScript 中任何有效类型的组合,比如“{} + []”、“[] + null”、“1 + 2”等等。因此,引擎在对“+”运算符表达式进行求值时,会根据 ECMAScript 标准中规定的“+”运算符的语义,来对表达式进行求值。

通过下图你可以看到,在 ECMAScript 标准中定义的,“+”运算符的运行时求值流程,实际上十分复杂和繁琐。这也是相对于静态语言来说,JavaScript 很少能够进行优化的地方。

在现代的 JavaScript 引擎中,尽管可以使用诸如 JIT 等技术来提高代码的执行效率,但在实际使用中,如果代码执行没有遵守 JIT 优化路径中特定 Guard 的要求,“去优化”的过程,也同样会影响引擎的整体执行效率。而这些影响都是由于 JavaScript 的“动态性”导致的。

图片来自 ECMAScript@2020 官方标准文档

最初的尝试 —— NaCl 与 PNaCl

JavaScript 的发展困境在逐渐显现,人们对 Web 性能的担忧也在与日俱增,人们永远没有停下优化的脚步。NaCl 是由 Google 在 2011 年于 Chrome 浏览器中发布的一项技术,该技术旨在提供一个沙盒环境,可以让基于 C/C++ 语言编写的 Native 应用,安全地运行在浏览器中。NaCl 的全称“Native Client”也暗示了这一点。

如下图所示,一个标准 NaCl 应用的组成结构,与普通的 JavaScript Web 应用十分类似。NaCl 模块作为应用的一部分,主要用来进行复杂的数据处理和运算,JavaScript 则负责处理应用与外部用户的交互逻辑。NaCl 实例与 JavaScript 代码之间可以通过“订阅 / 发布”模型,来互相传递消息。

图片来自 Chrome 官方相关文档

理想虽好,但现实却存在着很多问题。通常,一个 NaCl 模块文件需要在开发者本地进行编译,然后才能够在浏览器中使用。而本地编译的模块文件通常仅含有架构相关(architecture-dependent)的代码,因此没有办法直接在其他类型的系统中使用。

一个完整的 NaCl 应用,在分发时需要提供支持多个架构平台(X86_32 / X86_64 / ARM 等)的模块文件。浏览器在实际使用时,会根据当前系统的具体架构类型,来动态地选择,对应合适的模块文件进行使用。

不仅如此,由于 NaCl 模块“平台依赖”的特殊性,因此 NaCl 模块进行分发的过程,仅能够在 Chrome Web Store 中进行。另一方面,如果你想要将已经存在的 C/C++ 代码库编译至 NaCl,并在浏览器中使用,你还需要通过名为 Pepper 的库来对这些代码进行重写。

Pepper 提供了很多包装类型,以及用于和浏览器进行交互的 API,比如“PP_Bool”等。这些 API 和特殊类型可以便于整合传统 C/C++ 代码与 Web 浏览器的沙盒环境。

鉴于 NaCl 存在的“平台依赖”问题,Google 在后期又推出了名为 PNaCl 的技术。这里名字中多出来的“P”代表着“Portable”,也就是“可移植”的意思。

PNaCl 采用了不一样的生命周期,参考下图我们可以看到,相较于 NaCl 模块直接包含有平台架构相关的代码,PNaCl 将源 C/C++ 代码编译到一种中间代码。这些中间代码会在浏览器实际加载这个 PNaCl 模块时,再被转换为对应的平台相关代码。因此,对于 PNaCl 模块而言,分发的过程变得更加简单,且不用担心移植性的问题。

图片来自 Chrome 官方相关文档

不过,即使是对于 PNaCl 这类“可移植性”已经不再成为问题的技术而言,它们的面前还有很多“大山”难以逾越。比如:“需要使用 Pepper 重写 C/C++ 代码,标准较为封闭、仅 Chrome 浏览器支持”等等。

总而言之,无论是 NaCl 还是 PNaCl,它们都已经成为过去。现在,如果你再次回到 NaCl / PNaCl 在 Google 的官方文档网站,你会发现如下这样一段声明。Wasm 将会作为新一代的技术,接替并继续传承 Google 赋予给 NaCl / PNaCl 的使命。

图片来自 Chrome 官方相关文档

Wasm 的前身 —— ASM.js

除了 NaCl 与 PNaCl,另一个不可不提的技术便是 Mozilla 于 2013 提出的 ASM.js。同前两者一样,ASM.js 的设计目标也是为了能够在 JavaScript 语言之外,为“构建更高性能的 Web 应用”这个目标,提供另外一种实现的可能。

“ASM.js 是 JavaScript 的一个严格子集。它是一种可用于编译器的目标语言,低层次且高效。该目标语言有效地为内存不安全语言(如 C/C++),描述了一个沙盒虚拟机运行环境。静态和动态验证相结合的方式,使得 JavaScript 引擎能够使用 AOT 等优化编译策略来验证 ASM.js 代码”。这是 Mozilla 官方给出的关于“ASM.js 是什么?”这个问题的解答。

乍一看这段解释,可能会有点抽象和复杂。但实际上,我们只需要知道两件事情。

第一,ASM.js 是 JavaScript 的严格子集。这也就意味着,对于一段 ASM.js 代码,JavaScript 引擎可以将它视作普通的 JavaScript 代码来执行,这便保障了 ASM.js 在旧版本浏览器上的可移植性。

第二,ASM.js 使用了“Annotation(注解)”的方式来标记代码中包括:函数参数、局部 / 全局变量,以及函数返回值在内的各类值的实际类型。

当 JavaScript 引擎满足一定条件后,便会通过 AOT 静态编译的方式,将这些被 Annotation 标记的 ASM.js 代码,编译成对应的机器码并加以保存。当 JavaScript 引擎再次执行(甚至在第一次执行)这段 ASM.js 代码时,便会直接使用先前已经存储好的机器码版本。因此,引擎的性能会得到大幅的提升。

对于一段标准 ASM.js 代码的具体组成形式,你可以参考下面给出的这段代码,以便有一个更加直观的印象。

function asm (stdin, foreign, heap) {
“use asm”;

function add (x, y) {
x = x|0; // 变量 x 存储了 int 类型值;
y = y|0; // 变量 y 存储了 int 类型值;
var addend = 1.0, sum = 0.0; // 变量 addend 和 sum 默认存放了"双精度浮点"类型值;
sum = sum + x + y;
return +sum; // 函数返回值为"双精度浮点"类型;
}
return { add: add };
}

在这段 JavaScript 代码中,最为重要的是函数“asm”在其函数体定义开头处使用的“use asm”指令。这个指令将会在代码执行过程中“告诉”JavaScript 引擎,当前这个函数体内的代码可以按照 ASM.js 代码,来进行相应的优化和处理。

实际上,上述这样的一个 JavaScript 函数,便定义了一个标准的 ASM.js 模块。模块内部可以通过 return 的方式,导出包含有若干内联方法的对象。这些方法可以在外部的 JavaScript 代码中进行调用。

在上述 asm 模块内定义的内联函数 add 中,我们在其开头的前两行代码通过“x|0”和“y|0”的方式,分别对变量 x 与 y 的值类型进行了标记。而这种方式便是我们之前提到的 ASM.js 所使用的 Annotation。

当 JavaScript 引擎在编译这段 ASM.js 代码时,便会将这里的变量 x 与 y 的类型视为 int 整型。同样的,还有我们对函数返回值的处理“+sum”。通过这样的 Annotation,引擎会将变量 sum 的值视为双精度浮点类型。类似的,ASM.js 在标准中还规定了其他的诸多 Annotation 形式,可以将变量值标记为不同的类型,甚至对值类型进行转换。

为了确保上述的这样一个 JavaScript 函数,能够被当做一个标准的 ASM.js 模块进行必要的优化处理,JavaScript 引擎通常会在实际编译加载这些模块前,进行很多必要的检查验证工作。

因此,并不是说只要为函数添加了“use asm”指令,并且为使用到的变量添加 Annotation 之后,JavaScript 引擎就会通过 AOT 的方式来优化代码的执行。所以这也是为什么我们先前提到的,ASM.js 通常被作为一种可用于编译器的,低层次且高效的目标语言,而不是用于手写。

从过去到未来

时间来到 2015 年 5 月。Chrome 团队的 Ben 正在为 V8 设计一种新的 Prototype(原型),而另一位团队成员 Rosbery,正在为这种 Prototype 设计对应的字节码格式。实际上,这个 Prototype 和对应的字节码格式,便是如今 Wasm 所分别对应的 WAT 可读文本格式与二进制字节码格式。在当时的谷歌内部,这两部分暂时被称为 ml-proto 与 v8-native-prototype。

随着 V8 团队对 ml-proto 与 v8-native-prototype 的不断修改和优化,它们最终便成为了 Wasm 早期标准的一部分。与此同期出现的,还有一个名为“sexpr-wasm”的内部工具,在当时这个工具用于对这两种格式进行相互转换。随着 Wasm 的标准化,它也同样成为了 Wasm 常用调试工具的一部分,这也就是我们所熟知的 —— WABT。

Chrome V8 团队作为参与过 PNaCL 与 ASM.js 这两个标准制定的团队,在设计和实现 Wasm 时也同样参考了很多从这两种技术中总结下来的优缺点。而这些经验也将会帮助 Wasm 做好准备,避开那些曾经走过的坑。最后,这些经验使得 Wasm 能够以一种更好的方式,展现在人们的面前。

总结

好了,讲到这,今天的内容也就基本结束了。最后我来给你总结一下。

实际上在 Wasm 真正出现之前,人们就已经开始尝试探索各类新型技术以赋予 Web 应用更高的运行效率。

从 NaCl、PNaCl 到 ASM.js,它们主要有三点共同特征:

1. 源码中都使用了类型明确的变量;

2. 应用都拥有独立的运行时环境,并且与原有的 JavaScript 运行时环境分离;

3. 支持将原有的 C/C++ 应用通过某种方式转换到基于这些技术的实现,并可以直接运行在 Web 浏览器中。

Wasm 这项技术的设计与实现,离不开从这些“前辈”们身上学习到的经验。从表面上来看,互联网技术迭代飞快。但实际上,当稍微深入和总结之后,你就会发现其实它们都有着基本相同的,想要去解决的目标问题,比如对于性能的执著要求。以及十分类似的技术解决方案,比如尽最大可能去确定那些能够确定、不会发生变化的部分(比如类型),然后再以此为基础进行优化。Wasm 也不例外。

课后思考

最后,我们来做一个思考题吧。

你觉得就目前的 Web 技术领域而言,存在着哪些困境?或者说需要去解决和优化的地方?

今天的课程就结束了,希望可以帮助到你,也希望你在下方的留言区和我参与讨论,同时欢迎你把这节课分享给你的朋友或者同事,一起交流一下。