WebAssembly 游戏开发技术专题进阶争议辩论4 / 5 已发布

WebAssembly 多线程游戏架构:SharedArrayBuffer、Atomics 与 Web Workers

SharedArrayBuffer 历史 · COOP/COEP 部署挑战 · Web Workers vs Wasm 线程 · 物理引擎线程化案例 · Atomics 设计模式

· 20 分钟阅读·4.8k 阅读·384
WebAssembly 多线程游戏架构:SharedArrayBuffer、Atomics 与 Web Workers — WebAssembly 游戏开发技术专题

WebAssembly 多线程游戏架构:SharedArrayBuffer、Atomics 与 Web Workers

多核 CPU 的算力,游戏开发者最难拿到的那一块

现代游戏引擎在本地原生平台上对多线程的利用已经相当成熟:物理引擎运行在独立线程,音频处理有专属线程,资源加载异步进行,渲染命令并发生成——多核 CPU 的算力被充分调动。但在浏览器 WebAssembly 环境中,同样的多线程架构在 2018 年初被几乎完全冻结,原因是一个影响整个行业的安全事件。

六年过去了,浏览器多线程的能力逐步恢复,但条件比原生平台更复杂:需要特定的 HTTP 响应头配置,有 Safari 兼容性历史债务,有不同 API 的语义差异。许多独立游戏开发者在尝试启用多线程时,面对一连串陌生的术语(COOP、COEP、Atomics、SAB),不知道从哪里开始,也不知道为什么需要这些配置。

本文系统梳理 WebAssembly 多线程的完整历史脉络,解释每一层技术限制背后的安全动机,提供实际可用的游戏线程架构参考,并正视在独立游戏实践中这些技术的真实适用边界。

SharedArrayBuffer 的跌宕历史:从发布到禁用到复活

理解浏览器多线程的现状,必须先理解 SharedArrayBuffer 走过的这段曲折历程——因为这段历史直接决定了今天你使用多线程时面对的所有额外配置要求。

2017 年 11 月:SharedArrayBuffer 正式上线

SharedArrayBuffer(SAB)是 JavaScript 提供的共享内存机制:它允许主线程和多个 Web Worker 线程共享同一块内存区域,不同线程可以直接读写这块内存,无需通过消息传递复制数据。这对高性能并发计算场景(物理引擎、图像处理)意义重大,因为大量数据不再需要在线程间来回复制。

2017 年 11 月,所有主流浏览器完成了 SAB 的实现,准备面向开发者开放。这也是 WebAssembly 多线程功能的基础——Wasm 线程通过 SAB 共享线性内存,实现真正意义上的并行计算。

2018 年 1 月:Spectre 漏洞,SAB 全面禁用

2018 年 1 月 3 日,Google Project Zero 公开披露了 Spectre 和 Meltdown 两个 CPU 级别的推测执行侧信道漏洞。Spectre 漏洞与 SAB 的关联在于:通过精心设计的基于时间差的测量代码,攻击者可以读取同一浏览器进程内任意内存位置的内容。而 SharedArrayBuffer 提供了高精度的计时能力(通过原子操作读取共享内存中的计数器)——这使得 SAB 成为 Spectre 攻击的理想工具。

各浏览器厂商在 48 小时内紧急禁用了 SharedArrayBuffer(Chrome 63 发布后数天即通过热更新撤回了 SAB 支持)。WebAssembly 多线程的发布计划随之全面搁置。

2020 年:COOP/COEP 方案,条件恢复

经过约两年的研究,浏览器厂商提出了一种允许在受控条件下重新启用 SAB 的方案,核心是两个 HTTP 响应头的组合:

Cross-Origin-Opener-Policy(COOP)要求页面与其他跨域页面进行进程隔离——设置 COOP: same-origin 后,页面无法通过 window.opener 引用其他跨域页面,因此攻击者无法从跨域页面注入恶意计时代码。

Cross-Origin-Embedder-Policy(COEP)要求页面加载的所有子资源(图片、脚本、WebAssembly 模块)必须明确声明允许跨域嵌入——设置 COEP: require-corp 后,任何未加 Cross-Origin-Resource-Policy 头的第三方资源无法被加载,消除了资源计时攻击面。

