独立读取-修改-写入顺序

Independent Read-Modify-Write Ordering

本文关键字:顺序 修改 读取 独立      更新时间:2023-10-16

我通过Relacy运行了一堆算法来验证它们的正确性,但我偶然发现了一些我并不真正理解的东西。这是它的简化版本:

#include <thread>
#include <atomic>
#include <iostream>
#include <cassert> 
struct RMW_Ordering
{
std::atomic<bool> flag {false};
std::atomic<unsigned> done {0}, counter {0};
unsigned race_cancel {0}, race_success {0}, sum {0};
void thread1() // fail
{
race_cancel = 1; // data produced
if (counter.fetch_add(1, std::memory_order_release) == 1 &&
!flag.exchange(true, std::memory_order_relaxed))
{
counter.store(0, std::memory_order_relaxed);
done.store(1, std::memory_order_relaxed);
}
}
void thread2() // success
{
race_success = 1; // data produced
if (counter.fetch_add(1, std::memory_order_release) == 1 &&
!flag.exchange(true, std::memory_order_relaxed))
{
done.store(2, std::memory_order_relaxed);
}
}
void thread3()
{
while (!done.load(std::memory_order_relaxed)); // livelock test
counter.exchange(0, std::memory_order_acquire);
sum = race_cancel + race_success;
}
};
int main()
{
for (unsigned i = 0; i < 1000; ++i)
{
RMW_Ordering test;
std::thread t1([&]() { test.thread1(); });    
std::thread t2([&]() { test.thread2(); });
std::thread t3([&]() { test.thread3(); });
t1.join();
t2.join();
t3.join();
assert(test.counter == 0);
}
std::cout << "Done!" << std::endl;
}

两个线程争相进入受保护区域,最后一个线程修改done,从无限循环中释放第三个线程。这个例子有点做作,但原始代码需要通过标志声明这个区域,以表示"完成"。

最初,fetch_add具有acq_rel排序,因为我担心交换可能在它之前被重新排序,这可能会导致一个线程声明该标志,首先尝试fetch_add检查,并阻止另一个线程(通过增量检查)成功修改计划。在使用Relacy进行测试时,我想如果我从acq_rel切换到发布,我会看到我预期发生的活锁是否会发生,但令我惊讶的是,它没有发生。然后,我用放松来做任何事情,再次,没有活栓。

我试图在C++标准中找到任何关于这方面的规则,但只找到了以下规则:

1.10.7此外,还有非同步操作的宽松原子操作和原子读-修改-写操作,它们具有特殊的特性。

29.3.11原子读取-修改-写入操作应始终读取写入关联之前写入的最后一个值(按修改顺序)通过读取-修改-写入操作。

我可以一直依赖RMW操作不被重新排序吗?即使它们影响不同的内存位置?标准中有什么可以保证这种行为吗

编辑

我想出了一个更简单的设置,可以更好地说明我的问题。下面是它的CppMem脚本:

int main() 
{
atomic_int x = 0; atomic_int y = 0;
{{{
{
if (cas_strong_explicit(&x, 0, 1, relaxed, relaxed))
{
cas_strong_explicit(&y, 0, 1, relaxed, relaxed);
}
}
|||
{
if (cas_strong_explicit(&x, 0, 2, relaxed, relaxed))
{
cas_strong_explicit(&y, 0, 2, relaxed, relaxed);
}
}
|||
{
// Is it possible for x and y to read 2 and 1, or 1 and 2?
x.load(relaxed).readsvalue(2);
y.load(relaxed).readsvalue(1);
}
}}}
return 0; 
}

我认为该工具不够复杂,无法评估这种情况,尽管它似乎确实表明这是可能的。以下是几乎等效的松弛设置:

#include "relacy/relacy_std.hpp"
struct rmw_experiment : rl::test_suite<rmw_experiment, 3>
{
rl::atomic<unsigned> x, y;
void before()
{
x($) = y($) = 0;
}
void thread(unsigned tid)
{
if (tid == 0)
{
unsigned exp1 = 0;
if (x($).compare_exchange_strong(exp1, 1, rl::mo_relaxed))
{
unsigned exp2 = 0;
y($).compare_exchange_strong(exp2, 1, rl::mo_relaxed);
}
}
else if (tid == 1)
{
unsigned exp1 = 0;
if (x($).compare_exchange_strong(exp1, 2, rl::mo_relaxed))
{
unsigned exp2 = 0;
y($).compare_exchange_strong(exp2, 2, rl::mo_relaxed);
}
}
else
{
while (!(x($).load(rl::mo_relaxed) && y($).load(rl::mo_relaxed)));
RL_ASSERT(x($) == y($));
}
}
};
int main()
{
rl::simulate<rmw_experiment>();
}

