WebAssembly上手把玩体验

背景:从 JavaScript 说起

JavaScript 占据着统治地位,不管是公开还是私有的项目、任何组织、世界任何地区,JavaScript 都是第一。但是有一个领域一直无法突破 —- 游戏。

随着JavaScript的快速发展,目前它已然成为最流行的编程语言之一,这背后正是 Web 的发展所推动的。但是随着JavaScript被广泛的应用,它也暴露了很多问题:

  • 语法太灵活导致开发大型 Web 项目困难;
  • 性能不能满足一些场景的需要;
  • 没有静态类型,优化困难
  • 代码体积越来越大,压缩有极限
  • 动态类型,编译器解析流程复杂冗余 (js->AST->Bytecode Compiler->Machine Code->…)
  • 越来越长的起步及运行时间


这几大问题成为JavaScript头顶上的达摩克利斯之剑,危及着JavaScript更广泛的应用。

MS、Google、Mozilla的探索

静态变量类型所带来的问题


这是Microsoft Edge浏览器的JavaScript引擎ChakraCore的结构。我们来看一看我们的JavaScript代码在引擎中会经历什么。

  • JavaScript文件会被下载下来。
  • 然后进入Parser,Parser会把代码转化成AST(抽象语法树).
  • 然后根据抽象语法树,Bytecode Compiler字节码编译器会生成引擎能够直接阅读、执行的字节码。
  • 字节码进入翻译器,将字节码一行一行的翻译成效率十分高的Machine Code.

在项目运行的过程中,引擎会对执行次数较多的function记性优化,引擎将其代码编译成Machine Code后打包送到顶部的Just-In-Time(JIT) Compiler,下次再执行这个function,就会直接执行编译好的Machine Code。但是由于JavaScript的动态变量,上一秒可能是Array,下一秒就变成了Object。那么上一次引擎所做的优化,就失去了作用,此时又要再一次进行优化。

MS:TypeScript

第一个问题被著名开源软件大厂MicroSoft解决。

MicroSoft集结了C#的首席架构师以及Delphi和Turbo Pascal的创始人Anders Hejlsberg等明星阵容,打造了TypeScript。
TypeScript它是JavaScript的一个严格超集,并添加了可选的静态类型和使用看起来像基于类的面向对象编程语法操作 Prototype。所以TypeScript可以这样理解:

1
ts = js + 静态类型检查

MicroSoft利用TypeScript这把锋利的武器打造了VSCode等史诗级项目,于是乎,第一把达摩克利斯之剑”语法太灵活导致开发大型 Web 项目困难”似乎已经被解决。
但是,由于TypeScript最终仍然是被编译成JavaScript在浏览器中执行,所以困扰着JavaScript开发者的性能问题,仍然没有被解决。

Google:V8

早在2008年,Google就推出了自家的JavaScript引擎V8,试图使用JIT技术提升JavaScript的执行速度,并且它真的做到了。

由于JIT技术的引入,V8使得Web性能得到了数十倍的增长!

上图展示了Chrome的v8与IE的Chakra benchmark结果。

既然性能得到了如此大的提升,那么JavaScript广为诟病的性能问题得到了解决吗?为啥Web性能还是被挑战?

单线程 -> 阻塞

Web应用中,性能瓶颈大部分的原因已经不在JavaScript,而在于DOM。浏览器中通常会把DOM和JavaScript独立实现。下图展示了不同浏览器DOM和JavaScript的实现情况:

由于Dom渲染和JavaScript引擎是相对独立的,这两个模块相互访问的时候,都是通过接口访问。由于JavaScript单线程的特性,这种访问只能是单工的。

可以把DOM和JavaScript各自想象为一个岛屿,他们之间用桥梁连接,JavaScript每次访问DOM,都要经过这座桥,并交纳过桥费,访问的次数越多,费用就越高,因此,推荐的做法是尽可能减少过桥的次数,一直待在JavaScript岛上。为了达到这个目的,可以使用 Virtual Dom,Web Worker 来实现。这里就不再赘述。

JIT VS AOT,在重型计算面前仍然力不从心

刚才谈到,V8引擎首次将JIT技术引入JavaScript当中,大幅提升了执行速度。那么首先我们需要理解什么是JIT,以及AOT。

AOT: Ahead-of-Time compilation

必须是强类型语言,编译在执行之前,编译直接生成CPU能够执行的二进制文件,执行时CPU不需要做任何编译操作,直接执行,性能最佳。

JIT: Just-in-Time compilation

没有编译环节。执行时根据上下文生成二进制汇编代码,灌入CPU执行。JIT执行时,可以根据代码编译进行优化,代码运行时,不需要每次都翻译成二进制汇编代码,V8就是这样优化JavaScript性能的。

由于JavaScript的动态语言类型已无法改变,所以只能采用JIT的形式对性能进行优化。
为了进一步JIT优化效率,继而提升JavaScript性能,浏览器鼻祖Mozilla推出了asm.js。

Mozilla:asm.js

和TypeScript比较相似的是,asm.js同样也是强类型的JavaScript,但是他的语法则是JavaScript的子集,是为了JIT性能优化而专门打造的。

