[译]简编漫画介绍WebAssembly

最近浮躁了不少,来篇翻译静一下心~~

翻译自:An Abridged Cartoon Introduction To WebAssembly

---译文---

最近前端圈中有很多对WebAssembly的不实宣传。大家都在谈论WebAssembly又如何之快,以及如何如何去革新web开发。但大都没有深入它为什么快的细节,在本文中,我将帮助你理解WebAssembly为什么快。

但是首先,什么是WebAssembly?WebAssembly是一种能够让浏览器中运行其他编程语言(除JavaScript)的的方式。

当你谈论WebAssembly时候会免不了和JavaScript比较。我在这里我没有暗示这是一个二选一的方案——你或者使用WebAssembly或者使用JavaScript。实际上,我们希望开发者能够在同一应用中混合着使用两者。不过和JavaScript的比较是有必要的,这样就能够理解WebAssembly潜在的影响 。

关于性能的一点小历史

JavaScript在1995年被创造出来,它的设计初衷并不是为了快,而且在头十年,它却是不快。

然后就是浏览器开始越来越相互竞争。

2008的这段时间,被称之为性能的起始阶段。多个浏览器增加了实时编译器,也就是JIT。在JavaScript运行时,JIT能够找到模式,并且基于这些模式让代码运行的更快。

这些JIT的因为也就带来了浏览器代码运行效率提升的拐点。突然之间JavaScript快了10倍。

随着性能的提升,JavaScript被用在一些从未被想到的地方。例如一些基于Node.js,Electron的应用。

因为WebAssembly,我们或许又迎来了一次拐点。

在我们理解JavaScript和WebAssembly性能的差异之前,我们需要理解JS引擎是如何工作的。

JavaScript是怎样运行在浏览器中的

当你向页面中添加JavaScript时,你有一个目的以及一个问题。

  • 目的:你要告知电脑你想做什么
  • 问题:你也你的电脑说着不同的语言

你说着人类的语言,你的电脑说着机器语言。甚至你可能不认为JavaScript或者其他的高阶编程语言不是人类语言,但它们确实是的。它们是为人类的认知设计,而不是为了电脑的认知。

因此JavaScript引擎的作用就是把你的人类语言转换成机器能够理解的东西。

我想这就像电影《降临》,人类和外星人需要能够相遇交流。

在电影中,人类和外星人不是一个词一个词把一种语言翻译成另一种语言。每种人对他们语言中的词有着不一样的理解。而这对人类和机器也是一样的。

那么到底翻译是如何进行的呢?

一般来说,编程语言有两种方式转换成机器语言,你可以使用解释器或者编译器。

使用解释器,差不多是运行中,一行一行的解释。

编译器的话,相反在执行之前就转换好了。

两者都各有优劣。

解释器的优势和劣势

解释器能够让代码快速跑起来。在你代码跑起来之前不需要进行编译。基于这个原因,解释器就和JavaScript天然的符合。及时的循环反馈对于web开发者来说非常的重要。

而且这也是为什么浏览器就使用JavaScript解释器的原因。

但是当你不断地执行了同样的代码时使用解释的劣势就暴露出来了。譬如当你在一个循环中,你不得不一遍又一遍地进行相同的转换。

编译器的优势和劣势

编译器有着相反的妥协。因为在开始就需要编译所以启动就需要点时间。但是这样在循环的时候就很快,因为它在循环的时候不需要重复执行转换。

为了改善解释器的低效(在循环时候重复转换同样的代码),浏览器也开始混合编译。

不同的浏览器的实现方式有些许的差别,但是基本思路都是相同的。都是往JavaScript引擎中添加一个检测器(也就是profiler)。在代码执过程中这个检测器监视你的代码,并且记录下执行了多少次并且使用了什么类型。

如果同样几行代码执行了几次,那么这个代码片段就被称为“温的”,如果执行了很多次,就称之为“热的”。温的代码让它进行一个基本的编译,让它执行的快起来,热的代码则让它进行一个优化过的编译器,这样也就让其执行的更快。

要了解更多,可以读A crash course in just-in-time (JIT) compilers

比较在执行JavaScript和WebAssembly时间花费的地方

既然浏览器中JIT的编译器相同,下图给出了大体的现代应用启动性能。

