一个普通的全局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;mfence 或 locked 操作很慢。)
-
保证 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(&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? 了解更多关于为什么一致缓存足以使volatile 像memory_order_relaxed 一样工作的详细信息。
有关示例,请参阅 Multithreading program stuck in optimized mode but runs normally in -O0。