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_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();
}
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)
所以看起来有一个僵局。
问题:
(不是C++11 memory_order_acquire 和 memory_order_release 语义的副本?,虽然它在同一个区域)
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++ 解决了为程序员提供一种方法来让编译器知道原子何时可以/不能安全优化的问题。)