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

如果另一个线程同时调用,是否有可能CheckFoo在上述程序中崩溃SetFoo,或者是否有一些保证不能将存储提升到编译器和 CPUfoo_has_been_set的调用之上?mu.lock

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

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

4

6 回答 6

8

我想我已经弄清楚了保证程序不会崩溃的特定偏序边缘。在下面的答案中,我引用了标准草案的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 之前,并且断言将通过。∎</p>

于 2017-08-04T04:54:13.023 回答
3

互斥保护区域内的任何内存操作都不能“逃离”该区域。这适用于所有内存操作,原子的和非原子的。

在第 1.10.1 节中:

获取互斥锁的调用将对包含互斥锁的位置执行获取操作相应地,释放相同互斥锁的调用将对这些相同位置执行释放操作

此外,在第 1.10.1.6 节中:

给定互斥体上的所有操作都以单个总顺序发生。每次互斥量获取都会“读取上一次互斥量释放所写入的值”。

而在 30.4.3.1

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

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

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

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

在您的示例中,foo_has_been_set已签入CheckFoo.. 如果它读取true您知道值 1 已分配给fooby SetFoo,但尚未同步。随后的互斥锁将获取foo,同步完成并且断言无法触发。

于 2017-08-03T07:15:15.817 回答
1

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

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

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

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

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

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

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

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

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

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

于 2019-12-15T15:45:18.527 回答
0

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

两个相关的部分是

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

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

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

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

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

于 2017-08-03T06:51:42.930 回答
0

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

如果触发器开始时的条件CheckFoo()(见下文),则可见值foo将为 1,因为mu.unlock()inSetFoo()mu.lock()in之间存在内存屏障和同步CheckFoo()

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

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

foo_has_been_set请注意,此参数依赖于仅设置 from falseto的事实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++ 原子操作。

于 2017-08-04T09:03:15.607 回答
0

在关键部分重新排序当然是可能的:

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 可以在读取之前推测 if 是真的foo_has_been_set吗?

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,然后摆脱其余代码。所以真正的代码一定是不同的。更复杂?...

于 2017-08-07T05:11:36.420 回答