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

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

本文关键字:是否 atomic 测试 std      更新时间:2023-10-16

由于std::atomic::is_lock_free()可能无法真正反映现实[ref],因此我正在考虑编写真正的运行时测试。然而,当我开始研究它时,我发现这不是我认为的微不足道的任务。我想知道是否有一些聪明的想法可以做到这一点。

除了性能之外,该标准并不能保证您可以分辨的任何方式;这或多或少是重点。

如果您愿意引入一些特定于平台的 UB,您可以做一些事情,例如将atomic<int64_t> *投射到volatile int64_t*,看看当另一个线程读取对象时是否观察到"撕裂"。 (何时使用易失性与多线程?-通常永远不会,但真正的硬件在运行线程的内核之间具有一致的缓存,因此普通的ASM加载/存储基本上就像松弛的原子一样。

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

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

(有趣的事实:具有静态链接libatomic的gcc7可能始终对x86-64上的16字节对象使用锁定,因为它没有机会在动态链接时进行运行时CPU检测并在支持它的CPU上使用lock cmpxchg16b,其机制与glibc用于为当前系统选择最佳memcpy/strchr实现的机制相同。


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

这就是 gcc7 决定停止在 16 字节对象上为is_lock_free()返回 true 的部分原因,如 GCC 邮件列表消息中所述,该消息涉及您正在询问的更改。

另请注意,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,就无法确定。 (如果大小不是 2 的幂,则可以将其加宽以进行对齐。

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

正常的机制是使用原子对象的地址作为锁的全局哈希表的键。 两个对象混叠/碰撞并共享同一锁是额外的争用,但不是正确性问题。 这些锁仅从库函数中获取/释放,而不是在持有其他此类锁时获取/释放,因此它不会创建死锁。

您可以通过在两个不同进程之间使用共享内存来检测这一点(因此每个进程都有自己的锁哈希表)。 C++11 atomic可以与 mmap 一起使用吗?

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

  • 从两个单独的进程映射共享内存段,否则这两个进程不共享其任何地址空间。 是否在每个进程中将其映射到不同的基址并不重要。

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

  • 还要测试原子增量:让每个线程执行 1G 增量,并检查每次结果是否为 2G。 即使纯加载和纯存储本质上是原子的(撕裂测试),像fetch_add/operator++这样的读取-修改-写入操作也需要特殊的支持:num++ 可以为"int num"原子吗?

从 C++11 标准来看,其意图是对于无锁对象来说,这仍然是原子的。 它也可能适用于非无锁对象(如果它们在对象中嵌入了锁),这就是为什么您必须通过选中sizeof()来排除它的原因。

为了通过共享内存促进进程间通信,我们的目的是无锁操作也是无地址的。 也就是说,通过两个不同地址对同一内存位置的原子操作将以原子方式进行通信。实现不应依赖于任何每个进程的状态。

如果您看到两个进程之间的撕裂,则对象不是无锁的(至少不是 C++11 的预期方式,也不是您在普通共享内存 CPU 上期望的方式。

我不确定如果进程不必共享除包含原子对象2的 1 页之外的任何地址空间,为什么无地址很重要。 (当然,C++11 根本不要求实现使用页面。 或者,也许实现可以将锁的哈希表放在每个页面的顶部或底部? 在这种情况下,使用依赖于页面偏移量上方的地址位的哈希函数将是完全愚蠢的。

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


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

(更新:英特尔最近记录了AVX功能位意味着对齐加载/存储的16字节原子性,例如movaps。 至少特别是在英特尔 CPU 上;实际上,带有AVX的AMD CPU似乎也是这样,但AMD尚未正式记录下来。 这个答案的其余部分是在此之前写的,但 GCC 的libatomic确实使用vmovdqa [mem], xmm/mfence用于 CPU 上的原子 16 字节存储,保证原子存储。

没有 x86 硬件文档支持带有 SSE 指令的 16 字节原子加载/存储。 实际上,许多现代 CPU 确实具有原子movaps加载/存储,但在英特尔/AMD 手册中无法保证这一点,就像奔腾及更高版本的 8 字节 x87/MMX/SSE 加载/存储一样。 并且无法检测哪些 CPU 具有/没有原子 128 位操作(lock cmpxchg16b除外),因此编译器编写者无法安全地使用它们。

请参阅 SSE 说明:哪些 CPU 可以执行原子 16B 内存操作? 对于一个令人讨厌的极端情况:在 K10 上的测试表明,对齐的 xmm 加载/存储显示同一套接字上的线程之间没有撕裂,但不同套接字上的线程很少出现撕裂,因为 HyperTransport 显然只提供 8 字节对象的最低 x86 原子性保证。 (IDK,如果在这样的系统上lock cmpxchg16b更昂贵。

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


一个指针 + 计数器结构,其中只读访问只需要指针可能很便宜,但当前的编译器需要union技巧才能让他们只对对象的前半部分进行 8 字节的原子加载。 如何使用 c++11 CAS 实现 ABA 计数器? 对于 ABA 计数器,您通常会使用 CAS 更新它,因此缺少 16 字节原子纯存储不是问题。

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


脚注2:无地址

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

虚拟内存也是可能的:同一物理页面到同一进程中不同虚拟地址的两个映射是可能的,但很奇怪。 这可能会也可能不会使用相同的锁,具体取决于哈希函数是否使用页面偏移量上方的任何地址位。 (地址的低位表示页面内的偏移量,对于每个映射都是相同的。 即这些位的虚拟到物理转换是无操作的,这就是为什么 VIPT 缓存通常设计为利用这一点来获得速度而不会混叠的原因。

因此,非无锁对象可能在单个进程中无地址,即使它使用单独的全局哈希表而不是向原子对象添加互斥锁也是如此。 但这将是一个非常不寻常的情况;使用虚拟内存技巧为同一进程中的同一变量创建两个地址的情况极为罕见,该进程在线程之间共享其所有地址空间。 更常见的是进程之间共享内存中的原子对象。 (我可能误解了"无地址"的含义;可能它的意思是"地址空间自由",即缺乏对共享其他地址的依赖。

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

在这种情况下,作为一个实际问题,我只会编写您的检测函数来硬编码您知道以这种方式运行的 gcc 版本范围。目前,在停止内联更改之后的所有版本显然cmpxchg16b仍然在幕后使用无锁实现,因此今天的检查将是"开放式"的(即,X 之后的所有版本)。在此之前,is_lock_free返回 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返回 16 字节对象的is_lock_free"错误"答案。请注意,由于此更改的后半部分(使__atomic_load_16和朋友使用锁),您今天只需要检查的前半部分。您需要确定is_lock_free()开始为 16 字节对象返回 false 时的确切版本:Peter 提供的讨论此问题的链接是一个好的开始,您可以在 godbolt 中进行一些检查 - 尽管后者不提供您需要的一切,因为它不会像__atomic_load16那样反编译库函数:您可能需要为此深入研究libatomic源代码。宏检查也可能应绑定到libstdc++libatomic版本而不是编译器版本(尽管 AFAIK 在典型安装中,所有这些版本都绑定在一起)。您可能希望向#if添加更多检查,以将其限制为 64 位 x86 平台。

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

这个想法的基础是,得到错误的答案不会是一个毁灭世界的功能问题,而是一个性能问题:我猜你正在尝试进行这种检测,以在替代实现之间进行选择,其中一个在"真正"无锁系统上更快,而另一个更适合当std::atomic是基于锁的。

如果你的需求更强,并且你真的想要更健壮,为什么不组合方法:使用这种简单的版本检测方法,并将其与运行时/编译时检测方法相结合,该方法检查 Peter 回答中建议的撕裂行为或反编译。如果两种方法都一致,请将其用作您的答案;但是,如果他们不同意,请浮出水面并进行进一步调查。这也将帮助您抓住 gcc 更改实现以使 16 字节对象锁定已满的点(如果有的话)。