WebAssembly 多线程支持的内部原理

阅读数:1158 2019 年 8 月 7 日

几年前 WebAssembly 刚刚发布时还是一个 MVP(最小可行产品),只有很少的一组功能来提供基本的可用性和实用性。彼时这个 MVP 缺少一个重要特性,就是多线程支持。而如今 WebAssembly 的多线程支持已经非常成熟了,可在工具和 Chrome 中使用。这篇博文探讨了此功能的内部机制与新的指令集,并介绍了这一功能如何为多线程应用程序提供支持。

多线程和并发

在深入研究 WebAssembly 多线程规范的细节之前,我们先来简单了解一下并发和多线程技术,看看它们的涵义、我们使用这些技术的原因以及它们带来的挑战。如果你是这方面的老手,大可直接跳过这部分!

现代计算机有多个 CPU(而且每颗 CPU 有多个内核——但为了简单起见,我将它们都简称为 CPU),每个 CPU 都能够独立执行程序。我们可以通过多种方式利用这一优势。例如,如果你要处理一个计算密集型任务(如图像处理),则可以将任务拆分到多个 CPU 上以更快地完成工作;或者如果你的任务需要花费的时间不算短(比如说一两秒),那么最好不要把这个任务放到负责刷新应用 UI 的 CPU 上执行,而是把它放到另一个 CPU 上运行以保持 60fps 的 UI 帧速率。

线程是编程结构的底层,使你可以在这些 CPU 之间分配工作。线程和 CPU 之间没有直接映射关系,但实践中你需要创建多个线程来实现并发处理。

创建充分利用并发能力的应用程序是很有挑战性的,你必须推断每个执行的“线程”,每个线程都具有本地和共享状态的组合。你还需要其他一些工具,例如“(线程)锁”,用来防止多个线程执行同一段代码。但是这些工具又会引入进一步的挑战,例如过度锁定影响并发性或死锁等问题。

现代工具、框架和架构方法一般都会隐藏并发性,这很容易理解。NodeJS、Lambda 函数和主流浏览器都表现为单线程环境。个人来说,我必须承认自己上一次创建一个线程池是好几年以前的事情了!

虽然现在使用多线程的情况不太常见,但也有时候你还是要用它才行。

Web Worker 和浏览器中的并发性

有许多 API 支持 JavaScript 开发人员在浏览器中使用并发能力。我们简单看一下这些 API 和它们的现状。

Web 浏览器本质上是单线程的(从开发人员的角度来看),你的应用程序逻辑与 UI 呈现引擎共享相同的线程。因此长时间运行的计算将导致 UI 锁定或挂起。这种方法以较少的代价换来了极大的便利,所以众多 UI 框架(例如 WPF、WinForms、Cocoa/iOS)都采用了这种方法。

Web Worker API 可以让你生成“Worker”线程,这种 API 已经流行多年。你可以用它创建在给定文件中执行 JavaScript 的 Worker。尽管 Worker 可以发出 XHR 请求并使用 WebSockets,但它们没有 DOM 访问权限。Worker 不能与主(UI)线程共享可变数据,而是依赖于异步消息传递:

复制代码
// ## main.js
const worker = new Worker("worker.js");
// pass a message to the worker
worker.postMessage("Hello World!");
// ## worker.js
// The onmessage property uses an event handler to retrieve information sent to a worker.
onmessage = event => {
console.log("Received message " + event.data);
};

Web Worker 提供了一个非常简单的 API,可以避免许多与并发相关的棘手问题。但实际上只有粗粒度的并发方法能这么简单地处理——所谓粗粒度是指传递给 worker 的都是相对较大的任务。

最近,新引入的 SharedArrayBuffer 原子操作使开发人员能跨多个线程使用共享的内存了。这样以来就能实现更细粒度的并发算法。Lin Clark 写了一份精彩的卡通指南介绍了原子操作的重要性。

遗憾的是,虽然共享数组缓存之前已得到了广泛支持,但 Spectre 和 Meltdown 漏洞(使数据在进程之间泄漏的时序攻击)的威胁迫使所有浏览器厂商迅速停止了支持,以减少这些漏洞带来的风险。目前只有 Chrome 浏览器通过站点隔离和跨源读取屏蔽技术控制了相关风险,进而重新启用了这些功能(2018 年末)。如果有合适的风险控制措施的话,我认为其他浏览器也会恢复支持的。

WebAssembly 多线程提案

