WEBKT

C++20 atomic wait在Windows上的底层实现与WaitOnAddress机制

7 0 0 0

在 C++20 之前,要实现线程间的等待与唤醒,开发者通常需要在“高CPU占用的自旋锁(Spinlock)”与“高开销的条件变量(std::condition_variable)”之间做出妥协。

C++20 引入了 std::atomic::waitstd::atomic::notify_one/all,为高效的等待/唤醒机制提供了标准库支持。在 Windows 平台上,MSVC STL 并没有采用复杂的传统同步对象(如 Event 或 Semaphore)来实现这一特性,而是直接基于 Windows 8 引入的底层 API——WaitOnAddress 系列函数。

下面我们将深度拆解 std::atomic::wait 在 Windows 平台上的底层映射关系、WaitOnAddress 的工作机制,以及它与 Linux futex 的异同。


为什么需要 WaitOnAddress

在早期的 Windows 编程中,若想让一个线程等待某个内存地址的值发生变化,常见的做法是:

  1. 自旋等待(Spinning):不断循环读取该地址。这在等待时间极短时性能最好,但长时间自旋会白白浪费 CPU 算力。
  2. 内核对象(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$ 调用 WaitOnAddress
  • std::atomic<T>::notify_one() $\to$ 调用 WakeByAddressSingle
  • std::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

它的执行逻辑是绝对原子的:

  1. 比较值:系统会读取 Address 处的值,并与 CompareAddress 处的值进行比较(按 AddressSize 指定的字节数)。
  2. 决定是否挂起
    • 如果值相等,说明目标值还没有被其他线程修改,调用线程会被立即挂起,进入等待状态。
    • 如果值不相等,说明在调用发生前或发生时,值已经被修改了,函数立即返回 TRUE,不进行挂起。
  3. 唤醒:当其他线程对该 Address 调用 WakeByAddressSingleWakeByAddressAll 时,挂起的线程将被唤醒。

这种“比较并挂起”的原子性非常关键,它彻底避免了**丢失唤醒(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, ...) 且条件满足需要挂起时:

  1. 系统根据 &data 的地址计算出哈希值,定位到对应的哈希桶。
  2. 将线程 A 的线程控制块(TCB)或其关联的等待节点插入到该桶的链表中。
  3. 线程 A 的状态被置为 Waiting(具体子状态通常为 WrUserRequest),让出 CPU 时间片。

当线程 B 调用 WakeByAddressSingle(&data) 时:

  1. 系统同样根据 &data 的地址哈希定位到同一个桶。
  2. 从链表中取出一个(或全部,如果是 WakeByAddressAll)正在等待该地址的线程。
  3. 将该线程的状态修改为 Ready,等待 CPU 调度执行。

3. 地址对齐与大小限制

WaitOnAddress 要求 AddressSize 只能是 1、2、4 或 8 字节。这是因为底层的比较操作必须通过 CPU 的单次原子内存读取指令(如 movlock 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::waitWaitOnAddress 也会受到**伪唤醒(Spurious Wakeup)**的影响。

所谓的伪唤醒,是指线程在没有任何人调用 WakeByAddress 的情况下,或者在被唤醒后发现条件其实仍不满足(例如有第三个线程抢先一步修改了数据)就从阻塞中返回。

产生伪唤醒的原因包括:

  1. 哈希冲突:内部等待队列使用地址哈希。如果两个不同的变量地址哈希到了同一个槽位,对 A 的唤醒可能会意外影响到等待 B 的线程。
  2. 内核调度事件:如 APC(异步过程调用)的中断或其他系统级信号。

因此,在编写 C++20 代码时,绝对不能假设 wait() 返回后条件一定成立。标准的写法应当始终配合循环检测:

// 推荐的鲁棒性写法
int expected = 0;
while (data.load() == expected) {
    data.wait(expected); 
}
// 此时可以确信 data 的值已经改变

总结

C++20 std::atomic::wait 在 Windows 上的高效表现,完全得益于底层的 WaitOnAddress 机制。

它通过将“用户态原子自旋检测”与“内核态地址哈希阻塞队列”完美结合,既规避了无效自旋对 CPU 的无谓消耗,又大幅度降低了传统内核同步对象高昂的上下文切换开销。了解这一底层映射和运行机理,能帮助我们在编写高性能多线程 Windows 程序时,对并发原语的开销边界有更清晰的认知。

CodeExplorer C20并发编程

评论点评