代码之家  ›  专栏  ›  技术社区  ›  Lingxi

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

  •  6
  • Lingxi  · 技术社区  · 6 年前

    自从 std::atomic::is_lock_free() 可能无法真实反映现实[ ref ],我正在考虑编写一个真正的运行时测试。然而,当我开始着手做这件事时,我发现这并不是我认为的一件小事。我想知道是否有什么聪明的主意可以做到这一点。

    2 回复  |  直到 6 年前
        1
  •  13
  •   Peter Cordes    4 年前

    除了性能,标准没有 保证 你能说的任何方式;这或多或少就是问题所在。

    如果您愿意引入一些特定于平台的UB,您可以执行以下操作 atomic<int64_t> * 到a volatile int64_t* 并查看当另一条线读取对象时是否观察到“撕裂”。( When to use volatile with multi threading? -通常不会,但真正的硬件在运行线程的内核之间有一致的缓存,所以简单的asm加载/存储基本上就像是放松的原子。)

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

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

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


    您可以通过可移植的方式查找性能差异(例如,具有多个读卡器的可扩展性),但x86-64 锁定cmpxchg16b 无法缩放 1. 与8字节和更窄的原子对象不同,多个读卡器相互竞争 where pure asm loads are atomic and can be used 锁定cmpxchg16b 在执行之前获取对缓存线的独占访问权;滥用原子化加载旧值对实现失败的副作用 .load() 比仅编译为常规加载指令的8字节原子加载更糟糕。

    这也是gcc7决定停止为返回true的部分原因 is_lock_free() 在16字节对象上,如所述 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,则无法确定。(如果大小不是2的幂,则可以将其加宽以对齐。)

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

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

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

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

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

    • 存储一个进程中的所有1和所有0等模式,同时读取另一个进程中的数据(并查找撕裂)。和我建议的一样 不稳定的 在上面

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

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

    为了通过共享内存促进进程间通信,我们的目的是使无锁操作也无地址。也就是说,通过两个不同的地址在同一内存位置上的原子操作将进行原子通信。 实施不应取决于每个过程的任何状态。

    如果您看到两个进程之间发生撕裂,则表示对象没有锁定 (至少不是C++11的预期方式,也不是普通共享内存CPU的预期方式。)

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

    无论如何 这取决于关于计算机如何工作的许多假设,这些假设在所有普通CPU上都是正确的,但C++无法做到这一点。 如果您关心的实现是在主流CPU(如x86或ARM)上的普通操作系统下进行的,那么这种测试方法应该相当准确,并且可能是一种替代读取asm的方法。 这不是在编译时自动完成的非常实用的事情,但它将是 可能的 自动化这样的测试并将其放入构建脚本中,这与读取asm不同。


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

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

    看见 SSE instructions: which CPUs can do atomic 16B memory operations? 对于一个棘手的情况:在K10上的测试表明,对齐的xmm加载/存储在同一个套接字上的线程之间没有撕裂,但不同套接字上的线程经历了罕见的撕裂,因为HyperTransport显然只提供了8字节对象的最小x86原子性保证。(IDK如果 锁定cmpxchg16b 在这样的系统上更昂贵。)

    如果没有供应商发布的保证,我们也无法确定奇怪的微体系结构转角情况。在一个简单的测试中,一个线程写入模式,另一个线程读取模式,这是很好的证据,但在某些特殊情况下,CPU设计者决定以不同于正常的方式处理某些事情,这总是有可能的。


    只读访问只需要指针的指针+计数器结构可能很便宜,但当前编译器需要 union 黑客让他们对对象的前半部分进行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:无地址

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

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

    因此,即使使用单独的全局哈希表而不是向原子对象添加互斥体,非无锁对象在单个进程中也可能是无地址的。但这将是一种非常不寻常的情况;使用虚拟内存技巧在 相同的 在线程之间共享其所有地址空间的进程。更常见的是进程之间共享内存中的原子对象。(我可能误解了“无地址”的含义;它可能意味着“无地址空间”,即不依赖于共享的其他地址。)

        2
  •  2
  •   BeeOnRope    6 年前

    我认为您实际上只是想检测gcc特定的特殊情况 is_lock_free 报告错误,但底层实现(隐藏在 libatomic 函数调用)仍在使用 cmpxchg16b .您想知道这一点,因为您考虑了这样的实现 真正地 解除锁定。

    在这种情况下,作为一个实际问题,我只需编写您的检测函数来硬编码您知道的gcc版本范围以这种方式运行。当前,更改为停止内联的版本之后的所有版本 cmpxchg16b 显然,在幕后仍然使用无锁实现,因此今天的检查将是“开放式的”(即X之后的所有版本)。在此之前 \u lock\u可用吗 返回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 返回的“错误”答案 \u lock\u可用吗 对于16字节对象。请注意,自本次变更的下半年起 __atomic_load_16 朋友使用锁)你今天只需要支票的前一半。您需要确定 is_lock_free() 开始为16字节对象返回false:Peter提供的讨论此问题的链接是一个很好的开始,您可以进行一些检入 godbolt -尽管后者不能提供您所需的一切,因为它不能反编译库函数,如 __atomic_load16 :您可能需要深入了解 libatomic公司 来源。宏检查也可能与 libstdc++ libatomic公司 版本而不是编译器版本(尽管AFAIK通常会安装所有这些版本的绑定在一起)。您可能需要在 #if 将其限制为64位x86平台。

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

    这个想法的基础是,得到错误的答案不会是一个破坏世界的功能问题,而是一个性能问题:我猜您正在尝试进行此检测,以选择替代实现,其中一个在“真正的”无锁系统上更快,另一个在以下情况下更合适 std::atomic 是基于锁的。

    如果您的需求更强大,并且确实想变得更健壮,那么为什么不将这些方法结合起来:使用这个简单的版本检测方法和 合并它 使用运行时/编译时检测方法,按照Peter的回答检查撕裂行为或反编译。如果两种方法都一致,请将其作为您的答案;但是,如果他们不同意,则应揭露错误并进行进一步调查。这也将有助于您抓住gcc更改实现以使16字节对象锁满的关键点(如果有的话)。