授权声明:本文最初发布于EVIL MARTIANS博客,原文标题:Hands-on WebAssembly: Try the Basics,作者 Polina Gurtovaya & Andy Barnov。本文经原博客授权由 InfoQ 中文站翻译并分享。
只需Web开发的一般知识就能通过本文轻松上手WebAssembly。要通过本文的可运行代码示例尝试WebAssembly,你只需要一个编辑器、任意现代浏览器和本文随附的,带有C和Rust工具链的Docker映像。
WebAssembly 已经诞生三年了。它可以在所有现代浏览器中使用,还有一些公司甚至开始勇敢地在生产环境中使用它了(说的自然是 Figma)。它背后的名字如雷贯耳:Mozilla、Microsoft、Google、Apple、Intel、RedHat——它们和其他很多公司的一些最优秀的工程师一直在为 WebAssembly 做出贡献。人们普遍认为它是 Web 技术的下一次重大变革,但更主流的前端社区并不急于采用它。我们都知道 HTML、CSS 和 JavaScript 是 Web 的三大基础,要改造世界需要花费的时间远不止三年这么短。尤其是人们一搜索它的概念就会蹦出下面这种内容:
WebAssembly 是一种用于基于栈的虚拟机的虚拟指令集架构二进制指令格式。
如果你看了后感到一头雾水,那肯定很难有兴趣继续研究下去。
这篇文章的目的是以一种更容易理解的方式来解释 WebAssembly,并引导你完成一些在 Web 页面上使用 WebAssembly 的具体示例。如果你是对 WebAssembly 感到好奇的开发人员,却从未有过尝试的机会,那么本文会很合适你——如果你很喜欢龙的话那就更好了。
龙出没注意
在我自己深入研究这一主题之前,我对 WebAssembly 的印象就是某种龙:强大、快速、危险诱人,但又神秘而致命。在我的 Web 技术思维导图上,WebAssembly 也属于“此处有龙出没”类别:探索这些技术时请自行承担风险。
那些担心其实是没有根据的,前端开发的基石并没有被打碎。WebAssembly 仍然属于客户端应用程序领域,因此它仍在你的浏览器沙箱中运行。它仍然依赖熟悉的 JavaScript API。它还允许你直接提供二进制文件,从而极大扩展了可以在客户端上执行的操作的范围。
本文将介绍其工作原理,如何将代码编译为 Wasm 以及何时在项目中使用 WebAssembly。
人类代码与机器代码
在 WebAssembly 诞生之前,JavaScript 是由浏览器执行的编程语言中唯一一种全功能的。为 Web 编写代码的人们知道如何使用 JS 表达想法,并知道客户端计算机可以运行他们的代码。
编程小白也能理解以下 JavaScript 代码的含义,虽说它“解决”的任务没什么意义:将随机数除以 2 并将其添加到数字数组 11088 次。
上面的代码是人类能读懂的,但对于通过 Web 接收代码的客户端计算机的 CPU 而言却毫无意义,可后者必须运行它。CPU 理解的是机器指令,它们按照处理器生成结果所必须的(相当平淡的)步骤序列来编码。
要运行我们这一小段代码,我的 CPU(Intel x86-64)需要 516 条指令。这些指令以汇编语言(机器语言的文字表示)显示时是下面的样子。指令名称是很难懂的,要理解它们,你需要一本处理器随附的厚厚手册。
x86_64 汇编的一些指令
在每个时钟周期(2GHz 表示每秒 20 亿个周期),处理器将尝试获取一个或多个指令并执行它们。通常,有许多指令是同时传送的(称为指令级并行性)。
为了尽可能快速地运行你的代码,处理器采用了一些技巧,例如流水线、分支预测、推测执行、预取等。处理器有复杂的缓存系统,以尽快获取指令数据(以及指令本身)。从主内存中获取数据比从缓存中获取数据要慢几十倍。
不同的 CPU 实现了不同的指令集架构(ISA),因此 PC 中的 CPU(很可能基于 Intel x86)将无法理解智能手机中 CPU(最可能是某种 ARM 架构)的机器代码。
好消息是——如果你为 Web 编写代码,则不必介意处理器架构之间的差异。现代浏览器是高效的编译器,可以将你的代码愉快地转换为客户端计算机的 CPU 可以理解的内容。
编译器入门
为了了解 WebAssembly 如何工作,我们不得不谈论一下编译器。编译器的工作是获取人类可读的源代码(JavaScript、C、Rust,诸如此类),并将其转变为一组指令,供目标处理器理解。在发出机器代码之前,编译器首先将你的代码转换为中间表示(IR),即对程序进行精确的“重写”,而这种重写与源语言和目标语言无关。
编译器将查看 IR,研究如何优化它,可能会因此生成另一个 IR,然后生成下一个 IR,直到它确定无法做进一步的优化为止。因此,你在编辑器中编写的代码可能与计算机将执行的代码完全不同。
为了具体说明,以下是一些 C 代码的加法和乘法运算。
下面是由编译器生成的,LLVM IR格式的内部表示形式,这种格式很流行。
这里的重点是,在执行优化时,编译器就会得出计算的结果,而不是让处理器在运行时进行数学运算。因此,i32 10395 部分正好是上面的 C 代码最终将输出的数字。
编译器有很多魔术来加速:避免在运行时执行“效率低下”的人工代码,并用更优化的机器版本代替。
编译器的工作机制
大多数现代编译器还有一个“中端”,可在后端和前端之间执行优化。
编译器管道是一头复杂的怪兽,但我们可以将其拆分为两部分:前端和后端。编译器前端解析源代码,对其进行分析,然后转换为 IR;然后编译器后端针对目标优化 IR,并生成目标代码。
前端和后端
现在我们回到 Web 上。
如果我们可以有一种所有浏览器都可以理解的中间表示会怎么样呢?
然后,我们可以将其用作程序编译的目标,而不必担心与客户端系统的兼容性。我们还可以使用任何语言编写程序,不再只限于 JavaScript。浏览器将获取我们代码的中间表示,并上演那些后端魔术:将 IR 转换为客户端架构的机器指令。
这就是 WebAssembly 的全部目的!
WebAssembly:Web 的 IR
为了实现用单一格式表示任何语言编写代码的梦想,WebAssembly 的开发人员必须做出一些战略性的架构选择。
为了使浏览器能够在最短的时间内获取代码,格式必须紧凑。二进制是你可以获得的最紧凑的文件。
为了使编译高效,我们需要在不牺牲可移植性的情况下尽可能接近机器指令。由于所有指令集架构都依赖于硬件,并且不可能针对能运行浏览器的所有系统进行定制,因此 WebAssembly 的创建者选择了虚拟 ISA:一组用于抽象机器的指令。它不对应任何实际的 CPU,但可以用软件有效地处理。
虚拟 ISA 非常底层,足以轻松转换为特定的机器指令。与实际的 CPU 不同,用于 WebAssembly 的抽象机不依赖寄存器——现代处理器在操作数据之前放置数据的位置。相反,它使用栈数据结构:例如,一条 add 指令将从栈中弹出两个最高的数字,将它们加在一起,然后将结果推回栈顶部。
现在,当我们终于了解“基于栈的虚拟机的虚拟指令集架构和二进制格式”的含义时,就该释放 WebAssembly 的能量了!
放开那条龙!
我们将实现一个简单的算法来绘制一条称为龙曲线的简单分形曲线。这里最重要的不是源码:我们将向你展示创建 WebAssembly 模块,并在浏览器中运行它所需要的操作。
这里不会直接使用emscripten这类好用的高级工具,而是直接使用一个 Clang 编译器,带有 LLVM WebAssembly 后端。
最后,我们的浏览器将能够绘制以下图片:
龙曲线和折点
我们将从画布的起点画一条线,然后左右交替转向,以实现所需的分形。
程序的目标是生成一个坐标数组,供我们的直线使用。将其变成图片是 JavaScript 的工作。负责生成数组的代码是用老字号的 C 语言编写的。
不用担心,你用不着花费几小时来设置开发环境,因为我们已经将你可能用到的所有工具烘焙到了一个Docker映像中。你在计算机上唯一需要的就是Docker本身,因此,如果你以前从未使用过它——现在是时候安装它了,只需按照对应你操作系统的指南操作即可。
提示:命令行示例假定你使用的是 Linux 或 Mac。要在 Windows 上运行,你可以使用WSL(建议升级到 WSL2)或更改语法以支持 Power Shell:使用反引号代替\来换行,并使用(pwd):$(pwd)。
启动你的终端并创建一个文件夹,在其中放置示例:
现在打开文本编辑器,并将以下代码放入新创建的文件中:
现在我们需要使用 LLVM 的Clang及其 WebAssembly后端和链接器将其编译为 WebAssembly。运行下面的命令让 Docker 容器来处理。这只是对带有一组标志的 clang 二进制文件的调用。
–target=wasm32告诉编译器将WebAssembly作为编译目标。
-O3应用最大优化。
-nostdlib声明不要使用系统库,因为它们在浏览器的上下文中是无用的。
-Wl、-no-entry-Wl、-export-all是标志,它们指示链接器导出我们从WebAssembly模块定义的所有C函数,并忽略main()的缺失。
结果,你将看到一个 dragon-curve.wasm 文件出现在文件夹中。它是一个包含我们程序中所有 530 字节的二进制文件!你可以像这样检查它:
wasm-objdump dragon-curve.wasm
可以使用 WebAssembly 工具链中一个叫做Bynarien的出色工具来优化二进制文件的体积。
这样我们可以从生成的文件中删除一百个左右的字节。
龙胆
二进制的文件一个缺陷是它们不能被人类理解。幸运的是,WebAssembly 具有两种格式:二进制和文本。你可以使用WebAssembly Binary toolkit在两者之间转换。试着运行:
现在,我们在文本编辑器中检查生成的 dragon-curve-opt.wat 文件。
.wat 内容
这些有趣的括号称为 s 表达式(就像在老派的 Lisp 中一样)。它们用于表示树状结构。所以我们的 Wasm 文件是一棵树。树的根是一个 module。它的工作原理很像你熟悉的 JavaScript 模块。它有导入和导出。
WebAssembly 的基本构建块是在栈上运行的指令。
wasm 指令
指令被组合成可以从模块导出的函数。
导出 sign 和 getTurn
你可能会看到代码周围散布着 if、else 和 loop 语句,这是 WebAssembly 最突出的特性之一:通过使用所谓的结构化控制流(就像高级语言),它可以避免 GOTO 跳转并允许一次性解析源。
结构化控制流
现在看一下导出的 sign 函数,并查看基于栈的虚拟 ISA 的工作方式。
sign 函数
还有另一个重要的实体,称为表(Table)。表是线性数组,就像内存一样,但是它们仅存储函数引用。无论它们是否是WebAssembly模块的一部分,它们都用于间接调用函数。
我们的函数接收一个整数参数(param i32)并返回一个整数结果(result i32)。一切都在栈上完成。首先,我们推入值:整数 2,其后是函数的第一个参数(local.get 0),然后是整数 4。然后应用 i32.rem_s 指令从栈中删除两个值(第一个函数参数和整数 4),将第一个值除以第二个值,然后将余数推回栈。现在,最上面的值是余数和数字 2。i32.sub 从栈中弹出它们,从一个中减去另一个,然后推入结果。前五个指令等效于(2 - (x % 4))。
Wasm 使用简单的线性内存模型:你可以将 WebAssembly 内存视为简单的字节数组。
在我们的.wat 文件中,它是通过(export memory(memory0))从模块中导出的。也就是说我们可以从外部在 WebAssembly 程序的内存上操作,这就是我们下面要做的。
灯光就绪,摄像就绪,开拍!
为了让浏览器绘制一条龙曲线,我们需要一个 HTML 文件。
放一个带有空 canvas 标签的样板,并初始化我们的初始值:size 是曲线的步数,len 是单步的长度,x0 和 y0 设置起始坐标。
现在,我们需要加载.wasm 文件并实例化 WebAssembly 模块。与 JavaScript 不同,我们不需要等待整个模块加载就可以使用它——WebAssembly 是在数据流入时即时编译和执行的。
我们使用标准的 fetch API 加载模块,并使用内置的 WebAssembly JavaScript API 对其实例化。WebAssembly.instantiateStreaming 返回一个用模块对象解析的 promise,其中包含我们模块的实例。现在,我们的 C 函数作为实例的 exports 可用,并且我们可以根据需要从 JavaScript 中使用它们。
仔细看看 instance.exports。除了生成坐标的 dragonCurve C 函数之外,我们还返回了一个表示 WebAssembly 模块线性内存的 memory 对象。这里需要小心,因为它可能包含重要内容,例如我们用于虚拟机的指令栈。
技术上讲,我们需要一个内存分配器来避免混乱。但对于这个简单的示例,我们将读取内部__heap_base 属性,其为我们提供了一个可以安全使用的内存区域(堆)的偏移量。
我们将这个偏移量赋给 dragonCurve 函数的“好”内存,调用它,然后将填充了坐标的堆内容提取为一个 Float64Array。
本章的灵感来自Surma的精彩文章“无需Emscripten将C编译为WebAssembly”
剩下的只是根据从 Wasm 模块提取的坐标在画布上画一条线。现在我们要做的就是在本地提供 HTML。我们需要一个基本的 Web 服务器,否则将无法从客户端获取 Wasm 模块。所幸 Docker 映像已完成了所有设置:
转到http://localhost:8000
,龙曲线就在眼前!
该来点进阶内容了
上面的“纯 LLVM”方法的目标是极为简单的;我们编译程序时没用系统库,还以最糟糕的方式管理内存:计算堆的偏移量,这样我们得以揭开 WebAssembly 内存模型的神秘面纱。但在实际的应用程序中,我们希望适当地分配内存并使用系统库,其中“系统”是我们的浏览器:WebAssembly 仍在沙箱中运行,无法直接访问你的操作系统。
所有这些都可以在emscripten的帮助下完成:这是一个用于编译 WebAssembly 的工具链,它可以模拟浏览器内部的许多系统功能:使用 STDIN、STDOUT 和文件系统,甚至可以将 OpenGL 图形自动转换为 WebGL。它还集成了我们之前用来压缩二进制文件的 Bynarien,因此我们用不着专门优化体积了。
Emscripten诞生早于WebAssembly:首先,它被用来将C/C++代码编译为JavaScript和asm.js,而且现在还能这么干!
Emscripten
是时候正常使用 WebAssembly 了!我们的 C 代码不会变。先创建一个单独的文件夹以便对比代码,并复制我们的源码。
我们已经把 ecmsripten 打包进了 Docker 映像,因此你无需在系统上安装任何程序即可运行以下命令:
如果命令成功执行,你将看到两个新文件:小巧的 dragon-curve-em.wasm,以及一个 15Kb 的怪物 dragon-curve-em.js(缩小后),其中包含 WebAssembly 模块的实例化逻辑和各种浏览器 polyfills。那就是目前在浏览器中运行 Wasm 的代价:我们仍需要大量 JavaScript 胶水才能将它们固定在一起。
这是我们所做的:
-Os告诉emscripten优化体积:Wasm和JS都要优化。
请注意,我们只需指定.js文件名作为输出,.wasm就会自动生成。
我们还可以从生成的Wasm模块中选择要导出的函数,注意名称前需要带下划线,也就是-s EXPORTED_FUNCTIONS=’["_dragonCurve", “_malloc”, “_free”]’。最后两个函数帮助我们处理内存。
由于我们的源码是C,因此还必须导出emscripten为我们生成的ccall函数。
MODULARIZE=1允许我们使用一个全局Module函数,其返回一个带有wasm模块实例的Promise。
现在我们可以创建 HTML 文件,并粘贴新内容:
有了 ecmscripten,我们不必直接使用浏览器 API 来实例化 WebAssembly,就像在上一个示例中使用 WebAssembly.instantiateStreaming 所做的那样。
相反,我们使用 emscripten 提供给我们的 Module 函数。当我们编译程序时,Module 将返回一个带有我们定义的所有导出的 promise。当这个 promise 被解析时,我们可以使用_malloc
函数在内存中为坐标保留一个位置。它返回一个带有偏移量的整数,然后将其保存到memoryBuffer
变量中。它比上一个示例中不安全的 heap_base 方法安全得多。
参数2 * size * 8
表示我们将分配足够长的数组,以便为每个步骤存储两个坐标(x,y),每个坐标占用 8 个字节的空间(float64)。
Emscripten 有一种调用 C 函数的特殊方法——ccall
。我们用它来调用 dragonCurve 函数,其以 memoryBuffer 提供的一个偏移量填充内存。画布代码与前面的示例相同。我们还利用emscripteninstance._free
方法在使用后清理内存。
Rust,和运行其他人的代码
C 能这么顺利地转换为 WebAssembly,原因之一是它使用简单的内存模型并且不依赖垃圾回收。否则,我们将不得不将整个语言运行时烘焙到我们的 Wasm 模块中。从技术上讲这是可行的,但是它将把二进制文件撑大好多圈,并影响加载和执行时间。
当然,并不是只有C 和 C++可以编译为 WebAssembly。拥有 LLVM 前端的语言是最好的备选,Rust 则是其中最突出的。
Rust 的妙处在于它有一个出色的内置软件包管理器Cargo,与老字号的 C 语言相比,它很容易发现和重用现有库。
我们将展示将现有的 Rust 库转换为 WebAssembly 模块有多容易——这里要用上非常棒的wasm-pack工具链,它让我们能够飞速引导 Wasm 项目。
我们的 Docker 镜像已经内置了 wasm-pack,用它开始一个新项目。如果你仍在上一个示例中的 dragon-curve-ecmscripten 文件夹中,请返回上一级。Wasm-pack 使用与 rails new 或 create-react-app 相同的方法来生成项目:
现在,你可以进入 rust-example 文件夹并在编辑器中打开。我们已经将龙曲线的 C 代码转换为 Rust,并打包成一个 Cargo crate。
Rust 项目中的所有依赖项都在 Cargo.toml 文件中管理,其行为与 package.json 或 Gemfile 很像。在编辑器中打开它,找到当前仅包含 wasm-bindgen 的[dependencies]部分,然后添加我们的外部 crate。
项目源码位于 src/lib.rs 中,我们要做的就是定义一个函数,从导入的 crate 中调用 dragon_curve。将下面的代码插入文件末尾:
是时候编译结果了。注意这些标志看起来更人性化。Wasm-pack 有用于绑定 JavaScript 的内置 Webpack 支持,并且需要的话甚至可以生成 HTML,但是我们将采用最小方法并设置-- target Web。只需将一个 Wasm 模块和一个 JS 包装器编译为一个原生 ES 模块。
这一步可能需要一些时间,具体取决于你的机器和网络连接:
你可以在项目的 pkg 文件夹中找到结果。是时候在项目根目录中创建 HTML 文件了。这里的代码是我们所有示例中最简单的:我们只是原生地将 dragon_curve 函数作为 JavaScript 导入来使用。在幕后,Wasm 二进制文件负责那些繁重工作,而且我们不再需要像前面的示例中那样手动处理内存。
另一件事是异步 init 函数,它让我们能等待 Wasm 模块完成初始化。
现在提供 HTML 并享受结果!
显然,从开发人员的经验层面来看 Rust 和 wasm-pack 明显胜出。当然,我们只是简单介绍了一些基础知识:emscripten 或 wasm-pack 可以做的事情还很多,例如直接操作 DOM。
请查阅“DOM hello world”“使用Rust的单页应用程序”和Emscripten文档。
同时,在遥远的浏览器中……
WebAssembly 不仅带来了可移植性、源独立性和代码重用。它还承诺当浏览器运行 Wasm 代码时会有显著的性能优势。要了解在 WebAssembly 中重写 Web 应用程序逻辑的优点(和缺点),我们必须了解客户端的底层操作,以及它与执行 JavaScript 有何不同。
在过去的几十年中,即便将 JavaScript 转换为有效的机器代码并非易事,浏览器还是非常擅长运行 JS。所有的火箭科学都发生在浏览器引擎内部,这是 Web 上最聪明的人才进行编译技术竞赛的地方。
我们可能无法涵盖所有引擎的内部工作原理,所以这里只谈一下V8,这是 Chromium 和 Node JS 的 JS 运行时,目前它在浏览器市场和 JavaScript 的后端环境中均占主导地位。
JS 和 Wasm 都能由 V8 编译并执行,但是方法略有不同。两者的管道很像:获取源码,对其解析、编译和执行。用户必须等待所有步骤完成才能在设备上看到结果。
对于 JavaScript,主要的权衡是在编译时间与执行时间之间:我们可以非常快速地生成未优化的机器代码,但是这将花费更长的时间运行;或者我们可以花更多的时间编译并确保由此产生的机器指令是最高效的。
V8 尝试解决这一问题的方法如下:
V8 的工作方式(JS)
首先,V8 解析 JavaScript 并将生成的抽象语法树提供给名为Ignition的解释器,后者将其转换为基于一个寄存器型虚拟机的内部表示。在处理 WebAssembly 时这一步可以跳过,因为 Wasm 源已经是一组虚拟指令了。
在将 JS 解释为字节码时,Ignition 会收集其他一些信息(反馈),帮助决定是否进一步优化。标为优化的函数被认为是“热”的。
生成的字节码最终出现在名为TurboFan的引擎的另一个组件中。它的工作是将内部表示转换为目标架构的优化机器代码。
为了获得最佳性能,TurboFan 必须根据 Ignition 的反馈来推测。例如,它可以“猜测”函数的参数类型。如果随着新代码的不断出现,这些猜测失效了,那么引擎将放弃所有优化并从头开始。这种机制使代码的执行时间无法预测。
JS 执行时间
Wasm 让浏览器引擎的工作更加轻松:由于.wasm 格式,代码已经采用了内部表示的形式,可以轻松进行多线程解析。另外,当我们在开发人员的机器上编译 WebAssembly 文件时,一些优化已经包含在其中了。这意味着 V8 可以立即编译和执行代码,而无需像对 JavaScript 那样反复优化和反优化。
V8 的工作方式(Wasm)
Liftoff基准编译器在 V8 中提供了“快速启动”功能。TurboFan 及其出色的优化功能仍在发挥作用,只是这一次它不必猜测任何内容,因为源代码已经具备所有必要的类型信息。“热”函数的概念不再适用,这使我们的执行时间具有确定性:我们提前知道了执行程序需要多长时间。
Wasm 执行时间
当然,你也可以在浏览器外部运行 WebAssembly。有许多项目可让你在任何客户端上使用 Wasm 运行任何代码:Wasm3、Wasmtime、WAMR、Wassmer等。如你所见,WebAssembly 的雄心是最终超越浏览器,进入各种系统和设备。
何时使用 WebAssembly
WebAssembly 的创建是为了补充现有的 Web 生态系统:绝不是要替代 JavaScript。使用现代浏览器时 JS 已经够快了,并且对于大多数常见的 Web 任务(例如 DOM 操作),WebAssembly 不会给我们带来任何性能上的优势。
WebAssembly 的承诺之一是消除 Web 应用程序与其他各类软件之间的界限:可以轻松地将用不同语言开发的成熟代码库引入浏览器。许多项目已经移植到 Wasm 中,包括游戏、图像编解码器,机器学习库,甚至是语言运行时。
Figma 是现代设计师必不可少的工具,它从一开始就在生产环境中使用WebAssembly。
在当下,没有 JavaScript 根本无法使用纯 Wasm:无论你自己编写代码还是依靠工具生成代码,你都需要那些“胶水”代码。
如果你希望用 Wasm 消除性能瓶颈,建议你三思,因为无需完全重写就可以解决这些瓶颈。你绝不应该依赖对比单个任务的 WebAssembly 和 JS 的基准测试结果,因为在实际应用程序中,Wasm 和 JS 是会一直互联的。
查看WebAssembly提案,以了解Web上二进制文件的前景。
尽管 WebAssembly 仍处于 MVP 阶段,但现在是开始尝试它的最佳时机:有了我们在本文中演示的工具,你就可以开始运行它了。
如果你想更深入地研究 WebAssembly,请查阅我们撰写本文时为自己编制的阅读清单。我们还创建了一个存储库,其中包含本文中的所有代码示例。
评论