小程序WebAssembly原理剖析与实践应用
发布于 1 年前 作者 xiahan 1903 次浏览 来自 分享

本文转发自

https://www.sohu.com/a/673852453_100093134


【万字长文】"网红"浪潮退去,WebAssembly原理剖析与生产应用


前言:因为工作需要对WebAssembly进行了一些研究,遗憾的是我发现整个中文社区居然没一篇博客能完整地讲清楚这项技术。工作空余时间也不多,大约花了我半年多时间才写完这2万字,你看到某一行字的背后我可能需要花几个小时去看很多的英文原文资料来论证。如果这篇文章对你有所启发,欢迎关注、点赞、收藏以及评论区交流~

WebAssembly,前身技术来自Mozilla和Google Native Client的asm.js,首次发布于2017年3月。并于2019年12月5日正式成为W3C recommendation,至此成为与HTML、CSS以及Java 并列的web领域第四类编程语言。

在web领域,已经有Java这样的利器,而WebAssembly则是打开新世界的大门。WebAssembly并不是要取代Java,而是要在图形图像处理、3D游戏、AR/VR这些应用领域开疆拓土。如今的现代浏览器已经越发朝着微型”第二操作系统“发展,人们希望在浏览器内能完成更多的事情,而WebAssembly作为web端高性能应用的基石,正在让更多的应用场景在浏览器内变为现实。

除了在浏览器内实现高性能应用,WebAssembly也可以脱离web端在搭载了不同硬件和操作系统的各个平台运行,进一步实现当年JAVA所期望的“一次编译,多处运行”。WebAssembly在服务端可用于微服务平台、无服务平台、第三方插件系统等场景。

本文共6个章节,全文逐字阅读约15分钟,具体章节介绍如下:

  1. 第一章 WebAssembly的前世今生 : 介绍了asm.js在Mozilla的起源
  2. 第二章 asm.js技术 : 底层原理介绍,探讨了asm.js比原生Java更快的原因
  3. 第三章 WebAssembly技术 : 底层原理介绍,探讨了WebAssembly .js比asm.js更快的原因
  4. 第四章 WebAssembly在Web端的应用 : 公司内外案例,在音视频处理、游戏、web端设计工具等领域中的生产应用
  5. 第五章 WebAssembly在服务端的应用 : 如果实现跨平台runtime,在微服务、无服务、容器化等场景的应用。
  6. 第六章 总结展望:web应用加速的瓶颈在哪?WebAssembly到底有没有成功?

WebAssembly技术体验可直接点击以下链接~

看看你的浏览器能跑多少FPS?浏览器直接玩2D/3D游戏:https://arcadespot.com/game/doom-3/

第一章 WebAssembly的前世今生:从Mozilla说起

Mozilla基金组织LOGO

1.1 一家伟大的互联网企业

说起WebAssembly,那就必须从一家没落而又伟大的互联网公司说起,它就是火狐浏览器的开发者Mozilla。Mozilla的前身是大名鼎鼎的网景公司(Netscape),也就是Java的开发者。从做浏览器起家一路坎坷至今,Mozilla最近更是频频传出裁员风波,其根源依然是没有找到太好的盈利点。作为互联网开源社区的领跑者,Mozilla在技术上的成就远高于其在商业领域。除了Java和Filefox,Mozilla还留下了Rust、HTML5、MDN(Mozilla Developer Network)以及asm.js这些引领互联网行业发展的重要基石。

1.2 脑洞大开的想法:浏览器里跑C++

2012年Mozilla的工程师在研究LLVM时,突然脑洞大开提出了一个想法:类似游戏引擎这样的高性能应用大多都是C/C++语言写的,如果能将C/C++转换成 Java ,那岂不是就能在浏览器里跑起来了吗?如果可以实现,那么浏览器是不是也就可以直接跑3D游戏之类的C/C++应用?于是Mozilla成立了一个叫做Emen的编译器研发项目, Emen可以将C/C++代码编译成Java,但不是普通的JS,而是一种被特殊改造的JS,其被命名为asm.js

Emen 的官方描述是:

Emen is a toolchain for compiling to asm.js and WebAssembly, built using LLVM, that lets you run C and C++ on the web at near-native speed without plugins.

中文译文:

Emen是一个基于LLVM的将C/C++编译到asm.js和WebAssembly的工具链,它可以让你在web上以接近原生的速度运行C/C++而不需要任何插件。

如下图所示:实际上,不只是C/C++代码,只要能转换成LLVM IR的语言,都可以通过Emen转换成asm.js。

C++代码转换JS流程

