0%

WebAssembly解决JavaScript多线程难题

开发者都知道 JavaScript 是单线程的,即只有一个调用栈和堆,所有代码都在浏览器渲染进程的主线程中执行,当然也包括本文的主角,即 WebAssembly。

虽然 WebAssembly 由高级语言编译而成,而且执行速度比 JavaScript 要快得多。然而,如果让 WebAssembly 具备多线程的能力,浏览器引入 WebAssembly 代码后运行效率将得到显著的改善。示例

  • Google Earth: 其 Web 版本也使用 WebAssembly 多线程
  • FFMPEG.WASM :流行的 FFmpeg 多媒体工具链的 WebAssembly 版本,使用 WebAssembly 线程直接在浏览器中高效地编码视频。

WebAssembly 多线程支持是 WebAssembly 最重要的性能增强之一,其允许开发者在单独的内核上并行运行部分代码,或者在同一个内核中运行具有不同输入的同一份代码,从而最大限度的利用内核资源并减少总体执行时间。

1.WebAssembly 多线程如何工作

WebAssembly 线程并非一个单独的功能,而是多个组件的组合,其允许 WebAssembly 应用程序在 Web 上使用传统的多线程。

1.1 WebAssembly 组件之 Web Worker

浏览器中的多线程目前只能通过 Web Worker 实现。

因此,WebAssembly 的第一个组件是 Worker, WebAssembly 多线程支持需要使用新的 Worker 构造函数来创建新的底层线程。

1
2
3
4
5
6
const myWorker = new Worker("worker.js");

first.onchange = () => {
myWorker.postMessage([first.value]);
console.log("消息已传递给 worker");
};

每个线程都会加载 JavaScript 胶水,然后主线程使用 Worker.postMessage 方法与其他线程共享编译后的 WebAssembly.Module 以及共享的 WebAssembly.Memory,从而建立通信并允许所有这些线程在同一共享内存上运行相同的 WebAssembly 代码,而无需再次通过 JavaScript。

接下来一起看看 WebAssembly.Module 和 WebAssembly.Memory 的作用。

  • WebAssembly.Module

该对象包含已经由浏览器编译的无状态 WebAssembly 代码,可以高效地与 Worker 共享和多次实例化。

以下示例使用
WebAssembly.compileStreaming() 方法编译 simple.wasm 加载后的字节码,并将返回的 WebAssembly.Module 实例通过 postMessage 发送给 worker。

1
2
3
4
5
6
const worker = new Worker("wasm_worker.js");

WebAssembly.compileStreaming(fetch("simple.wasm")).then((mod) =>
worker.postMessage(mod),
// 发送 WebAssembly.Module 为 worker
);

wasm_worker.js 定义了模块需要使用的导入对象,然后创建一个事件处理器,以接受主线程发送的模块。在接收到模块后使用 WebAssembly.instantiate() 方法创建一个实例并调用其导出的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const importObject = {
imports: {
imported_func(arg) {
console.log(arg);
},
},
};
onmessage = (e) => {
console.log("module received from main thread");
const mod = e.data;
// 实例化模块
WebAssembly.instantiate(mod, importObject).then((instance) => {
instance.exports.exported_func();
});
};
  • WebAssembly.Memory

WebAssembly.Memory() 构造函数创建一个新的 Memory 对象,该对象的 buffer 属性是一个可调整大小的 ArrayBuffer ,其内存储的是 WebAssembly 实例所访问内存的原始字节码。

从 JavaScript 或 WebAssembly 中所创建的内存,可以由 JavaScript 或 WebAssembly 来访问及更改。

1
2
var memory = new WebAssembly.Memory({initial: 10, maximum: 100});
// 初始大小为 10 页(640KB),最大值设置为 100 页(6.4MB)

Web Worker 已经存在十多年了,受到广泛支持,并且不需要任何特殊标志。

1.2 WebAssembly 组件之 SharedArrayBuffer

image-20250318213121449

WebAssembly 内存由 JavaScript API 中的 WebAssembly.Memory 对象表示。 默认情况下,WebAssembly.Memory 是 ArrayBuffer 的包装器 ,只能由单个线程访问的原始字节缓冲区。

1
2
> new WebAssembly.Memory({initial:1, maximum:10}).buffer
ArrayBuffer {…}

为了支持多线程,WebAssembly.Memory 也获得了一个共享变体。 当通过 JavaScript API 或 WebAssembly 二进制文件本身使用 shared 标志创建时,其会成为 SharedArrayBuffer 的包装器,可以与其他线程共享并从任意一侧同时读取或修改。

1
2
> new WebAssembly.Memory({initial:1, maximum:10, shared:true}).buffer
SharedArrayBuffer {…}

与通常用于主线程和 Web Workers 之间通信的 postMessage 不同,SharedArrayBuffer 不需要复制数据,甚至不需要等待事件循环(Event Loop)来发送和接收消息。 相反,所有线程几乎都会立即看到任何更改,这使其成为传统同步原语(Synchronisation Primitives)更好的编译目标。

比如以下代码将 SharedArrayBuffer 传递给 worker 操作:

1
2
3
4
5
6
7
8
9
10
11
const myWorker = new Worker("worker.js");

if (crossOriginIsolated) {
// 全局 crossOriginIsolated 是只读属性,指示网站是否处于跨域隔离状态(cross-origin isolation)
// 该状态降低了旁路攻击(side-channel )的风险并解锁了一些功能
const buffer = new SharedArrayBuffer(16);
myWorker.postMessage(buffer);
} else {
const buffer = new ArrayBuffer(16);
myWorker.postMessage(buffer);
}

因为安全问题 Chrome 68 通过利用站点隔离功能在 2018 年才重启 SharedArrayBuffer,该功能可将不同的网站置于不同的进程中,并使使用 Spectre 等旁路攻击变得更加困难。 然而,这种缓解措施仍然仅限于 Chrome 桌面版,因为站点隔离是一项相当昂贵的功能,并且无法默认为低内存移动设备上的所有站点启用,其他供应商也尚未实现。

不过到 2020 年,Chrome 和 Firefox 都实现了站点隔离,以及网站通过 COOP 和 COEP 标头选择加入该功能的标准方法。 即使在低功耗设备上,选择加入机制也允许使用站点隔离,因为在低功耗设备上为所有网站启用站点隔离的成本太高。 要选择加入,请将以下标头添加到服务器配置中的主文档中:

1
2
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

通过以上方式就可以访问 SharedArrayBuffer,包括: SharedArrayBuffer 支持的 WebAssembly.Memory、精确计时器、内存测量以及出于安全原因需要隔离源的其他 API。

1.3 WebAssembly 组件之 WebAssembly 原子(Atomics)

虽然 SharedArrayBuffer 允许每个线程读取和写入同一内存,但为了正确通信,开发者需要确保其不会同时执行冲突的操作, 例如:一个线程可能开始从共享地址读取数据,而另一个线程正在写入数据,此类错误称为竞争条件。 为了防止竞争条件,开发者需要以某种方式同步这些访问,这就是原子操作的用武之地。

WebAssembly 原子是 WebAssembly 指令集的扩展,允许 “原子地” 读取和写入小数据单元,通常是 32 位和 64 位整数。 也就是说,确保没有两个线程同时读取或写入同一单元,从而在底层防止此类冲突。

此外,WebAssembly 原子还包含两种指令类型,即 “wait” 和 “notify”,从而允许一个线程在共享内存中的给定地址上休眠(“等待”),直到另一个线程通过 “通知” 将其唤醒。所有更高级别的同步原语,包括:通道、互斥锁和读写锁都建立在这些指令的基础上。

2.WebAssembly 多线程如何工作

WebAssembly 原子和 SharedArrayBuffer 是相对较新的功能,尚未在所有支持 WebAssembly 的浏览器中提供。

image-20250318213500646

为了确保所有用户都可以加载当前应用程序,需要构建两个不同版本的 Wasm 来实现渐进增强,一个版本支持多线程,另一个版本不支持,然后根据功能检测结果加载支持的版本。

要在运行时检测 WebAssembly 是否支持多线程可以使用 wasm-feature-detect 库,其用于检测当前环境支持哪些 WebAssembly 功能。

  • ✅ 在浏览器、Node 和 Deno 中运行
  • ✅ Tree-shakable(仅打包使用的探测器)
  • ✅ 作为 ES6、CommonJS 和 UMD 模块提供。
  • ✅ 兼容 CSP
  • ✅ 所有检测器加起来仅 ~730B gzipped

wasm-feature-detect 使用起来也非常简单,如下所示:

1
2
3
4
5
6
7
8
import {threads} from 'wasm-feature-detect';
const hasThreads = await threads();
// 支持 thread
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);

比如该库还能监测是否支持 SID:

1
2
3
4
5
6
import {simd} from "wasm-feature-detect";
if (await simd()) {
/* SIMD support */
} else {
/* No SIMD support */
}

Comlink 让 Web Workers 变得非常简单,其是一个小型库(1.1kB),消除了考虑 postMessage 的心理障碍,并隐藏了正在与 Worker 一起工作的事实。

在更抽象的层面上,Comlink 底层是基于 postMessage 和 ES6 代理的 RPC 实现。目前 Comlink 在 Github 通过 Apache-2.0 协议开源,有超过 10.7k 的 star,是一个优质的前端开源项目。

下面是 main.js 的示例:

1
2
3
4
5
6
7
8
9
10
import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";
async function init() {
const worker = new Worker("worker.js");
// WebWorkers 使用 `postMessage` 因此与 Comlink 兼容
const obj = Comlink.wrap(worker);
alert(`Counter: ${await obj.counter}`);
await obj.inc();
alert(`Counter: ${await obj.counter}`);
}
init();

下面是 worker.js 的代码示例:

1
2
3
4
5
6
7
8
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
const obj = {
counter: 0,
inc() {
this.counter++;
},
};
Comlink.expose(obj);
-------------本文结束感谢您的阅读-------------