C++标准:松弛的原子存储是否可以提升到互斥锁以上

C++ standard: can relaxed atomic stores be lifted above a mutex lock?

本文关键字:是否 标准 存储 C++      更新时间:2023-10-16

标准中是否有任何措辞可以保证对原子的宽松存储不会超过互斥锁的锁定?如果没有,是否有任何措辞明确表示编译器或 CPU 这样做是犹太洁食?

例如,采用以下程序(它可能使用 acq/rel 进行foo_has_been_set并避免锁定,和/或使foo本身原子化。 这样写就是为了说明这个问题。

std::mutex mu;
int foo = 0;  // Guarded by mu
std::atomic<bool> foo_has_been_set{false};
void SetFoo() {
mu.lock();
foo = 1;
foo_has_been_set.store(true, std::memory_order_relaxed);
mu.unlock();
}
void CheckFoo() {
if (foo_has_been_set.load(std::memory_order_relaxed)) {
mu.lock();
assert(foo == 1);
mu.unlock();
}
}

如果另一个线程同时调用SetFooCheckFoo是否有可能在上述程序中崩溃,或者是否可以保证foo_has_been_set存储无法提升到编译器和 CPU 对mu.lock的调用之上?

这与一个较老的问题有关,但我不是 100% 清楚那里的答案是否适用于此。特别是,该问题答案中的反例可能适用于对SetFoo的两个并发调用,但我对编译器知道有一个调用SetFoo和一个调用CheckFoo的情况感兴趣。这能保证安全吗?

我正在寻找标准中的特定引用。

我想我已经找到了保证 程序无法崩溃。在下面的答案中,我引用了版本 N4659的标准草案。

编写器线程 A 和读取器线程 B 涉及的代码为:

A1: mu.lock()
A2: foo = 1
A3: foo_has_been_set.store(relaxed)
A4: mu.unlock()
B1: foo_has_been_set.load(relaxed) <-- (stop if false)
B2: mu.lock()
B3: assert(foo == 1)
B4: mu.unlock()

我们寻求一个证据,证明如果 B3 执行,那么 A2 发生在 B3 之前,如 [intro.races]/10 中所定义。通过 [intro.races]/10.2,足以证明 A2 线程间发生 在 B3 之前。

因为给定互斥锁上的锁定和解锁操作发生在单个总数中 order ([thread.mutex.requirements.mutex]/5),我们必须有 A1 或 B2 先来。两种情况:

  1. 假设 A1 发生在 B2 之前。然后通过 [thread.mutex.class]/1 和 [thread.mutex.requirements.mutex]/25,我们知道 A4 将与 B2 同步。 因此,在 [intro.races]/9.1 中,A4 线程间发生在 B2 之前。由于 B2 是 在 B3 之前排序,通过 [intro.races]/9.3.1 我们知道 A4 线程间 发生在 B3 之前。由于 A2 是在 A4 之前排序的,因此由 [intro.races]/9.3.2, A2 线程间发生在 B3 之前。

  2. 假设 B2 发生在 A1 之前。那么通过与上面相同的逻辑,我们知道 B4 与 A1 同步。因此,由于 A1 是在 A3 之前排序的,因此由 [intro.races]/9.3.1,B4 线程间发生在 A3 之前。因此,由于 B1 是 在 B4 之前排序,通过 [intro.races]/9.3.2,B1 间线程发生在 A3 之前。 因此,通过 [intro.races]/10.2,B1 发生在 A3 之前。但是根据[intro.races]/16,B1必须从A3之前的状态取值。因此,负载将返回 false,并且 B2 将永远不会首先运行。换句话说,这种情况不会发生。

因此,如果 B3执行(情况 1),则 A2 发生在 B3 之前,断言将通过。

互斥保护区域内的任何内存操作都无法从该区域"转义"。这适用于所有内存操作,原子和非原子。

在第 1.10.1 节中:

获取互斥锁

的调用将在组成互斥锁的位置上执行获取操作 相应地,释放相同互斥锁的调用将在这些相同的位置上执行释放操作

此外,在第 1.10.1.6 节中:

给定互斥锁上的所有操作都发生在一个总订单中。每次互斥锁获取都会"读取"上一个互斥锁版本写入的值。

在 30.4.3.1 中

互斥对象有助于防止数据争用,并允许在执行代理之间安全同步数据

这意味着,获取(锁定)互斥锁会设置一个单向屏障,防止在获取后(在受保护区域内)排序的操作在互斥锁上移动。

释放(解锁)互斥锁会设置一个单向屏障,防止在释放之前排序的操作(在受保护区域内)在互斥锁解锁中向下移动。

此外,互斥锁释放的内存操作与获取相同互斥锁的另一个线程同步(可见)。

在您的示例中,foo_has_been_setCheckFoo中被检查。如果它读取true则您知道值 1 已由SetFoo分配给foo,但尚未同步。 后面的互斥锁将获取foo,同步完成,断言无法触发。

该标准不能直接保证这一点,但您可以在 [thread.mutex.requirements.mutex] 的行之间读取它:

为了确定是否存在数据争用,这些操作表现为原子操作([intro.multithread])。
单个互斥锁上的锁定和解锁操作应显示为在单个总订单中发生。

现在第二句话看起来像是一个硬保证,但实际上并非如此。单个总顺序非常好,但它只意味着有一个明确定义的获取和释放一个特定互斥锁的单一总顺序。仅凭这一点,这并不意味着任何原子操作或相关的非原子操作的效果应该或必须在与互斥锁相关的某个特定点全局可见。或者,随便什么。唯一可以保证的是代码执行的顺序(特别是一对函数的执行lockunlock),没有说数据可能会或可能不会发生什么,否则。
然而,人们可以从字里行间读出,这仍然是"表现为原子操作">部分的意图。

从其他地方来看,也很清楚这是确切的想法,并且实现应该以这种方式工作,而没有明确说它必须。例如,[intro.races] 写道:

[注意:例如,获取互斥锁的调用将对组成互斥锁的位置执行获取操作。相应地,释放相同互斥锁的调用将在这些相同的位置执行释放操作。

注意不幸的小,无害的单词"注意:">。注释不是规范性的。因此,虽然很明显这就是它的理解方式(互斥锁=获取;解锁=释放),但这实际上并不是一个保证。

我认为最好的,尽管非直接的保证来自[thread.mutex.requirements.general]中的这句话:

互斥对象有助于防止数据争用,并允许在执行代理之间安全同步数据。

所以这就是互斥锁的作用(没有说明具体如何)。它可以防止数据竞争。句号。

因此,无论人们想出什么微妙之处,也无论写了什么或没有明确说什么,使用互斥锁都可以防止数据竞争(...任何类型的,因为没有给出特定的类型)。这就是所写的。因此,总而言之,只要您使用互斥锁,即使使用宽松的排序或根本没有原子操作,您也可以使用。加载和存储(任何类型的)都无法移动,因为这样您就无法确定不会发生数据争用。然而,这正是互斥锁所保护的。
因此,不这么说,这说明互斥锁必须是完全的屏障。

答案似乎就在 http://eel.is/c++draft/intro.multithread#intro.races-3

两个相关部分是

[...]此外,还有松弛的原子操作,这不是同步操作 [...]

[...]在A上执行释放操作强制先前对

其他内存位置的副作用对后来在A上执行消耗或获取操作的其他线程可见。

虽然宽松订单原子不被视为同步操作,但这就是标准在这种情况下必须说明的全部内容。由于它们仍然是内存位置,因此它们由其他同步操作控制的一般规则仍然适用。

因此,总而言之,该标准似乎没有任何专门的内容来防止您所描述的重新排序,但是目前的措辞会自然地阻止它。

编辑:哎呀,我链接到草稿。涵盖这一点的C++11段落是1.10-5,使用相同的语言。

当然可以在关键部分中重新排序:

void SetFoo() {
mu.lock();
// REORDERED:
foo_has_been_set.store(true, std::memory_order_relaxed);
PAUSE(); //imagine scheduler pause here 
foo = 1;
mu.unlock();
}

现在,问题是CheckFoo——foo_has_been_set的读数会掉进锁里吗? 通常这样的读取可以(东西可能会落入锁中,只是不出来),但如果 if 为假,则永远不应该使用锁,所以这将是一个奇怪的排序。 有没有说不允许"投机锁"?或者 CPU 可以在读取foo_has_been_set之前推测 if 为真吗?

void CheckFoo() {
// REORDER???
mu.lock();
if (foo_has_been_set.load(std::memory_order_relaxed)) {
assert(foo == 1);
}
mu.unlock();
}

这种排序可能不行,而只是因为"逻辑顺序"而不是内存顺序。 如果mu.lock()是内联的(并成为一些原子操作),是什么阻止它们被重新排序?

我不太担心你当前的代码,但我担心任何使用这样的东西的真实代码。它太接近错误了。

即,如果 OP 代码是真正的代码,您只需将 foo 更改为原子代码,并摆脱其余代码。 所以真正的代码一定是不同的。 更复杂?...

>CheckFoo()不会导致程序崩溃(即触发assert()),但也不能保证assert()会被执行。

如果CheckFoo()开始时的条件触发(见下文),则foo的可见值将为 1,因为内存障碍以及SetFoo()中的mu.unlock()CheckFoo()中的mu.lock()之间的同步。

我相信其他答案中引用的互斥体描述涵盖了这一点。

然而,不能保证if条件(foo_has_been_set.load(std::memory_order_relaxed)))会为真。 宽松的内存顺序不能保证,只能保证操作的原子性。因此,在没有其他障碍的情况下,无法保证SetFoo()中的放松存储何时在CheckFoo()中可见,但如果可见,则只是因为存储已执行,然后遵循mu.lock()必须在mu.unlock()之后排序,并且在它可见之前写入

。请注意,此参数依赖于以下事实:foo_has_been_set仅从false设置为true。如果有另一个名为UnsetFoo()的函数将其设置回 false:

void UnsetFoo() {
mu.lock();
foo = 0;
foo_has_been_set.store(false, std::memory_order_relaxed);
mu.unlock();
}

这是从另一个(或第三个)线程调用的,因此无法保证在没有同步的情况下检查foo_has_been_set将保证设置foo

需要明确的是(并假设foo_has_been_set永远不会取消设置):

void CheckFoo() {
if (foo_has_been_set.load(std::memory_order_relaxed)) {
assert(foo == 1); //<- All bets are off.  data-race UB
mu.lock();
assert(foo == 1); //Guaranteed to succeed.
mu.unlock();
}
}

在实践中,在任何长时间运行的应用程序的任何真实平台上,放松存储最终对另一个线程可见可能是不可避免的。但是,除非存在其他障碍来保证这种情况,否则没有正式的保证是否或何时会发生。

正式参考资料:

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3690.pdf

请参阅第13页末尾和第14页开头的注释,特别是注释17-20。它们基本上是在确保"宽松"行动的连贯性。它们的可见性是宽松的,但发生的可见性将是连贯的,并且短语"发生在之前"的使用符合程序排序的总体原则,特别是获取和释放互斥体的障碍。 注19特别相关:

前面的四个一致性要求实际上不允许 编译器将原子操作重新排序为单个对象,即使 这两个操作都是松弛负载。这有效地使缓存 大多数可用于C++原子的硬件提供的一致性保证 操作。