1. atomic flag 到底在解决什么问题:一次只让一个线程说“是”
atomic flag 这个词,第一次见的人很容易以为它是某个现成的锁对象。去翻 JavaScript 文档会发现并没有 AtomicFlag 这个类,也没有 new Atomics.Mutex。很多人搜到的其实是 C++ 里的 std::atomic_flag——它是标准库里最底层、最轻量的一种原子布尔标志,只提供两个操作:test_and_set 和 clear。
JavaScript 里的 Atomics 虽然 API 名称不同,但它在共享内存里能做的事情,恰恰就是这类底层原语。系列文章做到第四篇,该把这个“最小单元”展开讲透了。
先说一个我实际遇到过的场景。业务里有两条 Web Worker,一条负责拉流解码,一条负责做画面合成。解码线程把一帧数据写进共享内存后,原来代码是这样通知合成线程的:
js复制// 共享的 Int32Array 里第一个格子当作“这一帧好了”的标志
ready[0] = 1;
合成线程那边:
js复制if (ready[0] === 1) {
// 开始合成
}
看起来没有任何问题。一个写一个读,布尔量嘛。但跑久了之后,问题开始浮出来:合成端偶尔会漏掉帧,甚至出现上一秒的数据覆盖下一秒的情况。排查到最后,根子不在帧数据本身,而在“普通赋值”这件事。
ShardArrayBuffer 确实共享了内存,但共享内存不是万能同步原语。普通赋值和 Atomics 操作之间的差别,不只是“原子不原子”这么简单。更深层的差别在于顺序保证:A 线程做 ready[0] = 1 之前可能还有别的写入,如果合成线程只看到 ready[0] === 1,不代表它一定能看到那些写入。普通访问没有提供跨线程的顺序约束,编译器、CPU cache、甚至运行时的重排都可能让观察者看到的顺序和写入顺序不同。
atomic flag 解决的核心问题,正是这种“谁先谁后”的竞争:当多个线程同时试图把标志从 0 改成 1,客观世界里不能有两个人同时成功。这个需求如果你用 if (value === 0) value = 1 去写,那两步之间一定会被插入一次检查。
所以“atomic flag”翻译成实际开发语言就是:对一个共享状态位的“抢先修改”必须是原子的,并且它的结果必须能被其他执行线程可靠观察到。
这篇文章我把这套东西展开讲一遍,从 SharedArrayBuffer 搭地基,到用 compareExchange 实现标志位的拿和放,再到把单个标志位扩展成能让人排队等待的互斥锁。虽然文章标题是“atomic flag”,但我尽量用大家真能跑起来的 JavaScript 代码去演示,而不是停留在概念层面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 std::atomic_flag 到 JavaScript:先准备一块共享内存
要用 JavaScript 的 Atomics,先要有共享内存。普通 Int32Array 只能在一个线程里用,postMessage 传过去也是一个深拷贝,大家操作的是不同内存。真正能共享的是 SharedArrayBuffer:
js复制const sab = new SharedArrayBuffer(4 * 3);
const flag = new Int32Array(sab, 0, 1);
const count = new Int32Array(sab, 4, 1);
const extra = new Int32Array(sab, 8, 1);
这里 new Int32Array(sab, 0, 1) 的第一个参数指定共享内存,第二个参数是字节偏移,第三个参数是元素个数。因为我只想要一个“格子”做标志位,所以长度是 1。为什么不用一整个 Int32Array(sab) 然后取下标 0?当然可以,但如果你同一块内存里还要放别的状态、计数器,最好把视图切清楚,避免一个 View 里既当 flag 又当别的业务字段,后面读代码容易昏头。
flag[0] 这个格子,我约定:
- 0 表示“没有获取”“没有就绪”“锁空闲”
- 1 表示“已经获取”“已经就绪”“锁被占用”
至于 true/false 那种布尔语义,这里不直接用,因为 Atomics 操作是基于整数 typed array 的。底层所有东西都是整数,布尔只是你的脑内映射。
对照一下 C++ 和 JavaScript:
| C++ std::atomic_flag | JavaScript 实现思路 |
|---|---|
ATOMIC_FLAG_INIT |
new Int32Array(sab, offset, 1),初始填 0 |
test_and_set() |
Atomics.compareExchange(view, 0, 0, 1) |
clear() |
Atomics.store(view, 0, 0) |
| 判断是否置位 | Atomics.load(view, 0) !== 0 |
C++ 的 test_and_set 返回旧值,如果旧值是 0,说明你拿到了;如果旧值是 1,说明别人已经占了。JS 里没有直接函数叫 testAndSet,但 Atomics.compareExchange 的返回旧值行为,刚好能模拟这一整套逻辑。
SharedArrayBuffer 还有一点要特别提醒。在浏览器里它并不是永远可用的,如果页面没有启用跨源隔离,SharedArrayBuffer 会被禁用。写 demo 之前建议先确认环境,比如浏览器控制台敲一下 typeof SharedArrayBuffer,如果返回 'undefined',说明当前页面没有开跨源隔离。Node.js 环境基本没这个问题,但浏览器环境每次都要先确认。
我说的“搭地基”阶段有一个容易忽略的坑:虽然 Atomics 的 store、load、compareExchange 可以作用于多种整数 typed array,但后面要用的 Atomics.wait 和 Atomics.notify,只允许 Int32Array 和 BigInt64Array。所以从第一行开始,统一用 Int32Array 最省事。你要是图省空间用 Uint8Array 做标志位,后面想做等待唤醒就得返工。
初始化的时候尽量用 Atomics.store 而不是直接赋值:
js复制Atomics.store(flag, 0, 0);
为什么?虽然对一块刚创建、还没被其他线程接触过的内存做普通赋值通常没问题,但一旦这个 flag 被多线程共享,普通赋值就不提供足够的稳定性。如果你养成了“凡共享必原子”的习惯,后面就不会偶然写出不可靠代码。
3. 最核心的操作:把自己变成不可打断的 test-and-set
有了 Int32Array 里的一格 cell,下一步就是实现核心动作:拿到标志位、释放标志位。
先看一个错误版本:
js复制if (flag[0] === 0) {
flag[0] = 1;
// 我拿到了吧?
}
两个线程同时执行这段代码,可能同时通过 flag[0] === 0 的检查,然后都把自己的值写成 1。“检查旧值”和“写入新值”是两条独立指令,中间没有任何同步。这种竞态就是典型的 check-then-act。
正确版本交给 Atomics.compareExchange:
js复制function tryAcquire(flag) {
const old = Atomics.compareExchange(flag, 0, 0, 1);
return old === 0;
}
function release(flag) {
Atomics.store(flag, 0, 0);
}
我可以很直白地解释这段比较交换:只有当 flag[0] 当前等于第二个参数 0 时,才把第三个参数 1 写进去。函数返回的是“写之前”的旧值。 返回值能告诉调用方到底是谁赢了:返回 0,说明之前没人占,现在归你了;返回 1,说明在你之前已经有别人站在这个位置上了。
这一步从语义上看,和 C++ std::atomic_flag 的 test_and_set() 完全等价。它必须是不可打断的:不会有第二个线程在“比较”和“写入”之间插入一个操作。底层的 CPU 会保证这条读改写操作要么成功、要么没发生,不存在“我看到是 0,但写的时候别人已经改了”这种中间态。
把这个逻辑包成一个类,用起来比较顺手:
js复制class AtomicFlag {
constructor(sab, byteOffset = 0) {
this.cell = new Int32Array(sab, byteOffset, 1);
}
tryAcquire() {
return Atomics.compareExchange(this.cell, 0, 0, 1) === 0;
}
tryRelease() {
Atomics.store(this.cell, 0, 0);
}
isSet() {
return Atomics.load(this.cell, 0) !== 0;
}
// 一次性版本:抢到过一次后,再把格子置回 0 就是一个 reset
reset() {
Atomics.store(this.cell, 0, 0);
}
}
这里我用 tryAcquire 而不是 acquire,是想传达一个关键心智模型:一次 CAS 只做一次尝试,它不会帮你等。 如果你想要“没抢到就排队等”,那是下一步 Atomics.wait 的活。
有人会问,为什么不用 Atomics.exchange?它确实也能实现这个效果:
js复制// exchange 会无条件把第二个参数写进去,并返回旧值
const old = Atomics.exchange(flag, 0, 1);
if (old === 0) {
// 拿到了
}
exchange 像是更粗暴的“接管”:不管当前是多少,我都把它改成 1,然后告诉你之前是什么。compareExchange 则是“只有当前是期望值时我才改”。如果你的业务只想要一个“占位符”,exchange 没问题。但要实现锁的获取,通常建议用 compareExchange,因为后面配合 Atomics.wait 时,期望值这个参数非常有用,它能让等待逻辑精确表达“当锁还处于占用态时,我才愿意睡”。
3.1 一次抢锁竞态的实际验证
我刚接触这套原语的时候也怀疑过:真的会抢失败吗?于是写了两条 Web Worker,让它们对同一个 cell 各执行 10000 次 tryAcquire,谁抢到谁给 counter 加一。最后统计成功次数,并不会刚好各 5000 也不是重点。重点在于:在不用 CAS 的版本里,最后 counter 总数比 20000 小很多,因为很多人同时通过了那个 if,互相覆盖了写入。换成 Atomics.compareExchange 之后,总次数才严格等于 20000。
那次实验之后,我彻底放弃了“普通赋值 + 延时拼运气”的做法。共享内存里没有运气,只有你没意识到的竞争。
4. 只抢一次不够:wait/notify 才能实现正经互斥锁
atomic flag 如果只停留在“tryAcquire 没抢到就返回 false”,那使用场景非常窄。现实中更常见的是:一个资源同一时间只允许一个 worker 操作,其他 worker 没抢到不能直接退出,得等别人释放之后继续抢。
也就是说,我们需要一个“没抢到就睡、有人释放就唤醒”的机制。
好消息是 Atomics 恰好提供了 wait 和 notify,它们的作用很像操作系统里的 futex。先看一个能跑的 Mutex:
js复制const UNLOCKED = 0;
const LOCKED = 1;
class SharedMutex {
constructor(sab, byteOffset = 0) {
this.lockCell = new Int32Array(sab, byteOffset, 1);
}
tryLock() {
return Atomics.compareExchange(this.lockCell, 0, UNLOCKED, LOCKED) === UNLOCKED;
}
lock() {
while (!this.tryLock()) {
Atomics.wait(this.lockCell, 0, LOCKED);
}
}
unlock() {
Atomics.store(this.lockCell, 0, UNLOCKED);
Atomics.notify(this.lockCell, 0, 1);
}
}
拆开看这段代码。
tryLock 做的事还是 CAS,从 0 改成 1。如果成功,当前 worker 拿到了锁,立刻进入临界区。如果失败,说明锁被别人占着,旧值是 1,于是调用:
js复制Atomics.wait(this.lockCell, 0, LOCKED);
Atomics.wait 的语义是:如果当前格子里的值等于第三个参数,就阻塞;如果不等,立即返回。 这里传入 LOCKED,意思就是“只有当锁仍然被占用时,我才去睡觉”。万一在我尝试失败、但还没调用 wait 的间隙,别人已经释放了锁,当前值变成 0,那 wait 会发现值和期望值不一致,立刻返回,然后外层 while 再抢一次。这个机制能天然避免很多竞态,不眠不休地循环,也是这个原语比较优秀的地方。
释放锁分为两步:
js复制Atomics.store(this.lockCell, 0, UNLOCKED);
Atomics.notify(this.lockCell, 0, 1);
先清零,再唤醒一个等待者。为什么唤醒一个不是全部?原因很简单——当前锁只能被一个人拿到。就算你把所有等待者都唤醒,最后也只有一个能通过 CAS 成功,其他被唤醒的人还是要乖乖回到 wait 里睡觉。唤醒全部会带来不必要的调度开销,也就是“惊群”,所以这里用 1 刚好。
使用方式:
js复制const sab = new SharedArrayBuffer(4);
const mutex = new SharedMutex(sab);
function workerMain() {
for (let i = 0; i < 10000; i++) {
mutex.lock();
try {
Atomics.add(sharedCount, 0, 1);
} finally {
mutex.unlock();
}
}
}
finally 里释放锁是必须养成的习惯。如果临界区抛了异常,锁不释放,其他 worker 会永远睡下去。这不是理论问题,我见过真实线上因为一个 throw 导致整个 worker 集体挂起的事故,排查半天才意识到锁没进 finally。
4.1 为什么说这把锁不是可重入锁
SharedMutex 的 lock() 方法只能在“没拿锁”的情况下调用。如果是同一个 worker,第一次 lock() 成功之后,没释放又调第二次 lock(),它会发现自己拿不到锁——因为现在值是 LOCKED,于是走进 Atomics.wait 睡死。单线程把自己锁死,这不太友好。
atomic flag 本身只表达了“这个标记是否被占”,它不记录占用的 owner 是谁。如果你需要知道当前持锁的 worker ID,比如支持可重入锁、锁超时强制回收,那就得扩展数据结构,至少要用两个 cell:
- cell0:锁状态
- cell1:owner workerId
释放时判断 owner 是否是当前线程,否则直接返回或报错。
但要注意,在“没有真正多线程抢占”的 JavaScript 业务里,大多数 SharedMutex 都只是用来协调 Web Worker,不会有人在一个 worker 内部做大量临界区嵌套。如果设计时发现需要可重入,更值得反思的是临界区边界是不是划错了。Java/C++ 里重入锁是家常便饭,是因为线程可以在函数调用链条里反复进入同一个锁。JavaScript worker 内部没有函数重入锁的天然需求,尽量避免。
5. 单次初始化、Leader 选举:atomic flag 更细腻的玩法
把 atomic flag 当作锁很直观,但不代表它只能做锁。另一类常见场景是“只让第一个到的人执行某段逻辑,其他人放弃或等待”,比如单次初始化、Leader 选举、缓存预热。
这种场景的核心不是抢锁后进入临界区,而是第一次把状态从“未初始化”改到“初始化中”。
伪代码是这样:
js复制const STATE_EMPTY = 0;
const STATE_RUNNING = 1;
const STATE_FINISHED = 2;
function tryBecomeLeader(stateView) {
const old = Atomics.compareExchange(stateView, 0, STATE_EMPTY, STATE_RUNNING);
return old === STATE_EMPTY;
}
compareExchange 的三个参数里,第二个参数写的是 STATE_EMPTY 而不是 0,第三个参数写的是 STATE_RUNNING 而不是 1。这样代码可读性更强,而且它强调了一个进阶概念:虽然叫 atomic flag,但内存里的东西本质上是一个可以取多个值的格子。只要维护好“当前 expected value”和“next value”,就能做出比布尔标志位更有表达力的状态机。
从使用者角度,Leader 选举可以这么写:
js复制if (tryBecomeLeader(stateView)) {
await initSharedResource();
Atomics.store(stateView, 0, STATE_FINISHED);
Atomics.notify(stateView, 0, Infinity);
} else {
while (Atomics.load(stateView, 0) !== STATE_FINISHED) {
const result = Atomics.wait(stateView, 0, STATE_RUNNING);
// result 可能是 'ok' 或 'timed-out'
if (result === 'not-equal') break;
}
}
这里用 STATE_RUNNING 作为 Atomics.wait 的期望值,意思是“只要状态还在初始化中,我就睡;一旦状态被改成完成,wait 就返回”。另一侧初始化结束后先把状态改成 STATE_FINISHED,再调用 notify,被唤醒的 worker 就会醒来检查到新状态。
这样一个“单次初始化”场景,不需要一个完整的 mutex,也不需要把临界区代码包起来,原子状态就是最顺手的写法。
其实还有一个非常经典的应用:缓存双写避免。
两个 worker 同时拿到脏数据,都想去回源拉数据。这时最早抢到“正在刷新”标志的 worker 执行网络请求,其他人直接等一个“刷新完成”标志:
js复制const CACHE_EMPTY = 0;
const CACHE_REFRESHING = 1;
const CACHE_READY = 2;
function refreshIfNeeded(cacheState) {
if (Atomics.compareExchange(cacheState, 0, CACHE_EMPTY, CACHE_REFRESHING) !== CACHE_EMPTY) {
// 已经有人在刷新,等它结束
if (Atomics.load(cacheState, 0) !== CACHE_READY) {
Atomics.wait(cacheState, 0, CACHE_REFRESHING);
}
return;
}
try {
// 真正干活
writeCache();
} finally {
Atomics.store(cacheState, 0, CACHE_READY);
Atomics.notify(cacheState, 0, Infinity);
}
}
注意这个方案有一个隐含前提:如果 worker 在 wait 之后醒来,得自己再检查状态是不是 CACHE_READY,而不是直接认为“被唤醒=缓存一定就绪了”。因为 Atomics.notify 只负责把睡着的等待者唤醒,真正等到的是哪个状态,需要循环验证。
5.1 一次初始化不能和“多次轮询”混为一谈
有经验的人应该看出来了,上面这种“一次性 CAS 成功 + 后续等待”的模式,本质上是一个 latch,类似 C++ 里的 std::once_flag。它和 Mutex 的区别在于:
| 维度 | Mutex | Latch / Leader 选举 |
|---|---|---|
| 目的 | 保护临界区互斥 | 确保一段逻辑只执行一次 |
| 锁可以释放并重新拿吗 | 可以 | 通常不行 |
| 没抢到的线程 | 排队等锁释放 | 等“终态”出现 |
| 实现核心 | compareExchange + wait/notify | compareExchange + store |
在做系统设计时,先问清楚你要的是哪种。如果用 Mutex 去实现单次初始化,也能跑,但很容易出现“某 worker 退出临界区后别人又抢到、又要初始化一次”的问题。正确做法就是“状态一旦从 EMPTY 变成 RUNNING,就再也不回到 EMPTY”。
6. 实操中踩过的边界:主线程 wait、字节偏移、notify 数量
技术点说完,讲几个我实际踩过、以及经常在别人代码里看到的坑。
6.1 浏览器主线程不能 Atomics.wait
在 Web Worker 里可以用 Atomics.wait,但浏览器主线程调用它会被直接拒绝,抛异常。原因很直白:如果主线程阻塞,整个页面 UI 都会卡死,浏览器不允许以可交互的页面做这种事。
所以如果你的锁有可能跑在主线程,不能调用阻塞版本。替代方案是 Atomics.waitAsync,它不阻塞主线程,而是返回一个结果对象:
js复制const result = Atomics.waitAsync(view, 0, LOCKED);
if (result.async) {
// 说明进入了等待状态,result.value 是一个 Promise
await result.value;
// 被唤醒或超时后重新尝试
} else {
// 并没有真正等,因为条件已经不满足了
// result.value 通常会是 'not-equal'
}
用 waitAsync 写一个非阻塞的主线程锁获取循环,逻辑会稍微绕一点,但可用:
js复制async function lockAsync(view) {
while (true) {
if (Atomics.compareExchange(view, 0, 0, 1) === 0) return;
const r = Atomics.waitAsync(view, 0, 1);
if (r.async) {
await r.value;
}
// 如果 r.async 为 false,说明锁状态在我们等待过程中已经变化,直接下一轮抢锁
}
}
Atomics.waitAsync 的支持没有 Atomics.wait 那么普及,旧环境不支持,正式项目里要查一下目标浏览器版本。但它是“主线程 + 共享内存锁”这个组合目前最合理的解法。
6.2 视图字节偏移必须对齐,别把 View 长度搞错
用 new Int32Array(sab, byteOffset, 1) 创建视图时,byteOffset 必须是 4 的倍数,因为 Int32 元素是 4 字节对齐的。如果你开一块 16 字节共享内存,要放两个 cell,千万别写:
js复制const a = new Int32Array(sab, 0, 1);
const b = new Int32Array(sab, 1, 1); // 错误,byteOffset 不是 4 的倍数
正确的偏移是 4:
js复制const a = new Int32Array(sab, 0, 1);
const b = new Int32Array(sab, 4, 1);
还有一个隐蔽问题:new Int32Array(sab, byteOffset, 1) 的第二个参数是字节 offset,第三个参数是元素个数,不是字节数。如果写成 new Int32Array(sab, 4, 4),它会把从第 4 字节开始的 16 字节全部包进来,长度是 4 个元素,不是 4 个字节。想只要一个元素,请写 1。
6.3 notify 的数量不是越多越好
Atomics.notify(view, 0, count) 里的 count 表示最多唤醒多少个等待者。
- 锁释放:用
Atomics.notify(view, 0, 1),一次唤醒
