WebAssembly, Web 的新时代 (一)

S c r o l l D o w n

> 注: 原文刊载于《程序员》杂志 2017 年 2 月刊.

*文 / 张敏*

## 缘起

让我们从浏览器大战说起. Microsoft 凭借 Windows 捆绑 Internet Explorer 的先天优势击溃 Netscape 后, 进入了长达数年的静默期. 而 Netscape 则于 1998 年将 Communicator 开源, 并由 Mozilla 基金会衍生出 Firefox 浏览器, 在 2004 年发布了 Firefox 1.0 版本. 从此, 第二次浏览器大战拉开帷幕, 由 Firefox 领衔, Safari, Opera 等也积极进取, Internet Explorer 的主导地位首次受到挑战. 2008 年 Google 推出 Chrome 浏览器, 不但逐步侵蚀 Firefox 的市场, 更是压制了老迈的 Internet Explorer. 在此次大战之后的 2012 年, StatCounter 的数据指出 Chrome 以微弱优势超越 Internet Explorer 成为世界上最流行的浏览器.

分析 Google Chrome 浏览器战胜 Internet Explorer 的原因, 除了对 Web 标准更友善的支持外, 卓越的性能是其中相当重要的因素, 而浏览器性能之争的本质则体现在 JavaScript 引擎. 此前, JavaScript 引擎的实现方式经历了遍历语法树到字节码解释器等较为原始的方式, 将每条源代码翻译成相应的机器码并执行, 并不保存翻译后的机器码, 使得解释执行很慢. 2008 年 9 月, Google 发布了 V8 JavaScript 引擎. V8 被设计用于提高 Web 浏览器中 JavaScript 的执行性能, 通过及时编译 JIT (Just-In-Time) 技术, 在执行时将 JavaScript 代码编译成更为高效的机器代码并保存, 下次执行同一代码段时无需再编译, 使得 JavaScript 获得了几十倍的性能提升.

然而, JavaScript 是个无类型 (untyped, 变量没有类型, 没有静态类型) 的语言, 这直接导致表达式 c = a + b 有多重含义:

  • a, b 均为数字, 则算术运算符+表示值相加
  • a, b 为字符串, 则+运算符表示字符串连接

表达式执行时 JIT 编译器需要检查 ab 的类型, 确定操作行为. 若 a, b 均为数字, JIT 编译器则将 a, b 确认为整型, 而一旦某一变量变成字符串, JIT 编译器则不得不将之前编译的机器码推倒重来. 由此可见, JavaScript 的无类型特性建立在消耗大量性能代价的基础之上. 即便 JIT 编译器在对变量类型发生变化时已进行相应优化, 但仍然有很多情况 JavaScript 引擎未进行或者无法优化, 例如 for-of, try-catch, try-finally, with 语句以及复合 let, const 赋值的函数等.

由此可见, JavaScript 的无类型是 JavaScript 引擎的性能瓶颈之一, 改进方案有两种: 一是设计一门新的强类型语言并强制开发者进行类型指定, 二是给现有的 JavaScript 加上变量类型.

微软开发的 TypeScript 属于第一种改进方案. 它是扩展了 JavaScript 特性的语言, 包含了类型批注, 编译时类型检查, 类型推断和擦除等功能, TypeScript 开发者在声明变量时指定类型, 使得 JavaScript 引擎能够更快的将这种强类型的语言编译成弱类型.

看看第二种方案:

这是带有两个参数 (ab) 的 JavaScript 函数, 和通常 JavaScript 代码不同的地方在于 a = a | 0b = b | 0 以及返回值后面均利用标注进行了按位 OR 操作. 这么做的优点是使 JavaScript 引擎强制转换变量的值为整型执行. 通过标注加上变量类型, JavaScript 引擎就能更快的编译.

既然增加变量类型能够提升 Web 性能, 有没有办法将静态类型代码例如 C/C++ 等转换成 JavaScript 指令的子集呢? 上面的这段代码恰恰是作为 JavaScript 子集的 asm.js, 由下面的 C 编译而来:

事实上, 早在 1995 年起就已经有 Netscape Plugin API (NPAPI) 在内的可以使用浏览器运行 C/C++ 程序的项目在开发. 而 2013 年问世的 asm.js 是目前较为广泛的方案. asm.js 是一种中间编程语言, 允许用 C/C++ 语言编写的计算机软件作为 Web 应用程序运行, 并保持更好的性能, 而 Mozilla Firefox 从版本 22 起成为第一个为 asm.js 特别优化的网页浏览器.

