认识 WebAssembly

1,305 阅读17分钟

起源

WebAssembly 起源于 Mozilla 员工的一个业余项目。2010年,在 Mozilla 从事 Android Firefox 开发的 Alon Zakai,为了把他以前开发的游戏引擎移植到浏览器上运行,利用业余时间开发了一款名叫 Emscripten 的编译器,可以把 C++ 代码通过 LLVM IR 编译成 JavaScript 代码。

到了 2011 年底,Emscripten 甚至能够成功编译 Python 和 Doom 等大型 C++ 项目,Mozilla 此时觉得这个项目很有前途,于是成立团队并邀请 Alon 全职开发这个项目。2013 年 Alon 和其他成员一起提出了 asm.js 规范,asm.js 是 JavaScript 语言的一个严格子集,试图通过“减少动态特性”和”添加类型提示“的方式帮助浏览器提升 JavaScript 优化空间。相较于完整的 JavaScript 语言,裁剪后的 asm.js 更靠近底层,更适合作为编译器目标语言。

asm.js 只提供两种数据类型:32位带符号整数,64位带符号浮点数,其他数据类型比如字符串、布尔值或者对象,asm.js 一概不提供,它们都是以数值的形式存在,保存在内存中,通过 TypedArray 调用。类型的声明也有固定写法:变量 | 0 表示整数,+变量 表示浮点数。例如下面一段代码:

function MyAsmModule() {
    "use asm";  // 告诉浏览器这是个 asm.js 模块
    function add(x, y) {
        x = x | 0;  // 变量 | 0 表示整数
        y = y | 0;
        return (x + y) | 0;
    }
    return { add: add };
}

支持 asm.js 的引擎提前识别出了类型,可以进行激进的 JIT(即时编译)优化,甚至是 AOT(事先编译)编译,大幅提升性能。不支持 asm.js 按普通 JavaScript 代码执行也不会影响运行结果。

但是 asm.js 的缺点也很明显,那就是“底层”得不够彻底,例如代码仍然是文本格式;代码编写仍然受 JavaScript 语法限制;浏览器仍然需要完成解析脚本、解释执行、收集性能指标、JIT 编译等一系列步骤。如果采用像 Java 类文件那样的二进制格式,不仅能缩小文件体积,减少网络传输时间和解析时间,还能选用更接近机器的字节码,这样 AOT/JIT 编译器实现起来会更轻松,效果也更好。

与此同时,Google 的 Chrome 团队也在试图解决 JavaScript 性能问题,但方向有所不同。Chrome 给出的解决方案是 NaCl(Google Native Client)和 PNaCl(Portable NaCl)。通过 NaCl/PNaC1,Chrome 浏览器可以在沙箱环境中直接执行本地代码。

asm.js 和 NaCl/PNaC1 技术各有优缺点,二者可以取长补短。Mozilla 和 Google也看到了这一点,所以从 2013 年开始,两个团队就经常交流和合作。后来他们决定结合两个项目的长处,合作开发一种基于字节码的技术。到了 2015 年,“WebAssembly” 确定为正式名称并对外公开,W3C 成立了 WASM 社区小组(成员包括Chrome、Edge、Firefox 和 WebKit),致力于推动 WASM 技术的发展。

2016 年 Rust 1.14发布,开始支持 WASM。 2017 年 Google 决定放弃 PNaCl 技术;四大浏览器 Chrome、Edge、Safari、Firefox 更新版本开始支持 WASM。 2018 年 Go 1.11 发布,开始支持 WASM。 2019 年 Emscripten 更新为默认使用 LLVM 编译为 WASM 代码,停止对 asm.js 的支持;WebAssembly 成为万维网联盟(W3C)的推荐标准,与 HTML,CSS 和 JavaScript 一起成为 Web 的第四种语言。

简介

官方给出的定义:WebAssembly / WASM 是基于栈式虚拟机的二进制指令集,可以作为编程语言的编译目标,能够部署在 Web 客户端和服务端的应用中。

WebAssembly 具有如下特性:

  • 是一种底层类汇编语言,能够在所有当代桌面浏览器及很多移动浏览器上以接近本地的速度运行。
  • 文件设计得很紧凑,因此可以快速传输和下载。这些文件的设计方式也使得它们可以快速解析和初始化。
  • 被设计为编译目标,让 C++、Rust 和其他语言编写的代码现在可以在 Web 上运行。

也就是说 WebAssembly 可以使得以各种语言编写的代码都可以以接近原生的速度在浏览器中运行。

