为什么在32位系统上__sync_add_and_fetch可以用于64位变量?

17
考虑以下精简代码:
/* Compile: gcc -pthread -m32 -ansi x.c */
#include <stdio.h>
#include <inttypes.h>
#include <pthread.h>

static volatile uint64_t v = 0;

void *func (void *x) {
    __sync_add_and_fetch (&v, 1);
    return x;
}

int main (void) {
    pthread_t t;
    pthread_create (&t, NULL, func, NULL);
    pthread_join (t, NULL);
    printf ("v = %"PRIu64"\n", v);
    return 0;
}

我有一个uint64_t变量,希望对其进行原子递增操作,因为该变量是多线程程序中的计数器。 为了实现原子性,我使用GCC的原子内置函数
如果我编译amd64系统(-m64),则生成的汇编代码很容易理解。通过使用lock addq,处理器保证递增操作是原子的。
 400660:       f0 48 83 05 d7 09 20    lock addq $0x1,0x2009d7(%rip)

但是同样的C代码在ia32系统(-m32)上产生了非常复杂的汇编代码:

804855a:       a1 28 a0 04 08          mov    0x804a028,%eax
804855f:       8b 15 2c a0 04 08       mov    0x804a02c,%edx
8048565:       89 c1                   mov    %eax,%ecx
8048567:       89 d3                   mov    %edx,%ebx
8048569:       83 c1 01                add    $0x1,%ecx
804856c:       83 d3 00                adc    $0x0,%ebx
804856f:       89 ce                   mov    %ecx,%esi
8048571:       89 d9                   mov    %ebx,%ecx
8048573:       89 f3                   mov    %esi,%ebx
8048575:       f0 0f c7 0d 28 a0 04    lock cmpxchg8b 0x804a028
804857c:       08 
804857d:       75 e6                   jne    8048565 <func+0x15>

这是我不理解的地方:
- lock cmpxchg8b 保证只有在目标地址中的期望值仍然存在时,才会写入更改后的变量。比较和交换保证原子性。
但是,是什么保证了在0x804855a和0x804855f处读取变量的原子性?
可能“脏读”并不重要,但请问是否可以简要概述一下没有问题的证明?
此外:为什么生成的代码跳回到0x8048565而不是0x804855a?我确定只有其他编写者也只增加变量时,这才是正确的。这是__sync_add_and_fetch函数的一个暗示要求吗?
2个回答

18

initial read使用2个单独的mov指令不是原子性的,但它不在循环中。@interjay的回答解释了为什么这样做是可以的。


有趣的是,即使没有lock前缀,由cmpxchg8b执行的读取操作也将是原子性的。(但是这段代码确实使用了lock前缀,以使整个RMW操作成为原子操作,而不是分别进行原子加载和原子存储。)
由于它被正确地对齐(并且它适合一个高速缓存行),因此保证是原子性的,并且因为英特尔是这样制定规范的,详见英特尔体系结构手册Vol 1, 4.4.1:

跨越4字节边界的字或双字操作数,或跨越8字节边界的四字操作数被视为未对齐,需要两个单独的内存总线周期来访问。

Vol 3A 8.1.1:

• 读取或写入在64位边界上对齐的四字

• 对于适合32位数据总线的未缓存内存位置的16位访问

P6系列处理器(以及之后的新处理器)保证始终原子地执行以下其他内存操作:

• 对于适合缓存行的非对齐16、32和64位访问

因此,通过对齐,可以在1个周期内读取,并且它适合一个缓存行,使cmpxchg8b的读取是原子性的。

如果数据未对齐,则lock前缀仍然会使其成为原子性,但性能成本将非常高,因为简单的缓存锁(延迟对该缓存行的MESI无效请求的响应)将不再足够。


代码跳回到 0x8048565(在mov加载后,包括复制和加1),因为v已经被加载; 如果CMPXCHG8B失败,则无需再次加载它,因为CMPXCHG8B将使EAX:EDX设置为目标中的值:

CMPXCHG8B Intel ISA手册Vol. 2A的描述:

将EDX:EAX与m64进行比较。如果相等,则设置ZF并将ECX:EBX加载到m64中。 否则,清除ZF并将m64加载到EDX:EAX中。

因此,代码只需要增加新返回的值并重试即可。 如果我们用C代码来看这个问题,就会变得更容易:

value = dest;                    // non-atomic but usually won't tear
while(!CAS8B(&dest,value,value + 1))
{
    value = dest;                // atomic; part of lock cmpxchg8b
}

value = dest实际上来自与cmpxchg8b用于比较部分的同一次读取。循环内部没有单独的重新加载。

事实上,C11 atomic_compare_exchange_weak / _strong已经内置了这种行为:它会更新“expected”操作数。

因此,gcc的现代内置函数__atomic_compare_exchange_n (type *ptr, type *expected, type desired, bool weak, int success_memorder, int failure_memorder)也是如此——它通过引用获取expected值。

使用GCC的旧式过时的__sync内置函数, __sync_val_compare_and_swap返回旧值(而不是__sync_bool_compare_and_swap的布尔交换/未交换结果)


这个问题在询问 mov 0x804a028,%eax + mov 0x804a02c,%edx 是否是原子性的。这是两个单独的32位加载; 它们不会融合成一个64位加载,因此它们不是原子性的。我编辑了你的回答来纠正这一点,但它将那整个手册引用部分留在那里,作为基本上不相关的“趣事”:对于正确性,lock 不要求对齐(但对于性能要求对齐)。 - Peter Cordes

4

在0x804855a和0x804855f中变量的读取不需要是原子性的。使用比较并交换指令来增加看起来就像这个伪代码:

oldValue = *dest; // non-atomic: tearing between the halves is unlikely but possible
do {
    newValue = oldValue+1;
} while (!compare_and_swap(dest, &oldValue, newValue));

由于比较和交换在进行交换之前会检查*dest == oldValue,因此它将作为一种保护措施 - 如果oldValue中的值是不正确的,则循环将再次尝试,因此如果非原子读取导致值不正确也没有问题。

lock cmpxchg8b*dest进行的64位访问是原子的(作为对*dest的原子RMW的一部分)。在单独加载2个半部分时的任何撕裂都会在此处被捕获。或者如果在lock cmpxchg8b之前进行了来自另一个内核的写入:即使使用单寄存器宽度的cmpxchg-重试循环也可能发生这种情况。(例如实现原子fetch_mul或原子float,或其他x86的lock前缀不能直接执行的RMW操作)


你的第二个问题是为什么oldValue = *dest这一行不在循环内。这是因为compare_and_swap函数总是将oldValue的值替换为*dest的实际值。因此,它本质上为您执行了oldValue = *dest这一行,再次执行没有意义。对于cmpxchg8b指令,当比较失败时,它将在edx:eax中放置内存操作数的内容。

compare_and_swap的伪代码如下:

bool compare_and_swap (int *dest, int *oldVal, int newVal)
{
  do atomically {
    if ( *oldVal == *dest ) {
        *dest = newVal;
        return true;
    } else {
        *oldVal = *dest;
        return false;
    }
  }
}

顺便提一下,在您的代码中,您需要确保v对齐到64位,否则它可能会分散在两个缓存行之间,cmpxchg8b指令将无法原子性地执行。您可以使用GCC的__attribute__((aligned(8)))来实现。


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