【问题标题】:Is assigning a pointer in C program considered atomic on x86-64在 x86-64 上被认为是原子的 C 程序中分配一个指针
【发布时间】:2020-11-23 17:47:06
【问题描述】:

https://www.gnu.org/software/libc/manual/html_node/Atomic-Types.html#Atomic-Types 说 - 在实践中,您可以假设 int 是原子的。你也可以假设指针类型是原子的;这很方便。这两个假设在 GNU C 库支持的所有机器和我们所知道的所有 POSIX 系统上都是正确的。

我的问题是,对于使用 gcc m64 标志编译的 C 程序,指针分配是否可以在 x86_64 架构上被视为原子分配。操作系统是 64 位 Linux,CPU 是 Intel(R) Xeon(R) CPU D-1548。一个线程将设置一个指针,另一个线程将访问该指针。只有一个写线程和一个读线程。阅读器应该获取指针的先前值或最新值,并且两者之间没有垃圾值。

如果它不被认为是原子的,请告诉我如何使用 gcc atomic 内置函数或者像 __sync_synchronize 这样的内存屏障在不使用锁的情况下实现相同的目的。只对 C 解决方案而不是 C++ 感兴趣。谢谢!

【问题讨论】:

  • 首先,几乎可以肯定的是,即使在 x86 硬件上也可以对非原子指针进行修改 - packed 立即跳出。其次,如果你错了,你刚刚介绍了一个很棒的小Heisenbug,你永远不会找到它。简而言之,从不“假设”。
  • 链接似乎是指信号处理程序和基线线程之间的原子性 [在 same CPU] 和 not 在多个内核上的线程之间.您将需要来自atomic.h 的一些原语。但是,即使使用原子访问,读取线程如何知道写入线程何时发布了新值?读者可以循环 stale 值。您需要一些其他同步机制(例如sem_wait/sem_post)或序列号或其他。

标签: c multithreading gcc x86-64 atomic


【解决方案1】:

请记住,仅原子性不足以在线程之间进行通信。没有什么能阻止compilerCPU 使用该“原子”存储重新排序先前/后续加载和存储指令。在过去,人们使用volatile 来防止重新排序,但它从未打算用于线程,也没有提供指定更少或更多限制性memory order 的方法(请参阅“与volatile 的关系”)。

您应该使用 C11 原子,因为它们保证原子性和内存顺序。

【讨论】:

  • 通常想要避免使用#include <stdatomic.h>_Atomic 的人希望这样做,因为他们认为这样做效率较低。 memory_order_relaxed 通常会编译为与 volatile 和/或`asm("" ::: "memory")` 屏障相同的 asm,以在没有 @ 的情况下使事情安全(不可移植,在特定实现上) 987654331@。相关:When to use volatile with multi threading? - 正如你所说,永远不会。
【解决方案2】:

对于几乎所有架构,指针加载和存储都是原子的。一个曾经值得注意的例外是 8086/80286,其中指针可以是 seg:offset;有一个 l[des]s 指令可以进行原子加载;但没有对应的原子存储。

指针的完整性只是一个小问题;您更大的问题围绕同步:指针位于 Y 值,您将其设置为 X;你怎么知道什么时候没有人使用(旧的)Y 值? 一个有点相关的问题是您可能在 X 中存储了 other 线程希望找到的东西。如果没有同步,other 可能会看到新的指针值,但是它指向的可能还不是最新的。