WebAssembly 也被设计为与 JavaScript 共存并协同工作,相对于 JavaScript(包括 asm.js)解决了如下几个问题:

  • 性能提升。由于 WebAssembly是一种底层类汇编语言,代码是静态类型,浏览器执行时可以直接将其编译成机器码去大幅提高性能;并且由于 WebAssembly 是字节码形式,文件体积也很小,便于网络快速传输,浏览器厂商甚至引入了“流编译”技术,让文件可以边下载边编译,下载完毕即可进行初始化。
  • 融合不同语言。之前想在 Web 上执行其他语言,只能把其他语言转成 JavaScript 语言,但这个过程并不容易,而且会带来执行性能上的大幅降低;而 WebAssembly 从设计之初就定位为编译目标语言,让其他语言可以轻松转成 WebAssembly 语言代码,不仅不用担心性能(虽然仍会有一定损失),也让代码复用变得简单。
  • 加强代码安全。对 JavaScript 代码进行保护通常只能使用混淆来大幅降低代码可读性,但是在一些工具的帮助下只要多花费一些时间仍然可读。但是转译而来的 WASM 代码则完全不具有可读性,即使通过 wasm2c 等工具进行反编译,依然比分析 JS 代码要难度大很多(当然并不会达到完全的代码安全,但增加逆向难度会使其风险大大降低)。

不过 WebAssembly 并不是纯浏览器平台的技术,犹如 JavaScript 与 Node.js,如今它也有自己的 Runtime,在浏览器之外的云原生、区块链、安全等系统应用领域都有诸多应用。

编译

C / C++ 通过 Emscripten 编译:

emcc hello.c -o hello.wasm

Rust 通过 Cargo 编译:

cargo build --target wasm32-example --release

还可以进一步压缩体积:

wasm-gc target/wasm32-example/release/hello.wasm

Golang 内置编译:

GOARCH=wasm GOOS=js go build -o hello.wasm main.go

运行

在 JavaScript 运行

为了在 JavaScript 中运行 WebAssembly,在编译/实例化之前,你首先需要把模块放入内存,比如通过 XMLHttpRequest 或 Fetch,模块将会被初始化为带类型数组。

使用 Fetch 的例子:

fetch('module.wasm').then(response =>
  response.arrayBuffer()
).then(bytes =>
  WebAssembly.instantiate(bytes, importObject)
).then(results => {
  result.instance.exports
});

上述方式是先创建一个包含你的 WebAssembly 模块二进制代码的 ArrayBuffer,然后使用 WebAssembly.instantiate() 编译它。

你也可以使用 WebAssembly.instantiateStreaming(),该方法直接从原始字节码中直接获取,编译和实例化模块,无需转换为 ArrayBuffer:

WebAssembly.instantiateStreaming(fetch('simple.wasm'), importObject)
.then(result => {
  result.instance.exports
});

WebAssembly 计划未来会支持 <script type='module'> 和 ES6 的 import 语句这种形式直接加载运行。

在浏览器之外运行

Wasm 社区提供了很多 Runtime 容器,让 WASM 可以在浏览器之外的系统上执行,并且运行环境是沙箱化的。

目前比较流行的 Runtime:

  • wasmtime:既可以作为一个CLI,也可以被嵌入到其他应用系统中,如 IoT 或者云原生
  • WebAssembly Micro Runtime:更偏向于芯片场景的虚拟机,如它的名字所示,体积非常小,起步速度只要 100 微秒,内存耗费最低只需 100KB
  • wasmer:特点是支持在更多的编程语言运行 WASM 实例,并有自己的包管理平台 Wapm
  • WasmEdge:之前名为 SSVM,对云原生、边缘和去中心化应用有针对性优化

底层概念

模块

WebAssembly 程序的主要单元称为模块(Module),这个术语既用来表示代码的二进制版本,也表示浏览器中的编译后版本。

一个大型 WebAssembly 应用往往由多个子模块组成,每个模块都拥有自己的独立数据资源,因此子模块无法篡改其他模块的数据;另外每个模块所能使用的权限由最上层的调用者指定,因此第三方子模块无法在上层模块不感知的情况下越权调用,这种权限管理类似于 Android 开发需要预先声明所有依赖的权限一样。

当其他高级语言编译成 WebAssembly 后,会成为了一个模块二进制文件,文件名是以 .wasm 后缀结尾,文件内容开头是 8 字节的用于描述的模块头:

0000000: 0061 736d              ; WASM_BINARY_MAGIC
0000004: 0d00 0000              ; WASM_BINARY_VERSION

前4 字节被称为“魔数(Magic Number)”,对应 \0asm 字符串,用来识别这是一个 Wasm 模块;后 4 字节是当前模块所使用的 WASM 标准版本号。

