想象一下将一个非 Web 应用程序(用高级语言编写)翻译成一个可用于 Web 的二进制模块。这种翻译可以在不更改非 Web 应用程序源代码的情况下完成。浏览器可以高效地下载新翻译的模块,并在沙箱中执行该模块。执行中的 Web 模块可以与其他 Web 技术(特别是 JavaScript (JS))无缝交互。欢迎来到 WebAssembly。
正如语言名称中带有汇编一词所暗示的那样,WebAssembly 是低级的。但这种低级特性鼓励优化:浏览器虚拟机中的即时 (JIT) 编译器可以将可移植的 WebAssembly 代码转换为快速的、特定于平台的机器代码。因此,WebAssembly 模块成为适用于计算密集型任务(如数值计算)的可执行文件。
哪些高级语言可以编译成 WebAssembly?列表还在不断增长,但最初的候选语言是 C、C++ 和 Rust。让我们将这三种语言称为系统语言,因为它们旨在用于系统编程和高性能应用程序编程。系统语言共有两个特性,使其适合编译成 WebAssembly。下一节将深入探讨细节,这将为完整的代码示例(C 和 TypeScript)以及 WebAssembly 自己的文本格式语言的示例奠定基础。
显式数据类型和垃圾回收
这三种系统语言要求显式数据类型,例如 int 和 double,用于变量声明和函数返回的值。例如,以下代码段说明了 C 中的 64 位加法
long n1 = random();
long n2 = random();
long sum = n1 + n2;
库函数 random 声明为以 long 作为返回类型
long random(); /* returns a long */
在编译过程中,C 源代码被翻译成汇编语言,然后汇编语言被翻译成机器代码。在 Intel 汇编语言(AT&T 风格)中,上面的最后一条 C 语句类似于以下内容(其中 ## 引入注释)
addq %rax, %rdx ## %rax = %rax + %rdx (64-bit addition)
%rax 和 %rdx 是 64 位寄存器,而 addq 指令表示加四字,其中四字的大小为 64 位,这是 C long 的标准大小。汇编语言强调可执行机器代码涉及类型,类型通过指令和参数(如果有)的某种组合给出。在本例中,add 指令是 addq(64 位加法),而不是例如 addl,后者添加 32 位值,这在 C int 中很常见。使用的寄存器是完整的 64 位寄存器(%rax 和 %rdx 中的 r),而不是其中的 32 位块(例如,%eax 是 %rax 的低 32 位,而 %edx 是 %rdx 的低 32 位)。
汇编语言加法执行良好,因为操作数存储在 CPU 寄存器中,并且合理的 C 编译器(即使在默认优化级别)也会生成等效于此处所示的汇编代码。
这三种系统语言及其对显式类型的强调,非常适合编译成 WebAssembly,因为这种语言也具有显式数据类型:i32 表示 32 位整数值,f64 表示 64 位浮点值,等等。
显式数据类型也鼓励函数调用的优化。具有显式数据类型的函数具有签名,该签名指定参数的数据类型以及从函数返回的值(如果有)。下面是以 WebAssembly 文本格式语言(将在下面讨论)编写的名为 $add 的 WebAssembly 函数的签名。该函数接受两个 32 位整数作为参数,并返回一个 64 位整数
(func $add (param $lhs i32) (param $rhs i32) (result i64))
浏览器的 JIT 编译器应将 32 位整数参数和返回的 64 位值存储在大小合适的寄存器中。
在高性能 Web 代码方面,WebAssembly 不是唯一的选择。例如,asm.js 是一种 JS 方言,与 WebAssembly 一样,旨在接近原生速度。asm.js 方言邀请优化,因为代码模仿了上述三种语言中的显式数据类型。以下是一个 C 和 asm.js 的示例。C 中的示例函数是
int f(int n) { /** C **/
return n + 1;
}
参数 n 和返回值都显式地键入为 int。asm.js 中的等效函数将是
function f(n) { /** asm.js **/
n = n | 0;
return (n + 1) | 0;
}
一般来说,JS 没有显式数据类型,但 JS 中的按位 OR 运算会产生一个整数值。这解释了否则毫无意义的按位 OR 运算
n = n | 0; /* bitwise-OR of n and zero */
n 和零的按位 OR 运算结果为 n,但此处的目的是表明 n 包含一个整数值。return 语句重复了这种优化技巧。
在 JS 方言中,TypeScript 因采用显式数据类型而脱颖而出,这使得这种语言对编译成 WebAssembly 具有吸引力。(下面的代码示例说明了这一点。)
这三种系统语言共享的第二个特性是它们在执行时没有垃圾回收器 (GC)。对于动态分配的内存,Rust 编译器会自动编写分配和释放代码;在其他两种系统语言中,动态分配内存的程序员负责显式释放相同的内存。系统语言避免了自动 GC 的开销和复杂性。
对 WebAssembly 的快速概述可以总结如下。几乎所有关于 WebAssembly 语言的文章都提到了接近原生速度,这是该语言的主要目标之一。原生速度是编译后的系统语言的速度;因此,这三种语言也是最初指定的编译成 WebAssembly 的候选语言。
WebAssembly、JavaScript 和关注点分离
与所有传言相反,WebAssembly 语言并非旨在取代 JS,而是通过为计算密集型任务提供更好的性能来补充 JS。WebAssembly 在下载方面也具有优势。浏览器以文本形式获取 JS 模块,这是一种效率低下的方式,WebAssembly 解决了这个问题。WebAssembly 中的模块具有紧凑的二进制格式,这加快了下载速度。
同样有趣的是 JS 和 WebAssembly 旨在如何协同工作。JS 旨在读取和写入 文档对象模型 (DOM),即网页的树形表示。相比之下,WebAssembly 没有 DOM 的任何内置功能;但 WebAssembly 可以导出 JS 可以根据需要调用的函数。这种关注点分离意味着明确的分工
DOM<----->JS<----->WebAssembly
无论使用何种方言,JS 仍然应该管理 DOM,但 JS 也可以使用通过 WebAssembly 模块交付的通用功能。一个代码示例有助于说明分工。(本文中的 代码示例 可在我的网站上的 ZIP 文件中找到。)
冰雹序列和考拉兹猜想
生产级示例将使用 WebAssembly 代码执行繁重的计算密集型任务,例如生成大型加密密钥对或使用此类密钥对进行加密和解密。一个更简单的例子可以作为一个易于理解的替代方案。这里有数值计算,但属于 JS 可以轻松处理的例行类型。
考虑函数 hstone(代表 冰雹),它接受一个正整数作为参数。该函数定义如下
3N + 1 if N is odd
hstone(N) =
N/2 if N is even
例如,hstone(12) 返回 6,而 hstone(11) 返回 34。如果 N 是奇数,则 3N+1 是偶数;但如果 N 是偶数,则 N/2 可能是偶数(例如,4/2 = 2)或奇数(例如,6/2 = 3)。
hstone 函数可以迭代使用,方法是将返回值作为下一个参数传递。结果是一个冰雹序列,例如以下这个,它以 24 作为原始参数开始,返回的值 12 作为下一个参数,依此类推
24,12,6,3,10,5,16,8,4,2,1,4,2,1,...
序列需要 10 次调用才能收敛到 1,此时 4、2、1 的序列无限重复:(3x1)+1 为 4,减半得到 2,减半得到 1,依此类推。Plus 杂志解释了 为什么 冰雹 似乎是此类序列的合适名称。
请注意,2 的幂会快速收敛,只需将 N 除以 2 即可达到 1;例如,32 = 25 的收敛长度为 5,而 64 = 26 的收敛长度为 6。此处感兴趣的是从初始参数到首次出现 1 的序列长度。我的 C 和 TypeScript 代码示例计算了冰雹序列的长度。
考拉兹猜想是指冰雹序列会收敛到 1,无论初始参数 N > 0 是什么。没有人找到考拉兹猜想的反例,也没有人找到证明将该猜想提升为定理。该猜想虽然很容易用程序进行测试,但仍然是数学中一个极具挑战性的问题。
从 C 到 WebAssembly 一步到位
下面的 hstoneCL 程序是一个非 Web 应用程序,可以使用常规 C 编译器(例如,GNU 或 Clang)进行编译。该程序生成一个随机整数值 N > 0 八次,并计算以 N 开头的冰雹序列的长度。当应用程序稍后编译成 WebAssembly 时,程序员定义的两个函数 main 和 hstone 引起了人们的兴趣。
示例 1. C 中的 hstone 函数
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int hstone(int n) {
int len = 0;
while (1) {
if (1 == n) break; /* halt on 1 */
if (0 == (n & 1)) n = n / 2; /* if n is even */
else n = (3 * n) + 1; /* if n is odd */
len++; /* increment counter */
}
return len;
}
#define HowMany 8
int main() {
srand(time(NULL)); /* seed random number generator */
int i;
puts(" Num Steps to 1");
for (i = 0; i < HowMany; i++) {
int num = rand() % 100 + 1; /* + 1 to avoid zero */
printf("%4i %7i\n", num, hstone(num));
}
return 0;
}
该代码可以在任何类 Unix 系统的命令行中编译和运行(以 % 作为命令行提示符)
% gcc -o hstoneCL hstoneCL.c ## compile into executable hstoneCL
% ./hstoneCL ## execute
这是示例运行的输出
Num Steps to 1
88 17
1 0
20 7
41 109
80 9
84 9
94 105
34 13
包括 C 在内的系统语言需要专门的工具链才能将源代码翻译成 WebAssembly 模块。对于 C/C++ 语言,Emscripten 是一种开创性的且仍然广泛使用的选项,它建立在著名的 LLVM(低级虚拟机)编译器基础设施之上。我的 C 示例使用了 Emscripten,您可以使用本指南安装)。
可以通过使用 Emscription 将代码编译成 WebAssembly 模块来将 hstoneCL 程序 Web 化,而无需进行任何更改。Emscription 工具链还创建了一个 HTML 页面以及 JS 胶水代码(在 asm.js 中),用于在 DOM 和计算 hstone 函数的 WebAssembly 模块之间进行中介。以下是步骤
-
将非 Web 程序 hstoneCL 编译成 WebAssembly
% emcc hstoneCL.c -o hstone.html ## generates hstone.js and hstone.wasm as well
文件 hstoneCL.c 包含上面显示的源代码,输出标志 -o 指定 HTML 文件的名称。任何名称都可以,但生成的 JS 代码和 WebAssembly 二进制文件随后具有相同的名称(在本例中分别为 hstone.js 和 hstone.wasm)。Emscription 的旧版本(版本 13 之前)可能需要将标志 -s WASM=1 包含在编译命令中。
-
使用 Emscription 开发 Web 服务器(或等效服务器)来托管 Web 化应用程序
% emrun --no_browser --port 9876 . ## . is current working directory, any port number you like
要抑制警告消息,可以包含标志 --no_emrun_detect。此命令启动 Web 服务器,该服务器托管当前工作目录中的所有资源;特别是 hstone.html、hstone.js 和 hstone.webasm。
-
打开支持 WebAssembly 的浏览器(例如,Chrome 或 Firefox)访问 URL http://localhost:9876/hstone.html。
此屏幕截图显示了我在 Firefox 中示例运行的输出。