当这两个头同时设置时,页面进入"跨域隔离"(Cross-Origin Isolated)状态,此时 SharedArrayBuffer 重新可用。Chrome 92(2021 年 7 月)将这一要求推向生产环境,Firefox 和 Chrome 同步支持,Safari 的支持则推迟到 2022 年初(iOS Safari 的支持更晚)。

COOP/COEP 的实际部署挑战

了解了这两个 HTTP 头的背景,下一个问题是:它们在实际游戏部署中有多难配置?

自托管服务器的配置

如果游戏部署在自己控制的服务器上,配置相对直接:在服务器配置文件(Nginx、Apache、Node.js 服务器)中为游戏页面添加这两个响应头即可。通常 5 分钟内可以完成配置。

第三方资源的连锁问题

COEP 要求所有子资源都声明允许跨域嵌入。这意味着:如果游戏使用了任何第三方服务(CDN 上的字体、外部统计脚本、广告 SDK、社交登录 SDK),这些第三方资源必须也加上 Cross-Origin-Resource-Policy 响应头——而你无法控制这些第三方服务的响应头。

实际上,大量主流第三方服务(Google Fonts、某些广告 SDK、老版本的统计工具)并未添加这个响应头,导致游戏在启用 COEP 后这些资源会加载失败,产生功能断裂。对于依赖第三方服务的游戏(尤其是有广告变现需求的 HTML5 游戏),这是一个实质性的部署障碍。

平台托管的限制

在 itch.io、Newgrounds 等游戏托管平台发布游戏时,通常无法自行控制 HTTP 响应头。itch.io 在 2022 年底更新支持了 COOP/COEP,允许开发者在上传游戏时申请启用跨域隔离。但 Poki、CrazyGames 等平台各有其支持状态,开发者在目标平台上架前需要先确认多线程支持情况。

Safari/iOS 的历史遗留

iOS Safari 在 2022 年 Safari 15.2 才正式支持 SharedArrayBuffer(在 COOP/COEP 条件下)。更老的 iOS 设备无法接收系统更新,永久停留在不支持 SAB 的状态。对于以移动端为主要发行目标的 HTML5 游戏,多线程架构必须为不支持 SAB 的设备准备完整的单线程降级路径——这意味着本质上你要维护两套代码路径。

Web Workers vs Wasm 线程:两种多线程模型的本质区别

在浏览器环境中,有两种不同的多线程机制,理解它们的区别对架构设计至关重要。

Web Workers:基于消息传递的隔离线程

Web Workers 是浏览器提供的原生多线程 API。每个 Worker 运行在独立的 JavaScript 上下文中,拥有自己的内存堆,与主线程之间通过消息传递(postMessage/onmessage)通信。默认情况下,消息传递会复制数据(Structured Clone),两个线程不共享内存。

Web Workers 是真正的操作系统线程,可以在不同 CPU 核心上并行运行。但由于内存隔离,每次线程间通信都需要数据复制,对于大量数据的高频通信(每帧传递物理状态)效率较低。通过 Transferable Objects(ArrayBuffer 的所有权转移)可以避免复制,但转移后原线程无法再访问该数据,需要所有权明确交接。

Wasm 线程:基于共享内存的 POSIX 式线程

WebAssembly 的多线程提案(基于 SharedArrayBuffer)实现了接近 POSIX pthreads 语义的线程模型:多个线程共享同一个线性内存实例,线程之间通过读写共享内存交换数据,通过原子操作(Atomics)实现同步。

这种模型更接近本地原生多线程的语义,对于从 C/C++ 多线程代码(使用 pthreads)迁移到 Wasm 的场景尤其自然——Emscripten 可以将 pthreads 代码编译为使用 SharedArrayBuffer 的 Wasm 线程代码,基本无需手动改写线程逻辑。

代价是更高的部署复杂度(需要 COOP/COEP)和更复杂的并发安全保证(共享内存的竞争条件需要显式原子操作保护)。

混合使用策略