1.3 另一次失败的尝试:Google Native Client

Google在很早之前也一直致力于研究如何让C/C++能够在Chrome里运行起来,并在2009年的安全领域顶级会议IEEE Symposium on Security and Privacy 发表了Google的技术方案NaCl(Google Native Client)以及PNaCl(Portable Google Native Client)。NaCl的本质也是一种沙盒技术,使用工具链编译后的C/C++代码能够以接近原生应用的速度在web端运行,也可以与JS和webapi进行交互。NaCl在安全这块做了大量的设计,其使用了内外双层沙盒,并利用x86内存分段机制来隔离内存,甚至还用上了静态代码分析技术来做沙盒里运行的程序进行检查。

然而在经过了8年的挣扎后,在2017 年5月30日Google宣布弃用NaCl。其根本原因是NaCl这套方案只有自家的Chrome愿意配合支持,所以压根就不具备跨浏览器运行的能力。最终Chrome与Mozilla达成一致,共同推进WebAssembly方案,Chrome也直接用WebAssembly替换掉了NaCl。

第二章 asm.js:WebAssembly的前身,一种更快的JS 2.1 C++转换asm.js示例

一般来说,asm.js并不是直接编写的,而是一个面向JS编译器的中间产物。例如以下的C++代码:

//计算i+1int f(int i) { return i + 1;}//计算字符串长度size_t strlen(char *ptr) { char *curr = ptr; while (*curr != 0) { curr++; } return (curr - ptr);}

使用Emen转换后,生成的JS代码如下:

function f(i) { i = i|0; return (i + 1)|0;}function strlen(ptr) { ptr = ptr|0; var curr = 0; curr = ptr; while ((MEM8[curr>>0]|0) != 0) { curr = (curr + 1)|0; } return (curr - ptr)|0;}

可以看到这种生成的JS跟普通JS还是区别很大的,就像刚才我们所说, 程序员不直接编写asm.js代码,这些看起来怪异的语法都是为了配合编译器生成更高效的机器码。比如在asm.js里反复出现的"按位或"操作,其目的是将原来Java 里的double类型计算转为整形运算(CPU进行整形运算的速度快于浮点型)。而这里被命名为MEM8的数组实际上充当了"堆"的作用。如果只是作为使用者可以不用深究这些优化的具体实现,直接使用Emen 来帮助我们完成这一转换过程即可。

2.2 asm.js为什么比原生Java 快?

由于 asm.js 在浏览器中运行,其性能在很大程度上也取决于浏览器和JS引擎的优化支持。2015年6月,Microsoft Edge也开始加入了对asm. js的支持。为了直观展示asm.js所带来的的性能提升,微软发布了一个叫做"Chess Battle"的demo。Chess Battle让两个版本的开源象棋AI对战,其中一个用C实现然后转成asm.js,另外一个用原生JS实现。如下图所示,每个走棋回合限制为200毫秒,其中asm.js版本的AI因为可以在每个回合进行更多的评估运算(用于决定走棋策略),胜率获得了极大提升。

asm.js对战原生Java

asm.js运行的快慢取决于不同的测试用例、运行硬件、浏览器引擎优化程度等,一般来说我们可认为asm.js能达到原生C/C++运行速度的50%,有些场景下甚至能持平Clang编译的C/C++用例。asm.js运行比原生js快,那么它如此高效的原因是什么呢?阮一峰在他的一篇博客里写到的结论是:

一旦 Java 引擎发现运行的是 asm.js,就知道这是经过优化的代码,可以跳过语法分析这一步,直接转成汇编语言。另外,浏览器还会调用 WebGL 通过 GPU 执行 asm.js,即 asm.js 的执行引擎与普通的 Java 脚本不同。这些都是 asm.js 运行较快的原因。

这篇博客应该是对很多人造成了误导,具体错误在于:

  1. 首先,"跳过语法分析,直接生成汇编"是不存在的,语法分析是编译中不可缺少的一环节,asm.js跟原生JS的编译运行过程是一致的。
  2. 其次,WebGL作为一个图形api和asm.js技术可以说是没有任何直接关系,原生JS也调用WebGL来实现GPU硬件加速。
  3. 最后,也是最离谱的一点,WebGL 通过 GPU 执行 asm.js ?不管是asm.js、原生Java还是WebAssembly其编译产物都是CPU机器码而不是GPU机器码。而且WebGL只是一个图形渲染api,就算是把JS编译到GPU也需要类似CUDA/OpenCL这些通用计算api来支持。最新的WebGPU同时支持了图形和通用计算,这倒是目前web端在GPU里"执行JS"的可行方法。

