C++20 atomic wait在Windows上的底层实现与WaitOnAddress机制
在 C++20 之前,要实现线程间的等待与唤醒,开发者通常需要在“高CPU占用的自旋锁(Spinlock)”与“高开销的条件变量(std::condition_variable)”之间做出妥协。
C++20 引入了 std::atomic::wait 和 std::atomic::notify_one/all,为高效的等待/唤醒机制提供了标准库支持。在 Windows 平台上,MSVC STL 并没有采用复杂的传统同步对象(如 Event 或 Semaphore)来实现这一特性,而是直接基于 Windows 8 引入的底层 API——WaitOnAddress 系列函数。
下面我们将深度拆解 std::atomic::wait 在 Windows 平台上的底层映射关系、WaitOnAddress 的工作机制,以及它与 Linux futex 的异同。
为什么需要 WaitOnAddress?
在早期的 Windows 编程中,若想让一个线程等待某个内存地址的值发生变化,常见的做法是:
- 自旋等待(Spinning):不断循环读取该地址。这在等待时间极短时性能最好,但长时间自旋会白白浪费 CPU 算力。
- 内核对象(Kernel Event/Semaphore):当条件不满足时,调用
WaitForSingleObject挂起线程。这解决了 CPU 占用问题,但每次挂起和唤醒都需要陷入内核,上下文切换开销极大。
Linux 很早就通过 futex(Fast Userspace Mutex)解决了这个问题:在无竞争(或条件立即满足)时,完全在用户态通过原子操作完成;只有在需要真正挂起或唤醒线程时,才通过系统调用进入内核。
Windows 8 和 Windows Server 2012 引入的 WaitOnAddress 则是 Windows 版的 "futex"。它允许应用程序使用指定的内存地址作为同步的“Key”,实现用户态的高效等待与唤醒。
MSVC STL 对 std::atomic::wait 的封装
当我们在 Windows 上使用 MSVC 编译器编写如下代码时:
#include <atomic>
#include <thread>
std::atomic<int> data{0};
void worker() {
// 等待 data 的值不再是 0
data.wait(0);
}
void notifier() {
data.store(1);
data.notify_one();
}
MSVC 标准库的底层实现(参考 <atomic> 源码)会直接将这些调用映射到 ntdll.dll 导出的 API。其简化的调用链路如下:
std::atomic<T>::wait(old_val)$\to$ 内部调用std::atomic_wait系列辅助函数 $\to$ 调用WaitOnAddressstd::atomic<T>::notify_one()$\to$ 调用WakeByAddressSinglestd::atomic<T>::notify_all()$\to$ 调用WakeByAddressAll
WaitOnAddress 声明剖析
我们来看一下 Windows API 的原生定义:
BOOL WaitOnAddress(
[in] volatile VOID* Address,
[in] PVOID CompareAddress,
[in] SIZE_T AddressSize,
[in, optional] DWORD dwMilliseconds
);
Address:要监视的内存地址。CompareAddress:包含“预期值”的内存地址。AddressSize:必须是 1, 2, 4 或 8 字节。dwMilliseconds:超时时间,通常传入INFINITE。
它的执行逻辑是绝对原子的:
- 比较值:系统会读取
Address处的值,并与CompareAddress处的值进行比较(按AddressSize指定的字节数)。 - 决定是否挂起:
- 如果值相等,说明目标值还没有被其他线程修改,调用线程会被立即挂起,进入等待状态。
- 如果值不相等,说明在调用发生前或发生时,值已经被修改了,函数立即返回
TRUE,不进行挂起。
- 唤醒:当其他线程对该
Address调用WakeByAddressSingle或WakeByAddressAll时,挂起的线程将被唤醒。
这种“比较并挂起”的原子性非常关键,它彻底避免了**丢失唤醒(Lost Wake-up)**的经典竞态条件(即:线程 A 刚判断完需要挂起,还没来得及挂起,线程 B 就更新了值并发送了通知,随后线程 A 挂起,导致永久死锁)。
深入底层:WaitOnAddress 是如何工作的?
很多人会好奇,Windows 内部是如何管理这些“虚拟地址”的?它并没有像 Event 那样创建一个真正的内核对象(HANDLE)。
1. 用户态的快速通道与内核态的协作
WaitOnAddress 的实现主要驻留在 ntdll.dll 中。
- 无竞争阶段:如果
Address的当前值已经与CompareAddress不相等,它直接在用户态返回,不发生任何系统调用(Syscall),零内核开销。 - 有竞争阶段:当确定需要挂起时,代码会通过 undocumented 的系统调用(如
NtWaitForAlertByThreadId或内部的 Futex 变体调用)陷入内核。
2. 内部哈希表与等待队列
Windows 内核(或 ntdll 的线程池管理器)内部维护着一个全局哈希表。
- 这个哈希表的 Key 是被监视的内存虚拟地址(Virtual Address)。
- Value 是一个由等待该地址的线程组成的双向链表(Wait Queue)。
当线程 A 调用 WaitOnAddress(&data, ...) 且条件满足需要挂起时:
- 系统根据
&data的地址计算出哈希值,定位到对应的哈希桶。 - 将线程 A 的线程控制块(TCB)或其关联的等待节点插入到该桶的链表中。
- 线程 A 的状态被置为
Waiting(具体子状态通常为WrUserRequest),让出 CPU 时间片。
当线程 B 调用 WakeByAddressSingle(&data) 时:
- 系统同样根据
&data的地址哈希定位到同一个桶。 - 从链表中取出一个(或全部,如果是
WakeByAddressAll)正在等待该地址的线程。 - 将该线程的状态修改为
Ready,等待 CPU 调度执行。
3. 地址对齐与大小限制
WaitOnAddress 要求 AddressSize 只能是 1、2、4 或 8 字节。这是因为底层的比较操作必须通过 CPU 的单次原子内存读取指令(如 mov、lock cmpxchg 等)来完成,如果数据跨越了 Cache Line 或大小不合规,就无法保证比较操作的原子性。
与 Linux futex 的对比
尽管 WaitOnAddress 经常被称为 Windows 版的 futex,但它们的设计哲学和 API 粒度存在细微差异:
| 特性 | Windows WaitOnAddress |
Linux futex |
|---|---|---|
| 颗粒度 | 支持 1, 2, 4, 8 字节的数据宽度 | 通常仅支持 32 位(4字节)整数 |
| API 友好度 | 提供高级别的比较机制(直接传入比较地址) | 接口复杂,需要通过系统调用 syscall(SYS_futex, ...) 进行多模态配置 |
| 唤醒精准度 | 基于地址。由于哈希冲突的存在,可能产生伪唤醒 | 同样基于物理/虚拟地址,但在内核层管理更紧密 |
| 进程间同步 | 仅限于同一进程内的线程(轻量) | 支持跨进程同步(FUTEX_SHARED) |
注意:Windows 的
WaitOnAddress不支持跨进程同步。如果需要跨进程等待某个内存地址,仍需要使用具名的内核同步对象(如 Shared Memory + Named Mutex / Event)。
伪唤醒(Spurious Wakeups)问题
与 Linux 的 futex 以及 std::condition_variable 一样,std::atomic::wait 和 WaitOnAddress 也会受到**伪唤醒(Spurious Wakeup)**的影响。
所谓的伪唤醒,是指线程在没有任何人调用 WakeByAddress 的情况下,或者在被唤醒后发现条件其实仍不满足(例如有第三个线程抢先一步修改了数据)就从阻塞中返回。
产生伪唤醒的原因包括:
- 哈希冲突:内部等待队列使用地址哈希。如果两个不同的变量地址哈希到了同一个槽位,对 A 的唤醒可能会意外影响到等待 B 的线程。
- 内核调度事件:如 APC(异步过程调用)的中断或其他系统级信号。
因此,在编写 C++20 代码时,绝对不能假设 wait() 返回后条件一定成立。标准的写法应当始终配合循环检测:
// 推荐的鲁棒性写法
int expected = 0;
while (data.load() == expected) {
data.wait(expected);
}
// 此时可以确信 data 的值已经改变
总结
C++20 std::atomic::wait 在 Windows 上的高效表现,完全得益于底层的 WaitOnAddress 机制。
它通过将“用户态原子自旋检测”与“内核态地址哈希阻塞队列”完美结合,既规避了无效自旋对 CPU 的无谓消耗,又大幅度降低了传统内核同步对象高昂的上下文切换开销。了解这一底层映射和运行机理,能帮助我们在编写高性能多线程 Windows 程序时,对并发原语的开销边界有更清晰的认知。