理解 C++11 中的 std::atomic::compare_exchange_weak()

Posted

技术标签:

【中文标题】理解 C++11 中的 std::atomic::compare_exchange_weak()【英文标题】:Understanding std::atomic::compare_exchange_weak() in C++11 【发布时间】:2014-10-01 17:13:02 【问题描述】:
bool compare_exchange_weak (T& expected, T val, ..);

compare_exchange_weak() 是 C++11 中提供的比较交换原语之一。它是weak,即使对象的值等于expected,它也会返回false。这是由于虚假失败在某些平台上使用一系列指令(而不是 x86 上的一个)来实现它。在这样的平台上,上下文切换、另一个线程重新加载相同地址(或缓存行)等可能会使原语失败。这是spurious,因为它不是使操作失败的对象的值(不等于expected)。相反,这是一种时间问题。

但令我困惑的是 C++11 标准 (ISO/IEC 14882) 中所说的话,

29.6.5 .. 虚假失败的一个后果是几乎所有使用弱 比较和交换将处于循环中。

为什么它必须在几乎所有用途中循环?这是否意味着当它由于虚假故障而失败时我们将循环?如果是这样,我们为什么还要麻烦使用compare_exchange_weak() 并自己编写循环呢?我们可以只使用compare_exchange_strong(),我认为它应该为我们消除虚假的失败。 compare_exchange_weak()的常见用例有哪些?

另一个相关的问题。 Anthony 在他的《C++ Concurrency In Action》一书中说,

//Because compare_exchange_weak() can fail spuriously, it must typically
//be used in a loop:

bool expected=false;
extern atomic<bool> b; // set somewhere else
while(!b.compare_exchange_weak(expected,true) && !expected);

//In this case, you keep looping as long as expected is still false,
//indicating that the compare_exchange_weak() call failed spuriously.

为什么!expected 在循环条件中?它是否可以防止所有线程可能会饿死并且在一段时间内没有进展?

最后一个问题

在不存在单一硬件 CAS 指令的平台上,弱版本和强版本均使用 LL/SC(如 ARM、PowerPC 等)实现。那么以下两个循环之间有什么区别吗?为什么,如果有的话? (对我来说,他们应该有类似的表现。)

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_weak(..))
 .. 

// use LL/SC (or CAS on x86) and ignore/loop on spurious failures
while (!compare_exchange_strong(..)) 
 .. 

我想出了最后一个问题,你们都提到循环内部可能存在性能差异。 C++11 标准 (ISO/IEC 14882) 也提到了这一点:

当比较和交换处于循环中时,弱版本将产生 在某些平台上表现更好。

但如上所述,循环中的两个版本应该提供相同/相似的性能。我错过了什么?

【问题讨论】:

W/r/t 第一个问题,在很多情况下你无论如何都需要循环(无论你使用强版本还是弱版本),弱版本可能比强版本有更好的性能。 弱CAS和强CAS都是“使用LL/SC”实现的,就像冒泡排序和快速排序都是“使用swap”实现的一样;也就是说,从某种意义上说,这是用于完成任务的原始操作。他们围绕 LL/SC 的内容非常不同。弱 CAS 只是 LL/SC。强 CAS 是 LL/SC 和一堆其他东西。 forums.manning.com/posts/list/33062.page 有帮助吗? @TuXiaomi 与该链接中的答案,我不明白为什么“弱版本会在某些平台上产生更好的性能”如标准中所述。 @Deqing 在其他情况下,compare_exchange_weak 可能由于其他处理器或线程的中断或操作而虚假失败。在这些平台上, compare_exchange_strong 实际上是 compare_exchange_weak 上的一个循环——如果它虚假地失败,那么它会再次循环。它有帮助吗?也许我错了 【参考方案1】:

为什么要循环交换?

通常,您希望在继续之前完成工作,因此,您将compare_exchange_weak 放入一个循环中,以便它尝试交换直到成功(即返回true)。

请注意,compare_exchange_strong 也经常在循环中使用。它不会因为虚假失败而失败,而是因为并发写入而失败。

为什么要使用weak 而不是strong

很简单:虚假故障不会经常发生,因此不会对性能造成太大影响。相反,容忍这种故障允许在某些平台上更有效地实现weak 版本(与strong 相比):strong 必须始终检查虚假故障并将其屏蔽。这很贵。

因此,使用weak 是因为它在某些平台上比strong 快很多

什么时候应该使用weak,什么时候应该使用strong

reference 状态提示何时使用weak 以及何时使用strong

当比较和交换处于循环中时,弱版本将产生 在某些平台上性能更好。当弱比较和交换 将需要一个循环,而一个强大的则不需要,强大的一个是 最好的。

所以答案似乎很容易记住:如果您仅因为虚假失败而不得不引入循环,请不要这样做;使用strong。如果还是有循环,请使用weak

