把 WebAssembly 用于提高速度和代码重用

翻译:Marty Kalin

翻译:疯狂的技术宅javascript

原文:https://opensource.com/articl...html

未经容许严禁转载前端

有这样一种技术,能够把用高级语言编写的非 Web 程序转换成为 Web 准备的二进制模块,而无需对 Web 程序的源代码进行任何更改便可完成这种转换。浏览器能够有效地下载新翻译的模块并在沙箱中执行。执行的 Web 模块能够与其余 Web 技术无缝地交互 - 特别是 JavaScript(JS)。欢迎来到WebAssemblyjava

对于名称中带有 assembly 的语言,WebAssembly 是低级的。可是这种低级角色鼓励优化:浏览器虚拟机的即时(JIT)编译器能够将可移植的 WebAssembly 代码转换为快速的、特定于平台的机器代码。所以,WebAssembly 模块成为适用于计算绑定任务(例如数字运算)的可执行文件。node

有不少高级语言都能编译成 WebAssembly,并且这个名单正在增加,但最初的候选是C、C ++ 和 Rust。咱们将这三种称为系统语言,由于它们用于系统编程和高性能应用编程。系统语言都具备两个特性,这使它们适合被编译为 WebAssembly。下一节将详细介绍设置完整的代码示例(使用 C 和 TypeScript)以及来自 WebAssembly 本身的文本格式语言的示例。git

显式数据类型和垃圾回收

这三种系统语言须要显式数据类型,例如 intdouble,用于变量声明和从函数返回的值。例如如下代码段说明了 C 中的 64 位加法:程序员

long n1 = random();
long n2 = random();
long sum = n1 + n2;

库函数 random 声明以 long 为返回类型:github

long random(); /* returns a long */

