前言
“WebAssembly 是基于栈式虚拟机的虚拟二进制指令集(V-ISA),它被设计为高级编程语言的可移植编译目标”。
学习路线
可以做什么
目前已经有多达几十种编程语言(C/C++、Rust、Go、Kotlin 等)的代码,可以在相关基础设施的帮助下被编译为 Wasm 二进制格式,Wasm 已经逐渐成为编程语言不可或缺的一个重要的编译目标。
WASI:WASI是一个新的API体系, 由 Wasmtime 项目设计, 目的是为WASM设计一套引擎无关(engine-indepent), 面向非Web系统(non-Web system-oriented)的API标准.
原码、反码和补码
位(bit)是计算机中处理数据的最小单位,其取值只能是 0 或 1。
字节(Byte)是计算机处理数据的基本单位,通常系统中一个字节为 8 位。即:1 Byte=8 bit。
为便于演示,本节表示的原码、反码及补码均默认为 8 位。
准确地说,数据在计算机中是以其补码形式存储和运算的。在介绍补码之前,先了解原码和反码的概念。
正数的原码、反码、补码均相同。
原码:用最高位表示符号位,其余位表示数值位的编码称为原码。其中,正数的符号位为 0,负数的符号位为 1。
负数的反码:把原码的符号位保持不变,数值位逐位取反,即可得原码的反码。
负数的补码:在反码的基础上加 1 即得该原码的补码。
例如:
+11 的原码为: 0000 1011
+11 的反码为: 0000 1011
+11 的补码为: 0000 1011
-7 的原码为:1000 0111
-7 的反码为:1111 1000
-7 的补码为:1111 1001
注意,对补码再求一次补码操作就可得该补码对应的原码。
ISA 与 V-ISA
我们前面介绍了三种不同的计算模型,总体来看你会发现,对应于每一种计算模型的指令,都有着不同的基本结构。比如指令可以接受的操作数个数、可操作数据所存放的位置,以及指令与指令之间交互方式的细微差别等等。
通常来说,对于可以应用在诸如 i386、X86-64 等实际存在的物理系统架构上的指令集,我们一般称之为 ISA(Instruction Set Architecture,指令集架构)。而对另外一种使用在虚拟架构体系中的指令集,我们通常称之为 V-ISA,也就是 Virtual(虚拟)的 ISA。
对这些 V-ISA 的设计,大多都是基于堆栈机模型进行的。而 Wasm 就是这样的一种 V-ISA。
Wasm 之所以会选择堆栈机模型来进行指令的设计,其主要原因是由于堆栈机本身的设计与实现较为简单。快速的原型实现可以为 Wasm 的未来发展预先试错。
另一个重要原因是,借助于堆栈机模型的栈容器特征,可以使得 Wasm 模块的指令代码验证过程变得更加简单。
简单的实现易于 Wasm 引擎与浏览器的集成。基于堆栈机的结构化控制流,通过对 Wasm 指令进行 SSA(Static Single Assignment Form,静态单赋值形式)变换,可以保证即使是在堆栈机模型下,Wasm 代码也能够有着较好的执行性能。而堆栈机模型本身长短适中的指令长度,确保了 Wasm 二进制模块能够在相同体积下,拥有着更高密度的指令代码。
i32.const 1
i32.const 1
i32.eq
i32.const 10
i32.const 10
i32.add
i32.mul
--------
20
Section 概览
从整体上来看,同 ELF 二进制文件类似,Wasm 模块的二进制数据也是以 Section 的形式被安排和存放的。Section 翻译成中文是“段”,但为了保证讲解的严谨性,以及你在理解上的准确性,后文我会直接使用它的英文名词 Section。
对于 Section,你可以直接把它想象成,一个个具有特定功能的一簇二进制数据。通常,为了能够更好地组织模块内的二进制数据,我们需要把具有相同功能,或者相关联的那部分二进制数据摆放到一起。而这些被摆放在一起,具有一定相关性的数据,便组成了一个个 Section。
每一个不同的 Section 都描述了关于这个 Wasm 模块的一部分信息。而模块内的所有 Section 放在一起,便描述了整个模块在二进制层面的组成结构。在一个标准的 Wasm 模块内,以现阶段的 MVP 标准为参考,可用的 Section 有如下几种。
要注意的是,在我们接下来将要讲解的这些 Section 中,除了其中名为 “Custom Secton”,也就是“自定义段”这个 Section 之外,其他的 Section 均需要按照每个 Section 所专有的 Section ID,按照这个 ID 从小到大的顺序,在模块的低地址位到高地址位方向依次进行“摆放”。下面我来分别讲解一下这些基本 Section 的作用和结构。
单体 Section 介绍
魔数和版本号
到这里呢,我们就已经大致分析完在 MVP 标准下,Wasm 模块内 Section 的二进制组成结构。但少侠且慢,Section 信息固然十分重要,但另一个更重要的问题是:我们如何识别一个二进制文件是不是一个合法有效的 Wasm 模块文件呢?其实同 ELF 二进制文件一样,Wasm 也同样使用“魔数”来标记其二进制文件类型。所谓魔数,你可以简单地将它理解为具有特定含义 / 功能的一串数字。
一个标准 Wasm 二进制模块文件的头部数据是由具有特殊含义的字节组成的。其中开头的前四个字节分别为 “(高地址)0x6d 0x73 0x61 0x0(低地址)”,这四个字节对应的 ASCII 可见字符为 “asm”(第一个为空字符,不可见)。
接下来的四个字节,用来表示当前 Wasm 二进制文件所使用的 Wasm 标准版本号。就目前来说,所有 Wasm 模块该四个字节的值均为 “(高地址)0x0 0x0 0x0 0x1(低地址)”,即表示版本 1。在实际解析执行 Wasm 模块文件时,VM 也会通过这几个字节来判断,当前正在解析的二进制文件是否是一个合法的 Wasm 二进制模块文件。
Wasm 浏览器加载流程
那在开始真正讲解这些 API 之前,我们先来看一看,一个 Wasm 二进制模块需要经过怎样的流程,才能够最终在 Web 浏览器中被使用。你可以参考一下我画的这张图,这些流程可以被粗略地划分为以下四个阶段。
首先是 “Fetch” 阶段。作为一个客户端 Web 应用,在这个阶段中,我们需要将被使用到的 Wasm 二进制模块,从网络上的某个位置通过 HTTP 请求的方式,加载到浏览器中。这个 Wasm 二进制模块的加载过程,同我们日常开发的 Web 应用在浏览器中加载 JavaScript 脚本文件等静态资源的过程,没有任何区别。对于 Wasm 模块,你也可以选择将它放置到 CDN 中,或者经由 Service Worker 缓存,以加速资源的下载和后续使用过程。
接下来是 “Compile” 阶段。在这个阶段中,浏览器会将从远程位置获取到的 Wasm 模块二进制代码,编译为可执行的平台相关代码和数据结构。这些代码可以通过 “postMessage()” 方法,在各个 Worker 线程中进行分发,以让 Worker 线程来使用这些模块,进而防止主线程被阻塞。此时,浏览器引擎只是将 Wasm 的字节码编译为平台相关的代码,而这些代码还并没有开始执行。
紧接着便是最为关键的 “Instantiate” 阶段。在这个阶段中,浏览器引擎开始执行在上一步中生成的代码。在前面的几节课中我们曾介绍过,Wasm 模块可以通过定义 “Import Section” 来使用外界宿主环境中的一些资源。在这一阶段中,浏览器引擎在执行 Wasm 模块对应的代码时,会将那些 Wasm 模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块,以完成最后的实例化过程。这一阶段完成后,我们便可以得到一个动态的、保存有状态信息的 Wasm 模块实例对象。
最后一步便是 “Call”。顾名思义,在这一步中,我们便可以直接通过上一阶段生成的动态 Wasm 模块对象,来调用从 Wasm 模块内导出的方法。
WebAssembly JavaScript API
模块对象
- WebAssembly.Module
- WebAssembly.Instance
比如,可以按照以下方式来生成一个 WebAssembly.Module 对象:
// "..." 为有效的 Wasm 字节码数据;
bufferSource = new Int8Array([...]);
let module = new WebAssembly.Module(bufferSource);
这里的 WebAssembly.Module 构造函数接受一个包含有效 Wasm 二进制字节码的 ArrayBuffer 或者 TypedArray 对象。
WebAssembly.Instance 构造函数的用法与 WebAssembly.Module 类似,只不过是构造函数的参数有所区别。
The
WebAssembly.Instance()
constructor creates a new Instance
object which is a stateful, executable instance of a WebAssembly.Module
.const importObject = {
imports: {
imported_func: function(arg) {
console.log(arg);
}
}
};
fetch('simple.wasm').then(response =>
response.arrayBuffer()
).then(bytes => {
let mod = new WebAssembly.Module(bytes);
let instance = new WebAssembly.Instance(mod, importObject);
instance.exports.exported_func();
})
const importObject = {
imports: {
imported_func: function(arg) {
console.log(arg);
}
}
};
WebAssembly
.instantiateStreaming(fetch('simple.wasm'), importObject)
.then(obj => obj.instance.exports.exported_func());
导入对象
- WebAssembly.Global
- WebAssembly.Memory
- WebAssembly.Table
// 该对象所表示的 Wasm 线性内存其初始大小为 10 页,其最大可分配大小为 100 页。
let memory = new WebAssembly.Memory({ initial:10, maximum:100,});
错误对象
WebAssembly.CompileError 表示在 Wasm 模块编译阶段(Compile)发生的错误,比如模块的字节码编码格式错误、魔数不匹配
WebAssembly.LinkError 表示在 Wasm 模块实例化阶段(Instantiate)发生的错误,比如导入到 Wasm 模块实例 Import Section 的内容不正确
WebAssembly.RuntimeError 表示在 Wasm 模块运行时阶段(Call)发生的错误,比如常见的“除零异常”
模块实例化
- WebAssembly.instantiate(bufferSource, importObject)
这个方法接受一个包含有效 Wasm 模块二进制字节码的 ArrayBuffer 或 TypedArray 对象,然后返回一个将被解析为 WebAssembly.Module 的 Promise 对象。就像我上面讲的那样,这里返回的 WebAssembly.Module 对象,代表着一个被编译完成的 Wasm 静态模块对象。
模块编译方法
- WebAssembly.compile(bufferSource)
模块流式实例化方法
- WebAssembly.instantiateStreaming(source, importObject)
为了能够支持“流式编译”,该方法的第一个参数,将不再需要已经从远程加载好的完整 Wasm 模块二进制数据(bufferSource)。取而代之的,是一个尚未 Resolve 的 Response 对象。
模块流式编译方法
- WebAssembly.compileStreaming(source)
Wasm 运行时(Runtime)
这里提到的“运行时”呢,主要存在于我们开头流程图中的 “Call” 阶段。在这个阶段中,我们可以调用从 Wasm 模块对象中导出的函数。每一个经过实例化的 Wasm 模块对象,都会在运行时维护自己唯一的“调用栈”。
所有模块导出函数的实际调用过程,都会影响着栈容器中存放的数据,这些数据代表着每条 Wasm 指令的执行结果。当然,这些结果也同样可以被作为导出函数的返回值。
调用栈一般是“不透明”的。也就是说,我们无法通过任何 API 或者方法直接接触到栈容器中存放的数据。因此,这也是 Wasm 保证执行安全的众多因素之一。
除了调用栈,每一个实例化的 Wasm 模块对象都有着自己的(在 MVP 下只能有一个)线性内存段。在这个内存段中,以二进制形式存放着 Wasm 模块可以使用的所有数据资源。
这些资源可以是来自于对 Wasm 模块导出方法调用后的结果,即通过 Wasm 模块内的相关指令对线性内存中的数据进行读写操作;也可以是在进行模块实例化时,我们将预先填充好的二进制数据资源以 WebAssembly.Memory 导入对象的形式,提前导入到模块实例中进行使用。
浏览器在为 Wasm 模块对象分配线性内存时,会将这部分内存与 JavaScript 现有的内存区域进行隔离,并单独管理,你可以参考我下面给你画的这张图。在以往的 JavaScript Memory 中,我们可以存放 JavaScript 中的一些数据类型,这些数据同时也可以被相应的 JavaScript / Web API 直接访问。而当数据不再使用时,它们便会被 JavaScript 引擎的 GC 进行垃圾回收。
相反,图中绿色部分的 WebAssembly Memory 则有所不同。这部分内存可以被 Wasm 模块内部诸如 “i32.load” 与 “i32.store” 等指令直接使用,而外部浏览器宿主中的 JavaScript / Web API 则无法直接进行访问。不仅如此,分配在这部分内存区域中的数据,受限于 MVP 中尚无 GC 相关的标准,因此需要 Wasm 模块自行进行清理和回收。
Wasm 的内存访问安全性是众多人关心的一个话题。事实上你并不用担心太多,因为当浏览器在执行 “i32.load” 与 “i32.store” 这些内存访问指令时,会首先检查指令所引用的内存地址偏移,是否超出了 Wasm 模块实例所拥有的内存地址范围。若引用地址不在上图中绿色范围以内,则会终止指令的执行,并抛出相应的异常。这个检查过程我们一般称之为 “Bound Check”。
Wasm 内存模型
每一个 Wasm 模块实例都有着自己对应的线性内存段。准确来讲,也就是由 “Memory Section” 和 “Data Section” 共同“描述”的一个线性内存区域。在这个区域中,以二进制形式存放着模块所使用到的各种数据资源。
在 Web 浏览器这个宿主环境中,一个内存实例通常可以由 JavaScript 中的 ArrayBuffer 类型来进行表示。ArrayBuffer 中存放的是原始二进制数据,因此在需要读写这段数据时,我们必须指定一个“操作视图(View)”。你可以把“操作视图”理解为,在对这些二进制数据进行读写操作时,数据的“解读方式”。
局限性
- 无法直接引用 DOM
在 MVP 标准下,我们无法直接在 Wasm 二进制模块内引用外部宿主环境中的“不透明”(即数据内部的实际结构和组成方式未知)数据类型,比如 DOM 元素。因此目前通常的一种间接实现方式是使用 JavaScript 函数来封装相应的 DOM 操作逻辑,然后将该函数作为导入对象,导入到模块中,由模块在特定时机再进行间接调用来使用。但相对来说,这种借助 JavaScript 的间接调用方式,在某种程度上还是会产生无法弥补的性能损耗。
- 复杂数据类型需要进行编解码
还是类似的问题,对于除“数字值”以外的“透明”数据类型(比如字符串、字符),当我们想要将它们传递到 Wasm 模块中进行使用时,需要首先对这些数据进行编码(比如 UTF-8)。然后再将编码后的结果以二进制数据的形式存放到 Wasm 的线性内存段中。模块内部指令在实际使用时,再将这些数据进行解码。
使用 Wasm 完全重写现有框架
Web 前端框架作为一个需要与 DOM 元素,以及相关 Web API 强相互依赖的技术产品,可想而知其在实际使用过程中,必然会通过 Glue Code 去完成 Wasm 与 JavaScript 之间的频繁函数调用。而以性能为重的 Web 前端框架,则无法忽视这些由于频繁函数调用带来的性能损耗。
当 Glue Code 的代码越来越多时,JavaScript 函数与 Wasm 导出函数之间的相互调用会更加频繁,在某些情况下,这可能会产生严重的性能损耗。因此结合现实情况来看,整个方案的可用性并不高。
使用 Wasm 重写现有框架的核心逻辑
我们都知道,“编解码”实际上是十分单纯的数学计算,那么这便是 Wasm 能够大显身手的地方。通过替换 Web 应用中原有的基于 JavaScript 实现的编解码逻辑,使用 Wasm 来实现这部分逻辑则会有着明显的性能提升。而且由于这个过程不涉及与 Web API 的频繁交互,Wasm 所能够带来的性能提升程度更是显而易见的。
like WebCodecs
使用 Wasm 配合框架增强应用的部分功能
使用其他语言构建 Web 前端框架
类似的框架有基于 Rust 语言的 Yew、Seed,以及基于 Go 语言 Vugu 等等。
Yew 希望能够借助 Wasm 的能力,将视图(VDOM)差异的计算过程以更高性能的方式进行实现。但鉴于目前 MVP 标准下的一些限制,实际上在最后的编译产物中,Glue Code 执行时所带来的成本则会与 Wasm 带来的性能提升相互抵消。
Other
虽然社区中曾有人提议使用 Wasm 重写 React Fiber 架构中的 Reconciler 组件,但由于目前 Wasm 还无法直接操作 DOM 元素等标准上的限制,导致我们可预见,现阶段即使用 Wasm 重写 React 的 Fiber 算法,框架在实际处理 UI 更新时,可能也不会有着显著的性能提升。因此,对于 React 团队来说,投入产出比是一个值得考量的因素。
WASM 应用
eBay 识别
AutoCAD
多媒体(Multimedia)
ogv.js
可以看到,位于主线程中的 Demuxer 作为整个播放器的核心组件,主要用于解码并提取各类型媒体文件中的音视频内容。位于各个工作线程中的音视频解码过程,也同样属于整个播放器的核心逻辑。因此,这两部分计算密集的逻辑便交由 Wasm 来进行处理。
同时为了保证性能和兼容性,ogv.js 还使用了 ASM.js 实现来作为 Wasm 的一个兼容性补偿,以便在一些不支持 Wasm 的浏览器中,通过 ASM.js 来进行加速。在最不济的情况下,ogv.js 便可以直接退回到 JavaScript 的方案(将 ASM.js 代码视作普通 JavaScript 来执行)。
不仅如此,ogv.js 还可以同时利用浏览器支持的 “Multi-Cores Worker” 特性(每一个工作线程都使用 CPU 上的一个独立核心),来对整个解码过程进行加速。与此同时,随着 Wasm 最新的 SIMD 标准被越来越多的浏览器实现,ogv.js 在处理视频像素矩阵以及各类相关编解码工作时,还可以利用该特性来做到进一步的加速。
另一个值得讲的便是 ogv.js 对第三方编解码库(libogg、libvorbis、libtheora 等等)的复用。ogv.js 在构建时,直接使用了已有的一些 C/C++ 编解码库来完成对音视频流的编解码过程,而没有选择自己从头开始编写这部分功能。因此,得益于 Emscripten 提供的对 C/C++ 代码到 ASM.js / Wasm 代码的转译功能,ogv.js 的整个开发过程变得更加方便快速。
WXLnlinePlayer
优秀的 Wasm 运行时
Wasmtime
可以被独立作为 CLI 命令行工具进行使用,或者是被嵌入到其他的应用程序或系统中。
WAMR
更倾向于被应用在诸如 IoT、嵌入式芯片等对功耗和硬件资源要求较为严格的 Wasm 场景中。
Wasmer
不同于 Wasmtime 与 WAMR,Wasmer 基于 Rust 编写,它在支持 Wasm 核心标准、部分 WASI 系统接口以及部分 Wasm Post-MVP 标准的基础之上,还同时提供了对多达数十种编程语言的 Wasm 运行时绑定支持。这意味着,你可以在其他编程语言中使用 Wasmer 的能力来解析和执行 Wasm 字节码。
SSVM
它是一个专门针对云、AI 以及区块链应用程序设计的高性能、可扩展且经过硬件优化的 Wasm 虚拟机。