使用4个线程的获取/释放语义

29

我目前正在阅读安东尼·威廉姆斯的《C++ Concurrency in Action》。他的其中一段代码如下,并指出断言z != 0可能会触发。

#include <atomic>
#include <thread>
#include <assert.h>

std::atomic<bool> x,y;
std::atomic<int> z;

void write_x()
{
    x.store(true,std::memory_order_release);
}

void write_y()
{
    y.store(true,std::memory_order_release);
}

void read_x_then_y()
{
    while(!x.load(std::memory_order_acquire));
    if(y.load(std::memory_order_acquire))
        ++z;
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire))
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join();
    b.join();
    c.join();
    d.join();
    assert(z.load()!=0);
}

我可以想到的不同执行路径如下:

1)

Thread a (x is now true)
Thread c (fails to increment z)
Thread b (y is now true)
Thread d (increments z) assertion cannot fire

2)

Thread b (y is now true)
Thread d (fails to increment z)
Thread a (x is now true)
Thread c (increments z) assertion cannot fire

3)

Thread a (x is true)
Thread b (y is true)
Thread c (z is incremented) assertion cannot fire
Thread d (z is incremented)

有人能解释一下这个断言是如何触发的吗?

他展示了这个小图形:Image

存储到y的操作是否也应该与read_x_then_y中的加载同步,存储到x的操作是否也应该与read_y_then_x中的加载同步?我很困惑。

编辑:

谢谢你们的回答,我理解原子操作的工作原理以及如何使用Acquire/Release。我只是不明白这个具体的例子。我试图弄清楚如果断言被触发,那么每个线程都做了什么?为什么如果我们使用顺序一致性,断言永远不会触发。

我的理解是,如果“线程a”(write_x)将x存储,则其已完成的所有工作都将与使用acquire顺序读取x的任何其他线程同步。一旦read_x_then_y看到这一点,它就会跳出循环并读取y。现在,有两种可能性。一种是write_y已经写入了y,这意味着此释放将与if语句(load)同步,这意味着z会增加,并且断言不会触发。另一种选择是write_y尚未运行,这意味着if条件失败,z不会增加,在这种情况下,只有x为true,y仍为false。一旦write_y运行,read_y_then_x就会跳出其循环,但是x和y都为true,z会增加,而断言不会触发。我想不到任何“运行”或内存排序,其中z从未增加。有人能解释一下我的推理有什么问题吗?
另外,我知道循环读取将始终在if语句读取之前,因为acquire会防止此重新排序。

1
这个例子来自于这个页面http://en.cppreference.com/w/cpp/atomic/memory_order#Release-Acquire_ordering,解释也在那里,与编译器在优化时可以重新排序指令有关,除非你提供正确的语义,否则它是自由的 - 即使在实验中可能没有使用任何特定的实现。你必须提供memory_order_seq_cst以避免断言。 - Wyck
1
“Has written to y” 不意味着此写入在当前线程中可见。 - xskxzr
@Aryan 对你的编辑回答:看看例如线程A。通过对x使用release-acquire语义,您获得的保证是在释放x之前在线程A中完成的任何存储都对获取x的线程可见。由于对y的存储是在不同的线程中完成的,因此可能会出现在线程C中加载y在获取x之前重新排序的情况。 - Erik Alapää
相关或重复:在不同线程中对不同位置进行两次原子写入,其他线程是否总是以相同的顺序看到它们? 我在那里的答案解释了这在真实的POWER硬件上如何发生,而其他答案则解释了C++内存模型允许它。 - Peter Cordes
4个回答