最实用的架构通常是两者组合:使用 Web Workers 隔离不同功能模块(物理引擎 Worker、资源加载 Worker),在需要高频数据共享的模块内部使用 Wasm 线程(物理引擎内部使用 pthreads 并发计算)。这种混合策略兼顾了工作单元隔离的安全性和计算密集模块内部的高效并发。

游戏场景的具体线程架构案例

案例一:2D 物理引擎的 Worker 线程化

以基于 Box2D 的 2D 游戏为例(Box2D 已有 Wasm 编译版本)。典型架构:主线程负责渲染(Canvas 2D 或 WebGL)和用户输入处理;Physics Worker 运行 Box2D Wasm 模块,每帧接收玩家输入和状态命令,返回全部刚体的新位置数据。

通信方式:使用 SharedArrayBuffer 实现环形缓冲区,主线程写入输入事件,Physics Worker 读取并处理,将结果写回共享内存中的另一块区域,主线程在渲染时读取。这样主线程和物理线程可以异步运行,互不阻塞。

关键数据:对于 500 个刚体的 2D 游戏,将物理计算移到 Worker 线程通常可以将主线程帧时间降低 3-8ms(取决于物理复杂度),这对于维持 60fps 锁帧是显著的提升空间。

案例二:资产加载的并行解码

游戏中最容易利用多线程的场景是资产加载:在游戏运行时动态加载新地图或场景时,使用多个 Worker 并行解码图片(PNG/JPG 解码)、解压音频(OGG/MP3 解码)、解析关卡数据。这种架构不需要 SharedArrayBuffer(每个加载任务独立,Transferable 传递结果即可),是多线程受益最大、实现最简单的场景。

案例三:AI 行为树的计算卸载

对于拥有大量 NPC 的游戏(策略游戏、RPG),AI 决策计算是典型的可并行工作负载。将行为树评估逻辑编译为 Wasm,运行在一个 AI Worker 中,主线程每帧发送 NPC 状态数组(位置、生命值、威胁感知),AI Worker 返回行为决策数组(移动目标、攻击目标、状态转换)。即使不使用 SharedArrayBuffer(用 Transferable 传递 ArrayBuffer),这一架构也可以将 AI 计算从主线程完全解耦,消除 AI 卡顿对渲染帧率的影响。

初级用户路径:我的游戏真的需要多线程吗

在了解了多线程的复杂性之后,第一个需要回答的问题是:你是否真的需要它?

先用 profiler 测量,再做架构决策

打开 Chrome DevTools 的 Performance 面板,录制 10 秒的游戏实际运行。查看主线程的帧时间分布:如果每帧主线程时间在 8ms 以内(对应 120fps),你的游戏根本不需要多线程。如果主线程时间在 12-16ms(对应 60-80fps),且 profiler 显示某个特定计算函数占据了大部分时间,那时才值得考虑将这个函数迁移到 Worker 线程。

对于规模在 200 个以下活跃实体的大多数独立游戏,单线程 JavaScript 已经足够。多线程最有价值的场景是:大量同质化实体的批量计算(100 个以上 AI、1000 个以上物理刚体),或者资产加载与游戏逻辑的完全并行。

如果确定需要,最小可用配置

启用多线程的最小配置路径:(1)确认你的托管平台支持自定义 HTTP 响应头;(2)为游戏页面添加 Cross-Origin-Opener-Policy: same-origin 和 Cross-Origin-Embedder-Policy: require-corp 两个响应头;(3)验证游戏所有依赖的第三方资源在这两个头下仍然可以正常加载(在 DevTools Network 面板检查是否有 COEP 错误);(4)编写 SharedArrayBuffer 是否可用的特性检测代码,为不支持的环境提供单线程降级路径。

中级用户路径:在受限环境中最大化多线程收益的工程策略

Atomics.wait 与 Atomics.notify 的正确使用

Wasm 线程同步的核心是原子操作。Atomics.wait 允许一个线程等待(阻塞)直到共享内存中某个位置的值发生变化;Atomics.notify 允许另一个线程唤醒等待中的线程。这对在游戏循环中实现"主线程通知物理线程开始计算,物理线程完成后通知主线程"的同步模式至关重要。

