在x86-64上,C程序中赋值指针是否被认为是原子操作?

7

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

我的问题是,在使用gcc m64标志编译的C程序中,指针赋值是否可以被认为是原子操作,且CPU架构为x86_64,操作系统为64位Linux,CPU为Intel(R) Xeon(R) CPU D-1548。一个线程将设置指针,另一个线程将访问指针。只有一个写线程和一个读线程。读取者应该获取指针的先前值或最新值,而不会有任何垃圾值。

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


首先,即使在x86硬件上,几乎肯定有方法可以使指针非原子化 - packed 立即跳出。其次,如果你错了,你刚刚引入了一个美妙的小 Heisenbug,你将永远找不到它。简而言之,永远“假设”不要轻信。 - Andrew Henle
4
这段话似乎在讨论一个信号处理程序和基线线程[在相同的CPU上]之间的原子性,而不是在多个核心上的线程之间。您需要从 atomic.h 中获取一些原语。但即使具有原子访问权限,读取线程如何知道写入线程何时发布新值?读取器可能会在旧值上循环。您需要其他同步机制(例如sem_wait/sem_post)或序列号或其他内容。 - Craig Estey
4个回答

6
请记住,仅有原子性并不足以在线程之间进行通信。没有任何东西可以防止编译器CPU将先前/后续的加载和存储指令重新排序到那个“原子”存储中。在旧时代,人们使用volatile来防止这种重排序,但这从未用于线程,并且不提供指定更少或更严格的内存顺序的方法(请参见其中的“与volatile的关系”)。
您应该使用C11原子操作,因为它们保证了原子性和内存顺序。

3
通常希望避免使用#include <stdatomic.h>_Atomic的人这么做是因为他们认为这样效率更低。使用memory_order_relaxed通常编译成与使用volatile和/或 asm("" ::: "memory")屏障(在特定实现上)使事情更加安全的汇编代码相同,而不需要使用_Atomic。相关:何时在多线程中使用volatile? - 正如您所说,永远不要使用。 - Peter Cordes

4

一个普通的全局 char *ptr 不应被视为原子性。 它可能有时能工作,特别是在禁用优化的情况下,但您可以通过使用现代语言功能告诉编译器您想要原子性来使编译器生成安全且高效的优化汇编代码。

使用 C11 stdatomic.h 或 GNU C __atomic 内置函数。并参见 为什么在x86上自然对齐变量的整数赋值是原子的? - 是的,底层的汇编操作是免费的原子操作,但是您需要控制编译器的代码生成以获得多线程的合理行为。

还请参阅 LWN:谁害怕大坏优化编译器? - 使用普通变量的奇怪影响包括几个非常糟糕的众所周知的事情,但也包括更隐晦的东西,例如虚构负载,如果编译器决定优化掉本地 tmp 并两次加载共享变量而不是将其加载到寄存器中,则读取变量两次。使用 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);
}

使用__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;mfencelocked操作很慢。)

  • 保证存储将编译为单个汇编指令。您将依赖于此。它实际上在合理的编译器中发生,尽管可以想象编译器可能决定使用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 缓存非常不同;管理寄存器与内存中的内容是由编译器完成的,而硬件保持缓存同步。有关为什么一致的缓存足以使 volatilememory_order_relaxed 一样工作的更多详细信息,请参见When to use volatile with multi threading?

请参见多线程程序在优化模式下卡住但在-O0下正常运行的例子


3
对于几乎所有体系结构,指针的加载和存储都是原子操作。曾经有一个明显的例外是8086/80286,其中指针可以是seg:offset类型;虽然有一条l[des]s指令可以进行原子加载,但没有相应的原子存储指令。
指针本身的完整性只是个小问题;您更大的问题是同步:指针的值为Y,您将其设置为X;如何知道没有人在使用(旧的)Y值?还有一个相关的问题是,您可能已经将某些内容存储在X处,而其他线程期望找到它们。如果没有同步,其他线程可能会看到新的指针值,但它所指向的内容可能还没有更新。

-1
“原子性”被视为一种量子状态,其中某些东西可以同时是原子和非原子的,因为“可能”会有“某些机器”在“某个地方”不以原子方式写入“某个值”。也许。
但事实并非如此。原子性具有非常特定的含义,并解决了一个非常特定的问题:线程被操作系统抢占,以便在该核心上安排另一个线程。而你无法阻止线程在汇编指令执行中途停止。
这意味着任何单个汇编指令从定义上来说都是“原子”的。由于你有寄存器移动指令,任何寄存器大小的复制都是原子的。这意味着32位CPU上的32位整数和64位CPU上的64位整数都是原子的,当然还包括指针(忽略所有告诉你“某些体系结构”具有与寄存器“不同大小”的指针的人,自386以来就不是这样了)。
但是,你应该小心不要遇到变量缓存问题(即一个线程写入指针,另一个线程尝试读取它,但从缓存中获取旧值),根据需要使用“volatile”来防止这种情况。

2
这意味着任何单个汇编指令在定义上都是“原子性”的。问题在于证明一行C代码将始终被翻译为单个指令。你永远无法做到这一点。 - Andrew Henle
5
“任何单个的汇编指令在定义上都是原子性的”:这还要取决于您的定义。类似“add mem, reg”这样的读-修改-写指令从本质上讲是原子性的,因为它不会被同一核心中的其他操作中断,但从另一个角度来看,在该指令的“读取”和“写入”部分之间,另一个核心可能会写入该位置,所以在上下文之外,这句话可能会产生误导。 - Nate Eldredge
1
从缓存中获取旧值 - 不,那不是问题所在。问题在于从寄存器中获取旧值,假设内存没有改变。例如通过提升非原子/非易失性负载将while(!flag){}转换为if(!flag){ infinite_loop; }。所有真实的C11线程实现都运行在具有一致性缓存的硬件上,请停止传播关于需要_Atomicvolatile来解释缓存可能过期的错误观念。 - Peter Cordes
1
关于“你无法在汇编指令执行中停止线程”的问题:一些处理器具有可中断的指令。编译器不太可能使用它们来更新存储的指针,但它显示了这样做的不正确性和危险。ARM具有可中断的加载/存储多个寄存器指令,并且某些处理器(我忘记是VAX、IBM还是Intel)具有可中断的字节复制指令。 - Eric Postpischil
4
我已经是一名真正的程序员数十年了,由于假设未来的优化器不会对我的代码进行特定的转换而遭受了太多次严重的打击。你所建议的人们应该怎么做——假设特定的未来优化不可能或不太可能——这是一种灾难性的做法。而且为了什么?使用适当的原子操作没有性能成本,并使代码更易于维护和理解。 - David Schwartz
显示剩余14条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接