22
您正在考虑顺序一致性,这是最强(也是默认的)内存顺序。如果使用此内存顺序,则对原子变量的所有访问构成总序,并且确实无法触发断言。
然而,在此程序中,使用了较弱的内存顺序(释放存储和获取加载)。这意味着,根据定义,您不能假设操作的总序。特别地,您不能假设更改以相同顺序对其他线程可见。(对于任何原子内存顺序,包括memory_order_relaxed,仅保证每个单独变量的总序。)
对x和y的存储发生在不同的线程中,它们之间没有同步。对x和y的加载发生在不同的线程中,它们之间没有同步。这意味着完全可以让线程c看到x && ! y,线程d看到y && ! x。(我只是在这里缩写获取加载,请不要认为是顺序一致的加载语法。)
底线:一旦您使用比顺序一致性更弱的内存顺序,您就可以告别所有原子的全局状态的概念,这种状态在所有线程之间是一致的。这正是为什么有很多人建议坚持顺序一致性,除非您需要性能(顺便说一句,记得测量是否更快!)并且确定自己在做什么。此外,获取第二个意见也是明智之举。
现在,你是否会因此受到伤害是一个不同的问题。标准只允许基于用于描述标准要求的抽象机器失败的情况。然而,由于某种原因,您的编译器和/或CPU可能不利用这种容忍度。因此,在实践中,对于给定的编译器和CPU,您可能永远不会看到断言被触发。请记住,编译器或CPU可能始终使用比您要求的更严格的内存顺序,因为这永远不会引入违反标准最低要求的违规行为。它可能只会花费一些性能-但无论如何,这都不包含在标准中。
响应评论的更新:标准没有硬性上限,指定一个线程查看另一个线程的原子更改需要多长时间。有一个建议给实现者,即值应该最终变得可见。
有排序保证,但与您的示例相关的保证不会防止断言触发。基本获取-释放保证是:如果:
  • 线程e对原子变量x执行释放-存储操作
  • 线程f从同一原子变量执行获取-加载操作
  • 然后,如果f读取的值是由e存储的那个值,则e中的存储与f中的加载同步。这意味着,在此线程中,e中任何(原子和非原子)存储都在给定的对x的存储之前排序,对于f中的任何操作,都在此线程中排序,给定的加载之后。[请注意,不提供关于除这两个线程之外的线程的任何保证!]

因此,并没有保证f将读取由e存储的值,而不是例如某个旧值x。如果它没有读取更新的值,则加载也不会与存储同步,并且对于上述任何依赖操作,都没有排序保证。

我将比顺序一致性更小的原子操作类比为相对论理论,其中没有全局同时性的概念

PS:话虽如此,原子加载不能只读取任意旧值。例如,如果一个线程定期增加(例如使用释放顺序)初始化为0的atomic<unsigned>变量,而另一个线程定期从此变量中加载(例如使用获取顺序),则除了最终包装外,后者线程看到的值必须单调递增。但这是根据给定的排序规则得出的:一旦后者线程读取5,那么4到5的增量之前发生的任何事情都在随后读取5的任何事情的相对过去中。实际上,memory_order_relaxed甚至不允许除包装外的减少,但是此内存顺序不对访问其他变量的相对排序(如果有)做出任何承诺。


但是为什么线程c和线程d会看到这个呢?难道acquire/release不会使其对变量的更改对其他线程可见吗? - Aryan
1
我认为最后一条解决了我的误解。我以为如果你释放一个变量,那么它会强制每个线程都使用更新后的值。现在这更有意义了。即使x在read_x_then_y中是同步的,而且y被写入了,但该线程不必看到新的y值,因为它不是一个同步关系。非常感谢! - Aryan
即使线程使用 release 写入原子变量,也不能保证其他线程能够看到它,但如果能看到,则会参与同步关系。而顺序一致性是让所有线程都能看到最新值的特性。我的理解正确吗? - Aryan
3
不用谢!“Sequential consistency”并不能让所有线程同时看到最新的值,因为线程之间仍然没有同时性。实际上,“sequential consistency”的意思是更新发生或变得可见的顺序在所有线程中保持一致,就像所有原子访问都在一个线程上交错执行一样。但是,在不同线程之间可能仍然存在可以测量的延迟,并且这种延迟甚至可能会有所不同。因此,在一个线程上执行x && y,而在另一个线程上执行x && !y(或相反,但在同一次执行中不能同时出现),在某段时间内仍然是可能的。 - Arne Vogel
1
一个非常好的回答。太多时候,我读到一些帖子声称可以立即看到已发布值的可见性,好像在处理器和线程之间存在某种时间同步。这种观念经常在面试中出现。做得好。 - Mike Strobel
显示剩余3条评论