先抛开Java 不谈,我们可以思考一下,对于任何一门编程语言来说决定其运行快慢的根源是什么呢?我认为用一句话来总结就是: 代码运行的快慢,从硬件层面上看,直接取决于生成的机器码所需时钟周期的总和。从编程语言层面上看,取决于编译后的产物在运行时有多少"动态决议"。

例如,弱类型语言比强类型语言慢,是因为编译时类型是不确定的,需要运行时进行额外的型别推导,这就是"动态决议";

例如,C++里虚函数比普通函数开销大,是因为编译时函数地址是不确定的。普通函数编译后生成的跳转目的地是一串固定的地址,而虚函数的跳转地址是在运行时从CPU的寄存器里读取的,这也是"动态决议",编译后的机器码多了一条寄存器取值指令;

类似的场景还有GC机制、模板编程、JIT优化等等,归根结底就是如果在编译时候能完成更多事情,那么生成的机器码运行周期就越短,代码也就运行地越快。asm.js在减少运行时的"动态决议”这里所做的工作,wiki原文如下:

Much of this performance gain over normal Java is due to 100% type consistency and virtually no garbage collection.

可翻译为:

与原生Java相比,这里性能提升的主要原因是100%的类型一致性以及几乎没有(自动的)垃圾回收机制。

简而言之就是,asm.js的实现去掉大部分的自动GC机制,然后改成了强类型语言,编译器能够更大程度地进行优化,这才是asm.js能比普通JS运行更快的原因。在asm.js里不再支持除了浮点和整形之外的类型,内存的开辟和释放也需要代码手动进行处理。部分引擎甚至还可以以AOT或者JIT的形式运行asm.js。关于asm.js的原理,在微软的文档里也有一段更加详细的描述:

Asm.js is a strict subset of Java that can be used as a low-level, efficient target language for compilers. As a sublanguage, asm.js effectively describes a sandboxed virtual machine for memory-unsafe languages like C or C++. A combination of static and dynamic validation allows Java engines to employ techniques like type specialized compilation without bailouts and ahead-of-time (AOT) compilation for valid asm.js code. Such compilation techniques help Java execute at “predictable” and “near-native” performance, both of which are non-trivial in the world of compiler optimizations for dynamic languages like Java.

这段话从编译器优化的角度对asm.js原理描述地非常贴切了,比较难准确翻译,大概释义如下:

asm.js是Java的一个严格子集,是一种面向编译器的底层且高效的目标语言。作为一种子语言,asm.js高效地为类似C/C++这样的内存不安全语言描述了一个沙盒虚拟机。静态验证和动态验证的结合允许Java引擎对有效的asm.js代码使用型别特化编译和提前(AOT)编译等技术。这样的编译技术可以帮助Java具有"可预见性"和“接近原生”的性能表现,这两种特性在Java这样的动态语言编译器优化中是非常重要的。

其中"bailouts"应该是微软这个JS编译器里的专用名词,没有特别合适的翻译。"predictable"可理解为“更少的动态决议”。asm.js目前看已经是过时的技术,并非本文的重点也不再展开继续讨论,如果想继续了解Java编译优化的实现细节,读者可参阅文献的内容自行研读。

第三章 WebAssembly:绕过JS 直接生成机器码

3.1 WebAssembly是什么?

如上图所示,为了能便于程序员阅读和编辑 WebAssembly,源码除了被编译成二进制外还会生成一份文本文件。左边红色部分是C++源码,中间紫色部分是文本格式的.Wat文件的内容,右边蓝色部分是.wasm文件的内容。

多数情况下,人们把Wasm定义成web上的编程语言,认为这是一个前端编程技术。其实这里有一些的误解,首先Wasm并不是一个新的"编程语言",没有人会手写.wasm文件来进行编程。 WebAssembly 有一套完整的语义,但作为开发者并不需要去了解它,开发者依然可以继续使用自己熟悉的编程语言,由各个语言的编译器将其编译成Wasm格式后运行在浏览器内置的Wasm虚拟机中,我认为Wasm更倾向于是一个应用在web场景中的编译领域新技术。其次,Wasm也并非只能运行在浏览器内,设计者对其抱有更加远大的宏图大业,这部分我们将在后面Wasm容器化这里继续展开讨论。

Mozzila官方对WebAssembly的描述为:

WebAssembly is a new type of code that can be run in modern web browsers — it is a low-level assembly-like language with a compact binary format that runs with near-native performance and provides languages such as C/C++, C# and Rust with a compilation target so that they can run on the web. It is also designed to run alongside Java, allowing both to work together.

