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

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

标准中是否有任何措辞可以保证对原子的宽松存储不会被解除到互斥锁的锁定之上?如果不是,是否有任何措辞明确说明编译器或 CPU 这样做是符合犹太教规的?

例如,采取以下程序(它可能使用 acq/rel 作为 foo_has_been_set 并避免锁定,and/or 使 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 的存储不能高于编译器对 mu.lock 的调用和 CPU?

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

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

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

两个相关的部分是

[...] In addition, there are relaxed atomic operations, which are not synchronization operations [...]

[...] performing a release operation on A forces prior side effects on other memory locations to become visible to other threads that later perform a consume or an acquire operation on A. [...]

虽然宽松的订单原子不被视为同步操作,但这就是标准在此上下文中对它们的全部说明。由于它们仍然是内存位置,因此它们受 other 同步操作支配的一般规则仍然适用。

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

编辑: 糟糕,我链接到了草稿。涵盖此内容的 C++11 段落是 1.10-5,使用相同的语言。

互斥保护区域内的内存操作不能从该区域'escape'。这适用于所有内存操作,原子的和非原子的。

在第 1.10.1 节中:

a call that acquires a mutex will perform an acquire operation on the locations comprising the mutex Correspondingly, a call that releases the same mutex will perform a release operation on those same locations

此外,在第 1.10.1.6 节中:

All operations on a given mutex occur in a single total order. Each mutex acquisition “reads the value written” by the last mutex release.

并且在 30.4.3.1

A mutex object facilitates protection against data races and allows safe synchronization of data between execution agents

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

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

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

在您的示例中,foo_has_been_setCheckFoo 中被选中。如果它显示为 true,您知道值 1 已被 SetFoo,但还没有同步。 随后的互斥锁将获得 foo,同步已完成,无法触发断言。

I think I've figured out the particular partial order edges that guarantee the program can't crash. In the answer below I'm referencing version N4659 of the draft standard.

The code involved for the writer thread A and reader thread B is:

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()

We seek a proof that if B3 executes, then A2 happens before B3, as defined in [intro.races]/10. By [intro.races]/10.2, it's sufficient to prove that A2 inter-thread happens before B3.

Because lock and unlock operations on a given mutex happen in a single total order ([thread.mutex.requirements.mutex]/5), we must have either A1 or B2 coming first. The two cases:

  1. Assume that A1 happens before B2. Then by [thread.mutex.class]/1 and [thread.mutex.requirements.mutex]/25, we know that A4 will synchronize with B2. Therefore by [intro.races]/9.1, A4 inter-thread happens before B2. Since B2 is sequenced before B3, by [intro.races]/9.3.1 we know that A4 inter-thread happens before B3. Since A2 is sequenced before A4, by [intro.races]/9.3.2, A2 inter-thread happens before B3.

  2. Assume that B2 happens before A1. Then by the same logic as above, we know that B4 synchronizes with A1. So since A1 is sequenced before A3, by [intro.races]/9.3.1, B4 inter-thread happens before A3. Therefore since B1 is sequenced before B4, by [intro.races]/9.3.2, B1 inter-thread happens before A3. Therefore by [intro.races]/10.2, B1 happens before A3. But then according to [intro.races]/16, B1 must take its value from the pre-A3 state. Therefore the load will return false, and B2 will never 运行 in the first place. In other words, this case can't happen.

So if B3 executes at all (case 1), A2 happens before B3 and the assert will pass. ∎

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

如果 CheckFoo() 开始的条件触发(见下文),foo 的可见值将为 1,因为内存屏障和 mu.unlock() 之间的同步 [= CheckFoo().

中的 18=] 和 mu.lock()

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

但是,不能保证 if 条件 (foo_has_been_set.load(std::memory_order_relaxed))) 永远为真。 宽松的内存顺序不能保证,只能保证操作的原子性。因此,在没有其他障碍的情况下,无法保证 SetFoo() 中的宽松存储何时在 CheckFoo() 中可见,但如果它可见,那只是因为存储已执行,然后在 [=] 之后19=] 必须在 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();
  }
}

实际上,在任何长 运行 应用程序的任何真实平台上,relax 存储最终对其他线程可见可能是不可避免的。但是,除非存在其他障碍来确保它,否则没有关于是否或何时发生的正式保证。

正式参考资料:

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

请参阅第 13 页末尾和第 14 页开头的注释,尤其是注释 17 - 20。它们基本上确保 'relaxed' 操作的一致性。它们的可见性是宽松的,但发生的可见性将是连贯的,短语 'happens before' 的使用符合程序排序的总体原则,特别是获取和释放互斥锁的障碍。 注释 19 特别相关:

The four preceding coherence requirements effectively disallow compiler reordering of atomic operations to a single object, even if both operations are relaxed loads. This effectively makes the cache coherence guarantee provided by most hardware available to C++ atomic operations.

重新排序临界区当然是可能的:

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 的读取是否会落入锁中?通常像 can 这样的读取(事情可能会落入锁中,只是不会出来),但如果 if 为假,则永远不应该使用锁,所以这将是一个奇怪的顺序。有没有说 "speculative locks" 是不允许的?或者 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();
}

该顺序可能不正确,但这只是因为 "logic order" 不是内存顺序。如果 mu.lock() 被内联(并成为一些原子操作)是什么阻止它们被重新排序?

我不太担心您当前的代码,但我担心使用类似的任何真实代码。离错太近了。

也就是说,如果 OP 代码是真正的代码,您只需将 foo 更改为 atomic,然后去掉其余部分。所以真正的代码一定是不同的。更复杂? ...

标准不直接保证,但您可以在[thread.mutex.requirements.mutex].:

的字里行间阅读

For purposes of determining the existence of a data race, these behave as atomic operations ([intro.multithread]).
The lock and unlock operations on a single mutex shall appear to occur in a single total order.

现在第二句看起来[=44​​=]像是一个硬性保证,但实际上并非如此。单一全序很好,但这仅意味着有一个明确定义的获取和释放一个特定互斥锁的单一全序。就其本身而言,并不意味着任何原子操作或相关非原子操作的影响应该或必须在与互斥锁相关的某个特定点上全局可见。管他呢。唯一可以保证的是代码执行的顺序(具体来说,就是一对函数的执行,lockunlock),什么都没有正在谈论数据可能会发生什么或不会发生什么,或者其他。
然而,从字里行间可以看出,这正是 "behave as atomic operations" 部分的意图。

从其他地方,也很清楚这就是确切的想法,并且期望实现以这种方式工作,但没有明确说明必须。例如,[intro.races] 表示:

[ Note: For example, a call that acquires a mutex will perform an acquire operation on the locations comprising the mutex. Correspondingly, a call that releases the same mutex will perform a release operation on those same locations.

注意这个不吉利的小而无害的词 "Note:"。注释不规范。因此,虽然很明显这就是它的意图(互斥锁 = 获取;解锁 = 释放),但这实际上是 而不是 的保证。

我认为最好的,虽然非直截了当的保证来自[thread.mutex.requirements.general]中的这句话:

A mutex object facilitates protection against data races and allows safe synchronization of data between execution agents.

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

因此,无论人们想出了什么微妙之处,也无论写了什么或没有明确说明,使用互斥锁可以防止数据竞争(...任何类型,因为没有给出具体类型)。就是这么写的。因此,总而言之,只要您使用互斥锁,即使顺序松散或根本没有原子操作,您也可以放心使用。加载和存储(任何类型的)不能四处移动,因为那样你无法确定没有数据竞争发生。然而,这正是互斥量所要防止的。
因此,不用说,这表示互斥体 必须 是一个完整的屏障。