5
释放-获取同步(release-acquire synchronization)至少具有以下保证:在对某个内存位置进行释放操作之前的副作用在对该内存位置进行获取操作后是可见的。
如果内存位置不同,则没有这样的保证。更重要的是,没有总体(全局)排序保证。
看这个例子,线程A使线程C退出循环,线程B使线程D退出循环。
然而,释放可能“发布”到同一内存位置上的获取(或获取可能“观察”到释放)的方式并不需要总体排序。线程C可以观察到A的释放,线程D可以观察到B的释放,只有在将来的某个时间点,C才能观察到B的释放,D才能观察到A的释放。
这个例子有4个线程,因为这是您可以强制出这种非直观行为的最小示例。如果任何原子操作在同一线程中完成,就会有一个无法违反的顺序。
例如,如果write_x和write_y发生在同一个线程中,那么观察到y的任何线程都必须观察到x的变化。
同样,如果read_x_then_y和read_y_then_x发生在同一个线程中,您将至少在read_y_then_x中观察到x和y的两个更改。
将write_x和read_x_then_y放在同一个线程中是毫无意义的,因为它将变得明显它没有正确同步,而将write_x和read_y_then_x放在一起也是如此,它总是读取最新的x。
编辑:
我推理的方式是,如果线程a(write_x)存储到x,则它所做的所有工作都与使用获取排序读取x的任何其他线程同步。
(...)我想不出任何“运行”或内存排序,其中z从未增加。有人可以解释我的推理有什么缺陷吗?
另外,我知道循环读取将始终在if语句读取之前,因为获取防止了这种重新排序。
这是顺序一致的顺序,它施加了总体顺序。也就是说,它强制要求write_x和write_y都对所有线程可见,先是x然后是y,或者是y然后是x,但对于所有线程都是相同的顺序。
使用release-acquire模型时,没有总顺序。释放操作的效果仅保证对同一内存位置上的相应获取操作可见。使用release-acquire模型,可以保证write_x的效果对于注意到x已更改的任何人都是可见的。
注意到某些内容已更改非常重要。如果您没有注意到更改,则不会进行同步。因此,线程C未在y上进行同步,线程D未在x上进行同步。
本质上,将release-acquire视为仅在正确同步时才起作用的更改通知系统要容易得多。如果您不进行同步,则可能会观察到或不观察到副作用。
具有高强度内存模型硬件架构和NUMA中的缓存一致性,或者以总顺序为基础进行同步的语言/框架,很难从这些角度思考,因为实际上几乎不可能观察到这种效果。

问题在于,如果一个线程没有看到第二个变量的更新,那么另一个线程就会看到更新。我已经对代码运行的思路进行了编辑。你能告诉我其中的问题吗? - Aryan
所以你的意思是,如果一个线程使用release写入变量x,然后稍后另一个线程使用acquire读取x(比如if语句),并不能保证第二个线程将读取正确的值。如果它确实读取了正确的值(如果我们将其放在循环中进行检查),那么它会“同步”,所有与第一个线程的写入都是可见的吗? - Aryan
1
是的。然而,如果所有的写操作都是release,所有的读操作都是acquire,那么你读取的值在某个时间点总是正确的;也就是说,你总是读取到一个被写入的值,而不是一个随机、未定义或不正确的值。 - acelent

0
如果我们将两个if语句改为while语句,代码就会变得正确,并且z将保证等于2。
void read_x_then_y()
{
    while(!x.load(std::memory_order_acquire));
    while(!y.load(std::memory_order_acquire));
    ++z;
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    while(!x.load(std::memory_order_acquire));
    ++z;
}

0

让我们来逐步了解并行代码:

void write_x()
{
    x.store(true,std::memory_order_release);
}

void write_y()
{
    y.store(true,std::memory_order_release);
}

这些指令之前没有任何内容(它们位于并行性的起点,发生在其之前的所有内容也已经发生在其他线程中),因此它们没有实际释放:它们实际上是松散操作。

让我们再次浏览并行代码,注意这两个先前的操作不是有效的释放操作:

