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 之间同步共享数据状态(共享状态多线程)。
POSIX 线程 (Pthreads) API,以及
Wasm Workers API。
Pthreads API 在原生 C 编程和 POSIX 标准中有着悠久的历史,而 Wasm Workers API 仅适用于 Emscripten 编译器。
这两个 API 提供的功能集基本相同,但存在重要的区别,本文档旨在解释这些区别,以帮助您决定应该选择哪个 API。
这两个多线程 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_ASM
和EM_JS
API 在调用线程上执行 JS 代码。两者都可以调用 JS 库函数(使用
--js-library
指令链接)在调用线程上执行 JS 代码。pthreads 和 Wasm Workers 都不能与
-sSINGLE_FILE
链接器标志一起使用。
然而,它们的区别更为显著。
只有 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 实现了 POSIX 取消点(pthread_cancel()
,pthread_testcancel()
)的概念。
Wasm Workers 通过不启用该概念而更加轻量级和高性能。
创建新的 Worker 可能很慢。在 JavaScript 中生成 Worker 是一个异步操作。为了支持同步 pthread 启动(对于需要它的应用程序)并提高线程启动性能,pthreads 托管在缓存的 Emscripten 运行时管理的 Worker 池中。
Wasm Workers 省略了这个概念,因此 Wasm Workers 将始终异步启动。如果需要检测 Wasm Worker 何时启动,请在 Worker 及其创建者之间手动发布 ping-pong 函数和回复对。如果需要快速启动新线程,请考虑自行管理 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,请参见此处。
emscripten/wasm_worker.h
中提供的多线程同步原语(emscripten_lock_*
、emscripten_semaphore_*
、emscripten_condvar_*
)可以在 pthreads 中自由调用,但 Wasm Workers 无法利用 Pthread API 中的任何同步功能(pthread_mutex_*
、pthread_cond_
、pthread_rwlock_*
等),因为它们缺少所需的 pthread 运行时。
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 没有这个概念。
为了在其他线程上灵活地同步执行代码,并实现对例如 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 提供的另一个有助于可移植性的仿真功能是,emscripten_get_now()
返回的时间值在所有线程之间同步到一个公共时间基准。
Wasm Workers 省略了这个概念,建议在 Wasm Worker 中使用函数 emscripten_performance_now()
进行高性能计时,并避免比较 Workers 之间的结果值,或手动同步它们。
emscripten/html5.h
中提供的多线程输入 API 仅适用于 pthread API。调用任何 emscripten_set_*_callback_on_thread()
函数时,可以选择目标 pthread 作为接收事件的接收者。
对于 Wasm Workers,如果需要,应手动实现从主浏览器线程到 Wasm Worker 的“反向代理”事件,例如使用 emscripten_wasm_worker_post_function_*()
API 系列。
但是请注意,反向代理输入事件有一个缺点,它会阻止安全敏感操作,例如全屏请求、指针锁定和音频播放恢复,因为处理输入事件与执行初始操作的事件回调上下文分离。
pthread_mutex_*
的互斥锁实现有一些不同的创建选项,其中一个是“递归”互斥锁。
emscripten_lock_*
API 实现的锁是非递归的(并且不提供递归选项)。
Pthreads 还提供了一种编程保护措施,防止一个线程释放另一个线程拥有的锁。 emscripten_lock_*
API 不跟踪锁的所有权。
Pthreads 对动态内存分配有固定的依赖性,并执行 malloc
和 free
调用来分配线程特定数据、堆栈和 TLS 槽。
除了辅助函数 emscripten_malloc_wasm_worker()
之外,Wasm Workers 不依赖于动态内存分配器。内存分配需求由调用者在 Worker 创建时满足,并且可以根据需要静态放置。
Pthreads 的磁盘大小开销大约为几百 KB。另一方面,Wasm Workers 运行时针对小型部署进行了优化,磁盘上只有几百个字节。
要进一步了解 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_* API 或 C++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 Worker 时,必须为创建的 Worker 的 LLVM 数据堆栈创建一个内存数组。此数据堆栈通常仅包含已被 LLVM“溢出”到内存中的局部变量,例如包含大型数组、结构体或其他通过内存地址引用的变量。此堆栈将不包含控制流信息。
由于 WebAssembly 不支持虚拟内存,因此为 Wasm Workers 和主线程定义的 LLVM 数据堆栈的大小在运行时无法增长。因此,如果 Worker(或主线程)的堆栈空间不足,程序行为将是不确定的。使用 Emscripten 链接器标志 -sSTACK_OVERFLOW_CHECK=2 将运行时堆栈溢出检查添加到程序代码中,以便在开发过程中检测这些情况。
请注意,为了避免执行两次单独的分配,Wasm Worker 的 TLS 内存将位于 Wasm Worker 堆栈空间的底端(低内存地址)。
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 构建的 JS 文件轻松生成 Worker
您希望将单个单独的编译程序生成为 Worker,而不是主线程程序所代表的程序,并且主线程和 Worker 程序不共享公共代码
您不想要求使用 SharedArrayBuffer 或设置 COOP+COEP 标头
您只需要使用 postMessage() 函数调用与 Worker 进行异步通信
您希望创建一个或多个在新线程中同步计算的 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/
。