阅读 248

初识 WebAssembly

WebAssembly

2019年12月5日,万维网联盟(W3C)宣布,WebAssembly 核心规范 现在是一种正式的 Web 标准,继HTML、CSS 和 JavaScript 之后,WebAssembly加入了Web 标准大家庭;
W3C 项目负责人 Philippe LeHégaret 评价为:“WebAssembly 的到来扩展了仅仅用开放的 Web 平台技术就可以实现的应用程序的范围。在当今机器学习和人工智能越来越普遍的世界中,重要的是在不损害用户安全性的情况下在 Web 上运行高性能程序。”

那么究竟什么是WebAssembly,为什么会诞生WebAssembly,它凭什么实现了高性能,为什么那么快?接下来这篇文章对其做一个简单的了解认识;

什么是WebAssembly

首先,我们要知道WebAssembly到底是什么;
MDN的官方解释是:是为高效执行和紧凑表示而设计的运行在现代处理器(包括浏览器)中的一种快速、安全、可移植的底层代码格式,具有紧凑的二进制格式,可以以接近本机的性能运行;

我们可以总结为:

  • WebAssembly不是一种语言,而是一种新的编码方式,它是继html、css、JavaScript之后又一种可以在浏览器中运行的文件格式。
  • 是一种可以使用非 JavaScript 编程语言编写代码的技术方案。
  • 具有紧凑的二进制格式,可以接近原生的性能运行

诞生背景

