发布时间:2025 年 1 月 30 日
与原生应用一样,许多 Web 上的 WebAssembly 应用也能从多线程技术中受益。多线程可以并行执行更多工作,并将繁重工作从主线程转移出去,以避免延迟问题。直到最近,此类多线程应用可能会出现一些与分配和 I/O 相关的常见问题。幸运的是,Emscripten 中的最新功能可以大大帮助解决这些问题。本指南介绍了这些功能如何在某些情况下将速度提高 10 倍或更多。
扩缩
下图显示了纯数学工作负载中的高效多线程扩缩(来自我们将在本文中使用的基准测试):
这项指标衡量的是纯计算能力,即每个 CPU 核心可以单独执行的计算,因此核心越多,性能就越好。这种性能逐渐提升的下降线正是良好扩缩的表现。这表明,尽管使用 Web 工作器作为并行的基础、使用 Wasm 而非真正的原生代码,以及其他可能看起来不太理想的细节,但 Web 平台可以非常出色地执行多线程原生代码。
堆管理:malloc
/free
free
malloc
和 free
是所有线性内存语言(例如 C、C++、Rust 和 Zig)中的重要标准库函数,用于管理所有非完全静态或非栈内存。Emscripten 默认使用 dlmalloc
,这是一种紧凑但高效的实现方式(它还支持 emmalloc
,后者更加紧凑,但在某些情况下速度较慢)。不过,由于 dlmalloc
会对每个 malloc
/free
进行锁定(因为只有一个全局分配器),因此其多线程性能受到限制。因此,如果您在多个线程中同时进行大量分配,可能会遇到争用和运行缓慢的问题。运行非常耗用 malloc
的基准测试时,会发生以下情况:
不仅性能不会随着核心数量的增加而提高,反而会越来越差,因为每个线程最终都会长时间等待 malloc
锁。这是最糟糕的情况,但如果有足够的分配,在实际工作负载中也可能会发生这种情况。
mimalloc
存在经过多线程优化的 dlmalloc
版本,例如 ptmalloc3
,它会为每个线程实现单独的分配器实例,从而避免争用。还有一些其他分配器支持多线程优化,例如 jemalloc
和 tcmalloc
。Emscripten 决定专注于近期的 mimalloc
项目,这是 Microsoft 精心设计的分配器,具有非常出色的可移植性和性能。使用方式如下:
emcc -sMALLOC=mimalloc
以下是使用 mimalloc
的 malloc
基准测试的结果:
太好了!现在,性能可以高效扩展,随着每个核心的增加而越来越快。
如果您仔细查看前两个图表中单核性能数据,就会发现 dlmalloc
花费了 2660 毫秒,而 mimalloc
仅花费了 1466 毫秒,速度提升了近 2 倍。这表明,即使在单线程应用中,您也可能会受益于 mimalloc
的更复杂的优化,但请注意,这会增加代码大小和内存用量(因此,dlmalloc
仍然是默认选项)。
文件和 I/O
许多应用都需要出于各种原因使用文件。例如,在游戏中加载关卡,或在图片编辑器中加载字体。即使 printf
这样的操作也会在底层使用文件系统,因为它会通过将数据写入 stdout
来进行输出。
在单线程应用中,这通常不是问题,如果您只需要 printf
,Emscripten 会自动避免关联完整文件系统支持。不过,如果您确实使用文件,则多线程文件系统访问会很棘手,因为文件访问必须在线程之间同步。Emscripten 中的原始文件系统实现(由于它是使用 JavaScript 实现的,因此称为“JS FS”)采用了仅在主线程中实现文件系统的简单模型。每当其他线程想要访问文件时,都会将请求代理到主线程。这意味着,另一个线程会阻塞在跨线程请求上,而主线程最终会处理该请求。
如果只有主线程访问文件(这是一种常见模式),则此简单模型是最佳选择。但是,如果其他线程执行读写操作,就会出现问题。首先,主线程最终会为其他线程执行工作,从而导致用户可见的延迟时间。然后,后台线程最终会等待主线程空闲,以便执行所需的工作,因此速度会变慢(更糟糕的是,如果主线程当前正在等待该工作线程,您最终可能会陷入死锁)。
WasmFS
为了解决此问题,Emscripten 提供了新的文件系统实现 WasmFS。WasmFS 采用 C++ 编写并编译为 Wasm,而原始文件系统则采用 JavaScript 编写。WasmFS 通过将文件存储在 Wasm 线性内存(所有线程共享)中,支持从多个线程访问文件,同时最大限度地降低开销。现在,所有线程都可以以相同的性能执行文件 I/O,而且通常还可以避免互相阻塞。
一项简单的文件系统基准测试表明,与旧版 JS FS 相比,WasmFS 具有巨大优势。
这会比较直接在主线程上运行文件系统代码与在单个 pthread 上运行文件系统代码的性能。在旧版 JS FS 中,每个文件系统操作都必须代理到主线程,这使得在 pthread 上执行操作的速度慢了几个数量级!这是因为,JS 文件系统不仅会读取/写入一些字节,还会进行跨线程通信,这涉及锁、队列和等待。相比之下,WasmFS 可以从任何线程平等地访问文件,因此图表显示主线程和 pthread 之间实际上没有区别。因此,在 pthread 上,WasmFS 的速度比 JS FS 快了 32 倍。
请注意,主线程也有差异,其中 WasmFS 的速度是 2 倍。这是因为 JS FS 会针对每个文件系统操作调用 JavaScript,而 WasmFS 会避免这种情况。WasmFS 仅在必要时(例如使用 Web API)使用 JavaScript,这会使大多数 WasmFS 文件保留在 Wasm 中。此外,即使需要 JavaScript,WasmFS 也可以使用辅助线程(而非主线程)来避免出现用户可见的延迟。因此,即使您的应用不是多线程的(或者是多线程的,但仅在主线程中使用文件),您也可能会发现使用 WasmFS 后速度有所提升。
使用 WasmFS,如下所示:
emcc -sWASMFS
WasmFS 用于生产环境,并且被视为稳定版,但尚不支持旧版 JS FS 的所有功能。另一方面,它确实包含一些重要的新功能,例如对源私有文件系统 (OPFS) 的支持(强烈建议将其用于永久存储)。除非您需要的功能尚未移植,否则 Emscripten 团队建议您使用 WasmFS。
总结
如果您的多线程应用执行大量分配或使用文件,那么使用 WasmFS 和/或 mimalloc
可能会带来很大的好处。只需使用本文中所述的标志重新编译,即可在 Emscripten 项目中轻松试用这两种方法。
即使您不使用线程,也可能需要尝试这些功能:如前所述,更现代的实现会进行优化,在某些情况下,即使在单核上,优化效果也会明显。