关键约束:Atomics.wait 不能在主线程中调用(会导致页面挂起)——这是浏览器的安全设计,主线程必须保持响应以处理用户交互。主线程必须使用非阻塞的 Atomics.waitAsync(返回 Promise)来等待工作线程完成,不能使用阻塞的 wait。这一约束意味着 Wasm 线程同步代码在浏览器中的写法与纯 C/C++ pthreads 代码有微妙但重要的差异。

避免过度分割的线程粒度

Worker 线程并非越多越好。每个 Worker 的创建有启动开销(通常 50-200ms),线程间通信有调度延迟,过多的 Worker 会增加内存占用(每个 Worker 有独立的 JS 上下文)。对于独立游戏,2-4 个 Worker 的线程架构通常是最优解:一个物理 Worker,一个资源加载 Worker,可选的 AI Worker——超过这个数量的收益通常小于管理复杂度的增加。

单线程降级路径的必要性

由于 COOP/COEP 的部署限制和 Safari 兼容性历史,为不支持 SharedArrayBuffer 的环境设计完整的单线程降级路径不是可选项,而是发行到公开 Web 平台的必要保障。推荐的检测代码模式:在游戏初始化时检查 crossOriginIsolated 属性(浏览器全局属性,当 COOP/COEP 正确配置且页面进入跨域隔离状态时为 true),根据这个值决定启动多线程路径还是单线程路径。两条代码路径应共享同一套游戏逻辑接口,只在底层实现上区分。

Emscripten pthreads 模式的实际工程经验

如果使用 Emscripten 将 C/C++ 多线程代码(pthreads)编译为 Wasm,编译时需要加入 -pthread 标志,输出的 Wasm 模块会依赖 SharedArrayBuffer 进行线程内存共享。Emscripten 会自动生成 Worker 创建和线程同步的 JavaScript 胶水代码。主要注意事项:线程本地存储(TLS)的大小需要在编译时指定,过小会导致运行时崩溃;pthread_create 创建线程的开销在 Wasm 中比本地更高(约 1-5ms 而非微秒级),不应该在游戏每帧逻辑中动态创建线程,而应使用线程池预先创建。

争议:浏览器多线程限制是否过于保守

COOP/COEP 要求在技术社区引发了持续的争议,不同群体对这些限制的评价截然不同。

游戏开发者社区(尤其是有原生多线程游戏移植经验的开发者)普遍认为这些要求过于苛刻:一个额外的 HTTP 头配置,不应该成为独立开发者在 itch.io 上发布游戏时面临的架构障碍;而且 COOP 对 window.opener 的切断会破坏某些支付流程和 OAuth 登录流程,导致功能断裂而开发者不知道原因。

浏览器安全工程师社区则认为这些限制是必要的最小化安全边界:Spectre 类漏洞的影响远超最初预期,高精度计时器(SAB 是其核心来源之一)在浏览器中提供了显著的攻击面,在等待 CPU 微代码修复完全普及的过渡期,进程隔离是最可靠的缓解手段。

最有实质意义的进展是:浏览器厂商正在研究更细粒度的权限模型,未来可能允许在不完整的跨域隔离状态下使用"限功能版 SAB"(精度降低的共享内存),为无法满足完整 COOP/COEP 要求的应用提供折中方案。这个方向尚未进入规范成熟阶段,但代表了生态系统对过于苛刻部署要求的实际回应。

关键词

WebAssembly 多线程 SharedArrayBuffer Atomics Web Workers COOP COEP 跨域隔离 Spectre 漏洞 pthreads Wasm Emscripten 多线程 线程架构 物理引擎线程 环形缓冲区 Atomics.wait Atomics.notify 线程池 Safari SAB 兼容 游戏性能优化
文章标签
WebAssemblyWasmEmscriptenWASIwasm-bindgenComponent ModelSharedArrayBufferAtomicsWasm 多线程Wasm SIMDwasm-packRust Wasm
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:WebAssembly 游戏开发技术专题