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


86
bool compare_exchange_weak (T& expected, T val, ..);

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

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

29.6.5 ..伪失败的后果是几乎所有弱比较和交换的使用都将循环。

为什么几乎所有用途中它都必须处于循环状态?这是否意味着我们会在由于虚假故障而失败时循环?如果是这样,为什么我们还要compare_exchange_weak()自己动手使用和编写循环?我们可以使用compare_exchange_strong()我认为应该为我们消除虚假故障的方法。常见的用例是compare_exchange_weak()什么?

另一个问题有关。安东尼在他的《行动中的C ++并发性》一书中说:

//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)也提到了这一点:

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

但如上所述,循环中的两个版本应具有相同/相似的性能。我想念什么?


4
W / r / t第一个问题,在许多情况下,无论如何,您都需要循环(无论您使用的是强版本还是弱版本),而弱版本可能比强版本具有更好的性能。
TC

2
弱CAS和强CAS都是“使用LL / SC”实现的,就像气泡排序和快速排序都是“使用交换”实现的一样;也就是说,从某种意义上说,这是用于完成任务的原始操作。他们围绕LL / SC进行的包装非常不同。弱CAS只是LL / SC。Strong CAS是LL / SC,还有很多其他东西。
Sneftel


@TuXiaomi在该链接中给出了答案,我看不到为什么“弱版本在标准平台上会产生更好的性能”。
德庆

@Deqing在其他情况下,由于其他处理器或线程的中断或操作,compare_exchange_weak可能会虚假失败。在这些平台上,compare_exchange_strong实际上是compare_exchange_weak上的循环-如果它偶然失败,则会再次循环。有帮助吗?也许我错了
Tu Xiaomi

Answers:


72

为什么要循环交换?

通常,您希望在继续工作之前先完成工作,因此,您compare_exchange_weak陷入了一个循环,以便尝试交换直到成功为止(即return true)。

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

为什么用weak代替strong

非常容易:虚假故障很少发生,因此不会对性能造成重大影响。相反,容忍这种故障允许在某些平台上更有效地实施该weak版本(与相比strong):strong必须始终检查虚假故障并掩盖它。这很贵。

因此,weak之所以使用,是因为它比strong某些平台要快得多

当你应该使用weak以及何时strong

参考指出提示何时使用weak,何时使用strong

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

因此,答案似乎很容易记住:如果仅由于虚假故障而不得不引入循环,则不要这样做;否则,请执行以下操作:使用strong。如果仍然存在循环,请使用weak

为什么!expected在示例中

它取决于情况及其所需的语义,但通常不需要正确性。省略它会产生非常相似的语义。仅在另一个线程可能将值重置为的情况下false,语义可能会略有不同(但是我找不到您想要的有意义的示例)。有关详细说明,请参见Tony D.的评论。

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

关于最后一个问题

但如上所述,循环中的两个版本应具有相同/相似的性能。我想念什么?

维基百科

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

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

由于您在两个示例中都提供了显式循环,因此对于强版本而言,没有必要使用小循环。因此,在带有strong版本的示例中,两次失败检查;一次compare_exchange_strong(由于它必须区分虚假故障和并发访问,所以比较复杂),一次则通过循环。这项昂贵的检查是不必要的,并且在weak这里这样做会更快的原因。

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

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


“只有在绝对不能容忍虚假失败的情况下,才使用强大的功能。” -是否真的有一种算法可以区分由于并发写入和虚假故障而导致的故障?我能想到的只是允许我们有时错过更新,或者在任何情况下都不需要循环。
Voo

3
@Voo:更新的答案。现在包括参考中的提示。可能存在确实有所区别的算法。例如,考虑一个“必须更新它”的语义:更新某件事必须只完成一次,因此一旦由于并发写入而失败,我们就会知道其他人也做了,并且可以中止。如果我们由于虚假失败而失败,那么没人会对其进行更新,因此我们必须重试。
gexicide

8
为什么在示例中会出现!expected?它不需要正确性。忽略它会产生相同的语义。” -不是这样...如果说第一次交换失败是因为它发现b已经true,那么-expected现在true-没有&& !expected它循环并尝试进行另一个(傻)交换,true并且很true可能“成功”地从while循环中突然中断了,但是可能会出现如果b同时更改回false,则有意义的是不同的行为,在这种情况下,循环将继续,并且可能最终在中断之前b true 再次设置。
Tony Delroy,2014年

@TonyD:对,我应该澄清一下。
做为杀人罪

抱歉,我再添加一个问题;)
Eric Z

17

为什么几乎所有用途中它都必须处于循环状态?

因为如果您不循环而失败,则可能是您的程序没有做任何有用的事情-您没有更新原子对象,也不知道其当前值是什么(更正:请参阅下面来自Cameron的评论)。如果呼叫没有做任何有用的事情,那有什么意义呢?