可翻译为:

WebAssembly是一种可以在现代浏览器中运行的新型代码——它是一种低级的类似汇编的语言,具有紧凑的二进制格式,运行起来具有接近原生的性能,其为C/C++、C#和Rust等语言提供了一个编译目标,以便它们可以在web上运行。它还被设计为与Java一起运行,允许两者一起工作。

通过这段描述已经可以对WebAssembly有一个初步认识,我们再进一步给它拆开来看:

  1. 首先,WebAssembly是一门新的编程语言,它于2019年12月5日正式成为与HTML、CSS以及Java 并列的web领域第四类编程语言。
  2. 其次,WebAssembly是"汇编语言"而不是高级语言, 程序员不直接编写WebAssembly代码,而是通过特殊的编译器将高级语言转换成WebAssembly代码
  3. 再次, WebAssembly是预处理过后的二进制格式,它实际是一个IR(Intermediate Representation)!类似Java的ByteCode或者.Net的MSIL/CIL。
  4. 最后,WebAssembly是web上的语言,这意味着主流的浏览器可以读取并且执行它。

最后简单总结,程序员依然还是编写高级语言,然后通过“特殊的编译器”生成WebAssembly二进制代码,最终WebAssembly代码再被一个嵌入在浏览器里的"特殊的虚拟机"执行。这就是WebAssembly的全部工作过程。

3.2 为什么需要WebAssembly?

在web领域,我们已经有了Java这样利器,但美中不足的是Java的性能不佳,即使可以通过第二章里提到的各种编译优化来解决一部分问题,但在类似图形图像处理、3D游戏、AR、VR这些高性能应用的场景下,我们似乎任然需要一个更好的选择。

“ 快”是相对的,目前我们可以认为在运行速度上:原生C/C++代码 > WebAssembly > asm.js > 原生Java。其中WebAssembly比asm.js要快的原因在于:

  1. WebAssembly 体积更小,Java 通过gzip压缩后已经可以节约很大一部分空间,但WebAssembly 的二进制格式在被精心设计之后可以比gzip压缩后的Java 代码 小10-20%左右
  2. WebAssembly 解析更快,WebAssembly 解析速度比 Java 快了一个数量级,这也是得益于其二进制的格式。除此之外,WebAssembly还可以在多核CPU上进行并行解析。
  3. WebAssembly 可以更好利用CPU特性,之前我们说到asm.js可以通过各种“奇技淫巧”来编译优化,但其还是受限于Java的实现。而WebAssembly可以完全自由发挥,使得其可以利用更多CPU特性,其中例如:64位整数、加载/存储偏移量以及各种CPU指令。在这一部分,WebAssembly能比asm.js 平均提速5%左右
  4. 编译工具链的优化,WebAssembly的运行效率同时取决于两部分,第一个是生成代码的编译器,第二个是运行它的虚拟机。WebAssembly对其编译器进行了更多的优化,使用Binaryen编译器代替了Emen, 这部分所带来的的速度提升大约在5%-7%。

当然,速度上的提升并不是全部。WebAssembly的意义在于开辟了一个新的标准,不再拘泥于Java而是直接面向跟底层的机器码。用任何语言都可以开发WebAssembly,而WebAssembly又可以高效运行在任何环境下,这也是Mozilla的程序员对WebAssembly抱有的最远大的宏图大业。文章将在第六章对WebAssembly在非web端的应用继续展开讨论。

3.3 WebAssembly与Java运行性能详细对比

关于WebAssembly的性能,整体上我认为可以描述为“很快,但是不够快”。也就是说, 我们期望它比Java快非常多,快个10倍或者8倍,但实际上只能快一点点,大概也就是不到2倍左右,而且在不同的测试场景下差异可能会很大。也许你会说100%的性能提升已经很高了,但实际上这也许不能说服大量开发人员完全转向一个崭新的有学习成本的技术。

Zaplib(一个高性能web框架)的工程师从最大性能和标准性能两方面对WebAssembly与Java性能进行更详细的对比,结论如下:

3.3.1 最大性能(尽可能"奇技淫巧"地使用JS)

在最大性能上,特殊编写的原生JS是可以跟Wasm大致持平的。其原因在于JS可以通过ArrayBuffer来模拟成一个"memory managed language":

  1. 可以尽可能避免掉自动GC的额外开销。
  2. 可以对数据的局部性(cache locality)进行优化来提升缓存命中,从而提升数据读写的效率。( 缓存局部性对数组的性能很重要!)
  3. 当你尽可能避免掉其它开销,只使用循环、局部变量、算术、函数调用的时候,原生JS会非常快。