在模块头之后就是模块的主体内容,这些内容被分门别类放在不同的段(Section),Wasm 把特定功能或者有相关联的代码放进一个特定的段中,有些段是任何的模块都必需的,有些段是可选的。

段可能会包含多个项目,Wasm 规范一共定义了 12 种段,并给每种段分配了 ID。除了自定义段以外,其他所有的段都最多只能出现一次,且必须按照段 ID 递增的顺序出现。

下面是各个段的说明,其中粗体是必需存在的段:

ID说明
0自定义段(Custom)主要用于存储调试信息等数据
1类型段(Type)存储导入函数、模块内部函数的函数参数列表
2导入段(Import)用于存储导入函数的函数名称、函数参数索引
3函数段(Function)用于存储函数索引值
4表格段(Table)用于存储对象引用,通过表格段可以实现函数指针的功能(call_indirect 指令),可以从外部宿主导入,同时也可以导出到外部宿主环境
5内存段(Memory)用于存储程序的运行时动态数据,可以从外部宿主导入,同时也可以导出到外部宿主环境
6全局段(Global)用于存储全部变量值
7导出段(Export)用于存储导出函数的函数名称、函数参数索引
8开始段(Start)用于指定模块初始化时的函数索引值
9元素段(Elem)表格段并没有显式地初始化,元素段用于存储函数的索引值
10代码段(Code)用于存储函数的指令代码
11数据段(Data)用于存储初始化内存的静态数据

数据类型

WASM 在二进制编码里的数据类型如下:

  • 无符号整数。支持三种非负整数类型:uint8、uint16、uint32,后面的数字表示占用了多少个bit
  • 可变长无符号整数。支持三种可变长非负整数类型:varuint1、varuint7、varuint32,所谓可变长的意思是会根据具体数据大小决定使用多少bit,后面的数字表示最大可占用多少个bit
  • 可变长有符号整数。同上,这里允许负数的出现,支持varint7、varint32、varint64 三种类型
  • 浮点数。同 JavaScript,采用 IEEE-754 方案,单精度为32位

对于语言本身,提供以下数值类型:

  • i32: 32-bit 整型
  • i64: 64-bit 整型
  • f32: 32-bit 浮点型
  • f64: 64-bit 浮点型

每个参数和局部变量都必须是以上四种值类型之一 ,函数签名由 0 或多个参数的类型序列及 0 或多个返回值的类型序列组成。(在最小可行版本中,一个函数最多可以有一个返回类型)。需要注意的是,值类型 i32 和 i64 不是固有有符号或无符号的。 这些类型的解释取决于某个具体的运算符。

布尔值用无符号 32 位整数表示,0 为 false,非 0 值为 true。所有其他值类型(如字符串)需要在模块的线性内存空间中表示。

WAT

WASM 二进制文件是不可读的,WAT (WebAssembly Text Format) 是另外一种输出格式,是使用 “S- 表达式” 的文本格式,可以近似理解为与二进制等价的汇编语言。

C、WAT、WASM 代码转换

部分浏览器的开发者工具支持将 WASM 转换成 WAT 查看,便于在线调试。社区提供了 wasm2watwat2wasm 等成熟的工具将二者进行转换,可以在 WABT (WebAssembly Binary Toolkit) 工具集中找到,所以也是可以直接编写 WAT 再转换成 WASM。

WASI

WebAssembly 虽然是为了 Web 而生,但并不意味着它只能也不打算只在浏览器上运行。开发人员想将它推向了浏览器之外,而这需要提供一套与操作系统交互的接口。

由于 WebAssembly 是基于概念机器的汇编语言,而不是物理机器,因此,WebAssembly提供了一种快速,可扩展,安全的方式来在所有计算机上运行相同的代码。同时为了在所有不同的操作系统上运行,WebAssembly 需要一个概念机器的系统接口,而不是任何单个操作系统。于是开发人员定义了一种与不同操作系统通信统一标准,名为 WASI (WebAssembly System Interface),它是为 WASM 专门设计一套引擎无关(engine-indepent)、面向非 Web 系统(non-Web system-oriented)的 API 标准。

WASI 的设计遵循两大原则:

  • 可移植性。能够编译可移植的二进制文件,编译一次就能在不同的计算机上运行,让用户分发代码更容易。例如,Node 的原生模块如果是用 WebAssembly 编写的,那么当用户安装带有原生模块的应用时就不需要运行 node-gyp 了,开发人员也无需配置并分发几十个二进制文件了。
  • 安全性。当一行代码请求操作系统执行某些输入或输出时,操作系统需要确定该代码所请求的操作是否安全。WebAssembly 采用了沙箱机制,代码不能直接与操作系统交互,宿主机(可能是浏览器,也可能是 WASM 运行时)需要将相关函数放入代码可以使用的沙箱中,宿主机可以逐一限制每个程序可以做什么。虽然拥有沙箱机制并不会使系统本身变安全(宿主机仍然可以将所有能力都放入到沙箱中),不过它至少让宿主机能够选择创建更安全的系统。