void read_x_then_y()
{
    while(!x.load(std::memory_order_acquire)); // acquire what state?
    if(y.load(std::memory_order_acquire))
        ++z;
}

void read_y_then_x()
{
    while(!y.load(std::memory_order_acquire));
    if(x.load(std::memory_order_acquire))
        ++z;
}

请注意,所有的负载都是指那些从未被有效释放的变量,因此在这里没有任何东西被有效获取:我们重新获取了在main中已经可见的先前操作的可见性。
因此,您可以看到所有操作都是有效地松散的:它们不提供任何可见性(超过已经可见的内容)。这就像在获取栅栏之后立即进行获取栅栏一样,这是多余的。没有暗示任何新的内容,这些内容不是已经暗示的。
现在,既然一切都是松散的,那么所有的赌注都是关闭的。
另一种观察方式是注意到原子负载不是保持值不变的RMW操作,因为RMW可以释放而负载不能
就像所有的原子存储一样,它们都是原子变量修改顺序的一部分,即使该变量是一个有效的常量(即一个值始终相同的非const变量),原子RMW操作也在原子变量的修改顺序中,即使没有值的改变(因为代码总是比较并复制完全相同的位模式)。
在修改顺序中,您可以具有发布语义(即使没有修改)。

如果您使用互斥锁来保护变量,即使您只是读取变量,也会获得释放语义。

如果您将所有负载(至少在执行多个操作的函数中)设置为具有释放-修改-负载:

  • 使用保护原子对象的互斥锁(然后删除原子对象,因为它现在是多余的!)
  • 或者使用带有acq_rel顺序的RMW

之前证明所有操作都有效地放松了,现在不再适用,并且在read_A_then_B函数中的至少一个原子操作将必须在另一个操作之前排序,因为它们操作相同的对象。如果它们是变量的修改顺序,并且您使用acq_rel,则其中一个操作与另一个操作之间存在发生关系(显然哪个操作先发生是不确定的)。

无论哪种方式,执行现在都是顺序的,因为所有操作都有效地获取和释放,即作为操作性获取和释放(即使那些实际上是放松的操作!)。


所以现在一切都放松了,所有的赌注都没了。但是在标准语言6.9.2.1 12中,“如果一个评估A在一个评估D之前强烈发生,则 ...”,不完全一样。在这种情况下,在不同语句的同一个线程中进行的两个加载被序列化。这意味着“注意:非正式地,如果A在B之前强烈发生,则在所有上下文中A似乎被评估为在B之前。”对于有序获取负载,这排除了LoadLoad重新排序。尽管可以通过as-if规则仍然允许编译器进行整个程序优化,并决定它们的“负载”。 - Peter Cordes
但是,无论如何,C++标准都没有讨论任何从中读取的“全局状态”。无论如何,我们不需要LoadLoad重排序,只需要IRIW重排序这在PowerPC上实际发生。如果没有seq-cst,则允许不同的线程对于存储到不同对象的全局总顺序不能达成一致,即使它们控制了加载顺序。存储可以在所有线程之前对某些线程变得可见,尽管大多数ISA仅对所有线程进行操作。 - Peter Cordes
@PeterCordes "然后在所有情况下,A似乎会在B之前被评估" 是啊,我不知道那是什么意思,所以... - curiousguy
这是一条“非正式”的注释。似乎相当清楚的暗示是任何可能观察顺序的方式都将看到保证的顺序。但是除了这个注释之外,我们还必须查找标准的其余部分是否有任何规范性语言涉及该定义。例如,http://eel.is/c++draft/atomics.order#4 在描述其要求时使用“A强烈发生在B之前”作为部分。 - Peter Cordes
无论如何,我们可能可以从这个例子中找到几乎任何事情的理由,但是该示例的明确目的是演示IRIW重新排序:非seq_cst读取器不必同意两个独立存储的顺序。(理论上,为了保证这一保证,存储可能也必须是seq_cst)。即使有足够的其他代码使获取操作有意义,就像我们从https://preshing.com/20120913/acquire-and-release-semantics/中期望的那样,它们以某种顺序显示线程的所有操作。 - Peter Cordes
显示剩余5条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接