举个例子如下,这是一个计算多个2维向量平均长度的TS函数

// Unoptimized Typetype Vec2 = { x: number, y: number };function avgLen(vecs: Vec2[]): number { let total = 0; for (const vec of vecs) { total += Math.sqrt(vec.x*vec.x + vec.y*vec.y); } return total / vecs.length;a}

这是使用了ArrayBuffer替换数组了实现:

// Optimized Type, using ArrayBuffersfunction avgLen(vecs: ArrayBuffer): number { let total = 0; const float64 = new Float64Array(vecs); for (let i=0; i

在示例中,ArrayBuffer每16位存储一个二维向量,前8位是向量x,后8位是向量y。后者代码的性能会远高于前者,具体细节有兴趣可以参考( https://zaplib.com/docs/blog_ts ++.html)。总而言之就是,可以通过JS的ArrayBuffer来手动管理JS内存,尽量避免掉性能开销大的地方,剩下的普通指令的执行跟Wasm并无本质差异。除此之外,浏览器里的JS相比Wasm在某些方面甚至还具有优势:

  1. JS可以访问一些零拷贝(zero-copy)的方法。例如 TextEncoder和 FileReader.readAsArrayBuffer,而Wasm还需要额外再进行一次内存拷贝。

而Wasm相比JS的优势在于:

  1. SIMD加速。SIMD.js的API已经被弃用,取而代之的是Wasm的SIMD实现。
  2. 前置的编译优化。

3.3.2 标准性能(正常使用编程语言)

对于实际情况而言,用标准的JS的进行性能对比才是有意义的,原因在于:

  1. 代码的编写复杂度和可维护性也是很重要的,"奇技淫巧"并不适合生产工作中使用。
  2. 代码工程会依赖大量第三方库,这些库大概率都是标准JS来编写的。

如上图,这个3D人物动画是一个经典的CPU计算密集的测试用例,且可以直观感受到性能在帧数上的表现( http://aws-website-webassemblyskeletalanimation-ffaza.s3-website-us-east-1.amazonaws.com/)。感兴趣的同学可以在自己浏览器里尝试一下,当3D人物数量为100时JS版本会有明显卡顿,切换到Wasm则不会有卡顿感。

这是在17年Wasm诞生之初的测试,可以看到在不同的环境下Wasm比标准JS快了8-15倍。随着JS的不断优化,现在再去测试可能就不会有这么大的差异了。更重要的是,这个测试用例不一定能代表真实的web应用, 真正的web应用可能不会命中这么多"优化项",8倍以上的性能差异往往只存在于测试用例中。这里我必须再重复一下就是,Wasm快10%到1000%都有可能,不同的测试环境下不可一概而论。

3.4 如何正确使用WebAssembly?

首先需要再次强调的是 ,WebAssembly的诞生并不是要取代Java,web端整个主框架还是HTML+JS+CSS这一套。web应用的大部分基础功能也依然是靠Java来实现,我们只是将web应用中对性能有较高要求的模块替换为wasm实现。在这样的场景下,正确使用WebAssembly的步骤为 返回搜狐,查看更多

  1. 整理web应用中所有模块,梳理出有性能瓶颈的地方。例如你的web应用里有视频上传、文件对比、视频编解码、游戏等模块,这些都是很适合用WebAssembly来实现的。相反,基础的网页交互功能并不适合用WebAssembly来实现。
  2. 进行简单的demo性能测试,看是否能达到预期的加速效果。如果加速效果并不明显,那么就不适合切换到Wasm。
  3. 确定用来编译成WebAssembly的源语言,目前主流的语言基本都是支持WebAssembly的,唯一不同的区别是其编译器的优化程度。如果你使用过C++、RUST,最好还是用这两种语言来编写,其编译优化程度会更高。当然了如果你想使用PHP/GO/JS/Python这些你更加熟悉的语言的话,也是不错的选择,毕竟有时候开发效率会比运行效率要更加重要。
  4. 编码实现,然后导出.wasm文件。这一步基本没什么难度,确定了语言之后使用对应的编译器即可,需要注意的是记得尽量多打开debug选项,不然有运行时报错的话你就只能对着一堆二进制代码懵逼了。
  5. 编写Java胶水代码,加载.wasm模块。在最小可行版本的实现中 ,在 Web 上访问 WebAssembly 的唯一方法是通过显式的Java API调用,而在ES6标准中,WebAssembly 也可以直接从

作者:yannduan,腾讯IEG客户端开发工程师

回到顶部