GCC7 `std::atomic::is_lock_free` 的变化

GCC7 不再认为 x86 上的 16 字节原子变量无锁

原因:GCC7 开始在 std::atomic<T>::is_always_lock_free 不满足时会调用 libatomic 的 __atomic_is_lock_free() 函数,但是 libatomic 对无锁的内涵理解不同。

https://godbolt.org/z/nc34E716Y 这里表明 gcc7 处理 is_lock_free 的行为发生了变化,即便有了 -mcx16 编译选项,也不认为 16 字节原子变量是无锁的,gcc6 则认为 16 字节原子变量在对齐满足时是无锁的(无论 -mcx16 标志是否存在)。Clang 则是在有无 -mcx16 选项时呈现出不同的结果。GitHub 上有个相关的 讨论 。简单来说是 gcc7 之后不会直接通过当前编译架构来直接决定一个类型是否无锁(clang 会),而是将这个逻辑转移到对 libatomic 的函数的调用上,而 libatomic 认为 16 字节原子变量在 x86 上不算无锁。

GCC 的 libatomic 对是否无锁的判断可见 glfree.c 。函数签名为 bool libat_is_lock_free (size_t n, void *ptr)。对于 2 的幂(case 0/1/2/4/8/16先判断是否其大小是否可以做到无锁(稍后介绍),如果不行则放到一个更大的范围内去判断,在更大的范围内判断时要考虑其所占空间是否能刚好被更大的无锁原子类型覆盖。比如:

假设 8 字节的对齐访问是无锁的,那么以下 5 个字节的访问是无锁的:
| . . . . . . . . |
        * * * * *     ✔
这也是无锁的:
| . . . . . . . . |
      * * * * *       ✔
这是有锁的:
| . . . . . . . . | .
          * * * *   * 🔒

而 libatomic 判断一个大小是否能做到无锁,直接看代码注释就可以(C2 宏将两个 token 拼接起来,得到一个之前定义的宏):

/* Accesses with a power-of-two size are not lock-free if we don't have an
   integer type of this size or if they are not naturally aligned.  They
   are lock-free if such a naturally aligned access is always lock-free
   according to the compiler, which requires that both atomic loads and CAS
   are available.
   In all other cases, we fall through to LARGER (see below).  */
#define EXACT(N)						                              \
  do {								                                  \
    if (!C2(HAVE_INT,N)) break;	/* (在大多硬件上)要求 N 是 1,2,4,8 */   \
    if ((uintptr_t)ptr & (N - 1)) break; /* 要求自然对齐 */             \
    if (__atomic_always_lock_free(N, 0)) return true;		          \
    if (!C2(MAYBE_HAVE_ATOMIC_CAS_,N)) break;	/* 要求原子 CAS */      \
    if (C2(FAST_ATOMIC_LDST_,N)) return true;	/* 要求快速的原子 load */     \
  } while (0)

参考 这几行,因为 x86 上 16 字节的原子 load 是用 CAS 实现的,所以不是 fast 的。按照这个判断方式,libatomic 也就不认为 16 字节原子操作无锁

/* Since load and store are implemented with CAS, they are not fast.  */
# undef FAST_ATOMIC_LDST_16
# define FAST_ATOMIC_LDST_16		0

再看了一下 load_n.c 和 store_n.c 这两个文件,在最普遍的那个编译分支下,load/store 是用 CAS 的副作用实现的,load 只需要调用一次 CAS,而 store 要循环重试。(他这里的 atomic_compare_exchange_n 应该是 strong 版本的。)有两个地方比较有意思:

  1. load_n.c 设计让 *mptrt(初始化为 0)比较,若相等则将新值 0 赋给它,这样相当于没有改变值;若不等则加载其旧值,这样一来就等价于不修改 *mptr 的值,只是将 *mptr 的值加载到 t
  2. store_n.c 用了循环,但并不算是自旋锁。自旋锁有一个持有锁的概念,这意味着如果线程持有锁时,若时间片用完,其他线程获得时间片也无法继续前进。但是这里的循环不会出现这种情况,其他活跃线程依然能继续尝试,而且总有线程能成功。

libstdc++ 只在不满足 is_always_lock_free 时才使用 libatomic

和上一小节相比,这一小节想要强调的是小节标题中的“只在”。

https://godbolt.org/z/KK9MWPazK gcc 和 clang 好像并不会去检查地址是否对齐来判断 is_lock_free 的结果?这里将变量对齐到 1 个字节,结果和上一个实验比没有变化。

这其实是 libstdc++ 实现的问题了,和 libatomic 没有关系。从 https://godbolt.org/z/o8aqjqszM 可以看出,对 64 位原子变量根本没有调用 libatomic 中的函数,而是直接在 C++ 标准库就完成了处理,也没有去检查地址是否对齐!增加原子变量的大小可以发现链接失败,因为没有给出 -latomic 选项。切换到较新的 gcc14.2,表现依然如此。

这和 libatomic 的实现其实是有出入的,因为 libatomic 的 __atomic_is_lock_free() 函数在满足 is_always_lock_free 这个条件时仍然要检查地址是否自然对齐,才判断原子变量在运行时无锁。而 libstdc++std::atomic<T>::is_lock_free 并没有对 8 字节以内原子变量的地址对齐性做检查:这对 4 字节不成问题,但是对刚好 8 字节的原子变量在不支持 16 字节原子操作的平台上的检查会带来不一致性。

libatomic 的 16 字节原子操作看上去并不高效?

实际上不是的,libatomic 实现了多种版本的原子操作函数。

这篇文章 提到,x86 在 16 字节原子操作上曾只有 cmpxchg16b 指令,因此 16 字节 load/store 只能用 cmpxchg16b 实现。但 cmpxchg 系列指令需要 lock 前缀才能原子化,导致 16 字节 load/store 效率偏低。近些年 AMD 和 Intel 在所有支持 AVX 的机器上保证了 16 字节 SSE 读写的原子性,情况有所好转。文章还提到,ARM 早就有了 16 字节的原子读写,后来又引入了 16 字节的比较并交换,支持比 x86 好很多。

这里 展示了 clang 编译器在有 -mcx16 标志,但分别使用 -msse-mavx 标志时,16 位原子 load/store 的实现变化,印证了 16 字节原子 load/store 在支持 AVX 的机器上存在。之所以用 clang 而不用 gcc 是因为 clang 对原子操作有内联优化,而 gcc 将超过 8 字节的原子操作交给 libatomic 库来完成,不方便观察。

那么 libatomic 到底有没有利用到 AVX 的这个特性呢?libatomic 这几行注释 指出并不是所有支持 AVX 的 x86 机器都有 16 字节原子 VMOVDQA,比如非 Intel/AMD 的机器。因此 libatomic 在大多数情况下将 16 字节 load/store 实现为简单的读写指令,就像 第一节 提到的那样。不过,当 IFUNC_ALT == 1 时,HAVE_ATOMIC_LDST_16 被定义为 1(见这几行):

# if IFUNC_ALT == 1
#  undef HAVE_ATOMIC_LDST_16
#  define HAVE_ATOMIC_LDST_16 1
# endif

IFUNC(indirect function)是 GNU/GCC 工具链的一个函数派发机制,能在程序加载时通过 resolver 函数解析好实际调用的函数(只解析一次),比直接用 if-else 判断更加高效。按照 源码,如果 IFUNC_ALT 是 1,store_n.c 和 load_n.c 在 SIZE 为 16 时就可以直接利用 16 字节原子指令了。以 16 字节原子 store 为例,其过程是调用 __atomic_store_n 宏,然后该宏判断到 n==16,派发到函数 atomic_store_n 上,从代码可见并没有使用 16 字节 CAS。

#if defined(__x86_64__) && N == 16 && IFUNC_ALT == 1
#define __atomic_load_n(ptr, model) \
  (sizeof (*ptr) == 16 ? atomic_load_n (ptr, model) \
		       : (__atomic_load_n) (ptr, model))
#define __atomic_store_n(ptr, val, model) \
  (sizeof (*ptr) == 16 ? atomic_store_n (ptr, val, model) \
		       : (__atomic_store_n) (ptr, val, model))

static inline UTYPE
atomic_load_n (UTYPE *ptr, int model UNUSED)
{
  UTYPE ret;
  __asm__ ("vmovdqa\t{%1, %0|%0, %1}" : "=x" (ret) : "m" (*ptr));
  return ret;
}

static inline void
atomic_store_n (UTYPE *ptr, UTYPE val, int model UNUSED)
{
  __asm__ ("vmovdqa\t{%1, %0|%0, %1}\n\tmfence" : "=m" (*ptr) : "x" (val));
}
#endif

如果 N != 16,宏 __atomic_store_n 会将任务派发到同名函数 __atomic_store_n 上,这个函数是 gcc 的内置函数,不过我没有找到定义。

结合在 compiler-explorer 的实验和 libatomic 源码的阅读,得知在处理 16 字节原子 load/store 时,如果条件满足,libatomic 使用 vmovdqa 实现 load,使用 vmovdqa + mfence 实现 store;而 clang 在开启优化时使用 vmovaps 实现 load,使用 vmovaps + lock or 0 实现 store。

Tip

  • vmov* 的 v 表示 vector。
  • vmovdqa 的 d 表示 double,q 表示 64 位,因此 dq 表示 128 位,a 表示 aligned。
  • vmovaps 的 a 表示 aligned,p 表示 packed,s 表示单精度。