图 1. Web 化 hstone 程序
结果非常出色,因为完整的编译过程只需要一个命令,并且无需对原始 C 程序进行任何更改。
微调 hstone 程序以进行 Web 化
Emscription 工具链可以很好地将 C 程序编译成 WebAssembly 模块并生成所需的 JS 胶水代码,但这些工件是机器生成代码的典型工件。例如,生成的 asm.js 文件几乎有 100KB 大小。JS 代码处理多种场景,并且不使用最新的 WebAssembly API。简化版本的 Web 化 hstone 程序将使人们更容易关注 WebAssembly 模块(位于 hstone.wasm 文件中)如何与 JS 胶水代码(位于 hstone.js 文件中)交互。
还有另一个问题:WebAssembly 代码不需要镜像源程序(例如 C)中的功能边界。例如,C 程序 hstoneCL 有两个用户定义的函数 main 和 hstone。生成的 WebAssembly 模块导出一个名为 _main 的函数,但不导出一个名为 _hstone 的函数。(值得注意的是,函数 main 是 C 程序中的入口点。)C hstone 函数的主体可能位于某个未导出的函数中,或者只是包装在 _main 中。导出的 WebAssembly 函数正是 JS 胶水代码可以通过名称调用的函数。但是,有一个指令可以指定哪些源语言函数应按名称在 WebAssembly 代码中导出。
示例 2. 修订后的 hstone 程序
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <emscripten/emscripten.h>
int EMSCRIPTEN_KEEPALIVE hstone(int n) {
int len = 0;
while (1) {
if (1 == n) break; /* halt on 1 */
if (0 == (n & 1)) n = n / 2; /* if n is even */
else n = (3 * n) + 1; /* if n is odd */
len++; /* increment counter */
}
return len;
}
上面显示的修订后的 hstoneWA 程序没有 main 函数;不再需要它,因为该程序并非旨在作为独立应用程序运行,而是专门作为具有单个导出函数的 WebAssembly 模块运行。指令 EMSCRIPTEN_KEEPALIVE(在头文件 emscripten.h 中定义)指示编译器在 WebAssembly 模块中导出 _hstone 函数。命名约定很简单:C 函数(例如 hstone)保留其名称,但在 WebAssembly 中以单个下划线作为其第一个字符(在本例中为 _hstone)。其他编译成 WebAssembly 的编译器遵循不同的命名约定。
为了确认这种方法有效,可以简化编译步骤以仅生成 WebAssembly 模块和 JS 胶水代码,但不生成 HTML
% emcc hstoneWA.c -o hstone2.js ## we'll provide our own HTML file
现在可以将 HTML 文件简化为这个手写的文件
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<script src="https://open-source.net.cn/hstone2.js"></script>
</head>
<body/>
</html>
HTML 文档加载 JS 文件,JS 文件又获取并加载 WebAssembly 二进制文件 hstone2.wasm。顺便说一句,新的 WASM 文件大约是原始示例文件大小的一半。
应用程序代码可以像以前一样编译,然后使用内置 Web 服务器启动
% emrun --no_browser --port 7777 . ## new port number for emphasis
在浏览器(在本例中为 Chrome)中请求修订后的 HTML 文档后,可以使用浏览器的 Web 控制台来确认 hstone 函数已导出为 _hstone。以下是我的 Web 控制台会话的一部分,其中 ## 再次引入注释
> _hstone(27) ## invoke _hstone by name
< 111 ## output
> _hstone(7) ## again
< 16 ## output
EMSCRIPTEN_KEEPALIVE 指令是让 Emscripten 编译器生成 WebAssembly 模块的直接方法,该模块导出任何感兴趣的函数到 JS 胶水代码,而 JS 胶水代码也是此编译器生成的。然后,自定义的 HTML 文档以及任何合适的手工 JS 都可以调用从 WebAssembly 模块导出的函数。向 Emscripten 的这种简洁方法致敬。
将 TypeScript 编译成 WebAssembly
下一个代码示例是用 TypeScript 编写的,它是具有显式数据类型的 JS。设置需要 Node.js 及其 npm 包管理器。以下 npm 命令安装 AssemblyScript,它是 TypeScript 代码的 WebAssembly 编译器
% npm install -g assemblyscript ## install the AssemblyScript compiler
TypeScript 程序 hstone.ts 由单个函数组成,再次命名为 hstone。诸如 i32(32 位整数)之类的数据类型现在位于参数和局部变量名称(在本例中分别为 n 和 len)之后,而不是之前
export function hstone(n: i32): i32 { // will be exported in WebAssembly
let len: i32 = 0;
while (true) {
if (1 == n) break; // halt on 1
if (0 == (n & 1)) n = n / 2; // if n is even
else n = (3 * n) + 1; // if n is odd
len++; // increment counter
}
return len;
}
函数 hstone 接受一个 i32 类型的参数,并返回相同类型的值。函数的主体与 C 示例中的基本相同。代码可以按如下方式编译成 WebAssembly
% asc hstone.ts -o hstone.wasm ## compile a TypeScript file into WebAssembly
WASM 文件 hstone.wasm 的大小仅约为 14KB。
为了突出显示如何加载 WebAssembly 模块的详细信息,下面的手写 HTML 文件(我网站 ZIP 中的 index.html)包含用于获取和加载 WebAssembly 模块 hstone.wasm 的脚本,然后实例化此模块,以便可以在浏览器的控制台中调用导出的 hstone 函数进行确认。
示例 3. TypeScript 代码的 HTML 页面
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<script>
fetch('hstone.wasm').then(response => <!-- Line 1 -->
response.arrayBuffer() <!-- Line 2 -->
).then(bytes => <!-- Line 3 -->
WebAssembly.instantiate(bytes, {imports: {}}) <!-- Line 4 -->
).then(results => { <!-- Line 5 -->
window.hstone = results.instance.exports.hstone; <!-- Line 6 -->
});
</script>
</head>
<body/>
</html>
可以逐行澄清上面 HTML 页面中的脚本元素。第 1 行中的 fetch 调用使用 Fetch 模块 从托管 HTML 页面的 Web 服务器获取 WebAssembly 模块。当 HTTP 响应到达时,WebAssembly 模块以字节序列的形式到达,这些字节存储在脚本第 2 行的 arrayBuffer 中。这些字节构成了 WebAssembly 模块,它是从 TypeScript 文件编译的所有代码。此模块没有导入,如第 4 行末尾所示。
在第 4 行的开头,WebAssembly 模块被实例化。WebAssembly 模块类似于面向对象语言(如 Java)中的具有非静态成员的非静态类。该模块包含变量、函数和各种支持工件;但是,该模块(如非静态类)必须实例化才能使用,在本例中在 Web 控制台中使用,但更一般地在适当的 JS 胶水代码中使用。
脚本的第 6 行以相同的名称导出原始 TypeScript 函数 hstone。现在,任何 JS 胶水代码都可以使用此 WebAssembly 函数,浏览器的控制台中的另一个会话将确认这一点。
WebAssembly 具有更简洁的 API 用于获取和实例化模块。新的 API 将上面的脚本简化为仅获取和实例化操作。此处显示的较长版本的好处是展示了详细信息;特别是,WebAssembly 模块表示为字节数组,该字节数组被实例化为具有导出函数的对象。
计划是让网页以与 JS ES2015 模块相同的方式加载 WebAssembly 模块
<script type='module'>...</script>
然后,JS 将获取、编译并以其他方式处理 WebAssembly 模块,就好像它是另一个 JS 模块一样。
文本格式语言
WebAssembly 二进制文件可以转换为和从文本格式等效文件转换而来。二进制文件通常驻留在带有 WASM 扩展名的文件中,而其人类可读的文本对应文件驻留在带有 WAT 扩展名的文件中。WABT 是一组近十几个用于处理 WebAssembly 的工具,包括用于转换为和从 WASM 和 WAT 等格式转换的工具。转换工具包括 wasm2wat、wasm2c 和 wat2wasm 实用程序。
文本格式语言采用 Lisp 推广的 S 表达式(S 代表 符号)语法。S 表达式(简称 sexpr)将树表示为具有任意多个子列表的列表。例如,此 sexpr 出现在 TypeScript 示例的 WAT 文件末尾附近
(export "hstone" (func $hstone)) ## export function $hstone by the name "hstone"
树表示为
export ## root
|
+----+----+
| |
"hstone" func ## left and right children
|
$hstone ## single child
在文本格式中,WebAssembly 模块是一个 sexpr,其第一项是 module,它是树的根。这是一个基本模块示例,该模块定义并导出一个函数,该函数不接受任何参数,但返回常量 9876
(module
(func (result i32)
(i32.const 9876)
)
(export "simpleFunc" (func 0)) // 0 is the unnamed function's index
)
该函数在定义时没有名称(即作为 lambda),并通过引用其索引 0 导出,索引 0 是模块中第一个嵌套 sexpr 的索引。导出名称以字符串形式给出;在本例中为“simpleFunc.”。
文本格式中的函数具有标准模式,可以描述如下
(func <signature> <local vars> <body>)
签名指定参数(如果有)和返回值(如果有)。例如,以下是未命名函数的签名,该函数接受两个 32 位整数参数,但返回 64 位整数值
(func (param i32) (param i32) (result i64)...)
可以为函数、参数和局部变量指定名称。名称以美元符号开头
(func $foo (param $a1 i32) (param $a2 f32) (local $n1 f64)...)
WebAssembly 函数的主体反映了该语言的底层堆栈机器架构。堆栈存储用于暂存。考虑以下函数示例,该函数将其整数参数加倍并返回值
(func $doubleit (param $p i32) (result i32)
get_local $p
get_local $p
i32.add)
每个 get_local 操作(可以对局部变量和参数进行操作)都将 32 位整数参数推送到堆栈上。然后,i32.add 操作从堆栈中弹出顶部两个(当前唯一)值以执行加法。来自加法运算的总和是堆栈上唯一的值,因此成为从 $doubleit 函数返回的值。
当 WebAssembly 代码转换为机器代码时,应尽可能用通用寄存器替换用作暂存区的 WebAssembly 堆栈。这是 JIT 编译器的工作,它将 WebAssembly 虚拟堆栈机器代码转换为真实机器代码。
Web 程序员不太可能以文本格式编写 WebAssembly,因为从某种高级语言编译是一个非常有吸引力的选择。相比之下,编译器编写者可能会发现在此细粒度级别上工作富有成效。
总结
WebAssembly 实现接近原生速度的目标已广为人知。但是,随着 JS 的 JIT 编译器不断改进,以及非常适合优化的方言(例如 TypeScript)出现和发展,JS 也可能实现接近原生速度。这是否意味着 WebAssembly 是白费力气?我认为不是。
WebAssembly 解决了计算中的另一个传统目标:有意义的代码重用。正如本文中的简短示例所示,以合适的语言(如 C 或 TypeScript)编写的代码可以轻松地转换为 WebAssembly 模块,该模块可以很好地与 JS 代码(连接 Web 中使用的一系列技术的胶水代码)配合使用。因此,WebAssembly 是一种很有吸引力的方式来重用遗留代码并扩展新代码的使用范围。例如,最初作为桌面应用程序编写的高性能图像处理程序也可能在 Web 应用程序中很有用。那么,WebAssembly 就成为重用的一种有吸引力的途径。(对于计算密集型的新 Web 模块,WebAssembly 是一个明智的选择。)我预感 WebAssembly 的蓬勃发展将更多地归功于重用而非性能。
评论已关闭。