【问题标题】:why does spinlock with std::memory_order_relaxed perform correctly?为什么带有 std::memory_order_relaxed 的自旋锁可以正确执行?
【发布时间】:2017-06-23 03:56:00
【问题描述】:

我用 C++11 原子库实现了一个自旋锁:

class SpinLock {
  atomic_bool latch_;

  public:
  SpinLock() :latch_(false){
  }
  void lock() {
    while(tryLock() == false);
  }
  bool tryLock() {
    bool b = false;
    return latch_.compare_exchange_weak(b,true,std::memory_order_relaxed);
  }
  void unlock() {
    latch_.store(false,std::memory_order_relaxed);
  }
};

我通过产生多个线程来测试正确性如下:

static int z = 0;
static SpinLock spinLock;
static void safeIncrement(int run) {
  while(--run >= 0) {
    std::lock_guard<SpinLock> guard(spinLock);
    ++z;
  }
}

static void test(int nThreads =2) {
  std::vector<std::thread*> workers(nThreads);
  z = 0;
  for(auto& ptr : workers) ptr = new std::thread(safeIncrement,1<<20);
  for(auto ptr : workers) ptr->join();
  cout<<"after increment: " <<z << " out of " << (1<<20) * nThreads<<endl;
  for(auto ptr : workers) delete ptr;
}
int main() {

  test(4);

  return 0;

}

令我感到惊讶的是,最后的总数加起来是一个正确的值,顺序宽松。通过这篇文章:http://en.cppreference.com/w/cpp/atomic/memory_order,宽松的顺序意味着“没有同步或排序约束”,所以一个线程的更改并不意味着其他线程可以看到,对吧?为什么还是正确的?
(测试在 Intel(R) Core(TM) i5-3337U CPU @ 1.80GHz 上运行)

编辑:(感谢 Maxim 的评论)更新了代码:初始化 SpinLock 中的数据成员,并更新了测试代码。

【问题讨论】:

  • 提供完整的测试源代码。
  • SpinLock::latch_没有初始化,初始值不确定。
  • 具体是一个 C++11 问题吗?

标签: multithreading c++11 atomic memory-barriers spinlock


【解决方案1】:

C++11 标准为原子操作指定了最弱的保证。并非所有硬件都可以完全匹配每个最弱的保证,因此编译器和库编写者有时必须“四舍五入”到更强大的操作。例如,x86 上的所有原子读取-修改-写入操作都隐含了memory_order_acq_rel

此外,硬件架构的特定实现可能比硬件手册所说的具有更强的保证。例如,早期的 Itaniums 实现了 memory_order_acq_rel 语义,即使对于一些只承诺 memory_order_release 的硬件指令。

理论上,您的代码可能会在 x86 上失败,因为尊重原子操作的内存顺序涉及硬件和编译器。激进的编译器可以合法地将 'z' 的负载(也可能还有存储!)移到仅使用 memory_order_release 排序的 tryLock 操作之上。

【讨论】:

  • 我忽略的一点是,“释放”仅在(动态)与 same 位置上的“获取”配对时执行同步。由于您将spinLock 声明为静态,并且对其进行的所有操作对编译器都是可见的,因此编译器可以观察到spinLock 永远不会有匹配的“获取”操作,并合法地将您对变量spinLock 的所有操作更改为@ 987654327@.
  • Intel 系列处理器承诺释放每次写入(写入永远不会重新排序)并在每次读取时获取(如果发生写入冲突,则会取消读取推测)因此当然是读取和写入至少具有 acq_rel 语义。它遵循公理。
【解决方案2】:

我看到至少 GCC 6.3 在 x86-64 上为放松和发布/获取生成相同的代码。因此,结果相同也就不足为奇了。因此,要查看差异,您可能需要比 x86-64 提供的 TSO 更轻松的内存架构。可能,它可能是 ARM。

【讨论】:

  • z 可能具有期望值,因为它被声明为std::atomic&lt;int&gt;,或者run 是非正数,或者因为只有一个线程。
【解决方案3】:

在互斥体实现上使用宽松的排序约束会导致灾难。
根据定义,互斥锁旨在在线程之间同步数据。 acquirerelease 这两个术语与互斥锁密切相关; 您获取一个互斥锁,更改受它保护的数据并释放该互斥锁,以便在获得相同互斥锁后数据对另一个线程可见。

您所指的文章指出,对于宽松的操作,“没有同步或排序约束”......它适用于围绕互斥锁的内存操作, 不是互斥锁本身。通过宽松的排序,本应受互斥体保护的数据实际上可能会被多个线程同时修改(引入数据竞争)。

在诸如X86 这样具有隐式获取/释放语义的更强有序架构上,您将摆脱这种实现(因此您的测试成功)。 但是,在使用较弱内存排序的架构上运行它,例如PowerARMv7,你就有麻烦了。

您在 cmets 中建议的顺序是正确的。

【讨论】:

  • 不过,“围绕互斥体的内存操作”可能涉及对互斥体本身的操作,对吧?
  • @LeoLai 互斥体本身的操作是原子的,因为它们只修改内部的atomic_bool - 这就是即使在轻松排序的情况下也能得到的。但是,“围绕互斥锁的内存操作”是在互斥锁之前或之后的操作(按程序顺序)。由于互斥锁用于在线程之间进行同步,因此它必须在“解锁/释放”之前和“锁定/获取”之后强制执行内存操作之间的排序.....
  • ..... 对互斥体的“解锁/释放”操作必须防止它之前的内存操作(按程序顺序)向下移动穿过互斥体,而“锁定/获取”操作必须防止跟随它的内存操作在互斥体中向上移动。通过在互斥体实现中的 atomic_bool 操作上设置获取/释放屏障来保证这些顺序。
猜你喜欢
  • 1970-01-01
  • 2019-02-11
  • 1970-01-01
  • 1970-01-01
  • 2016-05-21
  • 1970-01-01
  • 1970-01-01
  • 2013-09-27
  • 2020-03-27
相关资源
最近更新 更多