一段典型的asm.js代码如下:

1
2
3
4
5
6
7
8
function add(x){   //使用注释注解,检测等黑魔法来确保强类型
"use asm";
var a = 1;

var x = a | 0; // x 是32位整数int
var y = +a; // y 是64位浮点数
return (x+1)|0;
}

可以看到,asm.js使用了按位或0的操作,来声明x为整形。从而确保JIT在执行过程中尽快生成相应的二进制代码,不用再去根据上下文判断变量类型。

如果变量的类型要在运行时确定,asm.js 就要求事先声明类型,并且不得改变,这样就节省了类型判断的时间。

asm.js 的类型声明有固定写法,变量 | 0表示整数,+变量表示浮点数。

asm.js难堪大任

针对JS缺陷:

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

以上尝试各有优缺点,其中:

  • TypeScript 只是解决了 JS 语法松散的问题,最后还是需要编译成 JS 去运行,对性能没有提升;
  • Dart 只能在 Chrome 预览版中运行,无主流浏览器支持,用 Dart 开发的人不多;
  • asm.js 语法太简单、有很大限制,开发效率低。

asm.js To WebAssembly
自从Mozilla提出了asm.js,Google、MicroSoft、Apple都觉得asm.js的思路不错,于是联合起来,一同共建WebAssembly生态。

同asm.js不同的是,WebAssembly是一份字节码标准,以字节码的形式依赖虚拟机在浏览器中运行。

WebAssembly


上图的左侧是用C++实现的求递归的函数。中间是十六进制的Binary Code。右侧是指令文本。可能有人就问,这跟WebAssembly有个屁的关系?其实,中间的十六进制的Binary Code就是WebAssembly。

可以依赖Emscripten等编译器将C++/Golang/Rust/Kotlin等强类型语言编译成为WebAssembly字节码(.wasm文件)。所以WebAssembly并不是Assembly(汇编),它只是看起来像汇编而已。一份典型的.wasm文件如下所示:

我们知道WebAssembly定义了一种二进制格式,这种格式就是wasm:

1
2
3
4
5
6
00000000: 0061 736d 0100 0000 0108 0260 017f 0060  .asm.......`...`
00000010: 0000 0215 0203 656e 7603 6d65 6d02 0001 ......env.mem...
00000020: 026a 7303 6c6f 6700 0003 0201 0107 0b01 .js.log.........
00000030: 0765 7861 6d70 6c65 0001 0a23 0121 0041 .example...#.!.A
00000040: 0042 c8ca b1e3 f68d c8ab ef00 3703 0041 .B..........7..A
00000050: 0841 f2d8 918b 0236 0200 4100 1000 0b .A.....6..A....


一种可移植,体积小且加载迅速的(二进制)格式,适用于编译到Web

主要目标是在Web环境支持高性能应用。但设计上不依赖Web特性,也不针对Web特性提供功能,也可以用在其它环境

简单理解,就是定义了一种编译目标格式,能在支持该格式的任何环境获得接近原生的执行性能。相当于允许扩展native模块,在苛求性能的场景,用其它更合适的语言(比如C++)来实现,再提前编译到WebAssembly形式,就能获得媲美native的性能体验

其设计目标分2方面:

快速,安全和可移植的语义

  • 快速:以接近原生代码的性能执行,并利用所有现代硬件通用的功能
  • 安全:代码经过验证并在内存安全的沙盒环境中执行,防止数据损坏或安全违规
  • 定义良好:充分且精确地定义合法程序及其行为,以一种容易推断非正式与正式的方式
  • 独立于硬件:可在所有现代架构,台式机或移动设备以及嵌入式系统上进行编译
  • 独立于语言:不偏向任何特定语言,编程模型或对象模型
  • 独立于平台:可以嵌入到浏览器中,作为stand-alone VM运行,或者集成到其他环境中
  • 开放:程序能够以简单通用的方式与他们的环境交互

高效、可移植的表示

  • 小巧:具有比典型文本或原生代码格式体积更小的二进制格式,能够快速传输
  • 模块化:程序可以拆分成较小的部分,可以单独传输,缓存和使用
  • 高效:可以在单趟(遍历)中快速对其进行解码,验证和编译,等同于实时(JIT)或提前(AOT)编译
  • 流式:允许在拿到所有数据之前,尽早开始解码、验证和编译
  • 可并行:允许将解码、验证和编译拆分成多个独立的并行任务
  • 可移植:对现代硬件上不受广泛支持的架构不做假设

由主流浏览器(Chrome, Edge, Firefox, and WebKit)合力推动其标准化进程:

优势

  • 代码体积很小
    300k左右(压缩后)JavaScript 逻辑改用WebAssembly重写后,体积仅有90k左右
    但使用WebAssembly需要引入一个50k-100k的JavaScript类库作为基础设施
  • 安全性稍有提升
    虽然源码对应的WebAssembly文本指令仍然毫无遮掩,但逆向成本高了一些
  • 性能提升
    理论上WebAssembly拥有接近native的执行性能,因为跳过了解释环节,并且文件体积在传输方面也有优势
    当然,前提是在业务代码量很大,且要求极致性能的场景,在benchmark等重复执行的场景,JIT并不比AOT慢多少