在编译过程当中,C 源被翻译成汇编语言,而后再将其翻译成机器代码。在英特尔汇编语言(AT&T flavor)中,上面的最后一个 C 语句的功能相似如下内容(## 为汇编语言的注释符号):web

addq %rax, %rdx ## %rax = %rax + %rdx (64-bit addition)

%rax%rdx 是 64 位寄存器,addq 指令意味着 add quadwords,其中 quadword 是 64 位大小,这是 C 语言中 long 类型的标准大小。汇编语言强调可执行机器代码涉及类型,经过指令和参数的混合给出类型(若是有的话)。在这种状况下,add 指令是 addq(64 位加法),而不是例如 addl 这样的指令,它增长了 C 语言典型的 int 的 32 位值。使用的寄存器字长是完整的 64 位( %rax %rdx )而不是其 32 位的(例如,%eax%rax 的低 32 位,%edx%rdx 的低 32 位)。面试

汇编语言的效果很好,由于操做数被存储在 CPU 寄存器中,而合理的 C 编译器(即便是默认的优化级别)也会生成与此处所示相同的汇编代码。

这三种系统语言强调显式类型,是编译成 WebAssembly 的理想选择,由于这种语言也有明确的数据类型:i32 表示 32 位的整数值,f64 表示 64 位的浮点值,依此类推。

显式数据类型也鼓励优化函数调用。具备显式数据类型的函数具备 signature,它用于指定参数的数据类型以及从函数返回的值(若是有)。下面是名为$add 的 WebAssembly 函数的签名,该函数使用下面讨论的 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 和 am.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 中的按位或运算符可以产生一个整数值。这就解释了看上去毫无心义的按位或运算符:

n = n | 0;  /* bitwise-OR of n and zero */

n 和 0 之间的按位或运算获得 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 模块提供的通用功能。代码示例有助于说明,本文中的代码案例能够在个人网站上找到(http://condor.depaul.edu/mkalin)。

冰雹(hailstone)序列和 Collatz 猜测

生产级代码案例将使 WebAssembly 代码执行繁重的计算绑定任务,例如生成大型加密密钥对,或进行加密和解密。

考虑函数 hstone(对于hailstone),它以正整数做为参数。该函数定义以下:

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 函数能够经过将返回值做为下一个参数传递来进行迭代。结果是一个 hailstone 序列,例如这个序列,以 24 做为原始参数开始,返回值 12 做为下一个参数,依此类推:

24,12,6,3,10,5,16,8,4,2,1,4,2,1,...

序列收敛到 4,2,1 的序列无限重复须要 10 次调用:(3 x 1)+ 1 是 4,它除以 2 得 2,再除以 2 得 1。 Plus 杂志提供了为何把这些序列的称作 hailstone 的解释。

请注意,两个幂很快收敛,只须要 N 除以 2 获得 1;例如,32 = 25的收敛长度为5,64 = 26的收敛长度为6。这里感兴趣的是从初始参数到第一个出现的序列长度。我在 C 和 TypeScript 中的代码例子计算了冰雹序列的长度。

Collat​​z 猜测是一个冰雹序列会收敛到 1,不管初始值 N> 0 刚好是什么。没有人找到 Collat​​z 猜测的反例,也没有人找到证据将猜测提高到一个定理。这个猜测很简单,就像用程序测试同样,是数学中一个极具挑战性的问题。

从 C 到 WebAssembly 一步到位

下面的 hstoneCL 程序是一个非 Web 应用,可使用常规 C 语言编译器(例如,GNU 或 Clang)进行编译。程序生成一个随机整数值 N> 0 八次,并计算从 N 开始的冰雹序列的长度。两个程序员定义的函数,mainhstone 是有意义的。该应用程序稍后会被编译为 WebAssembly。

示例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,你能够[使用本指南进行安装(https://github.com/emscripten...)。

hstoneCL 程序能够经过使用 Emscription 编译代码进行 Web 化,而无需任何更改。Emscription工具链还与 JS glue(在asm.js中)一块儿建立一个HTML页面,该页面介于 DOM 和计算 hstone 函数的 WebAssembly 模块之间。如下是步骤:

  1. 将非 Web 程序 hstoneCL 编译到WebAssembly中:
% emcc hstoneCL.c -o hstone.html  ## generates hstone.js and hstone.wasm as well

文件 hstoneCL.c 中包含上面显示的源代码,-o 输出标志用于指定 HTML 文件的名称。任何名称均可以,但生成的 JS 代码和 WebAssembly 二进制文件具备相同的名称(在本例中,分别为 hstone.jshstone.wasm)。较旧版本的 Emscription(在13以前)可能须要将标志 -s WASM = 1 包含在编译命令中。

  1. 使用 Emscription 开发 Web 服务器(或等效的)来托管 Web 化应用:
% emrun --no_browser --port 9876 .   ## . is current working directory, any port number you like

要禁止显示警告消息,能够包含标志 --no_emrun_detect。此命令用于启动 Web 服务器,该服务器承载当前工做目录中的全部资源;特别是 hstone.htmlhstone.jshstone.webasm

  1. 用支持 WebAssembly 的浏览器(例如,Chrome或Firefox)打开 URL http://localhost:9876/hstone.html

这个截图显示了我用 Firefox 运行的示例输出。

clipboard.png

图1. web 化 hstone 程序

结果很是显著,由于完整的编译过程只须要一个命令,并且不须要对原始 C 程序进行任何更改。

微调 hstone 程序进行 Web 化

Emscription工具链很好地将 C 程序编译成 WebAssembly 模块并生成所需的 JS 胶水,但这些是机器生成的典型代码。例如,生成的 asm.js 文件大小几乎为 100 KB。 JS 代码处理多个场景,而且不使用最新的 WebAssembly API。 webified hstone 程序的简化版本将使你更容易关注 WebAssembly 模块(位于 hstone.wasm 文件中)如何与 JS 胶水(位于 hstone.js 文件中)进行交互。

还有另外一个问题:WebAssembly 代码不须要镜像 C 等源程序中的功能边界。例如,C 程序 hstoneCL 有两个用户定义的函数,mainhstone。生成的 WebAssembly 模块导出名为 _ main 的函数,但不导出名为 _ hstone 的函数。 (值得注意的是,函数 main 是 C 程序中的入口点。)C 语言 hstone 函数的主体可能在某些未导出的函数中,或者只是包含在 _ main 中。导出的 WebAssembly 函数正是 JS glue 能够经过名称调用的函数。可是应在 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 函数。命名约定很简单:诸如 hstone 之类的 C 函数保留其名称 —— 但在 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="hstone2.js"></script>
  </head>
  <body/>
</html>

HTML 文档加载 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位整数)紧跟参数和局部变量名称(在本例中分别为 nlen):

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 的大小仅为14 KB。

要突出显示如何加载 WebAssembly 模块的详细信息,下面的手写 HTML 文件(个人网站上找到(http://condor.depaul.edu/mkalin)中的 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。此 WebAssembly 功能如今可用于任何 JS 粘合代码,由于在浏览器控制台中的另外一个会话将确认。

WebAssembly 具备更简洁的 API,用于获取和实例化模块。新 API 将上面的脚本简化为 fetchinstantiate 操做。这里展现的较长版本具备展现细节的好处,特别是将 WebAssembly 模块表示为字节数组,将其实例化为具备导出函数的对象。

计划是让网页以与 JS ES2015 模块相同的方式加载 WebAssembly 模块:

<script type='module'>...</script>

而后,JS 将获取、编译并以其余方式处理 WebAssembly 模块,就像是加载另外一个 JS 模块同样。

文本格式语言

WebAssembly 二进制文件能够转换为 文本格式的等价物。二进制文件一般驻留在具备 WASM 扩展名的文件中,而其人类可读的文本副本驻留在具备 WAT 扩展名的文件中。 WABT 是一套用于处理 WebAssembly 的工具,其中包括用于转换为 WASM 和 WAT 格式的工具。转换工具包括 wasm2watwasm2cwat2wasm 等。

文本格式语言采用 Lisp 推广的 S 表达式(S for symbolic)语法。 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,其第一项是模块,它是树的根。下面是一个定义和导出单个函数的模块的简单例子,该函数不带参数但返回常量 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 操做从栈中弹出前两个(当前惟一的)值以执行添加。最后 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 将在重用和性能方面茁壮成长。


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


相关文章
相关标签/搜索