gpt4 book ai didi

c++ - 原子操作、std::atomic<> 和写入顺序

转载 作者:行者123 更新时间:2023-12-03 18:42:51 32 4
gpt4 key购买 nike

GCC 编译这个:

#include <atomic>
std::atomic<int> a;
int b(0);

void func()
{
b = 2;
a = 1;
}

对此:
func():
mov DWORD PTR b[rip], 2
mov DWORD PTR a[rip], 1
mfence
ret

所以,为我澄清一下:
  • 是否有任何其他线程将“a”读为 1,保证将“b”读为 2。
  • 为什么 MFENCE 在写入“a”之后而不是之前发生。
  • 写入“a”是否保证是原子(狭义的,非 C++ 意义上的)操作,这是否适用于所有英特尔处理器?我从这个输出代码中假设是这样。

  • 此外,clang (v3.5.1 -O3) 这样做:
    mov dword ptr [rip + b], 2
    mov eax, 1
    xchg dword ptr [rip + a], eax
    ret

    在我的脑海中,哪个看起来更直接,但为什么采用不同的方法,每种方法的优点是什么?

    最佳答案

    我将您的示例放在 Godbolt compiler explorer, and added some functions 上以读取、增加或组合( a+=b )两个原子变量。我还使用了 a.store(1, memory_order_release); 而不是 a = 1; 以避免获得比需要更多的订单,因此它只是 x86 上的一个简单存储。

    有关(希望正确)的解释,请参见下文。 更新 :我将 "release" semantics 与 StoreStore 屏障混淆。我想我修正了所有错误,但可能留下了一些错误。

    首先是简单的问题:

    Is the write to ‘a’ guaranteed to be an atomic?



    是的,任何读取 a 的线程都将获得旧值或新值,而不是一些写入一半的值。这个 happens for free on x86 和大多数其他体系结构具有适合寄存器的任何对齐类型。 (例如,在 32 位上不是 int64_t。)因此,在许多系统上,这恰好适用于 b,大多数编译器生成代码的方式。

    有一些类型的存储在 x86 上可能不是原子的,包括跨越缓存线边界的未对齐存储。但是 std::atomic 当然可以保证任何必要的对齐。

    读-修改-写操作是有趣的地方。一次在多个线程中对 a+=3 进行 1000 次评估将始终产生 a += 3000 。如果 a 不是原子的,你可能会得到更少。

    有趣的事实:有符号原子类型保证二进制补码环绕,这与普通有符号类型不同。 C 和 C++ 仍然坚持在其他情况下不定义有符号整数溢出的想法。一些 CPU 没有算术右移,所以不定义负数的右移是有道理的,但除此之外,现在所有 CPU 都使用 2 的补码和 8 位字节,所以感觉就像一个荒谬的圈套。 </rant>

    Is any other thread reading ‘a’ as 1 guaranteed to read ‘b’ as 2.



    是的,因为 std::atomic 提供的保证。

    现在我们进入了语言的 memory model 及其运行的硬件。

    C11 和 C++11 有一个非常弱的内存排序模型,这意味着编译器可以重新排序内存操作,除非你告诉它不要。 (来源: Jeff Preshing's Weak vs. Strong Memory Models)。即使 x86 是您的目标机器,您也必须在编译时阻止编译器重新排序存储。 (例如,通常您希望编译器将 a = 1 提升出一个也写入 b 的循环。)

    默认情况下,使用 C++11 原子类型可以使您对它们的操作相对于程序的其余部分进行完整的顺序一致性排序。这意味着它们不仅仅是原子的。请参阅下文以将排序放宽到需要的程度,从而避免昂贵的围栏操作。

    Why does the MFENCE happen after the write to ‘a’ not before.



    StoreStore fences 是 x86 强内存模型的空操作,所以编译器只需要先把存储到 b 再存储到 a 来实现源代码排序。

    完全顺序一致性还要求存储在以后按程序顺序加载之前是全局排序/全局可见的。

    x86 可以在加载后重新排序存储。在实践中,乱序执行看到指令流中的独立加载,并在仍在等待数据准备好的存储之前执行它。无论如何,顺序一致性禁止这样做,所以 gcc 使用 MFENCE ,这是一个完整的屏障,包括 StoreLoad ( the only kind x86 doesn't have for free 。( LFENCE/SFENCE 只对弱排序操作有用)x25313340

    另一种说法是 C++ 文档使用的方式:顺序一致性保证所有线程以相同的顺序看到所有更改。每个原子存储之后的 MFENCE 保证该线程可以看到来自其他线程的存储。 否则,我们的负载会在其他线程的负载看到我们的商店 之前看到我们的商店。 StoreLoad 屏障 (MFENCE) 将我们的加载延迟到需要首先发生的存储之后。
    movnt 的 ARM32 asm 是:
    # get pointers and constants into registers
    str r1, [r3] # store b=2
    dmb sy # Data Memory Barrier: full memory barrier to order the stores.
    # I think just a StoreStore barrier here (dmb st) would be sufficient, but gcc doesn't do that. Maybe later versions have that optimization, or maybe I'm wrong.
    str r2, [r3, #4] # store a=1 (a is 4 bytes after b)
    dmb sy # full memory barrier to order this store wrt. all following loads and stores.

    我不知道 ARM asm,但到目前为止我发现它通常是 b=2; a=1; ,但加载和存储总是首先有寄存器操作数,第二个是内存操作数。如果您习惯于 x86,这真的很奇怪,其中内存操作数可以是大多数非 vector 指令的源或目标。加载立即数也需要很多指令,因为固定的指令长度只为 op dest, src1 [,src2] (move word)/ movw (move top) 的有效载荷留下了 16b 的空间。

    发布/获取

    单向内存屏障的 movt and release 命名来自锁:
  • 一个线程修改共享数据结构,然后释放锁。在所有加载/存储到它所保护的数据之后,解锁必须是全局可见的。 (StoreStore + LoadStore)
  • 另一个线程获取锁(读取,或带有释放存储的 RMW),并且必须在获取变为全局可见后对共享数据结构执行所有加载/存储。 (LoadLoad + LoadStore)

  • 请注意,即使对于与加载-获取或存储-释放操作略有不同的独立栅栏,std:atomic 也使用这些名称。 (请参阅下面的 atomic_thread_fence)。

    发布/获取语义比生产者-消费者所要求的更强。这只需要单向 StoreStore(生产者)和单向 LoadLoad(消费者),无需 LoadStore 排序。

    受读者/作者锁保护的共享哈希表(例如)需要获取-加载/释放-存储原子读-修改-写操作来获取锁。 x86 acquire 是一个完整的屏障(包括 StoreLoad),但 ARM64 具有加载链接/存储条件的加载-获取/存储-释放版本,用于执行原子读-修改-写。据我了解,即使锁定,这也避免了对 StoreLoad 屏障的需要。

    使用较弱但仍然足够的排序

    默认情况下,对 lock xadd 类型的写入相对于源代码中的所有其他内存访问(加载和存储)进行排序。您可以控制 std::atomic 的排序。

    在您的情况下,您只需要您的生产者确保商店以正确的顺序在全局范围内可见,即商店之前的 StoreStore 屏障到 std::memory_ordera 包括这个和更多。 store(memory_order_release) 只是所有商店的单向 StoreStore 屏障。 x86 免费提供 StoreStore,因此编译器所要做的就是将存储按源顺序排列。

    发布而不是 seq_cst 将是一个巨大的性能胜利,尤其是。在像 x86 这样的架构上,发行价便宜/免费。如果无争用情况很常见,则更是如此。

    读取原子变量还对所有其他加载和存储施加了加载的完全顺序一致性。在 x86 上,这是免费的。 LoadLoad 和 LoadStore 屏障是空操作并且隐含在每个内存操作中。您可以使用 std::atomic_thread_fence(memory_order_release) 使您的代码在弱序 ISA 上更高效。

    请注意 std::atomic standalone fence functions confusingly reuse the "acquire" and "release" names for StoreStore and LoadLoad fences that order all stores (or all loads) in at least the desired direction 。在实践中,它们通常会发出 2 路 StoreStore 或 LoadLoad 屏障的硬件指令。 This doc 是成为当前标准的提案。您可以看到 memory_order_release 如何映射到 SPARC RMO 上的 a.load(std::memory_order_acquire),我认为它被包含在内的部分原因是它分别具有所有屏障类型。 (嗯,cppref 网页只提到了订购商店,而不是 LoadStore 组件。虽然它不是 C++ 标准,所以也许完整标准说得更多。)
    #LoadStore | #StoreStore 对于这个用例来说不够强大。 This post 谈论您使用标志来指示其他数据已准备就绪的情况,并谈论 memory_order_consume

    如果您的标志是指向 memory_order_consume 的指针,甚至是指向结构或数组的指针,则 consume 就足够了。但是,没有编译器知道如何进行依赖项跟踪以确保它在 asm 中以正确的顺序放置东西,因此当前的实现总是将 b 视为 consume 。这太糟糕了,因为除 DEC alpha(和 C++11 的软件模型)之外的每个架构都免费提供这种排序。 According to Linus Torvalds, only a few Alpha hardware implementations actually could have this kind of reordering, so the expensive barrier instructions needed all over the place were pure downside for most Alphas.

    生产者仍然需要使用 acquire 语义(一个 StoreStore 屏障),以确保在更新指针时新的有效负载可见。

    使用 release 编写代码并不是一个坏主意,如果您确定自己理解其含义并且不依赖于 consume 不能保证的任何内容。将来,一旦编译器变得更智能,即使在 ARM/PPC 上,您的代码也将在没有障碍指令的情况下编译。实际的数据移动仍然必须在不同 CPU 上的缓存之间发生,但在弱内存模型机器上,您可以避免等待任何不相关的写入可见(例如生产者中的暂存缓冲区)。

    请记住, 您实际上无法通过实验测试 consume 代码 ,因为当前的编译器为您提供了比代码请求更强的排序。

    无论如何都很难通过实验来测试任何这些,因为它对时间很敏感。此外,除非编译器重新排序操作(因为你没有告诉它不要),生产者-消费者线程在 x86 上永远不会有问题。您需要在 ARM 或 PowerPC 或其他设备上进行测试,甚至尝试查找实际中发生的排序问题。

    引用:
  • https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67458 :我报告了我在 memory_order_consume 中发现的 gcc 错误,在 x86 上产生了 b=2; a.store(1, MO_release); b=3;,而不是 0x23131313181
  • https://gcc.gnu.org/bugzilla/show_bug.cgi?id=67461 :我还报告了这样一个事实,即 ARM gcc 连续使用两个 a=1;b=3 用于 b=3; a=1; ,并且可能使用更少的 mcc 进行 x86 gcc 操作。我不确定每个存储之间是否需要 dmb sy 来保护信号处理程序免于做出错误假设,或者这是否只是缺少优化。
  • The Purpose of memory_order_consume in C++11 (already linked above) 正好涵盖了这种使用标志在线程之间传递非原子有效负载的情况。
  • StoreLoad 屏障(x86 mfence)的用途:演示需求的工作示例程序:http://preshing.com/20120515/memory-reordering-caught-in-the-act/
  • 数据依赖障碍(只有 Alpha 需要这种类型的显式障碍,但 C++ 可能需要它们来防止编译器进行推测加载):http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#360
  • 控制依赖障碍:http://www.mjmwired.net/kernel/Documentation/memory-barriers.txt#592
  • Doug Lea 表示 x86 只需要 a=1; a=1; 用于使用“流式”写入的数据,如 mfence 或 0x2341 或 123341 (NT = 非时间性)。除了绕过缓存,x86 NT 加载/存储具有弱排序语义。
  • http://preshing.com/20120913/acquire-and-release-semantics/
  • http://preshing.com/20120612/an-introduction-to-lock-free-programming/(指向他推荐的书籍和其他东西的指针)。
  • 有趣 thread on realworldtech 关于障碍无处不在还是强内存模型更好,包括数据依赖性在硬件中几乎免费的一点,因此跳过它并给软件带来很大负担是愚蠢的。 (Alpha(和C++)没有的东西,但其他一切都有)。在他开始解释他的论点的更详细/技术原因之前,返回一些帖子,看看 Linus Torvalds 的有趣侮辱。
  • 关于c++ - 原子操作、std::atomic<> 和写入顺序,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/32384901/

    32 4 0
    Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
    广告合作:1813099741@qq.com 6ren.com