真实测试std::atomic是否无锁

Genuinely test std::atomic is lock-free or not

由于 std::atomic::is_lock_free() 可能无法真正反映现实 [],我正在考虑改为编写真正的运行时测试。然而,当我认真去做的时候,我发现这并不是我想象中的一件小事。我想知道是否有一些聪明的想法可以做到这一点。

除性能外,该标准不保证任何您能判断的方式;这或多或少是重点。

如果您愿意引入一些特定于平台的 UB,您可以将 atomic<int64_t> * 转换为 volatile int64_t* 并查看当另一个线程读取该对象时是否观察到“撕裂” . (When to use volatile with multi threading? - 通常从不,但真正的硬件在内核之间有一致的缓存,运行 线程如此简单的 asm load/store 基本上就像松散的原子。)

如果这个测试成功(即普通的 C++ 类型自然是原子的,只有 volatile),这告诉你任何理智的编译器都会非常便宜地使其无锁。但如果它失败了,它不会告诉你太多。该类型的无锁原子可能仅比 loads/stores 的普通版本稍微贵一点,或者编译器可能根本不会使其成为无锁的。例如在 32 位 x86 上,无锁 int64_t 是高效的,开销很小(使用 SSE2 或 x87),但是 volatile int64_t* 会使用两个单独的 4 字节整数加载或存储大多数编译器的方式产生撕裂编译它。

在任何特定平台/目标体系结构上,您可以在调试器中单步执行代码并查看 asm 指令 运行。 (包括进入像 __atomic_store_16 这样的 libatomic 函数调用)。这是唯一 100% 可靠的方法。(加上查阅 ISA 文档以检查不同指令的原子性保证,例如是否保证 ARM load/store 对,在什么条件下。)

(有趣的事实: 可能总是对 x86-64 上的 16 字节对象使用锁定,因为它没有机会进行 运行time CPU 检测在动态 link 时间并在支持它的 CPU 上使用 lock cmpxchg16b,使用 glibc 用于为当前系统选择最佳 memcpy / strchr 实现的相同机制。)


您可以便携地寻找性能差异(例如,多个读取器的可扩展性),但 x86-64 lock cmpxchg16b 无法扩展1 . 多个读者相互竞争,不像 8 字节和更窄的原子对象 lock cmpxchg16b 在执行之前获得对缓存行的独占访问权;滥用原子加载旧值实现失败的副作用 .load() 比编译为常规加载指令的 8 字节原子加载更糟糕。

这就是 gcc7 决定停止在 16 字节对象上为 is_lock_free() 返回 true 的部分原因,如 in the GCC mailing list message about the change you're asking about.

所述