基于上述两项关键原则,WASI 被设计为一组模块化的标准接口,其中最基础的核心模块为 wasi-core,其它的比如 sensorscryptoprocessesmultimedia 等子集合都是以单独的子模块的形式组织。

WASI 模块

wasi-core 包含所有程序都需要的基本接口,它会覆盖与 POSIX 近乎相同的领域,包括诸如文件、网络连接、时钟以及随机数等相关系统调用的 WASI 抽象函数接口。

WASI 在 WASM 字节码与虚拟机之间,增加了一层“系统调用抽象层”。比如对于在 C/C++ 源码中使用的 fopen 函数,当我们将这部分源代码与专为 WASI 实现的 C 标准库 wasi-libc 进行编译时,源码中对 fopen 的函数调用过程,其内部会间接通过调用名为 __wasi_path_open 的函数来实现。这个 __wasi_path_open 函数,便是对实际系统调用的一个抽象。

WASI 主要工作是定义 Import 接口标准,提供通用 Import 接口在不同系统上的具体实现(与不同操作系统上实现libc模式类似)。 基于 WASI 的设计思路,针对不同的领域我们还可以提供更上层的WADSI(WebAssembly Domain Specific Interface),将领域通用的接口作为 Import 接口提供,从而使得开发者可以直接使用。

安全性

WebAssembly 的安全性来源之一是,它是第一个共享 JavaScript VM 的语言,而 JavaScript VM 在运行时是沙箱化的,同时也经历了多年的检验和安全测试,这确保了其安全性。WebAssembly 模块的可访问范围不超过 JavaScript 的访问范围,同时也会遵守相同的安全性规则,包括同源策略(same-origin policy)这样的增强规则。

与桌面应用程序不同,WebAssembly 模块对设备内存没有直接访问权限,而是运行时环境在初始化过程中向模块传递一个 ArrayBuffer 。模块将这个 ArrayBuffer 当作线性内存来使用,WebAssembly 框架执行检查以确保代码不会对这个数组进行越界操作。

对于像函数指针这样存储在 Table 段中的项目,WebAssembly 模块也不能直接访问。代码会用索引值向WebAssembly框架提出访问某个项目的请求。然后框架访问内存,并代表代码执行这个项目。

在 C++ 中,执行栈与线性内存一起位于内存中,虽然 C++ 代码不应该修改执行栈,但是它可以使用指针实现修改。WebAssembly的执行栈与线性内存是分离的,代码无法访问。

应用案例

谷歌地球 谷歌地球在 2017 年发布是 9.0 版本中,采用的是 NaCl 技术开发,所以当时只能在 Chrome 上运行。2020 年谷歌使用 C++ 通过 WebAssembly 重写了该项目,从此可以在 Firefox 和 Edge 上运行。

谷歌地球

AutoCAD AutoCAD 是一款由将近 40 年历史的知名桌面端设计软件,被广泛地用于土木建筑、装饰装潢、工业制图等多个领域中。2014 年 AutoCAD 发布 Web 版,是通过 Google Web Toolkit(一个 Google 开发的可以使用 Java 语言开发 Web 应用的工具集)的帮助下开发,将 Android 端的 Java 代码转译成 JS 代码,但由于生成的 JS 代码十分庞大,导致浏览器上运行效率很低。2015 年又通过 asm.js 将原有的 C++ 代码中的主要功能直接进行编译移植到到 Web 平台,性能有了很大的提告。2018 年 3 月,基于 WASM 构建的 AutoCAD Web 也成功诞生。

AutoCAD

Figma Figma 是一个基于浏览器的协作式 UI 设计工具,核心的交互界面是在一个 Canvas 内承载,这个 Canvas 的交互是通过 WASM 控制的。基于浏览器让它可以轻松跨平台运行,而 WebAssembly 带来了高性能,让它即使在 Web 平台依然在速度上完胜那些基于原生 OS 开发的同类应用。

Figma

结语

可以看出 WebAssembly 并不是用来完全取代 JavaScript,而是作为 Web 技术的补充,在性能和代码复用等方面弥补 JavaScript 的局限。正如 WASM 官方的口号:“所有可以用 WebAssembly 实现的终将会用 WebAssembly 实现”,WebAssembly 的最终目标是用任何语言编译而来并可以高效运行在任何平台。最重要的是它背靠 Google、Mozilla、Edge 等主流开发机构的支持,相信在未来一定还会有更长足的发展。

参考资料