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

时间:2018-04-16 02:19:49

标签: c++ c++11 concurrency atomic lock-free

由于std::atomic::is_lock_free()可能无法真实地反映现实[ref],因此我考虑编写一个真正的运行时测试。然而,当我开始讨论它时,我发现它并不是我认为的那么简单的任务。我想知道是否有一些聪明的想法可以做到。

2 个答案:

答案 0 :(得分:9)

除了表现之外,标准不以任何方式保证 ;这或多或少是重点。

如果你愿意介绍一些特定于平台的UB,你可以做一些事情,例如将atomic<int64_t> *投射到volatile int64_t*,看看你是否观察过#34;撕裂&#34;当另一个线程读取对象时。但是在32位x86上失败,其中无锁int64_t只有很小的开销(使用SSE2或x87)是有效的,但volatile int64_t*将使用两个单独的4字节存储器,就像大多数编译器编译它一样。 / p>

如果这个测试成功(即普通的C ++类型只有volatile是自然原子的),那就告诉你任何理智的编译器都会非常便宜地使它无锁。但如果它失败了,它就不会告诉你。该类型的无锁原子可能仅比加载/存储的普通版本稍贵,或者编译器可能根本不使其无锁。

在任何特定的平台/目标体系结构中,您可以在调试器中单步执行代码,并查看运行的asm指令。 (包括踩到像__atomic_store_16这样的libatomic函数调用。这是唯一100%可靠的方式。

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

您可以轻松地寻找性能差异(例如,具有多个读者的可扩展性),但x86-64 lock cmpxchg16b不会缩放 1 与8字节和更窄的原子对象where pure asm loads are atomic and can be used不同,多个读者互相争斗。 lock cmpxchg16b在执行之前获取对缓存行的独占访问权限;滥用原子加载旧值的副作用,实现lock cmpxchg16b失败的原因是 比8字节原子加载更糟糕,原子加载仅编译为常规加载指令。

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

另请注意,32位x86上的clang使用is_lock_free()来实现lock cmpxchg8b,就像64位模式下的16字节对象一样。因此,您也会看到缺少并行读取缩放。 (https://bugs.llvm.org/show_bug.cgi?id=33109

使用锁定的

std::atomic<int64_t>实现通常仍然通过在每个对象中包含std::atomic<>字节或单词来使对象更大。它会改变ABI,但无锁与锁定已经是ABI的差异。标准允许这样,但奇怪的硬件可能需要在对象中额外的字节,即使在无锁时也是如此。无论如何lock并没有告诉你任何事情。如果它更大,那么您的实现最有可能添加了互斥锁,但是如果不检查asm,您就无法确定。

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

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

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

  • 映射来自两个独立进程的共享内存段,否则这些进程不会共享任何地址空间。如果您在每个流程中将其映射到不同的基址,则无关紧要。

  • 从一个进程中存储像all-one和all-zero的模式,同时从另一个进程读取(并寻找撕裂)。与我上面提到的T相同。

  • 同样测试原子增量:让每个线程以1G为增量并每次检查结果是2G。即使纯加载和纯存储是自然原子的(撕裂测试),像volatile / fetch_add这样的读 - 修改 - 写操作也需要特殊支持:Can num++ be atomic for 'int num'?

从C ++ 11标准来看,目的是对于无锁对象,这应该仍然是原子的。它也适用于非无锁对象(如果它们将锁嵌入对象中),这就是为什么你必须通过检查operator++来解决这个问题。

  

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

如果你看到两个进程之间发生撕裂,那么该对象就不会无锁(至少不是C ++ 11的意图,而不是你期望的方式)在普通的共享内存CPU上。)

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

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

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

没有x86硬件保证支持带有SSE指令的16字节原子加载/存储。在实践中,许多现代CPU确实具有原子sizeof()加载/存储,但在Intel / AMD手册中无法保证这与Pentium及更高版本上的8字节x87 / MMX / SSE加载/存储的方式相同。并且无法检测哪些CPU执行/不执行原子128位操作(movaps除外),因此编译器编写者无法安全地使用它们。

请参阅SSE instructions: which CPUs can do atomic 16B memory operations?了解一个令人讨厌的角落情况:在K10上进行测试表明,对齐的xmm加载/存储显示同一套接字上的线程之间没有撕裂,但不同套接字上的线程经历罕见的撕裂,因为HyperTransport显然只给出了最小值x86原子性保证8字节对象。 (如果lock cmpxchg16b在这样的系统上更贵,则为IDK。)

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

指针+计数器结构,其中只读访问只需要指针可以很便宜,但是当前的编译器需要lock cmpxchg16b hacks才能让它们只对对象的前半部分进行8字节的原子加载。 How can I implement ABA counter with c++11 CAS?。对于ABA计数器,您通常会使用CAS更新它,因此缺少16字节原子纯存储不是问题。

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

脚注2:无地址

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

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

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

答案 1 :(得分:2)

我认为你真的只是试图检测特定于gcc的特殊情况,其中is_lock_free报告为false,但底层实现(隐藏在libatomic函数调用后面)仍在使用{{1} }。您想了解这一点,因为您认为这样的实现真正无锁。

在这种情况下,作为一个实际问题,我会编写您的检测功能来硬编码您知道以这种方式操作的gcc版本范围。目前,在停止内联cmpxchg16b的更改之后的所有版本显然仍然使用无掩盖的无锁实现,因此今天的检查将是“开放式”(即,X之后的所有版本)。在此之前cmpxchg16b返回true(您认为正确)。在对gcc进行一些假设的未来更改后,使库调用使用锁定,is_lock_free答案将变为真实,并且您将通过记录其发生的版本来关闭您的支票。

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

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为16字节对象返回gcc的“错误”答案的版本范围。请注意,由于此更改的后半部分(使is_lock_free和朋友使用锁定),您今天只需要检查的前半部分。当__atomic_load_16开始为16字节对象返回false时,您需要确定确切的版本:Peter提供的讨论此问题的链接是一个良好的开端,您可以在godbolt中进行一些检查 - 尽管后者没有提供你需要的一切,因为它没有反编译像is_lock_free()这样的库函数:你可能需要深入研究__atomic_load16源代码。宏检查也可能与libatomiclibstdc++版本而不是编译器版本相关联(尽管AFAIK在典型安装中将所有这些版本绑定在一起)。您可能希望向libatomic添加一些检查,以将其限制为64位x86平台。

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

这个想法的基础是,得到错误的答案不会是一个破坏世界的功能问题,而是一个性能问题:我猜你正在尝试进行这种检测以在其他实现之间进行选择这在“真正的”无锁系统上更快,而在cmpxchg16b基于锁定时更适合。

如果你的要求更强,并且你真的想要更强大,为什么不结合方法:使用这种简单的版本检测方法和将它与运行时/编译时检测方法结合起来检查彼得的回答中提到的撕裂行为或反编译。如果两种方法都一致,请将其作为答案;但是,如果他们不同意,则表明错误并进行进一步调查。这也将帮助您了解gcc如何更改实现以使16字节对象锁满。