繁体   English   中英

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

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

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

例如,以下面的程序为例(它可能对foo_has_been_set使用 acq/rel 并避免锁定,和/或使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的调用mu.lock

这与一个较旧的问题有关,但我并不是 100% 清楚那里的答案适用于此。 特别是,该问题答案中的反例可能适用于对SetFoo两个并发调用,但我对编译器知道有一个对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 之前。

因为对给定互斥锁的锁定和解锁操作以单一全序 ( [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_set在检查CheckFoo 。如果它读取true ,你知道,值1已被分配给fooSetFoo ,但还没有与之同步。 随后的互斥锁将获取foo ,同步完成并且断言无法触发。

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

为了确定数据竞争的存在,这些行为表现为原子操作([intro.multithread])。
单个互斥锁上的锁定和解锁操作应出现在单个总顺序中。

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

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

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

请注意不幸的小而无害的词“注意:” 注释不规范。 所以,虽然很明显,这是它打算如何理解(互斥锁=获取;解锁=释放),这实际上不是一个保证。

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

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

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

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

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

两个相关的部分是

[...] 此外,还有宽松的原子操作,它们不是同步操作 [...]

[...] 对 A 执行释放操作会强制其他内存位置上的先前副作用对稍后对 A 执行消耗或获取操作的其他线程可见。 [...]

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

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

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

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

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

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

但是,不能保证 if 条件( foo_has_been_set.load(std::memory_order_relaxed)) )永远为真。 宽松的内存顺序不能保证,只保证操作的原子性。 因此在没有其他一些障碍也不能保证当放宽店SetFoo()将在可见CheckFoo()但如果它是可见的也只会是因为被执行的商店,然后按照mu.lock()必须的在mu.unlock()之后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。它们本质上是确保“轻松”操作的连贯性。 它们的可见性是放松的,但发生的可见性将是连贯的,并且“发生在之前”这个短语的使用符合程序排序的总体原则,特别是获取和释放互斥锁的障碍。 注释 19 特别相关:

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

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

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 更改为 atomic,然后去掉其余部分。 所以真正的代码一定是不同的。 更复杂? ...

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM