std :: atomic的锁在哪里?


71

如果数据结构中包含多个元素,则其原子版本不能(始终)是无锁的。有人告诉我,这对于较大的类型是正确的,因为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

由于原子和foo的大小相同,因此我认为原子中不会存储锁。

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


3
您是否尝试过使用大于16个字节的更大类型?我对x86体系结构不是很熟悉,但是如果它有一条16字节的CAS指令,这将排除对显式锁的需求,那么我不会感到惊讶。
希雷马

6
@Xirema在这种情况下,我希望is_lock_free()true.
弗朗索瓦·安德里厄

5
@Xirema:x86-64 Linux上的gcc / clanglock cmpxchg16b如果可用的话,可以使用,但是gcc7和更高版本is_lock_free在技​​术上仍然返回false ;但是纯负载和纯存储速度很慢,并且纯负载相互竞争。请参阅is_lock_free()在升级到MacPorts gcc 7.3后返回false,以获取有关此设计决策的更多详细信息的链接。
彼得·科德斯

12
@詹姆斯不,这打败了std :: atomic的目的。
benjarobin

8
根据github.com/llvm-mirror/compiler-rt/blob/master/lib/builtins/…,clang(可能还有gcc)使用原子的地址作为锁的哈希映射中的索引。
弗兰克

Answers:


54

回答此类问题的最简单方法通常是仅查看结果组装件,然后从那里取下组装件。

编译以下内容(我使您的结构更大,以躲避狡猾的编译器诡计):

#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下产生以下内容:在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),这并没有告诉我们这里到底发生了什么。但是,由于编译器是开源的,因此我们可以轻松地找到内在函数的实现(我在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);
}

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


1
您可以使用godbolt.org轻松生成和共享各种编译器的程序集。
弗朗索瓦·安德列

19
@FrançoisAndrieux是的,我知道。我个人的偏爱是在注释中使用Godbolt链接,但在编写答案时实际上会复制粘贴结果,以使它们保持完全独立(如果程序集足够短)
弗兰克(Frank

66

通常的实现是使用原子对象的地址作为键,实现互斥体的哈希表(甚至是简单的自旋锁,而不会回退到OS辅助的睡眠/唤醒)。哈希函数可能很简单,就像仅使用地址的低位作为2的幂的数组的索引一样,但是@Frank的回答显示LLVM的std :: atomic实现对某些高位进行XOR,因此您无需当对象被2的大方幂分隔时(比任何其他随机排列更常见),t自动获得别名。

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

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

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


具有GCC与MSVC的x86-64 16字节对象

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

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

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

使用gcc6和更早版本进行内联lock cmpxchg16b-mcx16(x86-64的cmpxchg16b不幸不是基线;第一代AMD K8 CPU缺少它。)

gcc7决定始终调用libatomic并且永远不报告16字节对象为无锁,即使libatomic函数仍将lock cmpxchg16b在有指令的机器上使用。请参阅is_lock_free()在升级到MacPorts gcc 7.3后返回false。解释此更改的gcc邮件列表消息在此处

您可以使用联合黑客利用gcc / clang在x86-64上获得相当便宜的ABA指针+计数器:如何使用c ++ 11 CAS实现ABA计数器?lock cmpxchg16b用于更新指针和计数器,但mov仅加载指针。但是,仅当16字节对象实际上使用时是无锁的时才有效lock cmpxchg16b


脚注1movdqa在某些(但不是全部)x86微体系结构中,16字节加载/存储实际上是原子的,并且没有可靠或有记录的方法来检测何时可用。请参阅为什么在x86上对自然对齐的变量原子进行整数赋值?以及SSE指令:哪些CPU可以执行原子16B内存操作?例如,K10 Opteron仅在具有HyperTransport的插槽之间显示8B边界处的撕裂。

因此,编译器编写者必须谨慎行事,不能使用movdqa将SSE2movq用于32位代码中8字节原子加载/存储的方式。如果CPU供应商可以为某些微体系结构提供一些保证,或者为原子16、32和64字节对齐的向量加载/存储(使用SSE,AVX和AVX512)添加CPUID功能位,那就太好了。也许有哪些主板供应商可能会在时髦的多路插座计算机上禁用固件,这些计算机使用特殊的一致性粘合芯片,这些芯片不会原子地传输整个缓存行。


2
Nitpick:LLVM的实现比使用低位作为索引要复杂得多。
弗兰克

1
@弗兰克:谢谢,固定。我看到了移位和掩码,但是当然也将使用更复杂的哈希函数来实现,因此我应该一直在寻找一些高位的XOR。这更有意义。在计算机程序中,大2幂的步幅并不罕见,并且幼稚的低位会相撞。
彼得·科德斯

12

从C ++标准的29.5.9开始:

注意:原子专业化的表示形式不必与其相应的参数类型相同。专业化代码应尽可能具有相同的大小,因为这可以减少移植现有代码所需的工作量。—尾注

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


5
没有在每个对象中放置锁的另一个原因是与C11原子的互操作性,其中静态初始化是一个问题。C11标准定义了一个ATOMIC_VAR_INIT宏(不适用于复合类型),并且还要求原子对象的静态零初始化有效。而且C11没有提供任何析构函数来释放每个对象锁中的OS资源。另请参阅developers.redhat.com/blog/2016/01/14/…,以获取有关C11标准中发现的问题的一些讨论。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.