那么 WebAssembly 中应该在哪里应用多线程支持呢?最早的 MVP 版本没有任何与多线程相关的结构,因为人们认为这并不是 MVP 的必要功能——这一选择显然很正确,因为已有的功能集合已经足够人们创造出各种有趣和有用的事物了。

我个人认为 WebAssembly 的多线程支持很重要,原因有二: 首先,WebAssembly 是处理计算密集型任务的理想技术,若能将这些任务分配到多个 CPU 上会有很大好处;其次,大多数将 WebAssembly 作为编译目标的语言都有自己的多线程结构,因此通过这一功能可以充分发挥这些语言的潜力。

WebAssembly 与 JavaScript 有类似的提案 / 规范制定过程(后者是通过 TC39)。多线程支持目前是第 2 阶段提案,意味着规范草案已完成,实现也是可用状态。当前的规范草案可以访问 GitHub 来获取。基本上来说,它已经准备好让开发者使用了!

WebAssembly 多线程规范包含以下内容:

  1. 共享线性内存
  2. 原子操作
  3. wait/notify 操作符

第一项是共享线性内存,它很像 JavaScript 的 SharedArrayBuffer,允许多个 WebAssembly 模块(和 JavaScript 主机)直接访问相同的“内存块”。

并发内存访问可能会导致内存损坏(memory corruption),具体来说就是一个线程读取一个值的时候,另一个线程却在写入这个值。这里就需要用到原子操作了。原子操作指的是一组确保原子化的简单指令(读、写、增量…),也就是说其他线程只能在原子操作完成时看到它们的结果。这是一个基本但至关重要的构建块,它为更高级别的并发概念(如锁和屏障)铺平了道路。

最后,wait/notify 操作符提供了一种挂起线程的机制(不需要干等),还能从另一个线程重新唤醒它。

你可能意识到这份多线程规范没有提供产生新线程的机制。听起来是很不可思议,但这个设计是故意的。实际上托管方(例如执行 WebAssembly 模块的环境)需要自己来创建线程。这份规范提供了在多线程环境中安全有效地使用 WebAssembly 所需的各种工具。

深入探索

理论部分讲得够多了,该来点实战了!

要试用 WebAssembly 的多线程功能,首先我需要一个适合并发的任务。我选择的是渲染 Mandelbrot 分形这个经典问题,这是一个非常简单但需要密集计算的任务,很容易分成多个可以并行处理的部分。

渲染 Mandelbrot 集的传统方法是迭代图像的每个像素,(基于定义该集合的基础方程的迭代次数)计算这个像素的值,然后为其上色。原理非常简单:

复制代码
for (let y = 0; y < 1200; y++) {
for (let x = 0; x < 800; x++) {
const value = mandelbrot(x, y);
setpixel(x, y, value);
}
}

每个像素的值都完全独立于其他像素,所以我们可以很容易跨多个线程共享负载来加速上述操作。一种可能的方法是让每个线程计算一部分像素的值,然后将各个部分的结果返回主线程以生成最终图像。这不需要任何共享状态,因此可以使用 Web Worker 实现,无需 SharedArrayBuffer。但它不太适合用来测试 WebAssembly 的多线程功能。

另一种方法是使用单个索引来表示需要计算的下一个像素的位置,并使用一个 while 循环来连续获取该值,然后计算并上色像素,以此类推——如下所示:

复制代码
let index = 0;
while (index < 1200 * 800) {
index++;
const value = mandelbrot(x, y);
setpixel(index % 1200, Math.floor(index / 1200), value);
}

可以将多个线程设置为执行上述 while 循环,这样就能更快完成全部任务。但在这种情况下,index 值的状态会在多个线程之间共享,进而引发一些问题,我们之后会处理。不管怎样,我们将采用这种方法并将其转换为 WebAssembly 实现。

手写的并发程序

我喜欢手写 WebAssembly 代码(例如直接在原生指令集下用 WebAssembly 文本格式编写代码,而不是从更高级的语言编译过来);本文的示例非常适合手写代码,因为我想看看运行时到底提供了哪些多线程功能。

我没有手写整个应用程序,而是走了一些捷径,使用 WebAssembly Studio 将基本算法从 AssemblyScript 编译成 wasm。

下面是用 AssemblyScript 编写的 mandelbrot 算法:

复制代码
function iterateEquation(x0: f64, y0: f64, maxiterations: u32): u32 {
let a = 0.0,
b = 0.0,
rx = 0.0,
ry = 0.0,
ab: f64;
let iterations: u32 = 0;
while (iterations < maxiterations && rx * rx + ry * ry <= 4) {
rx = a * a - b * b + x0;
ab = a * b;
ry = ab + ab + y0;
a = rx;
b = ry;
iterations++;
}
return iterations;
}