1995 年 JavaScript 诞生,其设计初衷并不是为了执行起来快,(是为了解决一些简单的网页互动(比如,检查“用户名”是否填写),并没有考虑复杂应用的需要。

2008 年,由于其前十年发展迅速,浏览器厂商竞争 => 浏览器性能大战引入了即时编译器,又称 JIT;基于 JIT 的模式,JavaScript 代码的运行渐渐变快,性能提升

接着,性能的提升使得 JavaScript 的应用范围得到很大的扩展,催生了Node.js,Electron 等技术,JS的根本问题逐渐暴露出来:

  • 语法太灵活导致开发大型 Web 项目困难
  • 性能不能满足一些场景的需要。

各大浏览器厂商纷纷提出了自己的解决方案,比如:

  • 微软的 TypeScript 通过为 JS 加入静态类型检查来改进 JS 松散的语法,提升代码健壮性;
  • 谷歌的 Dart 则是为浏览器引入新的虚拟机去直接运行 Dart 程序以提升性能;
  • 火狐的 asm.js 则是取 JS 的子集,JS 引擎针对 asm.js 做性能优化

但是,这些方案各有各的缺点,且互不兼容,这违背了 Web 的宗旨;这就需要一个统一的技术规范方案。 所以,WebAssembly诞生了;

在 2015 年,WebAssembly首次发布,并提供了一个运行在 Unity 下的游戏的小型演示。这款游戏是直接在浏览器中运行的;

2019年12月5日,万维网联盟(W3C)宣布,WebAssembly 核心规范是一种正式的 Web 标准;

由此,WebAssembly正式活跃在web领域,继JIT的模式的提出之后,WebAssembly很有可能是浏览器运行代码机制的另一大转折点。

为什么 WebAssembly 更快?

WebAssembly的高性能是相对js而言的,那么在我们 JavaScript 和 WebAssembly 之间的性能差前,我们首先需要理解 JS 引擎所做的工作。

目前的浏览器,其在编程中,将代码翻译成机器语言的方式,比较常见的方法是 JIT 编译器;下面我们就以JIT 编译器为例,大概的给出一个程序的启动步骤和性能:

JS引擎各阶段.png

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

  • 文件抓取-从服务器获取文件所花费的时间
  • 解析——把源代码变成解释器可以运行的代码所花的时间;
  • 编译和优化——表示基线编译器和优化编译器花的时间。
  • 重优化阶段——当 JIT 发现优化假设错误,丢弃优化代码所花的时间。包括重优化的时间、抛弃并返回到基线编译器的时间。
  • 执行阶段——执行代码的时间。
  • 垃圾回收阶段——垃圾回收,清理内存的时间。

下面我们来对比下WebAssembly和JS在各个阶段,所花费的时间,由此比较两者性能

WebAssembly VS JS

文件抓取阶段

因为WebAssembly可以以二进制形式,所以与JS等效的WebAssembly文件更小;这就表示WebAssembly 比 JavaScript 抓取文件更快。即使 JavaScript 进行了压缩,WebAssembly 文件的体积也比 JavaScript 更小;这一段耗时的对比,在网速慢的情况对比会更加鲜明;

解析阶段

当到达浏览器时,JavaScript 源代码会解析成抽象语法树(AST)。
接着浏览器采用懒加载的方式进行,只解析真正需要的部分,而对于浏览器暂时不需要的函数只保留它的桩;
解析的目的是将AST转化成中间代码(叫做字节码),提供给 JS 引擎编译;
然而,WebAssembly 不需要被转换,因为它已经是字节码了。它仅仅需要被解码并确定没有任何错误。

编译和优化阶段

js是弱类型编程语言,初始化变量时无须显式地指出变量地具体类型,整个变量地类型完全由代码编译器在代码地运行过程中进行推断。

C语言等强类型语言可以提前将程序等源代码进行静态编译和优化,最后直接生成相应的经过优化的二进制机器码供CPU执行。

转化成的WebAssembly 与机器代码更接近 也就是说,相对于JS而言,WebAssembly在此阶段会更快,因为:

  • 编译器不需要在运行代码时花费时间去观察代码中的数据类型,在开始编译时做优化
  • 编译器不需要对同样的代码做不同版本的编译。
  • 很多优化已经在 LLVM 前完成,所以编译和优化的工作很少。

重新优化

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

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

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

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

执行

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

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

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

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

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

垃圾回收

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

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

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

总结

使用WebAssembly,可以更快地在 web 应用上运行代码。这里有 几个 WebAssembly 代码运行速度比 JavaScript 高效的原因。

  • 文件加载 - WebAssembly 文件体积更小,所以下载速度更快。

  • 解析 - 解码 WebAssembly 比解析 JavaScript 要快

  • 编译和优化 - 编译和优化所需的时间较少,因为在将文件推送到服务器之前已经进行了更多优化,JavaScript 需要为动态类型多次编译代码

  • 重新优化 - WebAssembly 代码不需要重新优化,因为编译器有足够的信息可以在第一次运行时获得正确的代码

  • 执行 - 执行可以更快,WebAssembly 指令更接近机器码

  • 垃圾回收 - 目前 WebAssembly 不直接支持垃圾回收,垃圾回收都是手动控制的,所以比自动垃圾回收效率更高。

WebAssembly 的出现是否会取代 JavaScript?

上面比较了WebAssembly和JS的性能,发现各个阶段 WebAssembly几乎完胜JavaScript,那么问题来了,WebAssembly会不会取代JS呢?

个人认为,WebAssembly的出现是不会取代JS的;

它的设计初衷是为了和JS一起协同合作的,正如MDN官网上所说:我们可以在同一个应用中利用WebAssembly的性能和威力以及JavaScript的表达力和灵活性:

  • JS是动态类型的,它灵活且富有表达性,不需要编译环节以及拥有一个巨大的能够提供强大框架、库和其他工具的生态系统。
  • WebAssembly提供了一条途径,以使得以各种语言编写的代码都可以以接近原生的速度在Web中运行
  • WebAssembly 不具备 DOM 操作能力
  • JavaScript 和 WebAssembly 之间是可以互相调用。

WebAssembly 应用小实例

模块生成:

我们可以通过安装诸如Emscripten的工具把高级语言编译为.wasm文件 步骤:

git clone https://github.com/juj/emsdk.git
cd emsdk

# 在 Linux 或者 Mac OS X 上
./emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit
./emsdk activate --global --build=Release sdk-incoming-64bit binaryen-master-64bit
# 如果在你的macos上获得以下错误
Error: No tool or SDK found by name 'sdk-incoming-64bit'
# 请执行
./emsdk install latest
# 按照提示配置环境变量即可
./emsdk activate latest
复制代码

进入emsdk文件夹,输入以下命令来让你进入接下来的流程,编译一个样例C程序到asm.js或者wasm。

source ./emsdk_env.sh
复制代码

Emscripten功能比较强大,我们可以直接生成HTML和JS模版,也可以生成专门的wasm文件 比如: 将math.c 生成 math.wasm

emcc math.c -Os -s WASM=1 -s SIDE_MODULE=1 -o math.wasm
复制代码

此时仅生成 math.wasm

emcc hello.c -s WASM=1 -o hello.html
复制代码

此命令则会生成:

  • hello.wasm : 二进制的wasm模块代码
  • hello.js: 一个包含了用来在原生C函数和JavaScript/wasm之间转换的胶水代码的JavaScript文件
  • hello.html: 一个用来加载,编译,实例化你的wasm代码并且将它输出在浏览器显示上的一个HTML文件

模块加载

wasm模块创建完成之后,我们可以获取wasm资源,然后对模块进行实例化来完成模块的加载

获取 wasm 资源

获取资源的方式和我们常规的获取方式一致,通过 XMLHttpRequest 或 Fetch,此处以 Fetch 为例,该函数返回一个可以解析为 Response 对象的 promise。接着使用 arrayBuffer 函数把响应转换为带类型数组,该函数返回一个可以解析为带类型数组的 promise。

模块实例化

我们获取的是 wasm 模块,最后使用 WebAssembly.instantiate 函数进行实例化。

javascript 调用WebAssembly的方法

math.c

int add (int x, int y) {
  return x + y;
}

int square (int x) {
  return x * x;
}
复制代码

math.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <h1></h1>
    <script>
      /**
       * @param {String} path wasm 文件路径
       * @param {Object} imports 传递到 wasm 代码中的变量
       */
      function loadWebAssembly(path, imports = {}) {
        return fetch(path) // 加载文件
          .then((response) => response.arrayBuffer()) // 转成 ArrayBuffer
          .then((buffer) => WebAssembly.compile(buffer))
          .then((module) => {
            imports.env = imports.env || {};

            // 开辟内存空间
            imports.env.memoryBase = imports.env.memoryBase || 0;
            if (!imports.env.memory) {
              imports.env.memory = new WebAssembly.Memory({ initial: 256 });
            }

            // 创建变量映射表
            imports.env.tableBase = imports.env.tableBase || 0;
            if (!imports.env.table) {
              // 在 MVP 版本中 element 只能是 "anyfunc"
              imports.env.table = new WebAssembly.Table({
                initial: 0,
                element: "anyfunc",
              });
            }

            // 创建 WebAssembly 实例
            return new WebAssembly.Instance(module, imports);
          });
      }
      //调用
      loadWebAssembly("./math.wasm").then((instance) => {
        const add = instance.exports.add; //取出c里面的方法
        const square = instance.exports.square; //取出c里面的方法

        console.log("10 + 20 =", add(10, 20));
        console.log("3*3 =", square(3));
        console.log("(2 + 5)**2 =", square(add(2 + 5)));
      });
    </script>
  </body>
</html>

复制代码

效果:

屏幕快照 2021-07-05 下午7.05.01.png

我们可以看到 math.c中的add和square方法被js成功引用;

参考文章:
WebAssembly 基本介绍
MDN
编译 C/C++ 为 WebAssembly
上手WebAssembly
几张图让你看懂WebAssembly
前端要懂的WebAssembly 前世今生

文章分类
前端
文章标签