缺点:目前能力有限:

  • 仅支持几种基本数据类型(i32 / i64 / f32 / f64 / i8 / i16)
  • 无法直接访问DOM和其它Web API
  • 无法控制GC

现状

  • JS拥有Typed Objects 草案,WASM拥有GC草案。通过这两个草案,JS和WASM都能够清晰的知道一个对象的结构以及如何去存储,使用,回收。
  • 异常处理。目前还在开发阶段。
  • debug。目前,大多数浏览器已经支持。

WebAssembly 或者 wasm 是一个可移植、体积小、加载快并且兼容 Web 的汇编格式

  • 与asm.js一样
    • 静态类型
    • 编译目标
    • 可移植

  • 与asm.js不一样
    • 汇编格式 == 体积小,起步快
    • 语法上完全脱离JavaScript
    • 沙盒(Sandbox)化的执行环境

什么时候使用WASM?

• 对性能有很高要求的app/module(游戏)
• 在Web上利用C/C++/Rust的库
• 需要独立的沙盒环境
• 需要在不同平台运行

WebAssembly 原理

要搞懂 WebAssembly 的原理,需要先搞懂计算机的运行原理。 电子计算机都是由电子元件组成,为了方便处理电子元件只存在开闭两种状态,对应着 0 和 1,也就是说计算机只认识 0 和 1,数据和逻辑都需要由 0 和 1 表示,也就是可以直接装载到计算机中运行的机器码。 机器码可读性极差,因此人们通过高级语言 C、C++、Rust、Go 等编写再编译成机器码。

由于不同的计算机 CPU 架构不同,机器码标准也有所差别,常见的 CPU 架构包括 x86、AMD64、ARM, 因此在由高级编程语言编译成可自行代码时需要指定目标架构。

WebAssembly 字节码是一种抹平了不同 CPU 架构的机器码,WebAssembly 字节码不能直接在任何一种 CPU 架构上运行, 但由于非常接近机器码,可以非常快的被翻译为对应架构的机器码,因此 WebAssembly 运行速度和机器码接近,这听上去非常像 Java 字节码。

相对于 JS,WebAssembly 有如下优点:

  • 体积小:由于浏览器运行时只加载编译成的字节码,一样的逻辑比用字符串描述的 JS 文件体积要小很多;
  • 加载快:由于文件体积小,再加上无需解释执行,WebAssembly 能更快的加载并实例化,减少运行前的等待时间;
  • 兼容性问题少:WebAssembly 是非常底层的字节码规范,制订好后很少变动,就算以后发生变化,也只需在从高级语言编译成字节码过程中做兼容。可能出现兼容性问题的地方在于 JS 和 WebAssembly 桥接的 JS 接口。
  • 每个高级语言都去实现源码到不同平台的机器码的转换工作是重复的,高级语言只需要生成底层虚拟机(LLVM)认识的中间语言(LLVM IR),LLVM 能实现:

LLVM IR 到不同 CPU 架构机器码的生成;

  • 机器码编译时性能和大小优化。
  • 除此之外 LLVM 还实现了 LLVM IR 到 WebAssembly 字节码的编译功能,也就是说只要高级语言能转换成 LLVM IR,就能被编译成 WebAssembly 字节码,目前能编译成 WebAssembly 字节码的高级语言有:

AssemblyScript:语法和 TypeScript 一致,对前端来说学习成本低,为前端编写 WebAssembly 最佳选择;

  • c\c++:官方推荐的方式,详细使用见文档;
  • Rust:语法复杂、学习成本高,对前端来说可能会不适应。详细使用见文档;
  • Kotlin:语法和 Java、JS 相似,语言学习成本低,详细使用见文档;
  • Golang:语法简单学习成本低。但对 WebAssembly 的支持还处于未正式发布阶段,详细使用见文档。

通常负责把高级语言翻译到 LLVM IR 的部分叫做编译器前端,把 LLVM IR 编译成各架构 CPU 对应机器码的部分叫做编译器后端; 现在越来越多的高级编程语言选择 LLVM 作为后端,高级语言只需专注于如何提供开发效率更高的语法同时保持翻译到 LLVM IR 的程序执行性能。

DEMO

1
2
3
int square(int num) {
return num * num;
}

在线编译https://wasdk.github.io/WasmFiddle/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function loadWebAssembly(fileName) {
return fetch(fileName)
.then(response => response.arrayBuffer())
.then(buffer => WebAssembly.compile(buffer))
.then(module => {
return new WebAssembly.Instance(module);
});
}

//load WebAssembly
loadWebAssembly('program.wasm').then(instance => {
var square = instance.exports.square;
// call any exported function, e.g. instance.exports.main()
//console.log(Object.keys(instance.exports));

document.getElementById('res2').innerHTML = square(2);
document.getElementById('res3').innerHTML = square(3);
document.getElementById('res4').innerHTML = square(4);
document.getElementById('res5').innerHTML = square(5);
});

live-server