打开网易新闻 查看精彩图片

一个.NET转WebAssembly的GPU计算库,在2个Worker时跑得好好的,加到3个就崩。团队花了数周定位,发现问题不在他们的代码——Chrome、Firefox、Node.js的底层原子操作全有漏洞

SpawnDev.ILGPU团队把这个漏洞报给了Chromium(Issue #495679735),还做了在线演示。漏洞影响范围:V8(Chrome/Node.js)、SpiderMonkey(Firefox),以及所有ARM设备。

66%的数据是脏的

66%的数据是脏的

问题出在Atomics.wait的"not-equal"返回路径。当3个以上Web Worker用"代际计数屏障"同步时,预期行为是:屏障完成后,所有Worker都能看到其他Worker的写入。

实际测出来:约66%的跨Worker读取是过期数据。3个Worker时,每个要读另外2个的槽位,2/3≈66.7%,和实测完全吻合。

漏洞的触发链条是这样的:Writer写入 → 最后到达者(bump代际)→ Atomics.notify → 被唤醒的等待者。但当某个Waiter调用Atomics.wait时,如果代际已经变了,它会立即返回"not-equal"。

ECMAScript规范说这条路径会进入并退出WaiterList临界区,应该完成同步。但引擎的实现里,"not-equal"快速路径似乎跳过了完整的seq_cst(顺序一致性)内存屏障。

打开网易新闻 查看精彩图片

返回码是对的——确实告诉你值变了。但它不保证你能看到导致这个变化的所有写入。

两个独立引擎,同一个bug

两个独立引擎,同一个bug

团队最初以为是V8的问题。测了Firefox后,发现故障率几乎一样。两个完全独立的JavaScript引擎,在相近的速率上表现出相同的bug,说明这不是某个引擎的实现错误,而是更深的东西。

x86的TSO(全存储序)硬件内存模型提供了隐式的存储序保证,部分掩盖了这个bug——需要3个Worker才能触发。但在ARM上(测试设备:联发科天玑8300,Cortex-A715/A510),宽松的内存模型没有这层保护。

同一个2-Worker测试,在所有x86系统上通过,在ARM上失败率22.3%。"not-equal"快速路径缺了一道ARM必需、x86免费送的内存屏障

这指向两种可能:要么ECMAScript规范对这条路径的内存序描述不够精确,要么所有主流引擎都抄了同一个有缺陷的实现模式。

修复方案:把wait换成spin

修复方案:把wait换成spin

打开网易新闻 查看精彩图片

团队给出的workaround很直接——用自旋循环替代Atomics.wait

// 有问题的写法:Atomics.wait的"not-equal"路径缺序保证
Atomics.wait(view, genIdx, myGen);

// 修复方案:每个Atomics.load都是seq_cst
while (Atomics.load(view, genIdx) === myGen) {}

Atomics.load终于观察到新的代际值时,seq_cst的全序保证所有之前的写入对所有线程可见。代价是CPU会忙等,但数据正确性回来了。

团队还放出了3测试套件,浏览器里直接跑,无需安装。链接在Chromium issue里。

这个bug的特殊之处在于:它藏在"看起来正常工作"的代码里。2个Worker时一切正常,3个才暴露;x86上很难复现,ARM上频繁翻车。对于做WebAssembly多线程计算、或者任何依赖Worker同步的开发者来说,这是一个潜伏的雷。

你现在的代码里有几个Worker在同步?测过ARM上的表现吗?