另请注意,32 位 x86 上的 clang 使用 lock cmpxchg8b 来实现 std::atomic<int64_t>,就像 64 位模式下的 16 字节对象一样。所以你也会看到它缺乏并行读取扩展。 (https://bugs.llvm.org/show_bug.cgi?id=33109)


std::atomic<> 使用锁定的实现通常仍然不会 通过在每个对象中包含一个 lock 字节或字来使对象变大。它会改变 ABI,但无锁与锁定已经是 ABI 的区别。我认为该标准允许这样做,但即使在无锁的情况下,奇怪的硬件也可能需要对象中的额外字节。不管怎样,sizeof(atomic<T>) == sizeof(T) 不会告诉你任何事情。如果它更大,则很可能是您的实现添加了互斥量,但如果不检查 asm.h 就无法确定。 (如果大小不是 2 的幂,它可以加宽它以便对齐。)

(在 C11 中,在对象中包含锁的范围要小得多:它必须在最小初始化(例如静态为 0)且没有析构函数的情况下工作。编译器/ABI 通常希望他们的 C stdatomic.h 原子与他们的 C++ std::atomic 原子兼容。)

正常的机制是使用原子对象的地址作为锁的全局散列table的键。两个对象别名/碰撞和共享同一个锁是额外的争用,但不是正确性问题。这些锁仅 taken/released 来自库函数,而不是同时持有其他此类锁,因此它不会造成死锁。

您可以通过在两个不同进程之间使用共享内存来检测这一点(因此每个进程都会有自己的锁散列 table)。 Is C++11 atomic<T> usable with mmap?

  • 检查 std::atomic<T> 是否与 T 大小相同(因此锁不在对象本身中)。

  • 从两个不共享任何地址的独立进程映射一个共享内存段space。在每个进程中映射到不同的基址也没有关系。

  • 从一个进程读取模式,如全一和全零,同时从另一个进程读取(并寻找撕裂)。与我上面 volatile 的建议相同。

  • 同时测试原子增量:让每个线程做1G增量,每次检查结果是否为2G。即使纯加载和纯存储自然是原子的(撕裂测试),像 fetch_add / operator++ 这样的读-修改-写操作需要特殊支持:

根据 C++11 标准,意图是对于无锁对象,这仍然应该是原子的。它也可能适用于非无锁对象(如果它们将锁嵌入对象中),这就是为什么您必须通过检查 sizeof().

来排除这种情况的原因

To facilitate inter-process communication via shared memory, it is our intent that lock-free operations also be address-free. That is, atomic operations on the same memory location via two different addresses will communicate atomically. The implementation shall not depend on any per-process state.

如果您看到两个进程之间的撕裂,则该对象不是无锁的(至少不是 C++11 预期的方式,也不是您想要的方式期望在正常的共享内存 CPUs.)

我不确定如果进程不需要共享任何地址 - space 除了包含原子对象 2[=140= 的 1 页之外,为什么无地址很重要]. (当然,C++11 根本不要求实现使用页面。或者实现可以将锁的散列 table 放在每个页面的顶部或底部?在这种情况下使用散列依赖于页面偏移量以上的地址位的函数将是完全愚蠢的。)

无论如何,这取决于很多关于计算机如何工作的假设,这些假设在所有正常的 CPU 上都是正确的,但 C++ 没有。 如果您关心的实现是在主流 CPU 上,如 x86 或 ARM 在正常 OS 下,那么这种测试方法应该相当准确,并且可能是仅阅读 asm 的替代方法。 在编译时自动执行并不是很实用,但是 可能 自动化这样的测试并将其放入构建脚本中,这与阅读asm.


脚注 1:x86 上的 16 字节原子

没有 x86 硬件文档支持 16 字节原子 load/store 和 SSE 指令 。在实践中,许多现代 CPU 确实有原子 movaps load/store,但在 Intel/AMD 手册中没有像 8 字节 x87/MMX/SSE 那样保证这一点] loads/stores 奔腾及更高版本。并且无法检测哪些 CPUs do/don 没有原子 128 位操作(lock cmpxchg16b 除外),因此编译器编写者无法安全地使用它们。

SSE instructions: which CPUs can do atomic 16B memory operations? 一个讨厌的极端情况:在 K10 上的测试表明对齐的 xmm load/store 显示同一套接字上的线程之间没有撕裂,但不同套接字上的线程很少出现撕裂,因为 HyperTransport 显然只给出 8 字节对象的最小 x86 原子性保证。 (IDK if lock cmpxchg16b 在这样的系统上更昂贵。)

如果没有供应商发布的保证,我们也永远无法确定奇怪的微架构极端情况。在一个线程写入模式和另一个线程读取的简单测试中没有撕裂是很好的证据,但在某些特殊情况下总是有可能有所不同,CPU 设计师决定采用与正常情况不同的方式进行处理。


只读访问只需要指针的指针 + 计数器结构可能很便宜,但当前的编译器需要 union hack 来让它们仅对前半部分执行 8 字节原子加载物体。 。对于 ABA 计数器,您通常会用 CAS 对其进行更新,因此缺少 16 字节原子纯存储不是问题。

