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_release
与 std::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();
}
所以看起来有一个死锁。
问题:
- 标准如何防止此类互斥锁?
- 让自旋锁互斥体不受此问题困扰的最佳方法是什么?
- 这个 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 的问题无法安全地进行优化。)
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_release
与 std::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();
}
所以看起来有一个死锁。
问题:
- 标准如何防止此类互斥锁?
- 让自旋锁互斥体不受此问题困扰的最佳方法是什么?
- 这个 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 的问题无法安全地进行优化。)