std :: atomic的锁定在哪里?

时间:2018-05-11 18:38:42

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

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

答案 0 :(得分:60)

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

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

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

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

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

作为一个黑客,编译器可以使用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

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

脚注1 movdqa 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边界处撕裂的示例。

因此,编译器编写者必须谨慎行事,并且不能使用movdqa在32位代码中使用SSE2 movq进行8字节原子加载/存储的方式。如果CPU供应商可以记录某些微体系结构的某些保证,或者为原子16,32和64字节对齐的向量加载/存储(使用SSE,AVX和AVX512)添加CPUID功能位,那将是很好的。也许哪些主板供应商可以在使用特殊相干胶水芯片的时髦多插槽机器上的固件中禁用,这些芯片不会原子地传输整个缓存线。

答案 1 :(得分:45)

回答这些问题的最简单方法通常是查看生成的装配并从那里取出。

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

#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),而不是告诉我们这里到底发生了什么。但是,由于编译器是开源的,我们可以很容易地找到内在的实现(我在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);
}

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

答案 2 :(得分:11)

从C ++标准的29.5.9开始:

  

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

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