64 位模式下的 ILP32 ABI(32 位指针)(如 Linux's x32 ABI,或 AArch64 的 ILP32 ABI)意味着指针+整数只能容纳 8 个字节,但整数寄存器仍然是 8 个字节宽的。这使得使用指针+计数器原子对象比在指针为 8 字节的完整 64 位模式下更有效。


脚注 2:无地址

我认为术语“无地址”是独立于不依赖于任何每个进程状态的声明。据我了解,这意味着正确性不依赖于两个线程对同一内存位置使用同一地址。但是,如果正确性还取决于它们共享相同的全局哈希 table(IDK 为什么将对象的地址存储在对象本身中会有所帮助),那么只有在可能有多个相同的地址时才有意义同一个进程中的对象。 可能在类似 x86 的实模式分段模型上,其中 20 位线性地址 space 用 32 位 segment:offset 寻址。 (16 位 x86 的实际 C 实现向程序员公开了分段;将其隐藏在 C 的规则后面是可能的,但性能不高。)

虚拟内存也是可能的:同一物理页面到同一进程内不同虚拟地址的两个映射是可能的,但很奇怪。这可能会或可能不会使用相同的锁,具体取决于散列函数是否使用页面偏移量以上的任何地址位。 (地址的低位表示页面内的偏移量,对于每个映射都是相同的。即,这些位的虚拟到物理转换是空操作,这就是 VIPT caches are usually designed to take advantage of that to get speed without aliasing 的原因。)

因此,非无锁对象在单个进程中可能是无地址的,即使它使用单独的全局哈希 table 而不是向原子对象添加互斥锁。但这是一种非常不寻常的情况;在 same 进程中使用虚拟内存技巧为同一个变量创建两个地址是极其罕见的,该进程在线程之间共享其所有地址-space。更常见的是进程之间共享内存中的原子对象。 (我可能误解了“无地址”的意思;可能它的意思是“无地址space”,即不依赖于共享的其他地址。)

我认为您实际上只是在尝试检测 gcc 特有的这种特殊情况,其中 is_lock_free 报告错误,但底层实现(隐藏在 libatomic 函数调用后面)仍在使用 cmpxchg16b。您想知道这一点,因为您认为这样的实现 真正地 无锁。

在那种情况下,实际上,我会编写您的检测函数来硬编码您知道以这种方式运行的 gcc 版本范围。目前,停止内联 cmpxchg16b 的更改之后的所有版本显然仍在幕后使用无锁实现,因此今天的检查将是 "open ended"(即 X 之后的所有版本) .在此之前 is_lock_free returns true(您认为正确)。在对 gcc 进行一些假设的未来更改后,库调用使用锁,is_lock_free() == false 答案将变为真实,您将通过记录它发生的版本来关闭您的检查。

所以这样的事情应该是一个好的开始:

template <typename T>
bool is_genuinely_lock_free(std::atomic<T>& t) {
#if     __GNUC__ >= LF16_MAJOR_FIRST && __GNUC_MINOR__ >= LF16_MINOR_FIRST && \
        __GNUC__ <= LF16_MAJOR_LAST  && __GNUC_MINOR__ <= LF16_MINOR_LAST
    return sizeof(T) == 16 || t.is_lock_free();
#else
    return t.is_lock_free();
#endif
}

此处 LF16 宏定义版本范围,其中 gcc returns "wrong" 对 is_lock_free 的 16 字节对象的回答。请注意,由于此更改的后半部分(使 __atomic_load_16 和朋友使用锁),您今天只需要检查前半部分。您需要确定 is_lock_free() 开始为 16 字节对象返回 false 时的确切版本:Peter 提供的讨论此问题的链接是一个好的开始,您可以在 godbolt 中进行一些检查 - 尽管后者不提供您需要的一切,因为它不反编译像 __atomic_load16 这样的库函数:您可能需要为此深入研究 libatomic 源代码。也有可能宏检查应该绑定到 libstdc++libatomic 版本而不是编译器版本(尽管 AFAIK 在典型安装中所有这些版本都绑定在一起)。您可能希望向 #if 添加更多检查以将其也限制为 64 位 x86 平台。

我认为这种方法是有效的,因为 真正无锁 的概念并不是很明确:在这种情况下,您已经决定要考虑 cmpxchg16b gcc 中的无锁实现,但是如果在未来的其他实现中出现其他灰色区域,您将需要再次判断是否认为它是无锁的。因此,对于非 gcc 情况,硬编码方法似乎与某种类型的检测一样稳健,因为在任何一种情况下,未知的未来实现都可能触发错误的答案。对于 gcc 案例,它似乎更强大,而且绝对更简单。

这个想法的基础是得到错误的答案不会成为一个毁灭世界的功能问题,而是一个性能问题:我猜你正试图对 select 在替代实现之间,其中一个在 "genuinely" 无锁系统上更快,而另一个在 std::atomic 是基于锁的系统上更合适。

如果您的要求更高,并且您确实想要更健壮,为什么不结合使用这些方法:使用这种简单的版本检测方法并将其与runtime/compile-time 检测方法,如 Peter 的回答中所建议的那样检查撕裂行为或反编译。如果两种方法都同意,请将其用作您的答案;但是,如果他们不同意,则将错误浮出水面并进行进一步调查。这也将帮助您了解 gcc 更改实现以使 16 字节对象完全锁定的要点(如果有的话)。