【讨论】:

    【解决方案3】:

    一个普通的全局char *ptr 不应该被认为是原子的。它有时可能会起作用,尤其是在禁用优化的情况下,但你可以让编译器安全高效通过使用现代语言特性来优化 asm,告诉它你想要原子性。

    使用 C11 stdatomic.h 或 GNU C __atomic builtins。请参阅Why is integer assignment on a naturally aligned variable atomic on x86? - 是的,底层的 asm 操作是“免费”的原子操作,但您需要控制编译器的代码生成以获得多线程的合理行为。

    另请参阅 LWN:Who's afraid of a big bad optimizing compiler? - 使用普通变量的奇怪影响包括一些非常糟糕的众所周知的事情,但也包括更晦涩的东西,例如发明的负载,如果编译器决定优化掉一个本地变量,则多次读取一个变量tmp 并加载共享 var 两次,而不是将其加载到寄存器中。使用 asm("" ::: "memory") 编译器屏障可能不足以解决这个问题,具体取决于您放置它们的位置。

    因此,请使用适当的原子存储和加载来告诉编译器您想要什么:您通常也应该使用原子加载来读取它们。

    #include <stdatomic.h>            // C11 way
    _Atomic char *c11_shared_var;     // all access to this is atomic, functions needed only if you want weaker ordering
    
    void foo(){
       atomic_store_explicit(&c11_shared_var, newval, memory_order_relaxed);
    }
    
    char *plain_shared_var;       // GNU C
    // This is a plain C var.  Only specific accesses to it are atomic; be careful!
    
    void foo() {
       __atomic_store_n(&plain_shared_var, newval, __ATOMIC_RELAXED);
    }
    

    在普通 var 上使用 __atomic_store_n 是 C++20 atomic_ref 公开的功能。如果多个线程在需要存在的整个时间内访问一个变量,您不妨只使用 C11 stdatomic,因为每次访问都需要是原子的(而不是优化为寄存器或其他)。如果您想让编译器加载一次并重用该值,请执行 char *tmp = c11_shared_var;(或 atomic_load_explicit,如果您只想获取而不是 seq_cst;在一些非 x86 ISA 上更便宜)。


    除了缺乏撕裂(asm 加载或存储的原子性)之外,_Atomic foo * 的其他关键部分是:

    • 编译器会假设其他线程可能已经改变了内存内容(就像volatile 实际上暗示的那样),否则假设没有数据竞争 UB 将使编译器将负载提升到循环之外。如果没有这个,死存储消除可能只会在循环结束时执行一次存储,而不是多次更新值。

      问题的阅读方面通常会在实践中咬人,请参阅Multithreading program stuck in optimized mode but runs normally in -O0 - 例如。 while(!flag){} 变为 if(!flag) infinite_loop; 并启用优化。

    • 订购。其他代码。 例如您可以使用memory_order_release 确保看到指针更新的其他线程也看到指向数据的所有更改。 (在 x86 上,就像编译时排序一样简单,获取/释放不需要额外的障碍,仅用于 seq_cst。如果可以,请避免使用 seq_cst;mfencelocked 操作很慢。)

    • 保证 store 将编译为单个 asm 指令。你会依赖这个。在正常编译器的实践中确实会发生这种情况,尽管可以想象编译器可能会决定使用rep movsb 来复制一些连续的指针,并且某处的某些机器可能有一个微码实现,它的某些存储空间小于 8 字节。

      (这种故障模式不太可能发生;Linux 内核依赖于 volatile 加载/存储编译为使用 GCC / clang 的单个指令来实现其手动内在函数。但如果您只是使用 asm("" ::: "memory") 来确保store 发生在非volatile 变量上,有机会。)

    此外,ptr++ 之类的东西将编译为 lock add qword [mem], 4 之类的原子 RMW 操作,而不是像 volatile 之类的单独加载和存储。 (有关原子 RMW 的更多信息,请参阅 Can num++ be atomic for 'int num'?)。如果你不需要它,请避免它,它会更慢。例如atomic_store_explicit(&amp;ptr, ptr + 1, mo_release); - seq_cst 加载在 x86-64 上很便宜,但 seq_cst 存储不是。

    还要注意,内存屏障不能创建原子性(缺乏撕裂),它们只能创建 ordering wrt 其他操作。

    实际上,x86-64 ABI 确实有 alignof(void*) = 8,所以所有指针对象都应该自然对齐(除了违反 ABI 的 __attribute__((packed)) 结构,所以你可以在它们上使用 __atomic_store_n。它应该编译成什么你想要的(普通存储,没有开销),并满足 asm 要求是原子的。

    另请参阅When to use volatile with multi threading? - 您可以使用volatile 和 asm 内存屏障滚动您自己的原子,但不要。 Linux 内核可以做到这一点,但付出了很多努力却基本上没有收获,尤其是对于用户空间程序。


    旁注:一个经常重复的误解是需要volatile_Atomic 来避免从缓存中读取陈旧值不是这种情况。

    跨多个内核运行 C11 线程的所有机器都具有一致的缓存,不需要读取器或写入器中的显式刷新指令。只是普通的加载或存储指令,如 x86 mov。关键是不要让编译器将共享变量的值保存在 CPU registers(线程私有)中。由于没有数据竞争未定义行为的假设,它通常可以进行此优化。寄存器与 L1d CPU 缓存非常不同。管理寄存器与内存中的内容是由编译器完成的,而硬件则保持缓存同步。请参阅When to use volatile with multi threading? 了解更多关于为什么一致缓存足以使volatilememory_order_relaxed 一样工作的详细信息。

    有关示例,请参阅 Multithreading program stuck in optimized mode but runs normally in -O0

    【讨论】:

      【解决方案4】:

      “原子”被视为这种量子状态,其中某些东西可以同时是原子的和非原子的,因为“有可能”“某些机器”“某处”“可能不会”以原子方式写入“某个值”。也许吧。

      事实并非如此。原子性有一个非常具体的含义,它解决了一个非常具体的问题:操作系统抢占线程以在该内核上调度另一个线程。而且你不能阻止线程执行中间汇编指令。

      这意味着任何一条汇编指令根据定义都是“原子的”。而且由于您有注册表移动指令,因此任何寄存器大小的副本根据定义都是原子的。这意味着 32 位 CPU 上的 32 位整数和 64 位 CPU 上的 64 位整数都是原子的——当然包括指针(忽略所有会告诉你“一些架构”的人具有与寄存器“不同大小”的指针,自 386 年以来一直没有这种情况)。

      但是,您应该注意不要遇到变量缓存问题(即一个线程写入指针,另一个线程尝试读取它但从缓存中获取旧值),根据需要使用volatile 来防止这种情况。

      【讨论】:

      • 这意味着任何一条汇编指令在定义上都是“原子的”问题在于证明一行C代码将总是被翻译成一条指令。你永远不能那样做。
      • “根据定义,任何单个汇编指令都是原子的”:同样,取决于您的定义。像add mem, reg 这样的读-修改-写指令是原子的,因为它不会在自己的内核上被中断,但在另一个内核可以在“读取”和“写”部分的指令。我意识到这不是这里的问题,但是这句话可能会在上下文中产生误导。
      • 从缓存中获取旧值 - 不,这不是问题。 从寄存器获取旧值,假设内存没有改变。即通过提升非原子/非易失性负载将while(!flag){} 变成if(!flag){ infinite_loop; }。所有现实世界的 C11 线程实现都在具有一致缓存的硬件上运行,请停止传播这种错误观念,即缓存可能会过时作为您需要 _Atomicvolatile 的解释。
      • 关于“你不能阻止线程执行中间汇编指令”:一些处理器有可中断的指令。编译器不太可能使用它们来更新存储的指针,但它显示了做出这样的假设的不正确性和危险性。 ARM 具有可中断的加载/存储多个寄存器指令,并且有人(我忘记是 VAX、IBM 还是 Intel)具有可中断的复制字节指令。
      • @Blindy 几十年来,我一直是一名真正的程序员,但由于假设未来的优化器不会对我的代码进行特定的转换,我已经被严重烧伤了太多次。你建议人们做的事情——假设将来不可能或不可能进行特定的优化——是灾难的根源。为了什么?使用适当的原子操作没有性能成本,并使代码更易于维护和理解。
      猜你喜欢
      • 1970-01-01
      • 2014-02-22
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多