下图给出了一个理想应用中JS引擎所需要花费时间的地方。这个不是一个平均值。JS引擎执行其中步骤所需的时间依赖于具体页面上的具体类型的JavaScript代码。但是这个图可以给我们建立一个心智模型

各个部分暂时了执行特定任务所需的时间

  • 解析(Parsing)——把源代码处理成解释器可以运行的过程。
  • 编译(Compiling)+优化(Optimizing)——基本编译和优化编辑阶段。一些优化编译不在主线程,所以没在这里包括。
  • 再次优化(Re-optimizing)——当JIT假设失败需要调整所需要的代码,需要重新优化代码并且把一些优化过的代码恢复到基础代码所需的时间。
  • 执行(Execution)——运行代码所需的时间。
  • 垃圾回收(Garbage collection)—— 清理内存所需的时间

需要特别注意的一点是:这些任务不是按照独立的大块来执行,也不是按照一种特定的顺序来执行。相反它们时交叉式的执行的。可能是,执行一点解析,然后一些执行,然后编译,再然后一些解析,一些执行,等等。

和以前相比,这种拆分方式是一种巨大的性能改进,下图就是JavaScript早期的方式。

在刚开始的时候,只有一个解释器在执行JavaScript,运行起来相当的慢。引入JIT之后才极大的缩短了执行时间。

其中的权衡在于,在之上增加了一个检测器并且编译代码。如果JavaScript开发者不断的重复同样的写法,那么解析和编译时间就会很短。这种性能的提升也就导致了开发者创造更大的应用。

也就是说还是有优化的空间。

下图是个近似地对比了WebAssembly和传统的web应用。

浏览器的JS引擎有这个些微的差别。我是基于火狐的JS引擎SpiderMonkey。

拉取(FETCHING)

这个没有通过图示展示,但是从服务器拉取下来文件也需要时间。

相同的东西,WebAssembly的下载时间要少于JavaScript,因为WebAssembly比JavaScript更加的压缩。WebAssembly设计的更加压缩,并且它可以通过二进制形式来下载。

尽管gzip后的JavaScript也挺小的,不过同样代码的WebAssembly仍然更小。

也就说WebAssembly花更少的时间从服务器下载到客户端。这点在较慢的网络环境下更明显。

解析(PARSING)

一旦代码下载到浏览器。JavaScript代码会被解析成抽象语法树。

浏览器通常惰性的做这些事,只解析最开始就需要的代码,然后为尚未调用的函数创建占位代码(Stubs)。

从这里开始,抽象语法树被转换成针对JS引擎的中间代码(也就是bytecode)。

与此相反,因为WebAssembly已经是bytecode了,也就不需要经过这个转换过程了。它只需要解码并且做验证确实其中没有错误。

编译+优化(COMPILING + OPTIMIZING)

像我之前提到的。JavaScript在执行过程中被编译。因为JavaScript是动态类型,相同的代码或许需要为了不同的类型编译成不同的版本。这需要时间。

与此相反的是,WebAssembly开始就更接近机器代码。譬如,WebAssembly使用静态类型,它之所以快的原因是:

  • 编译器在开始编译优化过的代码之前无需花时间去监听之前所使用的类型。
  • 编译器无须对同样的代码,基于它监控的类型做出不同的版本。
  • 更多的优化已经在LLVM阶段做了,因此需要编译和优化的地方就更少了。

再次优化(REOPTIMIZING)

有些情况下,JIT需要丢弃已经优化过的代码,然后重新优化。

当JIT基于运行中的代码作出的优化结果不正确的时候就会重新优化。举个例子,循环中的变量如果发生变化,或者一个新函数被插入到原型链中时去优化(deoptimization)就会发生。

WebAssembly中,类型是静态的,因此JIT不需要对类型基于运行中收集的信息来做出假设。因此对于WebAssembly来说,重新优化这个环节也就不需要了。

运行(EXECUTING)

可以将JavaScript写的运行起来很高效,不过做到这一点,你需很了解JIT作出的优化。

然而,很多来发着并不了解JIT的内部实现。即使对于那些了解JIT内部实现的开发者,也很难写到正确地地方。有些为了易读性代码模式(例如,不同类型下将相同任务抽象成函数)会在试图优化的时候很难被编译。