WebAssembly 代码如下:

复制代码
(func $iterateEquation (param $p0 f64) (param $p1 f64) (param $p2 i32) (result i32)
(local $l0 i32)
(local $l1 f64)
(local $l2 f64)
(local $l3 f64)
(local $l4 f64)
loop $L0
get_local $l4
get_local $l4
f64.mul
get_local $l1
get_local $l1
f64.mul
f64.add
f64.const 0x1p+2 (;=4;)
f64.le
i32.const 0
get_local $l0
get_local $p2
i32.lt_u
select
if $I1
get_local $l2
get_local $l3
f64.mul
set_local $l1
get_local $l2
get_local $l2
f64.mul
get_local $l3
get_local $l3
f64.mul
f64.sub
get_local $p0
f64.add
tee_local $l4
set_local $l2
get_local $l1
get_local $l1
f64.add
get_local $p1
f64.add
tee_local $l1
set_local $l3
get_local $l0
i32.const 1
i32.add
set_local $l0
br $L0
end
end
get_local $l0
)

使用上面的函数和一些类似的实用程序(如颜色扩展、坐标转换等)后,我就能构建一个简单的循环使用上述策略渲染 Mandelbrot 了,具体来说就是获取下一个索引、递增、计算并设置像素值这样的循环。

这个函数有三个参数,cxcy 和 $diameter,它们只用来指示 mandelbrot 集合中的位置:

复制代码
(func $run (param $cx f64) (param $cy f64) (param $diameter f64)
(local $x i32)
(local $y i32)
(local $loc i32)
(block $myblock
(loop
;; load the next location that is being computed
(set_local $loc
(i32.load
(i32.const 0)
)
)
;; store the incremented version
(i32.store
(i32.const 0)
(i32.add
(get_local $loc)
(i32.const 1)
)
)
;; loop for 1200 * 800
(br_if $myblock
(i32.ge_u
(get_local $loc)
(i32.const 960000)
)
)
;; convert to coordinates
(set_local $y
(i32.div_u
(get_local $loc)
(i32.const 1200)
)
)
(set_local $x
(i32.rem_u
(get_local $loc)
(i32.const 1200)
)
)
;; compute the next mandelbrot step and store
(i32.store
(call $offsetFromCoordinate
(get_local $x)
(get_local $y)
)
(call $colour
(call $mandelbrot
(get_local $cx)
(get_local $cy)
(get_local $diameter)
(get_local $x)
(get_local $y)
)
)
)
;; repeat the current loop
(br 0)
)
)
)

我不打算深入探讨上面的代码示例中各种指令的细节,希望大家能一看就懂。值得一提的是一些应用程序状态的存储位置。

索引是用来指示下一个要计算的像素的,从加载索引并设置位置 loc0线Mandelbrot线offsetFromCoordinate 提供所需的偏移量(从位置 4 开始,以免覆盖上述索引!)。

那么该怎样改动这些代码来实现并发计算呢?

计算 mandelbrot 结果的函数和其他实用程序不是问题所在——它们是无状态的,也就是说它们肯定是线程安全的。此外,存储每个像素结果的写操作也不是问题,因为这些写操作永远不会写到同一个位置上。实际上唯一的问题是读取、递增和写入当前索引这部分内容,它使用内存地址 0,所以会受到并发读 / 写的影响,并且为了避免内存损坏需要原子化。

目前的多线程规范提供了原子加载、存储、读取 - 修改 - 写入和比较 - 交换操作。本例中,加载、递增和写入索引的全部过程都需要原子化,正好可以用上原子读取 - 修改 - 写入操作。

执行此步骤的原有(非线程安全)代码如下:

复制代码
;; load the next location that is being computed
(set_local $loc
(i32.load
(i32.const 0)
)
)
;; store the incremented version
(i32.store
(i32.const 0)
(i32.add
(get_local $loc)
(i32.const 1)
)
)

它可以用原子化等效操作替换如下:

复制代码
(set_local $loc
(i32.atomic.rmw.add
(i32.const 0)
(i32.const 1)
)
)

这里 i32.atomic.rmw.add 执行的是原子读取 - 修改 - 写入的原子添加操作,上面示例中 0 和 1 两个参数会使位于第 0 个存储器地址的值加 1。

很简单嘛!那么我们如何编译和运行这段代码呢?

WebAssembly 二进制工具包有许多用于处理 wasm 模块的相对底层的工具,其中就有 wat2wasm;这是一种将 WebAssembly 文本格式转换为发送到浏览器的二进制模块格式的工具。此工具有自己的功能标志,可用来转换使用 post-MVP 功能的模块,如下所示:

复制代码
wat2wasm --enable-threads mandelbrot.wat -o mandelbrot.wasm

Wasm 线性内存既可以由模块本身创建,也可以在创建模块时导入。在本例中我们需要将内存标记为“共享”并将其提供给多个模块(每个模块都位于不同的 Web worker 中)。线性内存内部使用了一个 SharedArrayBuffer,这也就是为什么这段代码目前只能跑在 Chrome 上的原因之一。

下面的代码创建了一个共享内存实例,生成许多 Web worker,然后等待它们全部通报完成状态,最后将内存中的内容渲染到画布上:

复制代码
const memory = new WebAssembly.Memory({
initial: 80,
maximum: 80,
shared: true
});
// https://commons.wikimedia.org/wiki/File:Mandel_zoom_04_seehorse_tail.jpg
const config = {
x: -0.743644786,
y: 0.1318252536,
d: 0.00029336
};
const workerCount = 4;
let doneCount = 0;
// spawn the required number of workers
for (let i = 0; i < workerCount; i++) {
const worker = new Worker("worker.js");
worker.onmessage = e => {
doneCount++;
// have they all finished?
if (doneCount === workerCount) {
// copy the contents of wasm linear memory to a canvas element
const canvasData = new Uint8Array(memory.buffer, 4, 1200 * 800 * 4);
const context = document.querySelector("canvas").getContext("2d");
const imageData = context.createImageData(1200, 800);
imageData.data.set(canvasData);
context.putImageData(imageData, 0, 0);
}
};
worker.postMessage({ memory, config });
}

上面的代码使用 postMessage 发送这段共享内存,还使用了 config 对象描述要渲染给每个 worker 的位置。

下面是 worker.js 脚本:

复制代码
onmessage = ({ data }) => {
const {
memory,
config: { x, y, d }
} = data;
fetch("mandlbrot.wasm")
.then(response => response.arrayBuffer())
.then(bytes =>
WebAssembly.instantiate(bytes, {
env: {
memory
}
})
)
.then(({ instance }) => {
instance.exports.run(x, y, d, id);
postMessage("done");
});
};

它只用来实例化 WebAssembly 模块,提供共享内存,然后让它运行,最后将消息发送回主线程。让人感到惊喜的是,只要写这么少的代码就能把负载分配在多个线程上了。

回顾一下,每个线程不断获取要从共享内存计算的下一个像素位置,然后将结果写入同一段共享内存中。所有像素都计算完毕后所有线程都会终止,最终生成一幅经典的 mandelbrot 分形:

那么,这种方法有多快?

我的机器有 4 个核心,我测得的成绩如下:

  • 1 个线程 - 11 秒
  • 2 个线程 - 5.7 秒
  • 4 个线程 - 4.2 秒

速度显著提升!

最后我想换种方法重新渲染一遍,这次每个像素根据计算它的线程来上色。

下面是完整的 mandelbrot 结果:

如果你不熟悉 Mandelbrot 集合的工作机制的话,注意这里每个像素的颜色取决于基础方程“逃逸”所需的迭代次数。暗区是等式永远不会逃逸并无限迭代的地方(直至达到上界)。正因如此,黑暗区域需要花费更多的计算时间。

下面是由线程上色的过程:

我发现这是一幅引人入胜的画面!

在图像顶部,你可以看到第一个线程在分形的一些较简单的部分上进展迅速。接下来你会看到有些区域中各个线程一个像素接一个像素地轮流工作。渲染到暗区时方程会无限迭代,所有四个线程都会轮流运算,而第一个算完的线程会一路跑下去画完这条线——如右边的条纹图案所示。

结论

WebAssembly 多线程支持是一个非常有趣的新功能,它为 WebAssembly 带来了共享内存、原子操作和 wait/notify 操作符。可惜这个功能与 SharedArrayBuffer 有一些关联,因此我们要等到 Chrome 以外的浏览器解决相关漏洞,才能看到这一功能得到广泛支持。等到那一天来临时,我相信浏览器中会出现一些非常强大的应用程序!

如果你想深入研究相关代码的话,所有内容都放到了 GitHub 上。你还可以在 Chrome 浏览器中运行代码

英文原文:
https://blog.scottlogic.com/2019/07/15/multithreaded-webassembly.html

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论