在 x86-64 上,在 C 程序中分配一个被认为是原子的指针

Is assigning a pointer in C program considered atomic on x86-64

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

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

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

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

事实并非如此。原子性有一个非常具体的含义,它解决了一个非常具体的问题:线程被 OS pre-empted 调度到该核心上它所在位置的另一个线程。而且你不能阻止线程执行 mid-assembly 指令。

这意味着任何单个汇编指令在定义上都是“原子的”。并且由于您有注册表移动说明,因此根据定义,任何 register-sized 副本都是原子的。这意味着 32 位 CPU 上的 32 位整数和 64 位 CPU 上的 64 位整数都是原子的——当然包括指针(忽略所有人谁会告诉你“某些架构”的指针与寄存器“大小不同”,自 386 以来就没有这种情况了。

但是你应该小心不要遇到变量缓存问题(即一个线程写一个指针,另一个线程试图读取它但从缓存中获取旧值),根据需要使用 volatile 来防止这个。

请记住,单靠原子性不足以在线程之间进行通信。没有什么能阻止 compiler and CPU from reordering previous/subsequent load and store instructions with that "atomic" store. In old days people used volatile to prevent that reordering but that was never intended for use with threads and doesn't provide means to specify less or more restrictive memory order(请参阅其中的“与 volatile 的关系”)。

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

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

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

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

使用 C11 stdatomic.h or GNU C __atomic builtins. And see - 是的,底层 asm 操作是“免费”的原子操作,但您需要控制编译器的 code-gen 以获得多线程的理智行为。

另请参阅 LWN:Who's afraid of a big bad optimizing compiler? - 使用普通变量的怪异效果包括几个非常糟糕的 well-known 事情,但也有一些更晦涩的事情,例如发明的负载,如果编译器读取一个变量不止一次决定优化本地 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;(如果您只想获取而不是 seq_cst,则执行 atomic_load_explicit;在一些非 x86 ISA 上更便宜)。


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

  • 编译器会假设其他线程可能已经改变了内存内容(就像volatile有效暗示),否则假设没有data-race UB 将让编译器将负载提升到循环之外。如果没有这个,dead-store 消除可能只会在循环结束时进行一次存储,而不是多次更新值。

    问题的阅读方面通常是在实践中咬人的地方,参见 - 例如while(!flag){} 启用优化后变为 if(!flag) infinite_loop;

  • 订购。其他代码。 例如您可以使用 memory_order_release 来确保看到指针更新的其他线程也看到对 pointed-to 数据的所有更改。 (在 x86 上,这就像 compile-time 排序一样简单,acquire/release 不需要额外的障碍,只需要 seq_cst。如果可以,请避免使用 seq_cst;mfencelock编辑操作很慢。)

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

    (这种故障模式极不可能;Linux 内核依赖于 volatile load/store 编译为使用 GCC / clang 的单个指令作为其 hand-rolled 内在函数。但是,如果您只是使用 asm("" ::: "memory") 来确保存储发生在非 volatile 变量上,那么就有机会。)

此外,类似ptr++的东西将编译为原子RMW操作,如lock add qword [mem], 4,而不是像volatile那样单独加载和存储将。 (有关原子 RMW 的更多信息,请参阅 )。如果你不需要它,请避免它,它会更慢。例如atomic_store_explicit(&ptr, ptr + 1, mo_release); - seq_cst x86-64 上的负载很便宜,但 seq_cst 商店则不然。

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

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

另请参阅 When to use volatile with multi threading? - 您可以使用 volatile 和 asm 内存屏障滚动您自己的原子,但不要这样做。 Linux 内核可以做到这一点,但基本上没有任何收获,尤其是对于 user-space 程序。


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

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

请参阅 示例。