基于这些原因,执行WebAssembly代码通常都更快。很多JIT特殊针对JavaScript的优化方式,在WebAssembly这边都不需要。

另外,WebAssembly是为了编译的目的而设计的。也就是说,它更多是为了编译器更好的编译,并不是为了人类的程序员更方便的书写。

因为开发者并不会直接书写WebAssembly代码,WebAssembly就可以提供一些对机器很为有好的指令。根据不同的业务,WebAssembly指令可以达到10%到80%的更快。

垃圾回收(GARBAGE COLLECTION)

在JavaScript中,开发者不需要为不再使用的旧变量清楚内存。相反JS引擎使用垃圾回收来自动做这些。

这样你就没法对性能有一个确定的预期。因为你没法控制何时垃圾回收,所以它可能会在一个不太合适的时候发生。

目前来说,WebAssembly并不支持垃圾回收,内存需要手动管理的(就像C和C++语言),尽管这样会导致代码很难编写,但是也会带来稳定的性能。

总起来说,这些就是为什么在大多数情况下,做同样的事情,WebAssembly能够比JavaScript优秀的原因。

当然还有些情况下,WebAssembly表现的没达到预期,这些也就是未来WebAssembly需要改变让它更快的地方。这些我在另一篇文章Where is WebAssembly now and what’s next?提到的。

WebAssembly是如何工作的?

现在你明白了,为什么开发者如此期待WebAssembly的原因了,接下来让我们明白它是如何工作的。

当上文提到JIT的时候,我说的是如何和机器进行交流,就像如何和外星人交流一样。

我想探究下外星人的大脑是如何工作的——机器的大脑是如何解析和理解沟通中的信息的。

大脑中有部分是专门为了思考的,例如,算术和逻辑。也有些部分是提供短期记忆,然后另一部分是在提供长期记忆的。

这些不同部分叫做:

  • 思考的部分叫做算术逻辑单元(Arithmetic Logic Unit, ALU)
  • 短期记忆是由寄存器(Register)提供
  • 长期记忆则由随机存取存储器(RAM)来提供。

机器代码中句子叫做指令。

当其中的一句指令到达大脑的时候会发生什么?它会被分解成不同的部分,不同的部分就是不同的事情。

分解指令的方法特定与大脑的排线。

例如,大脑或许会将4~10位发送到ALU。ALU会基于0和1的未知判断出他需要将两个东西加起来。

下面部分指令叫做“opcode”,或者操作码(operation code), 因为它告知ALU需要执行那个操作。

然后大脑会取后面的两块决定那两个需要被相加。这些会被放置到寄存器中。

注意上面我为机器代码添加的注释,这些会让我们更容易的理解这个过程。这就是所谓的汇编(assembly)。它叫做符号机器代码(symbolic machine code)。这就是人类理解机器代码的方式。

你可以看到这个机器代码和汇编非常直接的关系。当一个机器又这个不同的机构的时候你也就是需要针对它的汇编。

因为我们并不是只有一个转换的目标。相反,我们的目标是不同类型的机器码。就像我们人类说不通的语言,机器也有不同的语言。

当你想将任何一种高阶语言转换成其中一种汇编语言的时候。其中的一个方法是创造一堆针对不同语言的转换器。

这样非常的低效。为了解决这个问题,大多编译器会在至少会在其中再放置一层。编译器会讲高阶语言转换成不那么高阶但也不是机器码这么低阶的东西。这些叫做中间语言(intermediate representation , IR)。

这样编译器就可以只需将任何一种高阶语言转换成一种IR语言即可。然后编译器的另一部分,就可以再将IR编译成针对特定结构的东西。

编译器的前端将高阶语言转换成IR,后端的部分将IR在转换成针对特定结构的汇编代码。

WebAssembly又是在哪一层呢?

你或许会认为WebAssembly又是另一种特定的汇编语言。这样想也算是正确,除了每个特定的机器结构 (x86, ARM, etc) 有着不同的语言。

当然你通过web为用户提供代码的时候,你并不知道用户是那种类型的机器。

因此WebAssembly和其他的汇编语言有点点不一样。它是针对一种概念机器的机器码。不是一种实际的,物理的机器。

因此WebAssembly的指令有些情况下也叫做虚拟指令。和JavaScript相比,它能够非常直接和机器码映射,但它并不直接和某种特定硬件的特定机器码相关联。

