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

Ale*_*iev 5 c++ memory-model spinlock language-lawyer stdatomic

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
};
Run Code Online (Sandbox Code Playgroud)

到目前为止看起来还可以,关于使用单个这样的互斥锁: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();
}
Run Code Online (Sandbox Code Playgroud)

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

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();
}
Run Code Online (Sandbox Code Playgroud)

所以看起来有一个僵局。

问题:

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

(不是C++11 memory_order_acquire 和 memory_order_release 语义的副本,虽然它在同一个区域)

Pet*_*des 6

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

我们遇到问题的唯一方法是,如果编译时重新排序将该顺序固定到某个机器的 asm 中,这样m2锁定重试循环必须在实际释放之前退出m1

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

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


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

atomic在长时间运行的循环中重新排序操作是 C++ 标准允许的理论问题。 P0062R1:编译器什么时候应该优化原子?指出标准的 1.10p28 措辞在技术上允许将存储延迟到长时间运行的循环之后:

实现应确保原子或同步操作分配的最后一个值(按修改顺序)在有限的时间内对所有其他线程可见。

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

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

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

  • @AlexGuteniev:IDK,也许在理论上,但随后我们就进入了实施质量领域。人们想要使用的一个好的编译器不会对原子重新排序超过任何可能冗长的内容。在实践中,因为很难证明一些东西,可能不会通过任何在编译时行程计数不能证明很小的循环。并且没有经过任何非内联函数调用。重新排序过去的“asm 易失性”是合理的,但我希望不是,而且当然不是在循环中。但如果循环位于 asm 语句内,则在“rdtsc”上旋转 1 小时可能是合理的。 (2认同)