在x86中,增量操作是原子性的吗?

19
在一个多核 x86 机器上,假设一个线程在 core1 上执行并且同时递增一个整数变量 a,与此同时,在 core2 上的另一个线程也递增了它。给定 a 的初始值为 0,最终值总是 2 吗?或者可能有其他值吗?假设 a 被声明为 volatile 并且我们不使用原子变量(如 C++ 的 atomic<> 和 gcc 中内置的原子操作)。
如果在这种情况下,a 的值确实总是 2,那么这是否意味着在 x86-64 上的 long int 也具有相同的特性,即最终 a 始终为 2?

4
除非你使用特殊的原子类型,否则“增量”通常需要三个独立的操作:加载(load)、增量(increment)和存储(store)。 - Hunter McMillen
6
volatile 关键字无法实现原子访问。 - Cat Plus Plus
3
@CatPlusPlus你的名字是一个原子操作吗?:P (翻译:@CatPlusPlus,您的名字是一个原子操作吗? :P) - MByD
访问相同类型会导致竞争条件,如果没有正确处理,值将是意外的。在这种情况下,假设线程1和线程2同时加载值并使其为2,因此无论是线程1还是线程2先加载它,最终值都将为2。 - Invictus
3
只要不跨越缓存行边界,读写操作就是原子性的。但在两个操作之间插入另一个操作,则不一定具有原子性。 - Mark Ransom
显示剩余2条评论
4个回答

30

在X86上,只有使用LOCK前缀才能使增量内存机器指令具有原子性。

C和C ++中的x ++没有原子行为。如果您进行未锁定的增量操作,由于处理器正在读取和写入X,如果两个单独的处理器尝试增加,则可能会出现仅增加一个或两者都被看到的情况(第二个处理器可能已经读取了初始值,将其增加并在第一个处理器写回结果后再次写回)。

我相信C ++ 11提供了原子增量,大多数供应商编译器都有一种惯用方式来导致某些内置整数类型(通常是int和long)的原子增量;请参阅您的编译器参考手册。

如果要递增“大值”(例如,多精度整数),则需要使用某些标准锁定机制(例如信号量)。

请注意,您还需要担心原子读取。在x86上,读取32位或64位值恰好是原子性的,如果它是64位字对齐的。这对于“大值”不成立;您需要一些标准锁。


1
LOCK前缀是否也确保必要的内存屏障呢?(我认为是这样,但我不确定。) - James Kanze

10

这是一个证明它在某个实现(gcc)中不是原子性的例子,

正如您所看到的一样(?), gcc生成的代码:

  1. 将值从内存加载到寄存器中
  2. 增加寄存器的内容
  3. 将寄存器的值保存回内存中。

这与原子性相去甚远。

$ cat t.c
volatile int a;

void func(void)
{
    a++;
}
[19:51:52 0 ~] $ gcc -O2 -c t.c
[19:51:55 0 ~] $ objdump -d t.o

t.o:     file format elf32-i386


Disassembly of section .text:

00000000 <func>:
   0:   a1 00 00 00 00          mov    0x0,%eax
   5:   83 c0 01                add    $0x1,%eax
   8:   a3 00 00 00 00          mov    %eax,0x0
   d:   c3                      ret

不要被 mov 指令中的 0x0 所迷惑,实际上这里还有四个字节的空间,链接器会在将该目标文件链接时,在那里填入变量 a 的内存地址。

挺有意思的,实际上只有当 avolatile 时才会得到单独的load/add/store,否则gcc会使用一个read-modify-write(除非针对 [i586 (pentium)] 进行调整)。当然,如果周围的代码使用 num++ 的值,则很可能会使用单独的指令将结果留在寄存器中。我在我的回答中提到了这一点问题的非 volatile 版本 - Peter Cordes
@PaterCordes 如果没有使用volatile,gcc可能会生成一个单独的addl指令,但是除非您还使用了lock前缀,否则它也不是原子操作。 - nos
我之前评论中的第二个链接是我的答案,其中详细解释了为什么 addl $1, num 在MESI协议等方面不是原子操作(除非在单处理器系统上且不包括DMA观察者),以及为什么使用 lock 后就可以实现原子操作。 - Peter Cordes

9

由于没有人回答您的实际问题,而是向您展示了一种始终有效的方法:

线程1加载值为0

线程2加载值为0

线程1增加并存储1

线程2增加其本地寄存器副本的值并存储1。

正如您所看到的,最终结果是一个等于1而不是2的值。它不会总是在结束时为2。


7

不能保证这个方法一定可行。你可以使用lock xadd指令来实现相同的效果,或者使用C++的std::atomic,或者使用#pragma omp atomic,或者使用其他已经编写好的并发解决方案,以避免重复造轮子。


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