【问题标题】:Where is the lock for a std::atomic?std::atomic 的锁在哪里?
【发布时间】:2018-05-11 18:38:41
【问题描述】:

如果一个数据结构中有多个元素,它的原子版本不能(总是)是无锁的。 有人告诉我,这对于较大的类型是正确的,因为 CPU 不能在不使用某种锁的情况下自动更改数据。

例如:

#include <iostream>
#include <atomic>

struct foo {
    double a;
    double b;
};

std::atomic<foo> var;

int main()
{
    std::cout << var.is_lock_free() << std::endl;
    std::cout << sizeof(foo) << std::endl;
    std::cout << sizeof(var) << std::endl;
}

输出(Linux/gcc)是:

0
16
16

由于 atomic 和 foo 的大小相同,我不认为在 atomic 中存储了锁。

我的问题是:
如果一个原子变量使用了锁,它存储在哪里,对于该变量的多个实例意味着什么?

【问题讨论】:

  • 您是否尝试过使用大于 16 字节的类型?我对 x86 架构不是很熟悉,但如果它有一个 16 字节的 CAS 指令,就不需要显式锁定,我不会感到惊讶。
  • @Xirema 在这种情况下,我希望is_lock_free()true.
  • @Xirema:x86-64 Linux 上的 gcc / clang 确实使用 lock cmpxchg16b(如果可用),但 gcc7 及更高版本仍然为 is_lock_free 返回 false,即使在技术上它是;但是纯负载和纯存储速度很慢,并且纯负载相互竞争。请参阅is_lock_free() returned false after upgrading to MacPorts gcc 7.3 以获取有关此设计决策的更多详细信息的链接。
  • @James 不,这违背了 std::atomic 的目的。
  • 根据github.com/llvm-mirror/compiler-rt/blob/master/lib/builtins/…,clang(可能还有 gcc)使用原子的地址作为锁哈希图中的索引。

标签: c++ c++11 x86 atomic stdatomic


【解决方案1】:

通常的实现是互斥体的哈希表(甚至只是简单的自旋锁,没有回退到操作系统辅助的睡眠/唤醒),使用原子对象的地址作为键。散列函数可能就像使用地址的低位作为索引到 2 次幂大小的数组一样简单,但@Frank 的回答显示 LLVM 的 std::atomic 实现在一些更高的位上进行异或,所以你不要' t 当对象被 2 的大幂分隔时会自动获得锯齿(这比任何其他随机排列都更常见)。

我认为(但我不确定)g++ 和 clang++ 与 ABI 兼容;即他们使用相同的哈希函数和表,因此他们同意哪个锁序列化对哪个对象的访问。不过,锁定都是在libatomic 中完成的,所以如果你动态链接libatomic,那么同一个程序中调用__atomic_store_16 的所有代码都将使用相同的实现; clang++ 和 g++ 绝对同意调用哪个函数名,这就足够了。 (但请注意,只有在不同进程之间共享内存中的无锁原子对象才能工作:每个进程都有自己的锁哈希表。无锁对象应该(并且实际上)只是在普通 CPU 架构上的共享内存中工作,即使该区域映射到不同的地址。)

哈希冲突意味着两个原子对象可能共享同一个锁。这不是正确性问题,但可能是性能问题:不是两对线程分别为两个不同的对象相互竞争,您可以让所有 4 个线程竞争对任一对象的访问。大概这是不寻常的,通常您的目标是让您的原子对象在您关心的平台上无锁。但大多数时候你并没有真正倒霉,基本上没问题。

不可能出现死锁,因为没有任何std::atomic 函数会尝试同时锁定两个对象。因此,获取锁的库代码在持有其中一个锁的同时永远不会尝试获取另一个锁。额外的争用/序列化不是正确性问题,只是性能问题。


使用 GCC 与 MSVC 的 x86-64 16 字节对象

作为一种 hack,编译器可以使用 lock cmpxchg16b 来实现 16 字节的原子加载/存储,以及实际的读取-修改-写入操作。

这比锁定要好,但与 8 字节原子对象相比性能较差(例如,纯负载与其他负载竞争)。这是唯一记录在案的安全方法,可以用 16 个字节自动执行任何操作1

AFAIK,MSVC 从不将 lock cmpxchg16b 用于 16 字节对象,它们与 24 或 32 字节对象基本相同。

当您使用 -mcx16 编译时,gcc6 和更早的内联 lock cmpxchg16b(不幸的是 cmpxchg16b 不是 x86-64 的基准;第一代 AMD K8 CPU 缺少它。)

gcc7 决定始终调用 libatomic 并且永远不会将 16 字节对象报告为无锁,即使 libatomic 函数仍会在指令可用的机器上使用 lock cmpxchg16b。见is_lock_free() returned false after upgrading to MacPorts gcc 7.3。解释此更改的 gcc 邮件列表消息is here

您可以使用 union hack 在 x86-64 上使用 gcc/clang 获得相当便宜的 ABA 指针+计数器:How can I implement ABA counter with c++11 CAS?lock cmpxchg16b 用于更新指针和计数器,但简单的 mov 仅加载指针。不过,这仅适用于 16 字节对象实际上使用 lock cmpxchg16b 无锁时。