这是否意味着我们会在由于虚假故障而失败时循环?

是。

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

在某些体系结构compare_exchange_weak上效率更高,并且虚假失败应该很少见,因此有可能使用弱形式和循环编写更有效的算法。

通常,如果您的算法不需要循环,则最好使用强版本,因为您无需担心虚假故障。如果即使对于强版本也需要循环(并且许多算法确实需要循环),那么在某些平台上使用弱形式可能会更有效。

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

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

编辑:

但如上所述,循环中的两个版本应具有相同/相似的性能。我想念什么?

显然,在可能发生虚假故障的平台上,compare_exchange_strong必须执行更复杂的工作,以检查虚假故障并重试。

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


2
+1在所有计数上都实际上是准确的(Q非常需要)。
Tony Delroy 2014年

大约you don't know what its current value is在第一点,当发生虚假故障时,当前值不应该等于当时的期望值吗?否则,这将是一次真正的失败。
埃里克Z

IMO,弱版本和强版本都在没有单个CAS硬件原语的平台上使用LL / SC来实现。那么对我来说,为什么while(!compare_exchange_weak(..))和之间会有性能差异while(!compare_exchange_strong(..))
埃里克Z

抱歉,我又添加了一个问题。
埃里克Z

1
@Jonathan:只是一个nitpick,但是如果它偶然发生故障,您确实知道当前值(当然,读变量时该值是否仍然是当前值完全是另一个问题,但这与弱/强无关)。例如,我曾用它来尝试设置一个假设其值为null的变量,并且如果失败(有意或无意)失败,则继续尝试,但仅取决于实际值是多少。
卡梅伦

17

在尝试了各种在线资源(例如thisthis),C ++ 11 Standard以及此处给出的答案之后,我试图自己回答这个问题。

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


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

典型图案A

您需要基于原子变量中的值实现原子更新。失败表示该变量未使用我们想要的值更新,我们想重试。请注意,我们并不十分在意它是否由于并发写入或虚假失败而失败。但是我们确实关心 做出这一改变的是我们 自己。

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

一个实际示例是几个线程同时将元素添加到单链列表中。每个线程首先加载头指针,分配一个新节点,并将头附加到该新节点。最后,它尝试用头交换新节点。

另一个示例是使用实现互斥std::atomic<bool>。一次最多只能有一个线程进入关键部分,具体取决于首先设置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()。更差?这种方法无法充分利用提供硬件非伪造的比较与交换的机器

最后,如果您循环进行其他操作(例如,参见上面的“典型模式A”),则很有可能也compare_exchange_strong()应循环进行循环,这使我们回到了前面的情况。


13

好了,所以我需要一个执行原子左移的函数。我的处理器没有为此执行本机操作,并且标准库没有为此提供的功能,因此看来我正在编写自己的函数。开始:

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

现在,有两个原因可能导致循环执行不止一次。

  1. 在我左移时有人更改了变量。我的计算结果不应应用于atomic变量,因为它将有效地消除别人的写操作。
  2. 我的CPU发生故障,并且CAS虚弱失败。

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

什么是快,虽然是额外的代码,强有力的CAS需求环绕弱CAS以坚强。当弱CAS成功时,该代码不会执行太多操作……但是,当失败时,强CAS需要进行一些侦查工作以确定是案例1还是案例2。侦查工作采取第二个循环的形式,有效地在我自己的循环中。两个嵌套循环。想象一下您的算法老师现在瞪着您。

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

换句话说,弱CAS用于实现原子更新操作。当您关心CAS的结果时,将使用强CAS。


0

我认为以上大多数答案都将“虚假故障”视为某种问题,即性能与正确性之间的权衡。

可以看出,弱版本通常在大多数情况下速度较快,但是在出现虚假故障的情况下,速度会变慢。强大的版本是不可能出现虚假故障的版本,但它几乎总是较慢。

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

仅当没有人触摸加载与存储之间的缓存线时,弱版本才会成功,因此它将100%检测到ABA问题。

仅当比较失败时,强版本才会失败,因此,如果没有其他措施,它将无法检测到ABA问题。

因此,从理论上讲,如果在弱顺序体系结构上使用弱版本,则不需要ABA检测机制,并且实现会更加简单,从而提供更好的性能。

但是,在x86(强序体系结构)上,弱版本和强版本是相同的,并且都遭受ABA问题。

因此,如果您编写了一个完全跨平台的算法,则无论如何都需要解决ABA问题,因此使用弱版本不会带来性能上的好处,但是处理虚假故障会降低性能。

总而言之-出于便携性和性能方面的考虑,强版本始终是一个更好或更平等的选择。

如果弱版本可以让您完全跳过ABA对策,或者您的算法不关心ABA,则它是更好的选择。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.