WebAssembly 系列 一 ~ 六

WebAssembly 系列

本文作者:Lin Clark

英文原文:A cartoon intro to WebAssembly

1 生动形象地介绍 WebAssembly

你可能已经听说过,WebAssembly 执行的更快。但是 WebAssembly 为什么执行的更快呢?

在这个系列文章中,我会为你解释这一点。

1.1 什么是 WebAssembly?

WebAssembly 是除了 JavaScript 以外,另一种可以在浏览器中执行的编程语言。所以当人们说 WebAssembly 更快的时候,一般来讲是与 JavaScript 相比而言的。

这里并不是暗示大家说开发时只能选择 WebAssembly或 JavaScript。实际上,我们更希望在同一个工程中,两个你同时使用。

对二者的比较倒是非常有必要的,这样你就可以了解到 WebAssembly 所拥有的独特特性。

1.2 一些关于性能的历史

JavaScript 于 1995 年问世,它的设计初衷并不是为了执行起来快,在前 10 个年头,它的执行速度也确实不快。

紧接着,浏览器市场竞争开始激烈起来。

被人们广为传播的“性能大战”在 2008 年打响。许多浏览器引入了 Just-in-time 编译器,也叫 JIT。基于 JIT 的模式,JavaScript 代码的运行渐渐变快。

正是由于这些 JIT 的引入,使得 JavaScript 的性能达到了一个转折点,JS 代码执行速度快了 10 倍。

随着性能的提升,JavaScript 可以应用到以前根本没有想到过的领域,比如用于后端开发的 Node.js。性能的提升使得 JavaScript 的应用范围得到很大的扩展。

现在通过 WebAssembly,我们很有可能正处于第二个拐点。

2 JavaScript Just-in-time (JIT) 工作原理

英文原文: A crash course in just-in-time (JIT) compilers

JavaScript 刚出现时的运行速度是很慢的,多亏了 JIT,它的运行速度变得快了起来。JIT 是如何工作的呢?

2.1 JavaScript 是如何在浏览器中运行的

当开发者将 JavaScript 添加到页面当中,既有一定的目的也会遇到一定的困难。

目标: 告诉电脑要做什么

困难: 计算机和人类说着不同的语言

你说着人类的语言,而计算机使用的则是机器语言。就算你把或者其他的高级编程语言看作是人类的语言,它们也确实是。这些语言设计的初衷是方便人类识别,而不是方便计算机识别。

所以引擎的作用便是将人类语言转化成机器所能理解的东西。