脚注 1movdqa 16 字节加载/存储实际上在某些(但全部)x86 微架构上是原子的,并且没有可靠或记录的方式检测它何时可用。请参阅Why is integer assignment on a naturally aligned variable atomic on x86?SSE instructions: which CPUs can do atomic 16B memory operations? 的示例,其中 K10 Opteron 仅在具有 HyperTransport 的套接字之间显示在 8B 边界处撕裂。

因此编译器编写者必须谨慎行事,不能像使用 SSE2 movq 那样在 32 位代码中使用 movdqa 进行 8 字节原子加载/存储。如果 CPU 供应商可以为某些微架构记录一些保证,或者为原子 16、32 和 64 字节对齐向量加载/存储(使用 SSE、AVX 和 AVX512)添加 CPUID 功能位,那就太好了。也许哪些主板供应商可以在时髦的多插槽机器上禁用固件,这些机器使用特殊的一致性胶合芯片,不会自动传输整个缓存行。

【讨论】:

  • Nitpick:LLVM 的实现比使用低位作为索引更复杂/狡猾。
  • @Frank:谢谢,已修复。我看到了移位和掩码,但当然,更复杂的散列函数也会发生这种情况,所以我应该一直寻找一些高位的异或。这更有意义;大的 2 次方步长在计算机程序中并不少见,天真的低位会发生冲突。
【解决方案2】:

回答此类问题的最简单方法通常是查看生成的程序集并从中获取。

编译以下内容(我使你的结构更大以躲避狡猾的编译器恶作剧):

#include <atomic>

struct foo {
    double a;
    double b;
    double c;
    double d;
    double e;
};

std::atomic<foo> var;

void bar()
{
    var.store(foo{1.0,2.0,1.0,2.0,1.0});
}

在 clang 5.0.0 中,在 -O3 下产生以下内容:see on godbolt

bar(): # @bar()
  sub rsp, 40
  movaps xmm0, xmmword ptr [rip + .LCPI0_0] # xmm0 = [1.000000e+00,2.000000e+00]
  movaps xmmword ptr [rsp], xmm0
  movaps xmmword ptr [rsp + 16], xmm0
  movabs rax, 4607182418800017408
  mov qword ptr [rsp + 32], rax
  mov rdx, rsp
  mov edi, 40
  mov esi, var
  mov ecx, 5
  call __atomic_store

太好了,编译器委托给一个内在函数 (__atomic_store),这并没有告诉我们这里到底发生了什么。但是,由于编译器是开源的,我们可以很容易地找到intrinsic的实现(我在https://github.com/llvm-mirror/compiler-rt/blob/master/lib/builtins/atomic.c找到了):

void __atomic_store_c(int size, void *dest, void *src, int model) {
#define LOCK_FREE_ACTION(type) \
    __c11_atomic_store((_Atomic(type)*)dest, *(type*)dest, model);\
    return;
  LOCK_FREE_CASES();
#undef LOCK_FREE_ACTION
  Lock *l = lock_for_pointer(dest);
  lock(l);
  memcpy(dest, src, size);
  unlock(l);
}

lock_for_pointer() 好像发生了魔法,让我们来看看吧:

static __inline Lock *lock_for_pointer(void *ptr) {
  intptr_t hash = (intptr_t)ptr;
  // Disregard the lowest 4 bits.  We want all values that may be part of the
  // same memory operation to hash to the same value and therefore use the same
  // lock.  
  hash >>= 4;
  // Use the next bits as the basis for the hash
  intptr_t low = hash & SPINLOCK_MASK;
  // Now use the high(er) set of bits to perturb the hash, so that we don't
  // get collisions from atomic fields in a single object
  hash >>= 16;
  hash ^= low;
  // Return a pointer to the word to use
  return locks + (hash & SPINLOCK_MASK);
}

这是我们的解释:原子的地址用于生成一个哈希键来选择一个预分配的锁。

【讨论】:

  • 您可以使用godbolt.org为各种编译器轻松生成和共享程序集。
  • @FrançoisAndrieux 是的,我知道。我个人的偏好是在 cmets 中使用 Godbolt 链接,但实际上在编写答案时复制粘贴结果,以便它们保持完全独立(如果程序集足够短)
【解决方案3】:

从 C++ 标准的 29.5.9 开始:

注意:原子特化的表示不需要有 大小与其对应的参数类型相同。专业应该 尽可能使用相同的大小,因为这样可以减少工作量 需要移植现有代码。 ——尾注

最好使原子的大小与其参数类型的大小相同,但这不是必需的。实现这一点的方法是避免使用锁或将锁存储在单独的结构中。正如其他答案已经清楚解释的那样,哈希表用于保存所有锁。这是为所有正在使用的原子对象存储任意数量的锁的最有效的内存方式。

【讨论】:

  • 不在每个对象内加锁的另一个原因是与 C11 原子的互操作性,其中静态初始化是一个问题。 C11 标准定义了一个ATOMIC_VAR_INIT 宏(它不适用于复合类型),并且还要求对原子对象进行静态零初始化。 C11 不提供任何析构函数来释放每个对象锁中的操作系统资源。有关 C11 标准中发现的问题的一些讨论,另请参阅 developers.redhat.com/blog/2016/01/14/…
猜你喜欢
  • 2012-03-22
  • 2013-06-29
  • 1970-01-01
  • 1970-01-01
  • 2017-05-17
  • 2021-11-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多