示例中为什么是!expected

这取决于情况及其所需的语义,但通常不需要它来保证正确性。省略它会产生非常相似的语义。只有在另一个线程可能将值重置为false 的情况下,语义可能会略有不同(但我找不到你想要的有意义的示例)。详细解释见Tony D.'s comment。

另一个线程写入true时,这只是一个快速通道:然后我们中止而不是再次尝试写入true

关于你的最后一个问题

但如上所述,循环中的两个版本应该提供相同/相似的性能。 我错过了什么?

来自Wikipedia:

如果没有,LL/SC 的实际实现并不总是成功 对相关内存位置的并发更新。任何异常 两个操作之间的事件,例如上下文切换,另一个 加载链接,甚至(在许多平台上)另一个加载或存储 操作,将导致存储条件虚假失败。年长者 如果有任何更新广播通过 内存总线。

因此,例如,LL/SC 在上下文切换时会虚假失败。现在,强版本会带来它自己的“小循环”来检测虚假故障并通过重试来掩盖它。请注意,这个自己的循环也比通常的 CAS 循环更复杂,因为它必须区分虚假失败(并将其屏蔽)和由于并发访问导致的失败(导致返回值为 false)。弱版本没有这种自己的循环。

由于您在两个示例中都提供了显式循环,因此对于强版本来说,根本不需要小循环。因此,在strong 版本的示例中,失败检查进行了两次;一次是compare_exchange_strong(这更复杂,因为它必须区分虚假故障和并发访问),一次是你的循环。这种昂贵的检查是不必要的,weak 的原因在这里会更快。

另请注意,您的论点 (LL/SC) 只是实现这一点的一种可能性。还有更多的平台具有甚至不同的指令集。此外(更重要的是),请注意std::atomic 必须支持所有可能的数据类型的所有操作,因此即使您声明了一千万字节的结构,您也可以在此使用compare_exchange。即使在有 CAS 的 CPU 上,您也不能 CAS 千万字节,因此编译器会生成其他指令(可能是锁定获取,然后是非原子比较和交换,然后是锁定释放)。现在,想想在交换一千万字节时会发生多少事情。因此,虽然虚假错误对于 8 字节交换可能非常罕见,但在这种情况下可能更常见。

因此,简而言之,C++ 为您提供了两种语义,一种是“尽力而为”(weak),另一种是“我一定会这样做,无论中间可能发生多少坏事”(strong )。这些如何在各种数据类型和平台上实现是一个完全不同的话题。不要将您的心智模型与特定平台上的实现联系起来;标准库旨在与您可能知道的更多架构一起使用。我们可以得出的唯一一般性结论是,保证成功通常比仅仅尝试并为可能的失败留出空间更困难(因此可能需要额外的工作)。

【讨论】:

"只有在你不能容忍虚假失败的情况下才使用 strong。" - 真的有一种算法可以区分由于并发写入和虚假故障导致的故障吗?我能想到的所有方法要么允许我们有时错过更新,要么不允许我们在这种情况下无论如何都需要一个循环。 @Voo:更新了答案。现在包括来自参考的提示。可能有一种算法可以做出区分。例如,考虑一个“必须更新它”的语义:更新某些东西必须只完成一次,所以一旦我们由于并发写入而失败,我们知道是其他人做了它,我们可以中止。如果我们因为虚假失败而失败,那么没有人更新它,所以我们必须重试。 "为什么 !expected 在示例中? 正确性不需要它。省略它会产生相同的语义。" - 不是这样......如果说第一次交换失败是因为它发现b 已经是true,那么 - 使用expected 现在true - 没有&amp;&amp; !expected 它循环并尝试另一个(愚蠢的)@ 交换987654357@ 和 true 很可能“成功”地从 while 循环中脱离出来,但是如果b同时变回false,在这种情况下循环将继续,可能最终设置btrue再次在中断之前。 @TonyD:是的,我应该澄清一下。 对不起,我又添加了最后一个问题;)【参考方案2】:

为什么它必须在几乎所有用途中循环?

因为如果你不循环并且它虚假地失败了你的程序没有做任何有用的事情 - 你没有更新原子对象并且你不知道它的当前值是什么(更正:请参阅下面来自 Cameron 的评论)。如果调用没有做任何有用的事情,那么这样做有什么意义呢?

这是否意味着当它因虚假故障而失败时我们将循环?

是的。

如果是这样,我们为什么还要麻烦使用compare_exchange_weak() 并自己编写循环?我们可以只使用 compare_exchange_strong() 我认为应该为我们摆脱虚假失败。 compare_exchange_weak()的常见用例有哪些?

在某些架构上compare_exchange_weak 效率更高,虚假故障应该相当少见,因此可以使用弱形式和循环编写更高效的算法。

