一个普通的全局 char *ptr
不应被视为原子性。 它可能有时能工作,特别是在禁用优化的情况下,但您可以通过使用现代语言功能告诉编译器您想要原子性来使编译器生成安全且高效的优化汇编代码。
使用 C11 stdatomic.h
或 GNU C __atomic
内置函数。并参见 为什么在x86上自然对齐变量的整数赋值是原子的? - 是的,底层的汇编操作是免费的原子操作,但是您需要控制编译器的代码生成以获得多线程的合理行为。
还请参阅 LWN:谁害怕大坏优化编译器? - 使用普通变量的奇怪影响包括几个非常糟糕的众所周知的事情,但也包括更隐晦的东西,例如虚构负载,如果编译器决定优化掉本地 tmp 并两次加载共享变量而不是将其加载到寄存器中,则读取变量两次。使用 asm("" ::: "memory")
编译器屏障可能不足以击败它,具体取决于您放置它们的位置。
因此,请使用适当的原子存储和加载告诉编译器您想要什么: 您通常也应使用原子加载来读取它们。
#include <stdatomic.h>
_Atomic char *c11_shared_var;
void foo(){
atomic_store_explicit(&c11_shared_var, newval, memory_order_relaxed);
}
char *plain_shared_var;
void foo() {
__atomic_store_n(&plain_shared_var, newval, __ATOMIC_RELAXED);
}
使用
__atomic_store_n
来操作普通变量是C++20
atomic_ref
提供的功能。如果多个线程需要在整个变量存在期间访问它,最好使用C11 stdatomic,因为每次访问都需要原子性(不能被优化为寄存器或其他内容)。当您想让编译器加载一次并重复使用该值时,请执行
char *tmp = c11_shared_var;
(或者如果您只想获取而不是seq_cst,则执行
atomic_load_explicit
;在一些非x86 ISA上更便宜)。
< p >除了缺少撕裂(asm load或store的原子性)之外,
_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
或lock
ed操作很慢。)
保证存储将编译为单个汇编指令。您将依赖于此。它实际上在合理的编译器中发生,尽管可以想象编译器可能决定使用rep movsb
来复制一些连续的指针,并且某些机器可能具有比8字节更窄的微码实现一些存储。
(这种故障模式高度不太可能发生;Linux内核依赖于GCC / clang将volatile
load/store编译为单个指令进行其手动滚动的内部函数。但是,如果您只是使用asm("" ::: "memory")
来确保在非volatile
变量上发生存储,则有机会。
此外,类似于
ptr++
这样的语句将编译为原子 RMW 操作,例如
lock add qword [mem], 4
,而不是像
volatile
那样分开进行加载和存储。(有关原子 RMW 的更多信息,请参见
Can num++ be atomic for 'int num'?)。如果不需要,请避免使用它,因为它会减慢速度。例如:
atomic_store_explicit(&ptr, ptr + 1, mo_release);
- 在 x86-64 上,seq_cst 加载很便宜,但 seq_cst 存储不便宜。
此外,请注意,内存屏障不能创建原子性(缺乏 tearing),它们只能与其他操作创建排序。
在实践中,x86-64 ABIs 确实具有
alignof(void*) = 8
,因此所有指针对象应该自然对齐(除了违反 ABI 的
__attribute__((packed))
结构之外),所以您可以在它们上使用
__atomic_store_n
。它应该编译成您想要的内容(纯粹的存储,没有额外开销),并满足符合原子性所需的汇编要求。
另请参见
When to use volatile with multi threading? - 您可以使用
volatile
和 asm 内存屏障来自行创建原子操作,但不要这样做。Linux 内核就是这样做的,但对于用户空间程序而言,这需要付出很多努力,却没有什么收益。
附注:一个经常重复的误解是,需要使用
volatile
或
_Atomic
来避免从缓存中读取过时的值。这是错误的。
运行 C11 线程跨多个核心的所有机器都具有一致的缓存,不需要在读者或写者中使用显式刷新指令。只需普通的加载或存储指令,例如 x86 的
mov
。关键是不让编译器将共享变量的值保留在 CPU 寄存器中(这些寄存器是线程专用的)。它通常可以进行此优化,因为假设没有数据竞争 Undefined Behaviour。寄存器与 L1d CPU 缓存非常不同;管理寄存器与内存中的内容是由编译器完成的,而硬件保持缓存同步。有关为什么一致的缓存足以使
volatile
像
memory_order_relaxed
一样工作的更多详细信息,请参见
When to use volatile with multi threading?。
请参见多线程程序在优化模式下卡住但在-O0下正常运行的例子。
packed
立即跳出。其次,如果你错了,你刚刚引入了一个美妙的小 Heisenbug,你将永远找不到它。简而言之,永远“假设”不要轻信。 - Andrew Henleatomic.h
中获取一些原语。但即使具有原子访问权限,读取线程如何知道写入线程何时发布新值?读取器可能会在旧值上循环。您需要其他同步机制(例如sem_wait/sem_post
)或序列号或其他内容。 - Craig Estey