C++ 标准如何使用 memory_order_acquire 和 memory_order_release 防止自旋锁互斥体中的死锁?

How C++ Standard prevents deadlock in spinlock mutex with memory_order_acquire and memory_order_release?

TL:DR:如果互斥体实现使用获取和释放操作,实现是否可以像通常允许的那样进行编译时重新排序,并重叠两个应该独立的关键部分,来自不同的锁?这将导致潜在的死锁。


假设在 std::atomic_flag:

上实现了互斥锁
struct mutex
{
   void lock() 
   {
       while (lock.test_and_set(std::memory_order_acquire)) 
       {
          yield_execution();
       }
   }

   void unlock()
   {
       lock.clear(std::memory_order_release);
   }

   std::atomic_flag lock; // = ATOMIC_FLAG_INIT in pre-C++20
};

到目前为止看起来还不错,关于使用单个这样的互斥量:std::memory_order_releasestd::memory_order_acquire 同步。

这里使用std::memory_order_acquire/std::memory_order_release,乍一看应该不会产生疑问。 它们类似于 cppreference 示例 https://en.cppreference.com/w/cpp/atomic/atomic_flag

现在有两个互斥体保护着不同的变量,两个线程以不同的顺序访问它们:

mutex m1;
data  v1;

mutex m2;
data  v2;

void threadA()
{
    m1.lock();
    v1.use();
    m1.unlock();

    m2.lock();
    v2.use();
    m2.unlock();
}

void threadB()
{
    m2.lock();
    v2.use();
    m2.unlock();

    m1.lock();
    v1.use();
    m1.unlock();
}

释放操作可以在不相关的获取操作之后重新排序(不相关的操作==稍后对不同对象的操作),因此执行可以转换如下:

mutex m1;
data  v1;

mutex m2;
data  v2;

void threadA()
{
    m1.lock();
    v1.use();

    m2.lock();
    m1.unlock();

    v2.use();
    m2.unlock();
}

void threadB()
{
    m2.lock();
    v2.use();

    m1.lock();
    m2.unlock();

    v1.use();
    m1.unlock();
}

所以看起来有一个死锁。

问题:

  1. 标准如何防止此类互斥锁?
  2. 让自旋锁互斥体不受此问题困扰的最佳方法是什么?
  3. 这个 post 顶部的未修改互斥量是否可用于某些情况?

(不是 C++11 memory_order_acquire and memory_order_release semantics? 的副本,尽管它在同一区域)

ISO C++标准没有问题;它不区分编译时与 运行 时重新排序,并且代码仍然必须执行 就好像 它 运行 在 C++ 上的源代码顺序抽象机。因此,m2.test_and_set(std::memory_order_acquire) 尝试获取第二个锁的效果对于其他线程来说是可见的,同时仍然持有第一个(即在 m1.reset 之前),但是那里的失败无法阻止 m1被释放。

我们遇到问题的唯一方法是 编译时 重新排序将该顺序确定为某些机器的 asm,这样 m2 锁重试循环必须在实际释放 m1.

之前退出

此外,ISO C++ 仅根据同步和什么可以看到什么来定义排序,而不是根据 re 相对于某些新顺序的排序操作。这意味着存在某种秩序。除非您使用 seq_cst 操作,否则多个线程可以达成一致的顺序甚至不会保证 运行 存在于单独的对象中。 (并且每个对象单独的修改顺序是 gua运行teed 存在的。)

获取和释放操作的单向屏障模型(如 https://preshing.com/20120913/acquire-and-release-semantics 中的图表)是一种思考事物的便捷方式,并且符合 x86 上纯加载和纯存储的现实以 AArch64 为例。但就语言律师而言,这不是 ISO C++ 标准定义事物的方式。


您正在重新排序整个重试循环,而不仅仅是单个获取

在长 运行ning 循环中重新排序 atomic 操作是 C++ 标准允许的理论问题。 P0062R1: When should compilers optimize atomics? 指出将存储延迟到长 运行ning 循环之后是技术上允许的 1.10p28 标准措辞:

An implementation should ensure that the last value (in modification order) assigned by an atomic or synchronization operation will become visible to all other threads in a finite period of time.

但是潜在的无限循环会违反这一点,例如在死锁情况下不是有限的,因此编译器不能这样做。

这不是 "just" 实施质量问题。 成功互斥锁是一个获取操作,但您应该将重试循环视为单个获取操作。任何理智的编译器都不会。

(激进的原子优化可能破坏的经典示例是进度条,其中编译器将所有松弛存储从循环中下沉,然后将所有死存储折叠成一个 100% 的最终存储。请参阅也 - 当前的编译器不会,并且基本上将 atomic 视为 volatile atomic,直到 C++ 解决了为程序员提供一种让编译器知道何时原子 can/can 的问题无法安全地进行优化。)