Wasm是什么
大家在接触到WebAssembly这门技术的时候一定会有所发现,WebAssembly这个单词实际上是由两部分组成,也就是"web"和"Assembly"
"Web" 表明了 Wasm 的出身,它最早是为了解决Web浏览器中的问题出现的,而"Assembly"则表明了Wasm的本质,这个词翻译过来的意思是"汇编",也就是它的 V-ISA 属性。
因为Wasm所拥有的可移植、安全、高效的特性,它也被逐渐应用在Web领域之外的场景,像ByteFass、Cloudflare都使用它来构建Fass的运行时。
简介
为什么要有wasm?
web应用的增大与浏览器性能优化之比无限变大
- 应用逻辑
- 代码体积
JS的弱类型 ⇒ 引擎难优化
JavaScript 引擎在执行表达式 "x + y" 时的具体流程。这里 x 与 y 分别是在一段 JavaScript 代码中定义的两个变量,当引擎执行到 "x + y" 时,对于运算符 "+" 来说,位于其左右两侧的操作数可以是 JavaScript 中任何有效类型的组合,比如 "{} + []"、"[] + null"、"1 + 2" 等等。因此,引擎在对 "+" 运算符表达式进行求值时,会根据 ECMAScript 标准中规定的 "+" 运算符的语义,来对表达式进行求值。
在 ECMAScript 标准中定义的,"+" 运算符的运行时求值流程,实际上十分复杂和繁琐。这也是相对于静态语言来说,JavaScript 很少能够进行优化的地方。
最初的尝试-NaCI(2011) 与 PNaCI(2013) - google
NaCl 是由 Google 在 2011 年于 Chrome 浏览器中发布的一项技术,该技术旨在提供一个沙盒环境,可以让基于 C/C++ 语言编写的 Native 应用,安全地运行在浏览器中。
缺点:
- 平台依赖:分发时需要提供支持多个架构平台的模块文件
- 兼容性:只有chrome支持
- 开发成本:如果想要将已经存在的C/C++代码库编译到NaCI,需要用一个叫Pepper的库来重写代码
为了解决NaCI存在的平台依赖问题,Google在后期推出了PNaCI,名字中多出来的P也就是Portable,也就是可移植的意思
虽然解决了可移植性的问题,NacI的其它问题在PNaCI上依旧存在
Wasm的前身-ASM.js(2013)
"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 代码时,便会直接使用先前已经存储好的机器码版本。因此,引擎的性能会得到大幅的提升。
ini
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 };
}
暂时无法在飞书文档外展示此内容
Wasm(2015)
2015 年 5 月。Chrome 团队的 Ben 为 V8 设计一种新的 Prototype(原型),而另一位团队成员 Rosbery ,为这种 Prototype 设计对应的字节码格式。实际上,这个 Prototype 和对应的字节码格式,便是如今 Wasm 所分别对应的 WAT 可读文本格式与二进制字节码格式。
核心原理
栈式虚拟机
在引言中,我提到了"堆栈式虚拟机",虚拟机大家都能理解,那么这里的"堆栈式"的含义是什么呢?这里是指wasm指令集的依赖的计算模型。
计算模型
堆栈机,全称为"堆栈结构机器",即英文的 "Stack Machine"。堆栈机本身是一种常见的计算模型。换句话说,基于堆栈机模型实现的计算机,无论是虚拟机还是实体计算机,都会使用"栈"这种结构来实现数据的存储和交换过程。栈是一种"后进先出(LIFO)"的数据结构,即最后被放入栈容器中的数据可以被最先取出。
- 堆栈机
- 累加器机
在类似JS中的reduce
, 它仅能够使用可存放单一值的累加器寄存器(后简称"累加器")单元,来作为指令操作数的暂存场所。因此,基于累加器机模型设计的指令一般都仅支持一个操作数。
由于累加器的存储容量有限,因此对于一些需要进行暂存的中间数据,通常都只能够被存放到机器的线性内存中。又由于访问线性内存的速度,一般远远低于访问寄存器的速度,因此从某种程度上来讲,累加器机的指令整体执行效率会相对较低。
- 寄存器机
基于这种计算模型的机器,将使用特定的 CPU 寄存器组,来作为指令执行过程中数据的存储和交换容器。
三种模式的比较
- 堆栈机使用栈结构作为数据的存储与交换容器,由于其"后进先出"的特性,使得我们无法直接对位于栈底的数据进行操作。因此在某些情况下,机器会使用额外的指令来进行栈数据的交换过程,从而损失了一定的执行效率。但另一方面,堆栈机模型最为简单且易于实现,对应生成的指令代码长短大小适中。
- 累加器机由于其内部只有一个累加器寄存器可用于暂存数据,因此在指令的执行过程中,可能会频繁请求机器的线性内存,从而导致一定的性能损耗。但另一方面,由于累加器模型下的指令最多只能有一个操作数,因此对应的指令较为精简。
- 寄存器机内大多数与数据操作相关的指令,都需要在执行时指定目标寄存器,这无疑增加了指令的长度。过于灵活的数据操作,也意味着寄存器的分配和使用规则变得复杂。但相对的,众多的数据暂存容器,给予了寄存器机更大的优化空间。因此,通常对于同样的一段计算逻辑,基于寄存器机模型,可以生成更为高效的指令执行结构。
ISA与V-ISA
ISA(Instruction Set Architecture,指令集架构):一般是指应用在实际的物理物理系统架构上的指令集
V-ISA(Vitual Instruction Set Architecture,虚拟指令集架构):V,是指Vitual在虚拟架构体系中的指令集
V-ISA的设计,大多是基于堆栈机模型进行的。而 Wasm 就是一种 V-ISA
Wasm 之所以会选择堆栈机模型来进行指令的设计,其主要原因是由于堆栈机本身的设计与实现较为简单。快速的原型实现可以为 Wasm 的未来发展预先试错。
另一个重要原因是,借助于堆栈机模型的栈容器特征,可以使得 Wasm 模块的指令代码验证过程变得更加简单。简单的实现易于 Wasm 引擎与浏览器的集成。基于堆栈机的结构化控制流,通过对 Wasm 指令进行 SSA(Static Single Assignment Form,静态单赋值形式)变换,可以保证即使是在堆栈机模型下,Wasm 代码也能够有着较好的执行性能。而堆栈机模型本身长短适中的指令长度,确保了 Wasm 二进制模块能够在相同体积下,拥有着更高密度的指令代码。
Wasm虚拟指令集
rust
i32.const 1
i32.const 2
i32.add
前两条指令使用了 "i32.const",这个指令会将紧随其后的立即数作为一个 i32 类型,也就是 32 位整数类型的值,压入到堆栈机的栈容器中。最后一条指令 "i32.add",会取出位于栈容器顶部的两个 i32 类型的值,并相加,然后再将计算结果重新放回到栈容器中。同样的,堆栈机在实际执行这条指令前,也会首先检查当前的栈容器顶部是否含有至少两个 i32 类型的值。
这里我们看到的诸如 "i32.const" 与 "i32.add" ,其实都是 Wasm 这个 V-ISA 指令集中,各个指令所对应的文本助记符(mnemonic)。实际当这些助记符被编译到 Wasm 二进制模块中时,会使用助记符所对应的二进制字节码(一般被称为 OpCode,你可以简单地将其理解为一些二进制数字),并配合一些编码算法来压缩整个二进制模块文件的体积。
二进制存储结构:Section
Section(段),也就是一个个具有特定功能的一簇二进制数据。通常为了组织数据,我们会把结构、功能相关联的那部分数据放在一起,这点对于二进制数据也一样。而这些具有相关性的数据,就组成了一个个Section。wasm最终消费的形式是二进制数据。
下面这个图是wasm的一些section结构
Import Section 和 Export Section
首先是 Import Section,它的 ID 为 2。Import Section 主要用于作为 Wasm 模块的"输入接口"。在这个 Section 中,定义了所有从外界宿主环境导入到模块对象中的资源,这些资源将会在模块的内部被使用。
允许被导入到 Wasm 模块中的资源包括:函数(Function)、全局数据(Global)、线性内存对象(Memory)以及 Table 对象(Table)。那为什么要设计 Import Section 呢?其实就是希望能够在 Wasm 模块之间,以及 Wasm 模块与宿主环境之间共享代码和数据。与 Import Section 类似,既然我们可以将资源导入到模块,那么同样地,我们也可以反向地将资源从当前模块导出到外部宿主环境中。
为此,我们便可以利用名为 "Export Section" 的 Section 结构。Export Section 的 ID 为 7,通过它,我们可以将一些资源导出到虚拟机所在的宿主环境中。允许被导出的资源类型同 Import Section 的可导入资源一致。而导出的资源应该如何被表达及处理,则需要由宿主环境运行时的具体实现来决定。
Function Section 和 Code Section
Type Section 存放了wasm函数使用到的所有函数类型(签名)
Wasm 中,所有模块内使用到的函数都会通过整形 indices(一个索引数组)来进行索引并调用。Function Section就是这个数组
Code Section存储的就是具体的代码内容
魔数和版本号
如何识别一个二进制文件是不是一个合法且有效的Wasm模块文件?
一个标准 Wasm 二进制模块文件的头部数据是由具有特殊含义的字节组成的。其中开头的前四个字节分别为 "(高地址)0x6d 0x73 0x61 0x0(低地址)",这四个字节对应的 ASCII 可见字符为 "asm"(第一个为空字符,不可见)。
接下来的四个字节,用来表示当前 Wasm 二进制文件所使用的 Wasm 标准版本号。就目前来说,所有 Wasm 模块该四个字节的值均为 "(高地址)0x0 0x0 0x0 0x1(低地址)",即表示版本 1。在实际解析执行 Wasm 模块文件时,VM 也会通过这几个字节来判断,当前正在解析的二进制文件是否是一个合法的 Wasm 二进制模块文件。
总结
暂时无法在飞书文档外展示此内容
WAT( WebAssembly Text Format)
WAT,也就是wasm文本格式。wasm默认的产物是0101010101
形式的二进制码,阅读和调试都很不方便,WAT一定程度上解决了这个问题。
arduino
// C/C++ 代码
int factorial(int n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n-1);
}
}
swift
// 编译和转换的 WAT 文本代码
(func $factorial (; 0 ;) (param $0 i32) (result i32)
(local $1 i32)
(local $2 i32)
(block $label$0
(br_if $label$0
(i32.eqz
(get_local $0)
)
)
(set_local $2
(i32.const 1)
)
(loop $label$1
(set_local $2
(i32.mul
(get_local $0)
(get_local $2)
)
)
(set_local $0
(tee_local $1
(i32.add
(get_local $0)
(i32.const -1)
)
)
)
(br_if $label$1
(get_local $1)
)
)
(return
(get_local $2)
)
)
(i32.const 1)
)
这种格式使用 "S- 表达式" 的形式来表达 Wasm 模块及其定义,将组成模块各部分的字节码用一种更加线性的、可读的方式进行表达。
这种文本格式可以被 Wasm 相关的编译工具直接使用,比如 WAVM 虚拟机、Binaryen 调试工具等。Web 浏览器会在 Wasm 模块没有与之对应的 source-map 数据时(即无法显示模块对应的源语言代码,比如 C/C++ 代码),使用对应的 WAT 可读文本格式代码来作为代替,以方便开发者进行调试。
- Flat-WAT,也就是将WAT"拍平"
rust
(func $factorial (param $0 i32) (result i32)
block $label$0
local.get $0
i32.eqz
br_if $label$0
local.get $0
i32.const 255
i32.add
i32.const 255
i32.and
call $factorial
local.get $0
i32.mul
i32.const 255
i32.and
return
end
i32.const 1)
可能有同学会比较好奇为什么不直接用Flat的版本。这里主要是两点:
- wasm需要一个MVP版本,快速迭代
- S-表达式在当时是比较成熟一个表示方案
LLVM-将其它语言编译到WebAssembly
在实际构建Wasm模块时,我们往往不会直接去写WAT,难写难懂。通过开发其它语言再编译为Wasm是比较常见的选择,在这个过程中,我们也可以充分利用其它语言生态中的历史积累。
传统的编译器链路
其中,"编译器前端"主要用于对输入的源代码进行诸如:词法、语法及语义分析,并生成其对应的 AST 抽象语法树,然后再根据 AST 来生成编译器内部的中间代码表示形式(IR)。
"中间代码优化器"则主要用于对这些 IR 代码进行一定的优化,以减小最后生成的二进制文件大小,并同时提高二进制代码的执行效率。
最后的"编译器后端"则负责进行与本地架构平台相关的代码生成工作,主要会根据优化后的 IR 代码来进行寄存器分配和调优之类的工作,并生成对应的机器码,存储在构建出的二进制可执行文件中。当然,流程的细节根据具体编程语言实现可能有所不同。
传统的编译器链的优势在于:比如当我们需要为编译器添加对另外一种源语言的支持时,我们只需要编写整个链路中的"编译器前端"部分即可。
类似于可移植性中的中间层,满足这种"链路可分离"的一个前提就是整个链路中用于对接各个阶段的"中间产物(IR)"的格式,需要对所有源语言设计的编译器前端保持一致。从中间优化器输入到编译器后端的"中间产物"也是如此
在LLVM出现之前,各类编程语言的编译器链路中,并没有采用完全统一的中间产物表示形式。
LLVM(Low Level Virtual Machine)
最初的 LLVM 是 Chris Lattner 和 Vikram Adve 两人于 2000 年 12 月研发的一套综合性的软件工具链。在这套工具链中,包含了众多可用于开发者使用的相关组件,这些组件包括语言编译器、链接器、调试器等操作系统底层基础构建工具。
LLVM 在开发初期,被定位为一套具有良好接口定义的可重用组件库。这意味着,我们可以在所开发的第三方应用程序中,使用由 LLVM 提供的众多成熟高效的编译链路解决方案。大到"中间代码优化器",小到代码生成器中的一个 "SelectionDAG 图生成组件"。这些方案以"组件化"的形式被管理在整套 LLVM 工具集中,可用于支持整个编译链路中各个阶段遇到的各种问题。
除此之外,LLVM 还提供了众多可以直接使用的命令行工具。通过这些工具(如 llvm-as、llc、llvm-dis 等等),我们也可以快速地对经由 LLVM 组件生成的中间表示产物,进行一定的变换和处理,这极大地方便了我们的应用开发和调试流程。
LLVM-IR
在整个 LLVM 项目中,扮演着重要角色的 LLVM-IR 被定义成为一类具有明确语义的轻量级、低层次的类汇编语言,其具有足够强的表现力和较好的可扩展性。通过更加贴近底层硬件的语义表达方式,它可以将高级语言的语法清晰地映射到其自身。不仅如此,通过语义中提供的明确变量类型信息,优化器还可以对 LLVM-IR 代码进行更进一步的深度优化。
因此,通过将 LLVM-IR 作为连接编译器链路各个组成部分的重要中间代码格式,开发者便可以以此为纽带,来利用整个 LLVM 工具集中的任何组件。唯一的要求是所接入的源语言需要被转换为 LLVM-IR 的格式(编译器前端)。同样,对任何新目标平台的支持,也都需要从 LLVM-IR 格式开始,再转换成具体的某种机器码(编译器后端)。
WASI(WebAssembly System Interface)
大家在接触到WebAssembly这门技术的时候一定会有所发现,WebAssembly这个单词实际上是由两部分组成,也就是"web"和"Assembly"
"Web" 表明了 Wasm 的出身,它最早是为了解决Web浏览器中的问题出现的,而"Assembly"则表明了Wasm的本质,这个词翻译过来的意思是"汇编",也就是它的 V-ISA 属性。
因为Wasm所拥有的可移植、安全、高效的特性,它也被逐渐应用在Web领域之外的场景,像ByteFass、Cloudflare都使用它来构建Fass的运行时。
而WASI(WebAssembly System Interface,Wasm 操作系统接口),通过这项标准,Wasm 将可以直接与操作系统打交道。
安全
Capability-based Security是wasm中用于实现安全沙盒的权限模型,以确保代码在受限的环境中运行,防止恶意代码对系统的攻击。执行方式类似ACL
- 能力标识符(Capabilities) : 每个对象(如内存、函数等)都有一个唯一的标识符,称为能力标识符。只有持有特定能力标识符的代码才能访问相应的对象。
- 权限检查(Capability Checks) : 在访问对象之前,需要进行权限检查,以确保代码具有足够的能力来执行相应的操作。如果权限检查失败,则访问将被拒绝。
- 最小特权原则(Principle of Least Privilege) : Capability-based Security模型遵循最小特权原则,即代码只能获得执行所需操作的最低权限。这有助于减少潜在的安全风险。
- 沙盒环境(Sandboxing) : Wasm代码通常在一个受限的沙盒环境中运行,以隔离其对系统的访问。Capability-based Security模型有助于确保沙盒环境中的代码只能执行受允许的操作。
与之相对的,是基于"分级保护域"方式实现的安全模型,它广泛应用于类 Unix 的各类操作系统中。
在传统意义上,Ring0 层拥有着最高权限,一般用于内核模式;而 Ring3 层的权限则会被稍加限制,一般用于运行用户程序。当一个运行在 Ring3 层的用户程序,试图去调用只有 Ring0 层进程才有权限使用的指令时,操作系统会阻止调用。这就是"分级保护域"的大致概念。
系统调用(System Call)
看一个C标准库中定义的函数,它通过sys_open
来完成对应系统功能的调用
c
FILE *fopen(const char *restrict filename, const char *restrict mode) {
...
/* Compute the flags to pass to open() */
flags = __fmodeflags(mode);
fd = sys_open(filename, flags, 0666);
if (fd < 0) return 0;
...
}
下面是它的执行分层
我们前面都知道,wasm是一套V-ISA(虚拟指令集),其中的这些虚拟指令便无法被真实的物理CPU硬件直接执行。它需要一个类似V8那样的执行环境。这里我们可以类比node的运行环境之于V8
WASI为WASM提供了一个访问操作系统资源的接口
WASI标准也不只是包括实际的系统调用,在遵守Wasm的"可移植性"及"安全性"这两个基本原则,也包含了很多抽象的系统调用的定义
可移植性:WASI 通过在 Wasm 二进制字节码与虚拟机基础设施之间,提供统一的"系统调用抽象层"来保证 Wasm 模块的可移植性。上层只需要关注接口,而实现的细节交由下层处理
安全性:基础设施在真正实现 WASI 标准时,便会采用 "Capability-based Security" 的方式来控制每一个 Wasm 模块实例所拥有的 capability。
举个例子,假设一个 Wasm 模块想要打开一个计算机本地文件,而且这个模块还是由使用了 fopen 函数的 C/C++ 源代码编译而来,那对应的虚拟机在实例化该 Wasm 模块时,便会将 fopen 对应的 WASI 系统调用抽象函数 "__wasi_path_open" 以某种方式(比如通过包装后的函数指针),当做一个 capability 从模块的 Import Section 传递给该模块进行使用。
通过这种方式,基础设施掌握了主动权。它可以决定是否要将某个 capability 提供给 Wasm 模块进行使用。若某个 Wasm 模块偷偷使用了一些不为开发者知情的系统调用,那么当该模块在虚拟机中进行实例化时,便会露出马脚。掌握这样的主动权,正适合如今我们基于众多不知来源的第三方库进行代码开发的现状。对于没有经过基础设施授权的 capability 调用过程,将会被基础设施拦截。通过相应的日志系统进行收集,这些"隐藏的小伎俩"便会在第一时间被开发者 / 用户感知,并进行相应的处理。
web标准现有能力
WASM加载流程-web
暂时无法在飞书文档外展示此内容
-
fetch
阶段和加载其它资源并没有任何区别 -
Compile
阶段,在这个阶段浏览器会将从远程位置获取到的wasm模块二进制代码,编译为可执行的平台相关代码和数据结构。- 这些代码可以通过postMessage() 方法,在各个Worker 线程中分发
-
Instantiate
阶段。浏览器开始执行生成的代码。- 在这一阶段,浏览器引擎在执行wasm模块对应的代码时,会将那些wasm模块规定需要从外界宿主环境中导入的资源,导入到正在实例化中的模块。
- 这个阶段结束后,我们便可以得到一个动态的、保存有状态信息的wasm模块实例对象。
-
Call
- 在这一步中,我们可以直接通过上一阶段生成的动态Wasm模块对象,来调用Wasm模块内导出的方法
Wasm现阶段和未来能做什么
Web-性能
前端框架
前面的例子中,wasm相对于js性能提升。回到我们熟悉的前端,日常还是以React | Vue的开发为主,它能否给我们的开发带来什么变化。
根据wasm与框架的融合程度,我们大概有以下四种方案:
完全重写
我们现在的前端框架的使用方式大概是下图。除去样式文件外,我们的Web应用主要就由框架代码和应用代码组成
当我们使用wasm完全重写前端框架后,结构就会有很大变化
除了wasm的框架代码和我们的应用代码之外,还有一部分胶水代码(Glue Code)。
因为WASM的隔离性,它无法直接与Web 平台上的API交互,比如DOM,而这恰恰是现有前端框架的核心功能
csharp
// framework.cpp
extern void createEmptyDivElement();
int main(int argc, char** argv) {
createEmptyDivElement(); // 创建一个空的 "div" 标签;
createEmptyDivElement();
...
return 0;
}
javascript
// glue.js
...
WebAssembly.instantiateStreaming(wasmBytes, {
env: {
// 将函数导入到 Wasm 模块中;
createEmptyDivElement: () => document.createElement('div'),
...
}
})
频繁跨上下文调用的开销
我们使用wasm的目的:性能。前面生成的胶水代码可以当作额外的打包产物,会带来一定的体积增长。但无法脱离JS还有一个更致命的问题:Wasm 与 JS 两个上下文环境之间的函数调用开销
在早期的 Firefox 浏览器(版本 62 以前)上,由于实现问题,导致不管是使用 JavaScript 调用从 Wasm 模块中导出的函数,还是在 Wasm 模块内调用从 Web 浏览器导入到模块内的 JavaScript 函数,这两种方式的函数调用成本都十分高昂。在某些情况下,同样的函数调用过程会比 JavaScript 之间的函数调用过程慢约 20 倍。
但好在 Firefox 在 62 之后的版本中修复了这个问题。并着重优化了 JavaScript 与 Wasm 之间的函数调用效率。甚至在某些情况下,JavaScript 与 Wasm 之间的函数调用效率要高于 JavaScript 之间的函数效率。
虽然这个问题在 Firefox 上得到了修复,但不可否认的是,在其他浏览器厂商的 Wasm 实现中,也可能会出现类似的性能问题。
Web 前端框架作为一个需要与 DOM 元素,以及相关 Web API 强相互依赖的技术产品,可想而知其在实际使用过程中,必然会通过 Glue Code 去完成 Wasm 与JavaScript之间的频繁函数调用。而以性能为重的 Web 前端框架,则无法忽视这些由于频繁函数调用带来的性能损耗。
部分重写
- React
- Vue
使用wasm来增强应用中的部分功能
使用其它语言替换
这种方案相对来说就比较激进,直接去除代码中JS的部分,只留下wasm和胶水层。微软在它的.NET框架体系的Blazor中,就使用C#完全替代JS完成页面的构建
- 构建一个组件
- 使用一个组件
开发逻辑基本和我们使用JS框架一致
音视频编解码
eBay-Barcode Scanner
是eBay团队在2019年的实践,整体思路在现在也适用
背景背景
eBay 是一家知名的线上拍卖与购物网站,人们可以通过 eBay 来在线出售自己的商品。作为一家知名的购物网站,为了优化用户录入待售商品的操作流程,eBay 在自家的 iOS 与 Android 原生应用,使用中提供了"条形码扫描"功能。
通过这个功能,应用可以利用移动设备的摄像头扫描产品的 UPC 条形码,然后在后台数据库中查找是否有已经提交过的类似商品。若存在,则自动填写"商品录入清单"中与该物品相关的一些信息,从而简化用户流程,优化用户体验。
随着用户eBay HTML5 应用的使用人数越来越多,为了能够使用户的商品录入流程与Native应用保持一致,"如何为HTML5应用添加高效的条形码扫描功能?"就成为ebay团队需要解决的一个问题。
一开始技术团队使用了Github上的开源JS版本条形码扫描器,但随着不断收到用户的反馈,团队发现JS版本的条形码扫描器只能在
出现这种问题的一个最为重要的原因,便是由于 JavaScript 引擎在实际优化代码执行的过程中,无法确保用户的每一次扫描过程都能够得到 JIT 的优化。JavaScript 引擎采用的"启发式"代码执行和优化策略,通常会首先通过 Profiling 来判断出"热代码"的具体执行路径,然后再调用 JIT 引擎来优化这段代码。而实际上,究竟哪段代码能够被优化,谁也无从得知。
可能的解决方案
- Shape Detection API
WICG(Web Incubator Community Group,Web 孵化社区群组)曾提出的 "Shape Detection API" 提案。这个提案提出了一系列的 API,可以让 Web 平台应用直接利用硬件加速或者系统相关的资源,来支持如人脸识别、条形码识别等功能。但该提案目前仍处于起步阶段,要实现跨浏览器的兼容性还有很多路要走。
- Wasm
一方面,wasm有性能的提升:
从下图所示的 V8 引擎编译管道中你可以看出。相较于 JavaScript 而言,浏览器引擎在执行 Wasm 字节码时不需要经过诸如"生成 AST"、"生成 Bytecode 字节码"、"生成 IR" 以及"收集运行时信息"等多个步骤。JavaScript 引擎的优化编译器后端可以直接将 Wasm 字节码转换为经过优化的机器码,进而以接近 Native 代码的效率来执行。
另一方面,借助于 Wasm 相关编译工具链的帮助,eBay 技术团队可以直接使用曾经为 Native 平台设计开发的 C++ 条形码扫描库。总的来说,eBay 技术团队不需要为 Wasm 重新编写这部分功能,而仅需要对已有的代码库进行少量改动即可。
第一版方案
确认好方案之后,具体的执行流程就可以确定了
- 使用 Web Worker API 从主线程创建一个工作线程(Worker Thread),用于通过 JavaScript 胶水代码来加载和实例化 Wasm 模块;
- 主线程将从摄像头获得到的视频流数据传递给工作线程,工作线程将会调用从 Wasm 模块实例中导出的特定函数,来处理这些视频流像素。函数在调用完成后,会返回识别出的 UPC 字符串或者返回空字符串,以表示没有检测到有效的条形码内容;
- 应用在运行时会通过设置"阈值时间"的方式,来检测是否读取到有效的条形码信息。当扫描时间超过这个阈值时,应用会弹出提示信息以让用户重试,或选择手动输入二维码序列。当然,阈值超时可能意味着两种情况:一种是用户没有扫描到有效的条形码;第二种是读取到的二维码视频流无法被应用使用的算法正确解析。
项目中使用到的 Wasm 模块以及 JavaScript 胶水代码,均是通过 Emscripten 工具链编译已有的 C++ 条形码扫描库得来的。整个方案的工作流程如下图所示。
一致的编译流水线:
作为工程化的一部分,如何将 Wasm 模块的开发和编译流程,也一并整合到现有的 Web 前端项目开发流程中,是每个实际生产项目都需要考虑的事情。
一个 Wasm 模块,或者说是 Wasm Web 应用的完整开发流程涉及到多个部分。除了组成应用最基本的 HTML、CSS 以及 JavaScript 代码外,对于 Wasm 模块的开发和编译,我们还需使用到由 Rust 和 C++ 等系统级编程语言编写的模块源文件、相关的标准库,以及用于编译这些源代码的编译工具链,比如 Emscripten。
为了确保每次都能够在一个一致的环境中来编译和生成 Wasm 模块,同时简化整个项目中 Wasm 相关开发编译环境的部署流程。eBay 技术团队尝试采用了 Docker 来构建统一的 Wasm 编译管道。这样在每次编译 Wasm 模块时,Docker 都会启动一个具有相同环境的容器,来进行模块的编译流程,从而磨平了不同开发环境下可能带来的编译结果差异。
不仅如此,通过结合 NPM 下 "package.json" 文件中的自定义脚本命令,我们还可以让 Wasm 模块的开发与编译流程,与现有的 Web 前端应用开发编译流程,更加无缝地进行整合。举个例子,比如我们可以按照如下形式来组织 "package.json" 文件中的应用编译命令。
json
{
"name": "my-wasm-app",
"scripts": {
"build:emscripten": "docker run --rm -v $(pwd)/src:/src trzeci/emscripten ./build.sh",
"build:app": "webpack .",
"build": "npm run build:emscripten && npm run build:app",
// ...
},
// ...
}
其中,命令 "build:emscripten" 主要用于启动一个带有完整 Emscripten 工具链开发环境的 Docker 容器。并且在容器启动后,通过执行脚本 "./build.sh" ,来编译当前目录下 "src" 文件夹内的源代码到对应的 Wasm 二进制模块。"build:app" 命令则用于编译原有 Web 应用的 JavaScript 代码。最后我们将两部分再进行整合,便得到了最终的 "build" 命令。
并不理想
以上基于 Wasm 的方案看起来十分理想。但经过实际测试后,eBay 技术团队发现,虽然基于 Wasm 的实现可以在 1 秒的时间内处理多达 50 帧的画面,但实际的识别成功率却只有 60%。剩下 40% 的失败情况大多是因为采样的画面角度不好,进而使得条形码的拍摄图像质量不高。产生问题的关键点,在于当前应用使用的是自研的 C++ 条形码扫描库。
自研的 C++ 条形码扫描库其一大特征为条形码的识别解析算法效率高,但仅适用于条形码成像质量较高的情况下。因此,急需一种方式来弥补在成像质量偏低时的条形码识别。
此时,团队将目光锁定到了另外一个业界十分有名的、基于 C 语言编写的开源条形码扫描库 ------ ZBar。通过实验发现,当使用 ZBar 作为条形码扫描库时,在所设置的阈值时间范围内,整个应用的扫描成功率提高到了 80%。
但 80% 的成功率对于产品的用户体验来说仍然不够。团队继续对 ZBar 和自研的 C++ 条形码扫描库进行测试。在经过一段时间后,他们发现在某些 ZBar 超时的情况下,自研的 C++ 库却能够快速地得到扫描结果。显然,基于不同的条形码图像质量,这两个库的执行情况有所不同。
为了能够同时利用 ZBar 和自研的 C++ 库,eBay 技术团队选择了一个"特殊的方案"。我想你肯定也能够猜到方案的大致内容。
在这个方案中,应用会启动两个工作线程,一个用于 ZBar,另一个用于自研的 C++ 库,两者同时对接收到的视频流进行处理。当主线程接收到有效的识别结果时,便结束所有工作线程的执行。若超时,则显示错误信息。 经过测试,条形码在不同模拟测试场景中的识别成功率,可以提高到 95%。
无独有偶的是,当尝试把 JavaScript 版本的条形码扫描器实现同样作为工作线程,加入到竞争"队列"中时,整个应用的条形码扫描识别成功率达到了将近 100%。这样的结果让人感到惊喜。应用的最终架构可以通过下图很好地进行展示。
Web之外
事实上,由于wasm的使用成本和场景的限制,在浏览器平台下的应用并不是很多。在云原生的背景下,wasm冷启动速度快、多语言编译目标的特性,让它在服务端基建上得到了广泛应用
服务端平台建设
ByteFass
WebAssembly 天然的轻量、安全、快速、可移植等特性与 FaaS 的需求非常契合,可以帮助 FaaS 实现极致的轻量化和极致的冷启动速度。而对于 ByteFaaS 用户而言,采用一种可以编译至 WebAssembly 的语言编写函数代码即可,不会引入过多的学习成本,理论上所有基于 LLVM 架构的高级语言都可以编译到 WebAssembly。
- wasm本身的优点,轻量、安全、快速、可移植性
- 作为编译的目标语言,降低用户使用成本
Wasm现阶段和未来
现阶段的能力
多线程与原子操作
共享内存模型:在 Web 平台中,SharedArrayBuffer 对象便被用来作为这样的一个"共享内存对象",以便支持在多个 Worker 线程之间数据共享能力。
多线程模式: 每个 Worker 线程都将会实例化自己独有的 Wasm 对象,并且每个 Wasm 对象也都将拥有自己独立的栈容器用来存储操作数据。如果再配合浏览器的 "Multi-Cores Worker" 特性,我们便能够真正地做到基于多个 CPU 核心的 Wasm 多线程,而到那个时候 Wasm 应用的数据处理能力便会有着更进一步的提升。
原子内存操作:当你在多个线程中通过这些原子内存操作指令来同时访问同一块内存中的数据时,不会发生"数据竞争"的问题。每一个操作都是独立的事务,无法被中途打断,而这就是"原子"的概念。不仅如此,通过这些原子内存操作,我们还能够实现诸如"互斥锁","自旋锁"等各类并发锁结构。
SIMD
SIMD 的全称为 "Single Instruction, Multiple Data",即"单指令多数据流"。SIMD 是一种可以通过单一指令,对一组向量数据同时进行操作的一种并行性技术。
在左侧的"标量乘法运算"中,针对每一个乘法操作(An x Bn),需要使用一条独立的乘法操作指令来执行,因此对于这四组操作,便需要使用四条指令。而在右侧的 SIMD 版本中,针对 A1 到 A4 这四组乘法运算,可以仅通过一条 SIMD 指令,就能够同时完成针对这四组数字的对应乘法运算。相较于普通的标量乘法运算来说,SIMD 会使用特殊的寄存器来存储一个向量中的一簇数据,然后再以整个"向量"为单位进行运算。因此,相较于传统的标量计算,SIMD 的性能会有着成倍的增长。
流程中的提案
Wasm的提案流程:github.com/WebAssembly...
WASI的提案流程(包括现有的一些提案):
Wasm ES Module(phase 3)
csharp
import { add } from "./util.wasm";
console.log(add(1, 2)); // 3;
可以看到在上面的代码中,相较于我们之前介绍的通过 JavaScript API 来加载和实例化 Wasm 模块的方式,使用 import 的方式会相对更加简洁。不仅如此,在该提案下,我们也可以通过 <script type="module">
的方式来加载和使用一个Wasm模块
如何简单应用Wasm
wasm编译工具:Emscripten
详细文档:emscripten.org/
Emscripten简单来说就是一个基于LLVM的wasm编译工具集,它可以将C/C++编译为wasm。
整个的编译流程:C/C++ 源代码 -> LLVM IR -> Wasm
将C语言文件编译为wasm:emcc src.cc -s WASM=1 -O3 --no-entry -o target.wasm
简单样例:在vite中使用wasm fib函数
c语言版
arduino
// dip.cc
// 引入必要的头文件;
#include <emscripten.h>
extern "C"
{
// fib函数;
EMSCRIPTEN_KEEPALIVE int fib(int n)
{
if (n == 0 || n == 1)
{
return 1;
}
return fib(n - 1) + fib(n - 2);
}
}
perl
// 使用 -gsource-map 指定生成 source-map ,可以在chrome中调试
emcc fib.cc -gsource-map -s WASM=1 -O3 --no-entry -o fib.wasm
一个复杂一些的样例
- 这个样例主要涉及到对内存的处理
原理:
v8:ssshooter.com/2021-07-19-...
jit & aot: it-blog-cn.com/blogs/jvm/j...
wasm:
- time.geekbang.org/column/intr...
- About Emscripten ‒ Emscripten 3.1.55-git (dev) documentation
- web.dev
应用: