Wasm Workers API

Wasm Workers API 使 C/C++ 代码能够利用 Web Workers 和共享的 WebAssembly.Memory (SharedArrayBuffer) 通过类似 Web 的直接编程 API 构建多线程程序。

快速示例

#include <emscripten/wasm_worker.h>
#include <stdio.h>

void run_in_worker()
{
  printf("Hello from Wasm Worker!\n");
}

int main()
{
  emscripten_wasm_worker_t worker = emscripten_malloc_wasm_worker(/*stackSize: */1024);
  emscripten_wasm_worker_post_function_v(worker, run_in_worker);
}

通过在编译和链接步骤中传递 Emscripten 标志 -sWASM_WORKERS 来构建代码。示例代码在主浏览器线程上创建一个新的 Worker,该 Worker 共享相同的 WebAssembly.Module 和 WebAssembly.Memory 对象。然后将 postMessage() 传递给 Worker,要求它执行函数 run_in_worker() 以打印字符串。

要显式控制创建 worker 时的内存分配位置,请使用 emscripten_create_wasm_worker() 函数。此函数需要一个足够大的内存区域来容纳 worker 的堆栈和 TLS 数据。您可以使用 __builtin_wasm_tls_size() 在运行时找出程序的 TLS 数据需要多少空间。

简介

在 WebAssembly 程序中,包含应用程序状态的 Memory 对象可以在多个 Worker 之间共享。这使得可以直接、高性能地(如果不小心,则会出现竞争!)访问在多个 Worker 之间同步共享数据状态(共享状态多线程)。

Emscripten 支持两个多线程 API 来利用此 Web 功能
  • POSIX 线程 (Pthreads) API,以及

  • Wasm Workers API。

Pthreads API 在原生 C 编程和 POSIX 标准中有着悠久的历史,而 Wasm Workers API 仅适用于 Emscripten 编译器。

这两个 API 提供的功能集基本相同,但存在重要的区别,本文档旨在解释这些区别,以帮助您决定应该选择哪个 API。

Pthreads 与 Wasm Workers:应该使用哪个?

这两个多线程 API 的目标受众和用例略有不同。

Pthreads API 的重点是可移植性和跨平台兼容性。此 API 最适合在可移植性最重要的场景中使用,例如,当一个代码库被交叉编译到多个平台时,例如构建原生 Linux x64 可执行文件和基于 Emscripten WebAssembly 的网站。

Emscripten 中的 Pthreads API 致力于仔细模拟兼容性和原生 Pthreads 平台已经提供的功能。这有助于将大型 C/C++ 代码库移植到 WebAssembly。

另一方面,Wasm Workers API 旨在“直接映射”到 Web 上存在的 Web 多线程原语,并就此为止。如果应用程序仅开发用于 WebAssembly,并且不关心可移植性,那么使用 Wasm Workers 可以带来巨大的好处,例如更简单的编译输出、更低的复杂性、更小的代码大小以及可能更好的性能。

然而,这种好处可能不是显而易见的胜利。Pthreads API 旨在从同步 C/C++ 语言中使用,而 Web Workers 旨在从异步 JavaScript 中使用。WebAssembly C/C++ 程序可能会发现自己处于中间位置。

Pthreads 和 Wasm Workers 有几个相似之处

  • 两者都可以使用 emscripten_atomic_* Atomics API,

  • 两者都可以使用 GCC __sync_* Atomics API,

  • 两者都可以使用 C11 和 C++11 Atomics API,

  • 两种类型的线程都有本地堆栈。

  • 两种类型的线程都通过 thread_local (C++11), _Thread_local (C11) 和 __thread (GNU11) 关键字支持线程本地存储 (TLS)。

  • 两种类型的线程都通过显式链接的 Wasm 全局变量支持 TLS(例如,参见 test/wasm_worker/wasm_worker_tls_wasm_assembly.c/.S 代码)

  • 两种类型的线程都有线程 ID 的概念(pthreads 为 pthread_self(),Wasm Workers 为 emscripten_wasm_worker_self_id()

  • 两种类型的线程都可以执行基于事件和无限循环的编程模型。

  • 两者都可以使用 EM_ASMEM_JS API 在调用线程上执行 JS 代码。

  • 两者都可以调用 JS 库函数(使用 --js-library 指令链接)在调用线程上执行 JS 代码。

  • pthreads 和 Wasm Workers 都不能与 -sSINGLE_FILE 链接器标志一起使用。

然而,它们的区别更为显著。

Pthreads 可以代理 JS 函数

只有 pthreads 可以使用 MAIN_THREAD_EM_ASM*()MAIN_THREAD_ASYNC_EM_ASM() 函数以及 JS 库中的 foo__proxy: 'sync'/'async' 代理指令。

另一方面,Wasm Workers 不提供内置的 JS 函数代理机制。要使用 Wasm Workers 代理 JS 函数,可以将该函数的地址显式传递给 emscripten_wasm_worker_post_function_* API。

如果需要在 Worker 内部同步等待已发布函数完成,请使用 emscripten_wasm_worker_*() 线程同步函数之一,使调用线程休眠,直到被调用方完成操作。

请注意,Wasm Workers 不能

Pthreads 具有取消点

以性能和代码大小为代价,pthreads 实现了 POSIX 取消点pthread_cancel()pthread_testcancel())的概念。

Wasm Workers 通过不启用该概念而更加轻量级和高性能。

Pthreads 可以同步启动 - Wasm Workers 始终异步启动

创建新的 Worker 可能很慢。在 JavaScript 中生成 Worker 是一个异步操作。为了支持同步 pthread 启动(对于需要它的应用程序)并提高线程启动性能,pthreads 托管在缓存的 Emscripten 运行时管理的 Worker 池中。

Wasm Workers 省略了这个概念,因此 Wasm Workers 将始终异步启动。如果需要检测 Wasm Worker 何时启动,请在 Worker 及其创建者之间手动发布 ping-pong 函数和回复对。如果需要快速启动新线程,请考虑自行管理 Wasm Workers 池。

Pthread 拓扑是扁平的 - Wasm Workers 是分层的

在 Web 上,如果一个 Worker 生成自己的子 Worker,它将创建一个主线程无法直接访问的嵌套 Worker 层次结构。为了避免这种拓扑结构带来的可移植性问题,pthreads 在底层展平了 Worker 创建链,以便只有主浏览器线程才能生成线程。

Wasm Workers 没有实现这种拓扑扁平化,在 Wasm Worker 中创建 Wasm Worker 将产生嵌套的 Worker 层次结构。如果需要在 Wasm Worker 中创建 Wasm Workers,请考虑所需的层次结构类型,并在必要时通过将 Worker 创建发布到主线程来手动展平层次结构。

请注意,对嵌套 Workers 的支持因浏览器而异。截至 2022 年 2 月,Safari 不支持嵌套 Workers。有关 polyfill,请参见此处

Pthreads 可以使用 Wasm Worker 同步 API,但反之则不然

emscripten/wasm_worker.h 中提供的多线程同步原语(emscripten_lock_*emscripten_semaphore_*emscripten_condvar_*)可以在 pthreads 中自由调用,但 Wasm Workers 无法利用 Pthread API 中的任何同步功能(pthread_mutex_*pthread_cond_pthread_rwlock_* 等),因为它们缺少所需的 pthread 运行时。

Pthreads 具有“线程主”函数和 atexit 处理程序

pthreads 的启动/执行模型是启动执行给定的线程入口点函数。当该函数退出时,pthread 也将(默认情况下)退出,并且托管该 pthread 的 Worker 将返回到 Worker 池以等待在其上创建另一个线程。

Wasm Workers 实现的是类似 Web 的直接模型,其中新创建的 Worker 空闲在其事件循环中,等待将函数发布到它。当这些函数完成时,Worker 将返回到其事件循环,等待接收更多要执行的函数(或 worker 范围的 Web 事件)。Wasm Worker 仅在调用 emscripten_terminate_wasm_worker(worker_id)emscripten_terminate_all_wasm_workers() 时退出。

Pthreads 允许通过 pthread_atexit 注册线程退出处理程序,该处理程序将在线程退出时调用。Wasm Workers 没有这个概念。

Pthreads 具有每个线程的传入代理消息队列,而 Wasm Workers 没有

为了在其他线程上灵活地同步执行代码,并实现对例如 MEMFS 文件系统和屏幕外帧缓冲区(从 Worker 模拟的 WebGL)功能的支持 API,主浏览器线程和每个 pthread 都有一个系统支持的“代理消息队列”来接收消息。

这使得用户代码可以调用 API 函数,emscripten_sync_run_in_main_runtime_thread()emscripten_async_run_in_main_runtime_thread()emscripten_dispatch_to_thread() 等,从 emscripten/threading.h 执行代理调用。

Wasm Workers 不提供此功能。如果需要,用户应使用常规多线程同步编程技术(互斥锁、futex、信号量等)手动实现此类消息传递。

Pthreads 同步挂钟时间

Pthreads 提供的另一个有助于可移植性的仿真功能是,emscripten_get_now() 返回的时间值在所有线程之间同步到一个公共时间基准。

Wasm Workers 省略了这个概念,建议在 Wasm Worker 中使用函数 emscripten_performance_now() 进行高性能计时,并避免比较 Workers 之间的结果值,或手动同步它们。

输入事件 API 仅反向代理到 pthreads

emscripten/html5.h 中提供的多线程输入 API 仅适用于 pthread API。调用任何 emscripten_set_*_callback_on_thread() 函数时,可以选择目标 pthread 作为接收事件的接收者。

对于 Wasm Workers,如果需要,应手动实现从主浏览器线程到 Wasm Worker 的“反向代理”事件,例如使用 emscripten_wasm_worker_post_function_*() API 系列。

但是请注意,反向代理输入事件有一个缺点,它会阻止安全敏感操作,例如全屏请求、指针锁定和音频播放恢复,因为处理输入事件与执行初始操作的事件回调上下文分离。

Pthread 与 emscripten_lock 实现差异

pthread_mutex_* 的互斥锁实现有一些不同的创建选项,其中一个是“递归”互斥锁。

emscripten_lock_* API 实现的锁是非递归的(并且不提供递归选项)。

Pthreads 还提供了一种编程保护措施,防止一个线程释放另一个线程拥有的锁。 emscripten_lock_* API 不跟踪锁的所有权。

内存需求

Pthreads 对动态内存分配有固定的依赖性,并执行 mallocfree 调用来分配线程特定数据、堆栈和 TLS 槽。

除了辅助函数 emscripten_malloc_wasm_worker() 之外,Wasm Workers 不依赖于动态内存分配器。内存分配需求由调用者在 Worker 创建时满足,并且可以根据需要静态放置。

生成代码大小

Pthreads 的磁盘大小开销大约为几百 KB。另一方面,Wasm Workers 运行时针对小型部署进行了优化,磁盘上只有几百个字节。

API 差异

要进一步了解 Pthreads 和 Wasm Workers 之间可用的不同 API,请参阅下表。

特性 Pthreads Wasm Workers
线程终止 线程调用
pthread_exit(status)
或主线程调用
pthread_kill(code)
Worker 无法自行终止,父线程通过调用 来终止
emscripten_terminate_wasm_worker(worker)
线程堆栈 在 pthread_attr_t 结构中指定。 使用 函数显式管理线程堆栈区域,或
emscripten_create_wasm_worker_*_tls()
使用 API 自动分配堆栈+TLS 区域。
emscripten_malloc_wasm_worker()
API。
线程局部存储 (TLS) 透明地支持。 使用 显式支持,或者通过 自动支持。
emscripten_create_wasm_worker_*_tls()
使用 API 自动分配堆栈+TLS 区域。
emscripten_malloc_wasm_worker()
API。
线程 ID 创建 pthread 会获取其 ID。调用 以获取调用线程的 ID。
pthread_self()
创建 Worker 会获取其 ID。调用 以获取调用线程的 ID。
emscripten_wasm_worker_self_id()
高分辨率计时器 emscripten_get_now() emscripten_performance_now()
主线程上的同步阻塞 同步原语在内部回退到忙等待循环。 显式自旋与休眠同步原语。
Futex API
emscripten_futex_wait
emscripten_futex_wake
在 emscripten/threading.h 中
emscripten_atomic_wait_u32
emscripten_atomic_wait_u64
emscripten_atomic_notify
在 emscripten/atomic.h 中
异步 futex 等待 不适用
emscripten_atomic_wait_async()
emscripten_*_async_acquire()
但是,这些是一个难以处理的陷阱,请阅读 WebAssembly/threads 问题 #176
C/C++ 函数代理 emscripten/threading.h API 用于将函数调用代理到其他线程。 使用 emscripten_wasm_worker_post_function_*() API 将函数消息发送到其他线程。这些消息遵循事件队列语义,而不是代理队列语义。
构建标志 使用 -pthread 编译和链接 使用 -sWASM_WORKERS 编译和链接
预处理器指令 __EMSCRIPTEN_SHARED_MEMORY__=1 和 __EMSCRIPTEN_PTHREADS__=1 处于活动状态 __EMSCRIPTEN_SHARED_MEMORY__=1 和 __EMSCRIPTEN_WASM_WORKERS__=1 处于活动状态
JS 库指令 USE_PTHREADS 和 SHARED_MEMORY 处于活动状态 USE_PTHREADS、SHARED_MEMORY 和 WASM_WORKER 处于活动状态
原子 API 支持,使用任何 __atomic_* API__sync_* APIC++11 std::atomic API
非递归互斥锁
pthread_mutex_*
emscripten_lock_*
递归互斥锁
pthread_mutex_*
不适用
信号量 不适用
emscripten_semaphore_*
条件变量
pthread_cond_*
emscripten_condvar_*
读写锁
pthread_rwlock_*
不适用
自旋锁
pthread_spin_*
emscripten_lock_busyspin*
WebGL 离屏帧缓冲区
Supported with -sOFFSCREEN_FRAMEBUFFER
Not supported.

Wasm Workers 堆栈大小注意事项

实例化 Wasm Worker 时,必须为创建的 Worker 的 LLVM 数据堆栈创建一个内存数组。此数据堆栈通常仅包含已被 LLVM“溢出”到内存中的局部变量,例如包含大型数组、结构体或其他通过内存地址引用的变量。此堆栈将不包含控制流信息。

由于 WebAssembly 不支持虚拟内存,因此为 Wasm Workers 和主线程定义的 LLVM 数据堆栈的大小在运行时无法增长。因此,如果 Worker(或主线程)的堆栈空间不足,程序行为将是不确定的。使用 Emscripten 链接器标志 -sSTACK_OVERFLOW_CHECK=2 将运行时堆栈溢出检查添加到程序代码中,以便在开发过程中检测这些情况。

请注意,为了避免执行两次单独的分配,Wasm Worker 的 TLS 内存将位于 Wasm Worker 堆栈空间的底端(低内存地址)。

Wasm Workers 与之前的 Emscripten Worker API

Emscripten 在 emscripten.h 头文件中提供了第二个 Worker API。此 Worker API 早于 SharedArrayBuffer 出现,并且与 Wasm Workers API 非常不同,只是这两个 API 的命名由于历史原因而相似。

这两个 API 都允许从主线程生成 Web Workers,但语义不同。

使用 Worker API,用户可以从自定义 URL 生成 Web Worker。此 URL 可以指向未用 Emscripten 编译的完全独立的 JS 文件,以从任意 URL 加载 Worker。使用 Wasm Workers,不会指定自定义 URL:Wasm Workers 将始终生成在与主程序相同的 WebAssembly+JavaScript 上下文中进行计算的 Web Worker。

Worker API 不与 SharedArrayBuffer 集成,因此与加载的 Worker 的交互将始终是异步的。然而,Wasm Workers 构建在 SharedArrayBuffer 之上,每个 Wasm Worker 都在主线程的相同 WebAssembly 内存地址空间中共享和计算。

Worker API 和 Wasm Workers API 都为用户提供了将 postMessage() 函数调用发布到 Worker 的能力。在 Worker API 中,此消息发布仅限于需要从主线程向 Worker 发起(使用 <emscripten.h> 中的 emscripten_call_worker()emscripten_worker_respond() API)。然而,使用 Wasm Workers,还可以将 postMessage() 函数调用发布到其父(拥有)线程。

如果使用 Emscripten Worker API 发布函数调用,则要求目标 Worker URL 指向 Emscripten 编译的程序(因此它具有 Module 结构来定位函数名称)。只有已导出到 Module 对象的函数才是可调用的。使用 Wasm Workers,可以发布任何 C/C++ 函数,并且无需导出。

在以下情况下使用 Emscripten Worker API:
  • 您希望从未使用 Emscripten 构建的 JS 文件轻松生成 Worker

  • 您希望将单个单独的编译程序生成为 Worker,而不是主线程程序所代表的程序,并且主线程和 Worker 程序不共享公共代码

  • 您不想要求使用 SharedArrayBuffer 或设置 COOP+COEP 标头

  • 您只需要使用 postMessage() 函数调用与 Worker 进行异步通信

在以下情况下使用 Wasm Workers API:
  • 您希望创建一个或多个在新线程中同步计算的 Wasm 模块上下文

  • 您希望从相同的代码库生成多个 Worker,并通过在 Worker 之间共享 WebAssembly 模块(目标代码)和内存(地址空间)来节省内存

  • 您希望使用原子基元和锁来同步协调线程间的通信。

  • 您的 Web 服务器已配置所需的 COOP+COEP 标头,以便在站点上启用 SharedArrayBuffer 功能。

限制

Wasm Workers 目前不支持以下构建选项:

  • -sSINGLE_FILE

  • 动态链接 (-sLINKABLE, -sMAIN_MODULE, -sSIDE_MODULE)

  • -sPROXY_TO_WORKER

  • -sPROXY_TO_PTHREAD

示例代码

有关不同 Wasm Workers API 功能的代码示例,请参阅目录 test/wasm_workers/