一般来说,如果您的算法不需要循环,则使用强版本可能会更好,因为您无需担心虚假失败。如果即使是强版本也需要循环(并且许多算法确实需要循环),那么在某些平台上使用弱形式可能会更有效。

为什么!expected在循环条件中?

该值可能已被另一个线程设置为 true,因此您不想继续循环尝试设置它。

编辑:

但如上所述,循环中的两个版本应该提供相同/相似的性能。我错过了什么?

显然,在可能出现虚假故障的平台上,compare_exchange_strong 的实现必须更加复杂,以检查虚假故障并重试。

弱形式只是在虚假失败时返回,它不会重试。

【讨论】:

+1 在所有方面都准确无误(Q 迫切需要)。 关于you don't know what its current value is在第1点,当发生虚假故障时,当前值不应该等于那一刻的预期值吗?否则,这将是一个真正的失败。 IMO,弱版本和强版本都是在不存在单一 CAS 硬件原语的平台上使用 LL/SC 实现的。那么对我来说,为什么while(!compare_exchange_weak(..))while(!compare_exchange_strong(..)) 之间有任何性能差异? 对不起,我又添加了最后一个问题。 @Jonathan:只是一个挑剔,但如果它虚假失败,你确实知道当前值(当然,在你读取变量时它是否仍然是当前值完全是另一个问题,但这与弱/强无关)。例如,我用它来尝试设置一个变量,假设它的值为 null,如果失败(无论是否虚假)继续尝试,但仅取决于实际值是什么。【参考方案3】:

好的,所以我需要一个执行原子左移的函数。我的处理器对此没有本机操作,标准库也没有针对它的函数,所以看起来我正在编写自己的。如下:

void atomicLeftShift(std::atomic<int>* var, int shiftBy)

    do 
        int oldVal = std::atomic_load(var);
        int newVal = oldVal << shiftBy;
     while(!std::compare_exchange_weak(oldVal, newVal));

现在,循环可能被多次执行的原因有两个。

    当我左移时,其他人更改了变量。我的计算结果不应应用于原子变量,因为它会有效地擦除其他人的写入。 我的 CPU 发生了故障,弱 CAS 错误地失败了。

老实说,我不在乎哪一个。左移速度足够快,即使失败是虚假的,我也可以再做一次。

less 快的是,强 CAS 需要包裹弱 CAS 以使其变得强大的额外代码。当弱 CAS 成功时,该代码并没有做太多......但是当它失败时,强 CAS 需要做一些检测工作来确定它是案例 1 还是案例 2。这种检测工作采用第二个循环的形式,有效地在我自己的循环中。两个嵌套循环。想象一下你的算法老师现在正瞪着你。

正如我之前提到的,我不在乎那个侦探工作的结果!无论哪种方式,我都会重做CAS。因此,使用强 CAS 并没有给我带来任何好处,并且会损失少量但可衡量的效率。

也就是说,弱CAS是用来实现原子更新操作的。当您关心 CAS 的结果时,使用强 CAS。

【讨论】:

【参考方案4】:

在浏览了各种在线资源(例如,this one 和 this one)、C++11 标准以及此处给出的答案之后,我试图自己回答这个问题。