Google也同样在为原生代码运行在 Web 端而努力。Google Native Client(NaCl)采用沙盒技术,让Intel x86、ARM或MIPS子集的机器码直接在沙盒上运行。它能够在无需安装插件的情况下从浏览器直接运行原生可执行代码,使Web应用程序可以用接近于机器码运作的速度来运行。而Google Portable Native Client(PNaCl)则稍有变化,通过一些前端编译器将C/C++源代码编译成LLVM的中间字节码而不是x86或ARM代码,并且进行优化以及链接。

| 方案 | 年代| 发起人| 标准| 目标大小| 安全性 | 可移植性 | 载入时间| 跨浏览器| 性能| 共享内存|
|:—-:|:—-:|:—-:|:—-:|:—-:|:—-:|:—-:|:—-:|:—-:|
| JavaScript| 1995| Netscape| ECMA| – |是| 是 |快 |是| 慢| 否|
| ActiveX| 1996| Microsoft| 否| -| 否| 否| 慢| 否| 慢| 是|
| asm.js| 2013| Mozilla| 否| 大| 是| 是| 慢| 一般| 快| 否|
| NaCl| 2008| Google| 否| 小| 是| 一般| 快| 否| 快| 是|
| PNaCl |2013| Google| 否| 小| 是| 是| 快| 否| 快| 是|

表1: JavaScript 及原生代码支持对比

有了类型支持, 第二种方案性能提升潜力远远大于第一种.

然而, 无论是 asm.js 或现有 PNaCl 的解决方案, 都面临着一些缺陷 (例如 1KB 的 C 源码编译生成 asm.js 后的大小有 480KB) 或者其他浏览器不支持的窘境, 而 2016 年 10 月对Chromium 问题跟踪代码的评论更是表明, Google Native Client 小组已被关闭.

作为 Web 浏览器性能和代码重用的解决方案, asm.js 及 PNaCl 都没能被普遍接受, 那么有没有上述表格中的特性全部占优, 且跨厂商的解决方案呢?

## 新时代

WebAssembly 旨在解决这个问题.

WebAssembly (简称 wasm ) 是一种适合于编译到 Web 的, 新的可移植的, 大小和加载时间高效的格式. 这是一个新的平台无关的二进制代码格式, 目标是解决 JavaScript 性能的问题. 这个新的二进制格式远小于 JavaScript, 可由浏览器的 JavaScript 引擎直接加载和执行, 这样可节省从 JavaScript 到字节码, 从字节码到执行前的机器码所花费的及时编译 JIT (Just-In-Time) 时间. 作为一种低级语言, 它定义了一个抽象语法树 (Abstract Syntax Tree, AST) , 开发人员可以以文本格式进行调试.

WebAssembly 描述了一个内存安全的沙箱执行环境, 可以在现有的 JavaScript 虚拟机中实现. 当嵌入到 Web 中时, WebAssembly 将强制执行浏览器的同源和权限安全策略. 因此, 和经常出现安全漏洞的 Flash 插件相比, WebAssembly 是一个更加安全的解决方案.

WebAssembly 可由 C/C++ 等语言编译而来. 此外, WebAssembly 由 Google, Mozilla, Microsoft 以及 Apple 牵头的 W3C 社区组共同努力, 基本覆盖主流的浏览器厂商, 因此其可移植性相较 Silverlight 等有极大提升, 平台兼容问题将不复出现.

在 Web 平台的很多项目中, 对于原生新功能的支持需要 Web 浏览器或者 Runtime 提供复杂的标准化的 API 来实现, 但是 JavaScript API 往往较慢. 使用 WebAssembly, 这些标准 API 可以更简单, 并且操作在更低的水平. 例如, 对于一个面部识别的 Web 项目, 对于访问数据流我们可以由简单的 JavaScript API 实现, 而把面部识别原生 SDK 做的事情交由 WebAssembly 实现.

需要了解的是, WebAssembly 不是将 C/C++ 等其他语言编译到 JavaScript, 更不是一种新的编程语言.

## 探究

### asm.js

上文的 C 语言求和代码经由编译器生成 asm.js 后的代码如下:

上述代码转换为 WebAssembly 的文本格式稍显复杂, 为了理解方便, 我们从精简的 asm.js 开始:

### wast 文本文件

将 asm.js 代码转换为 WebAssembly 的文本格式 add.wast (转换工具见本文工具链章节) :

WebAssembly 中代码的可装载和可执行单元被称为一个模块 (module). 在运行时, 一个模块可以被一组 import 值实例化, 多个模块实例能够访问相同的共享状态. 目前文本格式中的 module 主要用 S 表达式来表示. 虽然 S 表达格式不是正式的文本格式, 但它易于表示 AST. WebAssembly 也被设计为与 ES6 的 modules 集成.

一个单一的逻辑函数定义包含两个部分: 功能部分声明在模块中每个内部函数定义的签名, 代码段部分包含由功能部分声明的每个函数的函数体. WebAssembly 是带有返回值的静态类型, 并且所有参数都含有类型. 上面的 add.wast 可以解读为:

  1. 声明了一个名为 $add 的函数
  2. 包含两个参数 $a$b, 两者都是 32 位整型
  3. 结果是一个 32 位整型
  4. 函数体是一个 32 位的加法
    * 上面是局部变量 $a 得到的值
    * 下面是局部变量 $b 得到的值
  5. 由于没有明确的返回节点, 因此 return 是该加法函数的最后加载指令

### 二进制 wasm文件

由 C 语言求和代码经过编译生成二进制文件, 通读文件可以找到相应的头部, 类型, 导入, 函数以及代码段等等. 通过 JavaScript API 载入 wasm 二进制文件后, 最终转换到机器码执行.

## 工具链

开发人员现在可以使用相应的工具链从 C / C ++ 源文件编译 WebAssembly 模块. WebAssembly 由许多工具支持, 以帮助开发人员构建和处理源文件和生成的二进制内容.

### Emscripten

Emscripten 是其中无法回避的工具之一.

Emscripten工具链

图1: Emscripten工具链流程图及生成JavaScript (asm.js) 流程

Emscripten SDK 管理器 (emsdk) 用于管理多个 SDK 和工具, 并且指定当前正被使用到编译代码的特定SDK和工具集.

Emscripten 的主要工具是 Emscripten 编译器前端 (emcc) , 它是例如 gcc 的标准编译器的简易替代实现.
Emcc 使用 Clang 将 C/C++ 文件转换为 LLVM (源自于底层虚拟机 Low Level Virtual Machine) 字节码, 使用 Fastcomp (Emscripten 的编译器核心, 一个 LLVM 后端) 把字节码编译成 JavaScript. 输出的 JavaScript 可以由 Node.js 执行, 或者嵌入 HTML 在浏览器中运行. 这带来的直接结果就是, C 和 C++ 程序经过编译后可在 Javascript 上运行, 无需任何插件.

### WABT 及 Binaryen
除此之外, 对于想要使用由其他工具 (如 Emscripten) 生成的 WebAssembly 二进制文件感兴趣的开发者, 目前 [webassembly.org](http://webassembly.org/) 官方额外提供了另外两组不同的工具:

– WABT – WebAssembly 二进制工具包
– Binaryen – 编译器和工具链

WABT 工具包支持将二进制 WebAssembly 格式转换为可读的文本格式. 其中 wasm2wast 命令行工具可以将 WebAssembly 二进制文件转换为可读的S表达式文本文件. 而 wast2wasm 命令行工具则执行完全相反的过程.

Binaryen 则是一套更为全面的工具链, 是用 C++ 编写的用于 WebAssembly 的编译器和工具链基础结构库. WebAssembly 是二进制格式 (Binary Format) 并且和 Emscripten 集成, 因此该工具以 Binary 和 Emscript-en 的末尾合并命名为 Binaryen. 它旨在使编译 WebAssembly 容易, 快速, 有效. 它包含且不仅仅包含下面的几个工具:

  • wasm-as: 将 WebAssembly 由文本格式 (当前为S表达式格式) 编译成二进制格式.
  • wasm-dis: 将二进制格式的 WebAssembly 反编译成文本格式.
  • asm2wasm: 将 asm.js 编译到 WebAssembly 文本格式, 使用 Emscripten 的 asm 优化器.
  • s2wasm: 在 LLVM 中开发, 由新 WebAssembly 后端产生的 .s 格式的编译器.
  • wasm.js: 包含编译为 JavaScript 的 Binaryen 组件, 包括解释器, asm2wasm, S 表达式解析器等.
Binaryen 生成 WebAssembly 流程

图2: Binaryen 生成 WebAssembly 流程

Binaryen 目前提供了两个生成 WebAssembly 的流程, 由于 emscripten 的 asm.js 生成已经非常稳定, 并且 asm2wasm 是一个相当简单的过程, 所以这种将 C/C ++ 编译为WebAssembly 的方法已经可用.

Emscripten + Binaryen 生成 WebAssembly 流程

图3: Emscripten + Binaryen 生成 WebAssembly 完整流程

由此可见, Emscripten 以及 Binaryen 提供了完整的 C/C++ 到 WebAssembly 的解决方案. 而 Binaryen 则帮助提升了 WebAssembly 的工具链生态.

### 提示

由于 WebAssembly 正处于活跃开发阶段, 各项编译步骤和编译工具会有大幅变更和改进, 相信最终的编译工具和步骤会趋于便捷, 需要留意官方网站 [webassembly.org](http://webassembly.org/) 的最新动态.

发表评论

电子邮件地址不会被公开。 必填项已用*标注