断言从未被违反,因此根据Relacy,1和2(或相反)是不可能的。

我还没有完全研究过你的代码,但大胆的问题有一个简单的答案:

我可以一直依赖RMW操作而不重新排序吗?即使它们影响不同的内存位置

不,你不能。在同一个线程中对两个松弛的RMW进行编译时重新排序是非常允许的。(我认为在大多数CPU上,两个RMW的运行时重新排序在实践中可能是不可能的。ISO C++没有区分编译时和运行时。)

但请注意,原子RMW包括加载和存储,并且这两部分必须保持在一起。因此,任何类型的RMW都不能提前通过获取操作,也不能推迟通过发布操作。

当然,作为释放和/或获取操作的RMW本身可以停止在一个或另一个方向上的重新排序。


当然,C++内存模型并不是根据对缓存一致共享内存的访问的本地重新排序来正式定义的,只是根据与另一个线程同步和创建发生前/发生后关系来定义的。但是,如果您忽略IRIW重新排序(两个读取器线程不同意两个编写器线程对不同变量进行独立存储的顺序),那么对同一事物建模的方法几乎有两种。

在第一个示例中,可以保证flag.exchange始终在counter.fetch_add之后执行,因为&&短路-即,如果第一个表达式解析为false,则永远不会执行第二个表达式。C++标准保证了这一点,因此编译器不能对这两个表达式重新排序(无论它们使用哪种内存顺序)。

正如Peter Cordes已经解释的那样,C++标准没有说明指令是否或何时可以相对于原子操作重新排序。通常,大多数编译器优化都依赖于,就好像:一样

本国际标准中的语义描述定义了一个参数化的不确定性抽象机器。本国际标准对一致性实施的结构没有提出任何要求。特别地,它们不需要复制或模拟抽象机器的结构。相反,一致性实现需要模拟(仅)抽象机器的可观察行为[..]。

该规定有时被称为"好像"规则,因为只要结果,实施可以自由忽略本国际标准的任何要求,就好像已经遵守了要求一样,只要可以从程序的可观察行为。例如,如果一个实际实现可以推断出它的值没有被使用,并且没有产生影响程序可观察行为的副作用,那么它就不需要评估表达式的一部分。

这里的关键方面是"可观察的行为"。假设在两个不同的原子对象上有两个松弛的原子负载AB,其中AB之前排序。

std::atomic<int> x, y;
x.load(std::memory_order_relaxed); // A
y.load(std::memory_order_relaxed); // B

先序列后关系是先发生后关系定义的一部分,因此可以假设这两个操作不能重新排序。然而,由于这两个操作是宽松的,因此无法保证"可观察行为",即即使使用原始顺序,x.load(A)也可能返回比y.load(B。如果它不等价,那么你将有一个种族条件!;-)

为了防止这种重新排序,您必须依赖(线程间)发生在关系之前。如果x.load(A)将使用memory_order_acquire,那么编译器将不得不假设此操作与某个发布操作同步,从而建立(线程间)先发生后发生的关系。假设其他线程执行两个原子更新:

y.store(42, std::memory_order_relaxed); // C
x.store(1, std::memory_order_release); // D

如果获取加载A通过存储释放D看到值存储,则这两个操作彼此同步,从而建立先发生后发生的关系。由于y.store的顺序在x.store之前,x.load的顺序在之前,因此先发生后关系的传递性保证了y.store发生在y.load之前。重新排序两个装载或两个存储将破坏这种保证,因此也会改变可观察的行为。因此,编译器无法执行这样的重新排序。

总的来说,争论可能的重新排序是错误的做法。在第一步中,您应该始终确定所需的发生在关系之前(例如,y.store必须发生在y.load之前)。然后,下一步是确保在所有情况下正确建立关系之前发生这些情况。至少这就是我为实现无锁算法而处理正确性论证的方式。

关于松弛:松弛只模拟内存模型,但它依赖于编译器生成的操作顺序。因此,即使编译器可以重新排序两条指令,但选择不重新排序,您也无法用Relacy识别这一点。