合并相关问题(例如,“why !expected ?”与“为什么将 compare_exchange_weak() 放在一个循环中? ")并给出相应的答案。


为什么 compare_exchange_weak() 几乎在所有用途中都必须处于循环中?

典型模式 A

您需要根据原子变量中的值实现原子更新。失败表明变量没有更新为我们想要的值,我们想重试它。请注意,我们并不真正关心它是否由于并发写入或虚假失败而失败。但我们确实在乎 是我们 做出了这种改变。

expected = current.load();
do desired = function(expected);
while (!current.compare_exchange_weak(expected, desired));

一个真实的例子是多个线程同时向一个单链表添加一个元素。每个线程首先加载头指针,分配一个新节点并将头附加到这个新节点。最后,它尝试将新节点与头部交换。

另一个例子是使用std::atomic&lt;bool&gt; 实现互斥锁。一次最多可以有一个线程进入临界区,具体取决于哪个线程先将current设置为true并退出循环。

典型模式 B

这实际上是安东尼书中提到的模式。与模式 A 不同,您希望原子变量更新一次,但您不在乎是谁做的。只要它没有更新,您就再试一次。这通常与布尔变量一起使用。例如,您需要实现一个触发器以使状态机继续前进。不管是哪个线程扣动扳机。

expected = false;
// !expected: if expected is set to true by another thread, it's done!
// Otherwise, it fails spuriously and we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

请注意,我们通常不能使用这种模式来实现互斥锁。否则,可能会有多个线程同时在临界区内。

也就是说,应该很少在循环外使用compare_exchange_weak()。相反,也有使用强版本的情况。例如,

bool criticalSection_tryEnter(lock)

  bool flag = false;
  return lock.compare_exchange_strong(flag, true);

compare_exchange_weak 在这里不合适,因为当它由于虚假故障返回时,很可能还没有人占用临界区。

饿死线程?

值得一提的是,如果虚假故障继续发生从而导致线程饥饿,会发生什么?理论上,当compare_exchange_XXX() 作为指令序列(例如,LL/SC)实现时,它可能会发生在平台上。在 LL 和 SC 之间频繁访问同一高速缓存行将产生连续的虚假故障。一个更现实的例子是由于所有并发线程以下列方式交错的愚蠢调度。

Time
 |  thread 1 (LL)
 |  thread 2 (LL)
 |  thread 1 (compare, SC), fails spuriously due to thread 2's LL
 |  thread 1 (LL)
 |  thread 2 (compare, SC), fails spuriously due to thread 1's LL
 |  thread 2 (LL)
 v  ..

会发生吗?

幸运的是,这不会永远发生,这要归功于 C++11 的要求:

实现应确保弱比较和交换 操作不会始终返回 false,除非原子 对象的值与预期不同或存在并发 修改原子对象。

为什么我们要费心使用 compare_exchange_weak() 并自己编写循环?我们可以只使用 compare_exchange_strong()。

视情况而定。

案例 1:当两者都需要在循环中使用时。 C++11 说:

当比较和交换处于循环中时,弱版本将产生 在某些平台上表现更好。

在 x86 上(至少目前是这样。当引入更多内核时,也许有一天它会采用与 LL/SC 类似的方案来提高性能),弱版本和强版本本质上是相同的,因为它们都归结为单指令cmpxchg。在compare_exchange_XXX() 没有实现原子的其他一些平台上(这里意味着不存在单个硬件原语),循环内的弱版本可能会赢得战斗,因为强版本必须处理虚假失败并相应地重试。

但是,

很少,即使在循环中,我们也可能更喜欢compare_exchange_strong() 而不是compare_exchange_weak()。例如,在加载原子变量和交换计算出的新值之间有很多事情要做(参见上面的function())。如果原子变量本身不经常变化,我们就不需要为每个虚假故障重复昂贵的计算。相反,我们可能希望compare_exchange_strong()“吸收”此类失败,并且仅在由于实际值更改而失败时才重复计算。

案例 2:仅当 compare_exchange_weak() 需要在循环内使用。 C++11 还说:

当一个弱比较和交换需要一个循环和一个强循环时 不会,强者更可取。

当您循环只是为了消除弱版本中的虚假故障时,通常会出现这种情况。由于并发写入,您重试直到交换成功或失败。

expected = false;
// !expected: if it fails spuriously, we should try again.
while (!current.compare_exchange_weak(expected, true) && !expected);

充其量,它是在重新发明***并执行与compare_exchange_strong() 相同的操作。更差? This approach fails to take full advantage of machines that provide non-spurious compare-and-exchange in hardware。

最后,如果您为其他事情循环(例如,参见上面的“典型模式 A”),那么很有可能compare_exchange_strong() 也将被放入循环中,这使我们回到了前面的情况。

【讨论】:

【参考方案5】:

我认为上面的大多数答案都将“虚假失败”作为某种问题,性能与正确性的权衡。

可以看出,弱版本大多数时候速度更快,但在虚假失败的情况下,它变得更慢。而强版本是一个没有虚假失败可能性的版本,但它几乎总是更慢。

对我来说,主要区别在于这两个版本如何处理 ABA 问题:

只有在加载和存储之间没有人触及缓存线时,弱版本才会成功,因此它会 100% 检测到 ABA 问题。

强版本只有在比较失败的情况下才会失败,因此如果没有额外的措施,它不会检测到ABA问题。

所以理论上,如果你在弱序架构上使用弱版本,你就不需要ABA检测机制,实现会简单得多,性能也更好。

但是,在 x86(强序架构)上,弱版本和强版本是一样的,都存在 ABA 问题。

所以如果你编写一个完全跨平台的算法,无论如何你都需要解决 ABA 问题,所以使用弱版本没有性能优势,但处理虚假故障会降低性能。

总之 - 出于可移植性和性能方面的原因,强版本始终是更好或同等的选择。

只有让您完全跳过 ABA 对策或您的算法不关心 ABA 时,弱版本才是更好的选择。

【讨论】:

以上是关于理解 C++11 中的 std::atomic::compare_exchange_weak()的主要内容,如果未能解决你的问题,请参考以下文章

c++11 std::atomic

std::atomic 负载中的段错误?

在实践中,C++11 中 std::atomic 的内存占用是多少?

C++ std::atomic 在程序员级别有啥保证?

为啥即使删除了复制构造函数,std::atomic 也会从 C++17 编译?

std::atomic 和 std::condition_variable 等待、notify_* 方法之间的区别