我把这个过程想象成电影《[降临](https://en.wikipedia.org/wiki/Arrival_(film)》中的场景 —— 人类和外星人尝试着互相沟通。

A person holding a sign with source code on it, and an alien responding in binary

在那部电影里,人类和外星人并不是纯粹地文字对文字地翻译。这两个种族对世界有着不一样的思考方式。对于人类和计算机来说也是一样的。 (我会在下一篇文章中做更多的解释)。

所以人类和计算机的沟通是如何翻译的呢?

在编程中,有两种方式转换成机器语言——使用解释器或者编译器。

使用解释器,这个翻译几乎是一行紧接着接着一行的。

A person standing in front of a whiteboard, translating source code to binary as they go

另一方面,编译器是不会逐行翻译的。它会在运行前提起翻译并且记录下来。

A person holding up a page of translated binary

这两种翻译方式各有其利弊。

解释器的利与弊

解释器很快就能准备好并且运行。在运行代码之前,解释器不必将整个编译过程都进行完。它在翻译完第一行时就开始执行。

正因为如此,编译器和 JavaScript 就好像一拍即合。对于 web 开发者来说,让他们能够快速的运行他们的代码这点是非常重要的。

这就是为什么浏览器在一开始使用 JavaScript 解释器。

但是在你多次使用解释器解释运行相同的代码时,它的弊端就出现了。比如,使用循环。它总是会反复地去翻译相同的东西。

编译器的利与弊

编译器对此有着相反的权衡。

它启动需要更多的时间,因为它必须在开始时就完成整个编译阶段。但是循环里的代码会运行地更快,因为它不需要去每次重复地翻译那个循环。

另外一个不同的地方是编译器会花一些时间对代码做出编辑,让代码能够更快地运行。这些编辑的行为被称之为优化。

解释器是在运行时工作的,所以它无法在翻译阶段花费很多时间去优化。

2.2 即时编译器:两全其美

为了摆脱解释器的重复翻译的低效行为,浏览器开始将编译器混入其中。

不同的浏览器有着不同的实现,但是基本思想都是一样的。他们会给 They added a new part to the JavaScript 引擎添加一个新的部分叫做监视器(也称之为分析器)。监视器会观察这些代码的运行,然后记录这些代码运行的次数以及他们使用的类型。

一开始,监视器会观察所有经过解释器的东西。

Monitor watching code execution and signaling that code should be interpreted

如果其中一行代码运行了几次,这段代码称之为温和的,如果它运行了很多次,那么它被称之为激烈的。

基线编译器

当一个函数开始变得温和起来,JIT 会将它发送至编译器,然后将编译结果储存下来。

Monitor sees function is called multiple times, signals that it should go to the baseline compiler to have a stub created

这个函数的每一行都会被编译到 “存根” 里。 这些存根根据行号和变量类型来编入索引。(稍后我会解释这一点的重要性)。如果监视器发现有着同样变量类型的同一段代码被重复执行了,那么它会将已经编译好的版本直接提取出来。

这有助于代码的快速运行。但是正如我所说的,编译器还能够做更多的事情。它会花费一些时间来找出最有效的运行方式,从而达到优化。

基线编译器会进行一些优化(我会在下面给出一些例子)。这个过程不会花费很多时间,因为它不会将代码的执行时间拖得太久。

然后,如果代码被执行的频率很高,执行花费的时间很多,那么花费一些额外的时间来对它进行优化是非常值得的。

优化编译器

当一部分代码出现的频率非常高时,监视器会将它们发送至优化编译器。这会创建一个更快的版本,并存储起来。

Monitor sees function is called even more times, signals that it should be fully optimized

为了创建出一个更快的版本,优化编译器必须做出一些假设。

打个比方,如果它能假设所有通过某个特定的构造器创建出来的对象都拥有同样的结构,也就是说,他们总是拥有相同的属性名,属性被添加的顺序也相同,那么它就能够走捷径。

优化编译器会使用监视器观察代码执行收集到的信息来做出决定。如果在之前所有的循环中的条件都为真,它会假设循环条件会继续为真。

不过对于 JavaScript 来说,没有什么是绝对的。你可能会有99个具有相同结构的对象,然后第100个对象可能就少了个属性。

所以被编译的代码需要在执行之前检查,确定之前编译器的猜测是否是正确的。如果是,那么这些代码就可以直接运行,反之,JIT 会假定它做了错误的猜测并将这些优化过的代码销毁。

Monitor sees that types don't match expectations, and signals to go back to interpreter. Optimizer throws out optimized code

然后执行又将回到解释器或者基线编译的版本。这个过程叫做去优化(或者是摆脱)。

通常来说,优化编译器会让代码能够更快地运行。但是有时候它们也会导致一些预料之外的性能问题。如果一段代码反复地处于优化和去优化的过程,那么最后它反而会比直接执行基线编译版本来得更慢。

大多数浏览器会限制优化/去优化循环的次数。比如说,JIT 执行了超过 10 次优化,而这 10 次优化尝试都失败了,那么它会对它停止优化。

2.3 一个优化的例子: 类型特化

优化的方式有很多种,但是我想看一下其中一种类型的优化,这样你就能够感受到优化是怎么发生的。 优化编译器的最大优势之一称之为类型特化。

JavaScript 所使用的动态类型系统在运行时需要做一些额外的工作。比如,考虑以下代码:

 function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

循环中的 += 这一步骤看起来非常简单,似乎你都可以一步计算出来,但是因为动态类型,它可能比你预期要花费更多的步骤。

让我们假设 arr是一个有着 100 个整数的数组。一旦代码变得“温和”,基线编译器会为函数内的每次操作都创建一个存根。所有会有对 sum += arr[i] 的存根,它掌管着对整数的 += 加法操作。

然而,sumarr[i] 并不总保证是整数。因为 JavaScript 的类型是动态的,可能在之后循环中的某一步中,arr[i]变成了一个字符串。整数加法和字符拼接这两种操作有着很大的不同,所以他们会被编译成非常不一样的机器码。

JIT 处理这种情况的方式是编译多个基线存根。如果一段代码是单型的(每次都是调用同样的类型),那么它会得到一个存根。如果这段代码是多态的(一种类型通过代码调用传递到另一种类型),那么每种类型的组合的操作产生的结果都会得到一个存根。

这意味着 JIT 在选择一个存根的时候要询问很多次。

Decision tree showing 4 type checks

因为每一行代码在基线编译器中都有自己的存根,JIT 在每行代码执行的时候都会去检查它的类型。所以对于循环中的每一次迭代,JIT 都会去询问相同的问题。

Code looping with JIT asking what types are being used in each loop

如果 JIT 不需要重复这些类型检查的话,那么代码的执行会变得更快。这就是优化编译器所做的事情之一。

在优化编译器当中,整个函数是一起编译的。类型检查被移动到循环之前以便于执行。

Code looping with questions being asked ahead of time

一些 JIT 更进一步地进行了优化。比如,在 Firefox 当中,对于只包含整数的数组有特殊的分类。如果 arr 是它们其中之一,那么 JIT 就不需要检查 arr[i] 是否是整数。这意味着 JIT 可以在进入循环之前就做完所有的类型检查。

2.4 JIT工作原理总结

以上就是对 JIT 的概括。它通过监测运行的代码并将运行频率高的的代码拿去优化来加快 JavaScript 的运行速度。这使得大多数 JavaScrip t应用程序的性能成倍地提高。

就算有了这些提升,JavaScript 的性能也是难以预计的。为了使得运行速度更快,JIT 使用了一些过度开销,包括:

  • 优化和去优化
  • 用于监视器记录以及去优化发生时恢复信息所占用的内存
  • 用于存储基线和函数优化版本的内存

这里仍然有改进的空间:移除这些过度的开销,使得性能变得更好预测。这也是 WebAssembly 所做的事情之一。

3 编译器如何生成汇编

理解 WebAssembly 是如何运行的,有助于理解什么是汇编以及编译器是如何产生汇编的。

在 关于JIT的这篇文章,我谈到了为什么机器沟通就像和外星人沟通一样。

A person holding a sign with source code on it, and an alien responding in binary

我现在想看一下那个外星人的大脑是如何工作的,对于来自外界的通讯,机器的大脑是如何分析和理解的。

在它的大脑中,有一部分致力于思考——像是加减法或者逻辑操作。也有相邻的一部分提供短期记忆,然后还有另外一部分提供长期记忆。

这些不同的部分都有各自的名称。

  • 思考的部分叫做算数逻辑单元 (ALU)。
  • 寄存器提供了储存短期记忆的功能。
  • 长期记忆就是我们所说的随机存储存储器 (RAM)。

A diagram showing the CPU, including ALU and Registers, and RAM

机器码中的语句称之为指令。

当指令传递给大脑的时候,究竟发生了什么?指令会被分成几个不同的部分,这些部分有着不同的含义。

指令被切分的方式取决于大脑的布线。

打个比方,如果大脑是这样的布线,它很可能总是取前 6 个比特然后输送到 ALU 当中。根据 1 和 0 的位置,ALU 会计算并知道是要讲这两者相加。

这一块称作 “opcode” 也叫称作操作码,因为它告诉 ALU 要执行什么样的操作。

6-bits being taken from a 16-bit instruction and being piped into the ALU

然后大脑会将之后的包含三个比特的两个块所代表的数字来相加。这些会决定寄存器的地址。

Two 3-bit chunks being decoded to determine source registers

注意图上的机器码的注释,它有利于我们人类理解机器内部的运作。这就是汇编。它被称为符号机器码,是人类理解机器码的一种方式。

在这里你可以发现这个机器的机器码和汇编的最直接的关系。因此,对于不同的机器架构会有与之对应的不同的汇编。当你的机器内部有两种不同的架构,那么很有可能这台机器有它自己独特的汇编方式。

所以我们有可能面对着不止一个翻译目标。并不是说仅仅只有一种叫做机器码的语言,而是存在和很多不同的类型的机器码。就像我们人类说着不同的语言一样,机器也说着不同的语言。

当将人类的语言翻译成外形人的语言的时候,你可能会将英语,或者俄语,或者是普通话翻译成对应的外星语言 A 或者外星语言 B。而在编程领域,这就像将 C 或者 C++ 或者 Rust 转换到 x86 或者是 ARM.

如果你想要将这些高级编程语言向下转译为任何的对应不同架构的汇编语言。一种做法就是去创造一堆出不同的转换器,将它们一对一地转换成对应的汇编。

Diagram showing programming languages C, C++, and Rust on the left and assembly languages x86 and ARM on the right, with arrows between every combination

这么做显然效率不高。为了解决这个问题,大多数的编译在他们中间放置了最少一个中间层。编译器会将高级编程语言,转换为没那么高的级别,当然它也无法在机器代码这样的级别上运行。这称作中介表示 (IR)。

Diagram showing an intermediate representation between high level languages and assembly languages, with arrows going from high level programming languages to intermediate representation, and then from intermediate representation to assembly language

这意味着编译器可以将任何一种高级语言转换成 IR 语言。至此,编译器的另外一部分就可以将 IR 向下编译成特定的目标结构的代码。

编译器的前端将高级程序语言转换成 IR。编译器的后端将 IR 转换成特定目标结构的汇编码。

Same diagram as above, with labels for front-end and back-end

4 WebAssembly 工作原理

WebAssembly 是一种在页面中运行除了以外的编程语言的方法。在过去,如果你想要使你的代码能在浏览器中运行并且和浏览器交互,JavaScript 是你唯一的选择。

所以当人们谈论到 WebAssembly 的运行之快时,对于 JavaScript,好比谈论的是是苹果和苹果之间的较量。但是这并不意味着你只能在 WebAssembly 与 JavaScript 之间二选一。

事实上,我们期望开发者能够在开发同一个应用时结合两种技术。就算你自己不写 WebAssembly,你也可以利用它的优势。

WebAssembly 模块定义的函数可以被 JavaScript 所用。就好比,你从 npm 下载了一个诸如 lodash 这样的模块然后调用了它提供的 API 。在将来你也可以下载 WebAssembly 的模块。

现在,就让我们来看怎样去创建 WebAssembly 模块并在 JavaScript 中使用这些它们。

4.1 WebAssembly 要安放在哪呢?

在这篇关于汇编的文章里,我谈论了编译器是如何将高级编程语言转换为机器码的。

Diagram showing an intermediate representation between high level languages and assembly languages, with arrows going from high level programming languages to intermediate representation, and then from intermediate representation to assembly language

对于上图,WebAssembly 要如何融入这个过程中呢?

你可能会认为它不过就是另一个目标汇编语言。也确实是这样,除了每一种语言(x86, ARM)都对应着不同的机器架构。

当你的代码通过互联网传输到用户的机器上执行的时候,你并不知道你的代码要在什么样的机器上执行。

因此 WebAssembly 和其他的汇编有些不同。它是一种概念中的机器的机器语言,而不是实际的机器的机器语言。

出于这个原因,WebAssembly 的指令有时也称作虚拟指令。 这些指令比 JavaScript 源码更加直接地映射到机器码。它们代表了某种交集,可以更加有效地跨越不同的流行硬件。但是它们也并不是直接地映射到特定的硬件的特定机器码。

Same diagram as above with WebAssembly inserted between the intermediate representation and assembly

浏览器下载完 WebAssembly,然后从 WebAssembly 跳转至目标机器的汇编代码。

4.2 编译至 .wasm 文件

目前对 WebAssembly 支持最好的编译工具链叫做LLVM。不同的前端和后端可以插入到 LLVM 当中。

注: 大多数的 WebAssembly 模块大多是开发者使用像 C 和 Rust 这样的语言编写的然后编译成WebAssembly。但是也有其他的办法可以创建 WebAssembly 模块。比如,这里一个实验性的工具可以让你使用TypeScript来创建 WebAssembly 模块。或者你也可以直接使用 WebAssembly 的文本表示来编码。

假设我们想要使 C 转换成 WebAssembly。我们可以使用 clang 前端(并非传统意义上的前端)将 C 转换为 LLVM 中间表示(IR)。一旦它到 LLVM 的中间表示,LLVM 就能理解它并且执行一些优化操作。

为了从 LLVM’s IR (intermediate representation) 转换到 WebAssembly,我们需要一个后端(并非传统意义上的后端)。 目前 LLVM 中有个一个正在开发中的后端。这个后端将会是主要的解决方案,并且很快就会敲定了。不过,目前使用它还是很困难。

另外一个叫做 Emscripten 的工具目前来说较为简单一些。它有自己的后端来产生将前端语言先编译成另外一种目标(叫做 asm.js) 然后再将这个目标转化成 WebAssembly。它底层使用了 LLVM,因此,你可以在 Emscripten 中切换两种后端。

Diagram of the compiler toolchain

Emscripten 包含很多额外的工具和库,允许移植整个 C/C++ 代码库。所以它更像是一个 SDK 而不是编译器。比如,系统开发者习惯于有个可以读写的文件系统,因此, Emscripten 可以用 IndexedDB 来模拟这个系统。

无论你使用什么工具链,最后都会生成 .wasm 文件。接下来我会解释 .wasm 文件的结构。不过首先我们先来看看怎么在 JS 中使用它。

4.3 在 JavaScript 中加载 .wasm 模块

.wasm 文件就是 WebAssembly 模块,它可以在 JavaScript 中加载。就目前而言,它的加载过程有一些复杂。

 function fetchAndInstantiate(url, importObject) {
  return fetch(url).then(response =>
    response.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results =>
    results.instance
  );
}

想更深入了解,请查看我们的文档.

目前我们正在努力使这个过程变得更加简单。我们期望能够增强工具链并且与现有的像 webpack 或者 System.js 这样的模块打包工具整合。我们相信未来加载模块可以像加载 modules can be as easy as as loading JavaScript 模块那样简单。

虽然目前 WebAssembly 模块和JS 模块有着一个主要的区别:WebAssembly 的函数只能使用数值 (整数 或者浮点数) 作为参数或者返回值。

Diagram showing a JS function calling a C function and passing in an integer, which returns an integer in response

对于其他更复杂的数据类型,比如字符串,你必须使用 WebAssembly 模块的内存。

如果你大多数情况都在和 JavaScript 打交道,那么直接访问内存对你来说可能不那么熟悉。更高性能的语言像 C,C++ 和 Rust,一般都有手动内存管理。WebAssembly 模块的内存模拟了堆,在这些语言中你是可以看到的。

为了达到这个目的,它使用了 JavaScript 中的 ArrayBuffer。数组缓冲就是一个全是字节的数组,数组的索引代表着具体的内存地址。

如果你想在 JavaScript 和 WebAssembly 之间传递一个字符串,你需要将字符转换成它对应的字符编码。然后将他们写进内存数组。由于索引是整数,索引就能够传递给 WebAssembly 的函数。因此,字符串中的第一个字符的索引就可以作为指针。

Diagram showing a JS function calling a C function with an integer that represents a pointer into memory, and then the C function writing into memory

任何开发 WebAssembly 模块给其他 web 开发者使用的开发者很可能会对模块外面进行包装。这样,使用模块的人就不必知道内部的内存管理的细节了。

如果你想学习更多关于这方面的知识,请查看文档中的 这一部分。

4.4 .wasm 文件的结构

如果你正在使用更高级的语言书写代码并将其编译为 WebAssembly。你不需要知道 WebAssembly 模块是怎样组织结构的。但是这可以有助于理解基础知识。

如果你还没有准备好,我们建议你先阅读 关于汇编的一篇文章 (这个系列的第三部分)。

这里是一个待转换为 WebAssembly 的 一个C 函数:

 int add42(int num) {
  return num + 42;
}

你可以尝试使用 WASM Explorer 来编译该函数。

如果你打开 .wasm 文件 (并且你的编辑器支持其显示),你会看到类似于以下的东西:

 00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60
01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80
80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06
81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65
6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69
00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20
00 41 2A 6A 0B

这是模块的“二进制”表示。这里我对二进制使用了引号是因为它通常以十六进制记数法显示,但可以很容易地转换为二进制符号,或者是人类可读的格式。

举个例子,这是 num + 42 看起来的样子。

Table showing hexadecimal representation of 3 instructions (20 00 41 2A 6A), their binary representation, and then the text representation (get_local 0, i32.const 42, i32.add)

代码是如何工作的: 堆栈机

如果你好奇的话,这里是这些指令的作用。

Diagram showing that get_local 0 gets value of first param and pushes it on the stack, i32.const 42 pushes a constant value on the stack, and i32.add adds the top two values from the stack and pushes the result

你或许已经注意到了, add 操作并没有说它要操作的值是从哪里来的。这是因为 WebAssembly 是一种以堆栈机为模板的东西。这意味着所有操作所需要的值都在操作执行之前排列在栈上。

add 这样的操作知道它需要多少个值,由于add 操作需要两个值,所以它会从栈顶提取两个值。这意味着 add 指令可以变得很短(单个字节),因为这个指令不需要指定源或者目标寄存器。这样能够减小 .wasm 文件的体积,这也意味着需要更少的时间来下载它。

尽管 WebAssembly 明确按照堆栈机的规定, 但是这并不是它在物理机器上工作的方式。当浏览器将 WebAssembly 翻译成浏览器所在的机器的机器码的时候,它会用到寄存器。由于Since the WebAssembly 代码并不指定寄存器,它给浏览器提供了更大的灵活性来分配最适合机器的寄存器。

模块的区块

除了 add42 函数本身,还有其他的部分在 .wasm 文件当中。这些叫做区块。有些区块是任何的模块都需要的,有些是可选的。

必需的:

  1. Type 包含任何定义在该模块或者导入进来的函数的签名。
  2. Function 给在该模块定义的每一个函数建立一个索引。
  3. Code 该模块的每一个函数的实际函数体。

可选的:

  1. Export 使得其他 WebAssembly 模块和 JavaScript 可以使用该模块内的函数,内存,表以及全局。这允许单独编译的模块能够被动态的链接到一起。这就是 WebAssembly版 的 .dll 。
  2. Import 指定从其他 WebAssembly 模块和 JavaScript 引入的函数,内存,表以及全局。
  3. Start 一个函数,在 WebAssembly 模块加载完成之后自动执行(有点像 main 函数)。
  4. Global 声明模块的全局变量。
  5. Memory 定义该模块将要使用的内存。
  6. Table 使得它可以映射到的 Webassembly 模块以外的值,如JavaScript对象。这对于允许间接函数调用特别有用。
  7. Data 初始化导入或本地内存。
  8. Element 初始化导入或本地表。.

5 为什么 WebAssembly 更快?

开发者们不必纠结于到底选择 WebAssembly 还是 JavaScript,已经有了 JavaScript 工程的开发者们,希望能把部分 JavaScript 替换成 WebAssembly 来尝试使用。

例如,正在开发 React 程序的团队可以把协调性代码(即虚拟 DOM)替换成 WebAssembly 的版本。而对于你的 web 应用的用户来说,他们就跟以前一样使用,不会发生任何变化,同时他们还能享受到 WebAssembly 所带来的好处——

而开发者们选择替换为 WebAssembly 的原因正是因为 WebAssembly 比较快。

5.1 当前的 JavaScript 性能如何?

在我们了解 JavaScript 和 WebAssembly 的性能区别之前,需要先理解 JS 引擎的工作原理。

下面这张图片介绍了性能使用的大概分布情况。

JS 引擎在图中各个部分所花的时间取决于页面所用的 JavaScript 代码。图表中的比例并不代表真实情况下的确切比例情况。

WebAssembly 系列 一 ~ 六_第1张图片

图中的每一个颜色条都代表了不同的任务:

  • Parsing——表示把源代码变成解释器可以运行的代码所花的时间;
  • Compiling + optimizing——表示基线编译器和优化编译器花的时间。一些优化编译器的工作并不在主线程运行,不包含在这里。
  • Re-optimizing——当 JIT 发现优化假设错误,丢弃优化代码所花的时间。包括重优化的时间、抛弃并返回到基线编译器的时间。
  • Execution——执行代码的时间
  • Garbage collection——垃圾回收,清理内存的时间

这里注意:这些任务并不是离散执行的,或者按固定顺序依次执行的。而是交叉执行,比如正在进行解析过程时,其他一些代码正在运行,而另一些正在编译。

这样的交叉执行给早期 JavaScript 带来了很大的效率提升,早期的 JavaScript 执行类似于下图,各个过程顺序进行:

WebAssembly 系列 一 ~ 六_第2张图片

早期时,JavaScript 只有解释器,执行起来非常慢。当引入了 JIT 后,大大提升了执行效率,缩短了执行时间。

JIT 所付出的开销是对代码的监视和编译时间。JavaScript 开发者可以像以前那样开发 JavaScript 程序,而同样的程序,解析和编译的时间也大大缩短。这就使得开发者们更加倾向于开发更复杂的 JavaScript 应用。

同时,这也说明了执行效率上还有很大的提升空间。

5.2 WebAssembly 对比

下面是 WebAssembly 和典型的 web 应用的近似对比图:

WebAssembly 系列 一 ~ 六_第3张图片

各种浏览器处理上图中不同的过程,有着细微的差别,拿 SpiderMonkey 作为例子。

文件获取

这一步并没有显示在图表中,但是这看似简单地从服务器获取文件这个步骤,却会花费很长时间。

WebAssembly 比 JavaScript 的压缩率更高,所以文件获取也更快。即便通过压缩算法可以显著地减小 JavaScript 的包大小,但是压缩后的 WebAssembly 的二进制代码依然更小。

这就是说在服务器和客户端之间传输文件更快,尤其在网络不好的情况下。

解析

当到达浏览器时,JavaScript 源代码就被解析成了抽象语法树。

浏览器采用懒加载的方式进行,只解析真正需要的部分,而对于浏览器暂时不需要的函数只保留它的桩(stub,译者注:关于桩的解释可以在之前的文章中有提及)。

解析过后 AST (抽象语法树)就变成了中间代码(叫做字节码),提供给 JS 引擎编译。

而 WebAssembly 则不需要这种转换,因为它本身就是中间代码。它要做的只是解码并且检查确认代码没有错误就可以了。

WebAssembly 系列 一 ~ 六_第4张图片

编译和优化

在关于 JIT 的文章中,我有介绍过,JavaScript 是在代码的执行阶段编译的。因为它是弱类型语言,当变量类型发生变化时,同样的代码会被编译成不同版本。

不同浏览器处理 WebAssembly 的编译过程也不同,有些浏览器只对 WebAssembly 做基线编译,而另一些浏览器用 JIT 来编译。

不论哪种方式,WebAssembly 都更贴近机器码,所以它更快,使它更快的原因有几个:

  1. 在编译优化代码之前,它不需要提前运行代码以知道变量都是什么类型。
  2. 编译器不需要对同样的代码做不同版本的编译。
  3. 很多优化在 LLVM 阶段就已经做完了,所以在编译和优化的时候没有太多的优化需要做。

WebAssembly 系列 一 ~ 六_第5张图片

重优化

有些情况下,JIT 会反复地进行“抛弃优化代码<->重优化”过程。

当 JIT 在优化假设阶段做的假设,执行阶段发现是不正确的时候,就会发生这种情况。比如当循环中发现本次循环所使用的变量类型和上次循环的类型不一样,或者原型链中插入了新的函数,都会使 JIT 抛弃已优化的代码。

反优化过程有两部分开销。第一,需要花时间丢掉已优化的代码并且回到基线版本。第二,如果函数依旧频繁被调用,JIT 可能会再次把它发送到优化编译器,又做一次优化编译,这是在做无用功。

在 WebAssembly 中,类型都是确定了的,所以 JIT 不需要根据变量的类型做优化假设。也就是说 WebAssembly 没有重优化阶段。

WebAssembly 系列 一 ~ 六_第6张图片

执行

自己也可以写出执行效率很高的 JavaScript 代码。你需要了解 JIT 的优化机制,例如你要知道什么样的代码编译器会对其进行特殊处理(JIT 文章里面有提到过)。

然而大多数的开发者是不知道 JIT 内部的实现机制的。即使开发者知道 JIT 的内部机制,也很难写出符合 JIT 标准的代码,因为人们通常为了代码可读性更好而使用的编码模式,恰恰不合适编译器对代码的优化。

加之 JIT 会针对不同的浏览器做不同的优化,所以对于一个浏览器优化的比较好,很可能在另外一个浏览器上执行效率就比较差。

正是因为这样,执行 WebAssembly 通常会比较快,很多 JIT 为 JavaScript 所做的优化在 WebAssembly 并不需要。另外,WebAssembly 就是为了编译器而设计的,开发人员不直接对其进行编程,这样就使得 WebAssembly 专注于提供更加理想的指令(执行效率更高的指令)给机器就好了。

执行效率方面,不同的代码功能有不同的效果,一般来讲执行效率会提高 10% - 800%。

WebAssembly 系列 一 ~ 六_第7张图片

垃圾回收

JavaScript 中,开发者不需要手动清理内存中不用的变量。JS 引擎会自动地做这件事情,这个过程叫做垃圾回收。

可是,当你想要实现性能可控,垃圾回收可能就是个问题了。垃圾回收器会自动开始,这是不受你控制的,所以很有可能它会在一个不合适的时机启动。目前的大多数浏览器已经能给垃圾回收安排一个合理的启动时间,不过这还是会增加代码执行的开销。

目前为止,WebAssembly 不支持垃圾回收。内存操作都是手动控制的(像 C、C++一样)。这对于开发者来讲确实增加了些开发成本,不过这也使代码的执行效率更高。

WebAssembly 系列 一 ~ 六_第8张图片

5.3 总结

WebAssembly 比 JavaScript 执行更快是因为:

  • 文件抓取阶段,WebAssembly 比 JavaScript 抓取文件更快。即使 JavaScript 进行了压缩,WebAssembly 文件的体积也比 JavaScript 更小;
  • 解析阶段,WebAssembly 的解码时间比 JavaScript 的解析时间更短;
  • 编译和优化阶段,WebAssembly 更具优势,因为 WebAssembly 的代码更接近机器码,而 JavaScript 要先通过服务器端进行代码优化。
  • 重优化阶段,WebAssembly 不会发生重优化现象。而 JS 引擎的优化假设则可能会发生“抛弃优化代码<->重优化”现象。
  • 执行阶段,WebAssembly 更快是因为开发人员不需要懂太多的编译器技巧,而这在 JavaScript 中是需要的。WebAssembly 代码也更适合生成机器执行效率更高的指令。
  • 垃圾回收阶段,WebAssembly 垃圾回收都是手动控制的,效率比自动回收更高。

这就是为什么在大多数情况下,同一个任务 WebAssembly 比 JavaScript 表现更好的原因。

但是,还有一些情况 WebAssembly 表现的会不如预期;同时 WebAssembly 的未来也会朝着使 WebAssembly 执行效率更高的方向发展。

6 WebAssembly 的现在与未来

2017 年 2 月 28 日,四个主要的浏览器一致同意宣布 WebAssembly 的MVP 版本已经完成,它是一个浏览器可以搭载的稳定版本。

WebAssembly 系列 一 ~ 六_第9张图片

它提供了浏览器可以搭载的稳定核,这个核并没有包含 WebAssembly 组织所计划的所有特征,而是提供了可以使 WebAssembly 稳定运行的基本版本。

这样一来开发者就可以使用 WebAssembly 代码了。对于旧版本的浏览器,开发者可以通过 asm.js 来向下兼容代码,asm.js 是 JavaScript 的一个子集,所有 JS 引擎都可以使用它。另外,通过 Emscripten 工具,你可以把你的应用编译成 WebAssembly 或者 asm.js。

尽管是第一个版本,WebAssembly 已经能发挥出它的优势了,未来通过不断地改善和融入新特征,WebAssembly 会变的更快。

6.1 提升浏览器中 WebAssembly 的性能

随着各种浏览器都使自己的引擎支持 WebAssembly,速度提升就变成自然而然的了,目前各大浏览器厂商都在积极推动这件事情。

JavaScript 和 WebAssembly 之间调用的中间函数

目前,在 JS 中调用 WebAssembly 的速度比本应达到的速度要慢。这是因为中间需要做一次“蹦床运动”。JIT 没有办法直接处理 WebAssembly,所以 JIT 要先把 WebAssembly 函数发送到懂它的地方。这一过程是引擎中比较慢的地方。

WebAssembly 系列 一 ~ 六_第10张图片

按理来讲,如果 JIT 知道如何直接处理 WebAssembly 函数,那么速度会有百倍的提升。

如果你传递的是单一任务给 WebAssembly 模块,那么不用担心这个开销,因为只有一次转换,也会比较快。但是如果是频繁地从 WebAssembly 和 JavaScript 之间切换,那么这个开销就必须要考虑了。

快速加载

JIT 必须要在快速加载和快速执行之间做权衡。如果在编译和优化阶段花了大量的时间,那么执行的必然会很快,但是启动会比较慢。目前有大量的工作正在研究,如何使预编译时间和程序真正执行时间两者平衡。

WebAssembly 不需要对变量类型做优化假设,所以引擎也不关心在运行时的变量类型。这就给效率的提升提供了更多的可能性,比如可以使编译和执行这两个过程并行。

加之最新增加的 JavaScript API 允许 WebAssembly 的流编译,这就使得在字节流还在下载的时候就启动编译。

FireFox 目前正在开发两个编译器系统。一个编译器先启动,对代码进行部分优化。在代码已经开始运行时,第二个编译器会在后台对代码进行全优化,当全优化过程完毕,就会将代码替换成全优化版本继续执行。

6.2 添加后续特性到 WebAssembly 标准的过程

WebAssembly 的发展是采用小步迭代的方式,边测试边开发,而不是预先设计好一切。

这就意味着有很多功能还在襁褓之中,没有经过彻底思考以及实际验证。它们想要写进标准,还要通过所有的浏览器厂商的积极参与。

这些特性叫做:未来特性。这里列出几个。

直接操作 DOM

目前 WebAssembly 没有任何方法可以与 DOM 直接交互。就是说你还不能通过比如element.innerHTML 的方法来更新节点。

想要操作 DOM,必须要通过 JS。那么你就要在 WebAssembly 中调用 JavaScript 函数(WebAssembly 模块中,既可以引入 WebAssembly 函数,也可以引入 JavaScript 函数)。

WebAssembly 系列 一 ~ 六_第11张图片

不管怎么样,都要通过 JS 来实现,这比直接访问 DOM 要慢得多,所以这是未来一定要解决的一个问题。

共享内存的并发性

提升代码执行速度的一个方法是使代码并行运行,不过有时也会适得其反,因为不同的线程在同步的时候可能会花费更多的时间。

这时如果能够使不同的线程共享内存,那就能降低这种开销。实现这一功能 WebAssembly 将会使用 JavaScript 中的 SharedArrayBuffer,而这一功能的实现将会提高程序执行的效率。

SIMD(单指令,多数据)

如果你之前了解过 WebAssembly 相关的内容,你可能会听说过 SIMD,全称是:Single Instruction, Multiple Data(单指令,多数据),这是并行化的另一种方法。

SIMD 在处理存放大量数据的数据结构有其独特的优势。比如存放了很多不同数据的 vector(容器),就可以用同一个指令同时对容器的不同部分做处理。这种方法会大幅提高复杂计算的效率,比如游戏或者 VR。

这对于普通 web 应用开发者不是很重要,但是对于多媒体、游戏开发者非常关键。

异常处理

许多语言都仿照 C++ 式的异常处理,但是 WebAssembly 并没有包含异常处理。

如果你用 Emscripten 编译代码,就知道它会模拟异常处理,但是这一过程非常之慢,慢到你都想用“DISABLEEXCEPTIONCATCHING” 标记把异常处理关掉。

如果异常处理加入到了 WebAssembly,那就不用采用模拟的方式了。而异常处理对于开发者来讲又特别重要,所以这也是未来的一大功能点。

其他改进——使开发者开发起来更简单

一些未来特性不是针对性能的,而是使开发者开发 WebAssembly 更方便。

  • 一流的开发者工具。目前在浏览器中调试 WebAssembly 就像调试汇编一样,很少的开发者可以手动地把自己的源代码和汇编代码对应起来。我们在致力于开发出更加适合开发者调试源代码的工具。
  • 垃圾回收。如果你能提前确定变量类型,那就可以把你的代码变成 WebAssembly,例如 TypeScript 代码就可以编译成 WebAssembly。但是现在的问题是 WebAssembly 没办法处理垃圾回收的问题,WebAssembly 中的内存操作都是手动的。所以 WebAssembly 会考虑提供方便的 GC 功能,以方便开发者使用。
  • ES6 模块集成。目前浏览器在逐渐支持用 script 标记来加载 JavaScript 模块。一旦这一功能被完美执行,那么像

你可能感兴趣的:(后端,rust,前端,ViewUI)