浏览器下载网WebAssembly代码后,它还需要转化成用户机器的汇编码。

当你为你的web页面增加WebAssembly的时候,你需要编译成.wasm 文件。

编译成.wasm文件

现在能够大部分支持WebAssembly的编译工具叫做LLVM。现在有很多不同的LLVM前端和后端插件。

注意:大多数的WebAssembly模块开发者使用C或者Rust语言来编写,当然也有其他的方式来创建WebAssembly模块。例如,现在有个试验阶段的工具可以帮助你使用Typescript来编写WebAssembly模块,或者你可以使用文本的方式编写WebAssembly模块

现在我们假设你想要从C到WebAssembly。我是用LLVM的C语言前端部分从C转换成IR即可。到了LLVM的IR,LLVM就可以理解了,然后LLVM就可以做一些优化了。

然后从LLVM IR到WebAssembly,我们就需要后端的部分了。这部分LLVM项目正在开发中。后端的部分大部分已经完成了,并且很快就可以竣工了。然而,现在要让它跑起来好有点困难。

眼下还有另一个叫做Emscripten的工具,稍微简单易用一点。它还可选的提供了一些有用的库,譬如机遇IndexDB的文件系统。

无论你使用何种工具,最终的结果都是一个.wasm的文件。现在让我们看下我们如何在web页面中使用。

在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或者加载器SystemJs兼容。我们相信加载一个WebAssembly模块可以和加载JS一样简单。

现在一个最大的WebAssembly模块和JavaScript模块不同点在于,WebAssembly中函数的参数和返回只能是WebAssembly类型(整数或者浮点数)。

对于任何的数据类型它更加的复杂,例如String,你必须得使用WebAssembly模块的内存。

如果你大多使用JS语言,可能很不熟悉直接接触内存。性能更高的语言例如C,C++和Rust就是手动管理内存。WebAssembly模块中的内存使用方式你可以在这些语言中学到。

为了实现这点,就需要JavaScript中叫做二进制数组(ArrayBuffer)的东西。二进制数组就是字节的数组。因此数组的索引就是内存的地址。

如果你需要在JavaScript和WebAssembly中传送字符串。你需呀转成字符为对应的字符编码,然后将其写入内存队列。因为数组值索引是整数型,这些就可以传到WebAssembly函数中了。因此,字符串第一个字符的索引可以被用做一个指针。

任何开发者,开发一个WebAssembly模块,都会再写个包裹器来方便别人使用。这样做为使用者就不必关心内存的管理了。

我也在另一篇文章 working with WebAssembly modules 做了更多的说明。

目前的WebAssembly状态。

在2017.2.28,四大浏览器一起公布了WebAssembly的一致性最小可行性版本的完成。然后Firefox一周后支持了WebAssembly,之后一周Chrome也只吃了,同时Edge和Safari预览版也只吃了。

这样浏览器也就能够开始提供一个稳定的初始版本。

这个初始的核心并没有包含了社区组所有的计划。尽管是初始版本,WebAssembly也会很快,当然在未来通过修复和新功能的发布,它会变得很快。我也在另一篇文章给了一些功能的细节

结论

使用WebAssembly,它可以让网页上的代码运行的更快。它有很多因素让它比JavaScript更快。

  • 下载——WebAssembly压缩性更好,也就可以更快的下载。
  • 解析——解码WebAssembly比解析JavaScript更快。
  • 编译和优化——WebAssembly花更少时间去编译和优化,因为更多的优化已经在它被放倒服务器以前完成了,并且它不需要为动态类型多代码的多次编译。
  • 再次优化——WebAssembly中代码不需要再次优化了,因为它有足够的信息让编译一次成功。
  • 执行——WebAssembly执行的更快,因为它的指令为机器理解而设计。
  • 垃圾回收——目前WebAssembly不支持垃圾回收,这样也就没有GC时间了。

目前浏览器的MVP版本中,WebAssembly已经很快了。也会在下面的几年中随着浏览器改进引擎,添加新功能变得更快。没人可以确保这些应用可以将性能提高到何种程度。但是眼下的端倪,已经可以让我们期待未来的惊喜了。

这篇文章hacks.mozilla.org上的再次发布。

小刀

Read more posts by this author.

Subscribe to cc log

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!
comments powered by Disqus