【问题标题】:Why does __sync_add_and_fetch work for a 64 bit variable on a 32 bit system?为什么 __sync_add_and_fetch 对 32 位系统上的 64 位变量有效?
【发布时间】:2012-03-17 02:14:54
【问题描述】:

考虑以下压缩代码:

/* 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 的 atomic builtins

如果我为 amd64 系统 (-m64) 编译,生成的汇编代码很容易理解。 通过使用lock addq,处理器保证增量是原子的。

 400660:       f0 48 83 05 d7 09 20    lock addq $0x1,0x2009d7(%rip)

但同样的 C 代码在 ia32 系统 (-m32) 上会产生非常复杂的 ASM 代码:

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 函数的隐含要求吗?

【问题讨论】:

    标签: c gcc x86 synchronization


    【解决方案1】:

    0x804855a 和 0x804855f 中变量的读取不需要是原子的。在伪代码中使用 compare-and-swap 指令递增如下所示:

    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 的一部分)。分别装载两半时的任何撕裂都将在这里捕获。或者,如果在初始读取之后,lock cmpxchg8b 之前发生了来自另一个内核的写入:即使使用单寄存器宽度 cmpxchg-retry 循环也是可能的。 (例如,实现原子 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)))

    【讨论】:

      【解决方案2】:

      initial 使用 2 个单独的 mov 指令读取是原子的,但它不在循环中。 @interjay's answer 解释了为什么这很好。


      有趣的事实:即使没有 lock 前缀,cmpxchg8b 所做的读取也是原子的。 (但这段代码确实使用了lock 前缀来使整个 RMW 操作原子化,而不是单独的原子加载和原子存储。)

      保证它是原子的,因为它被正确对齐(并且它适合一个缓存行)并且因为英特尔以这种方式制定了规范,请参阅英特尔架构手册第 1 卷,第 4.4.1 节:

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

      第 3A 卷 8.1.1:

      奔腾处理器(以及之后的更新处理器)保证 将始终执行以下额外的内存操作 原子地:

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

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

      P6 系列处理器(以及更新的 处理器自)保证以下附加内存 操作将始终以原子方式执行:

      • 未对齐的 16-、32-、 以及对适合缓存行的缓存内存进行 64 位访问

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

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


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

      CMPXCHG8B Intel ISA 手册卷的说明。 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 内置了这种行为:它更新“预期”操作数。

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

      使用 GCC 的 older obsolete __sync builtins__sync_val_compare_and_swap 返回旧的 val(而不是 __sync_bool_compare_and_swap 的布尔交换/未交换结果)

      【讨论】:

      • 这个问题是关于 mov 0x804a028,%eax + mov 0x804a02c,%edx 是原子的。这些是 2 个独立的 32 位加载;它们不会融合到单个 64 位负载中,因此它们不是 原子的。我编辑了你的答案来纠正这个问题,但它把整个手动引用部分留在那里,只是一个“有趣的事实”,基本上是无关紧要的:lock 不需要对齐以确保正确性(但它确实需要性能)。
      猜你喜欢
      • 2015-09-26
      • 2015-11-15
      • 1970-01-01
      • 2010-11-08
      • 2011-01-26
      • 1970-01-01
      • 2011-06-30
      • 2011-11-11
      • 2012-03-16
      相关资源
      最近更新 更多