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

时间:2018-01-22 14:31:28

标签: c++ multithreading memory-barriers

我目前正在阅读Anthony Williams的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

不应该将商店yread_x_then_y中的加载同步,商店加xread_y_then_x中的加载同步吗?我很困惑。

编辑:

感谢您的回复,我了解原子如何工作以及如何使用获取/发布。我只是没有得到这个具体的例子。我试图弄清楚如果断言触发,那么每个线程做了什么?如果我们使用顺序一致性,为什么断言永远不会触发。

方式,我的理由是,如果thread awrite_x)存储到x那么它到目前为止所做的所有工作都会与读取的任何其他线程同步x获得订购。一旦read_x_then_y看到这一点,它就会突破循环并读取y。现在,有两件事可能发生。在一个选项中,write_y已写入y,这意味着此版本将与if语句(加载)同步,这意味着z递增并且断言无法触发。另一个选项是write_y还没有运行,这意味着if条件失败且z不增加,在这种情况下,只有x为真且y仍然是假的。 write_y运行后,read_y_then_x会跳出其循环,但xy都为真,z递增,并且断言不会触发。我无法想到任何&#39; run&#39;或者z从不递增的内存排序。有人可以解释我的推理存在缺陷吗?

另外,我知道循环读取将始终在if语句读取之前,因为获取会阻止此重新排序。

3 个答案:

答案 0 :(得分:11)

您正在考虑顺序一致性,最强(和默认)内存顺序。如果使用此内存顺序,则对原子变量的所有访问都构成一个总顺序,并且确实无法触发断言。

但是,在此程序中,使用较弱的内存顺序(释放存储和获取负载)。这意味着,根据定义,您不能承担操作的总顺序。特别是,您不能假设更改对于同一订单中的其他线程可见。 (对于任何原子内存顺序,只保证每个变量的总顺序,包括memory_order_relaxed。)

xy的商店出现在不同的线程上,它们之间没有同步。 xy的负载出现在不同的线程上,它们之间没有同步。这意味着完全允许线程c看到x && ! y并且线程d看到y && ! x。 (我只是在这里缩写获取负载,不要将此语法表示为顺序一致的负载。)

底线:一旦你使用比顺序一致的更弱的记忆顺序,你可以亲吻你所有原子的全局状态的概念,这在所有线程之间是一致的,再见。这正是为什么这么多人建议坚持顺序一致性,除非你需要表现(顺便说一句,记得测量它是否更快!)并确定你在做什么。另外,得到第二个意见。

现在,你是否会因此被焚烧,这是一个不同的问题。该标准简单地允许断言失败的场景,基于用于描述标准要求的抽象机器。但是,您的编译器和/或CPU可能由于某种原因而无法利用此容差。因此,对于给定的编译器和CPU,您可能永远不会在实践中看到断言被触发。请记住,编译器或CPU可能始终使用更严格的内存顺序,而不是您要求的内存顺序,因为这绝不会导致违反标准的最低要求。它可能只会花费你一些性能 - 但是标准还没有涵盖这一点。

响应注释的UPDATE:标准没有定义一个线程看到另一个线程对原子的更改所需的时间的硬上限。建议实施者将值显示为最终

排序保证,但与您的示例相关的保证不会阻止断言触发。基本的获取 - 释放保证是:if:

  • 线程e对原子变量x
  • 执行发布存储
  • 线程f从相同的原子变量
  • 执行获取加载
  • 然后如果 f读取的值是e存储的值,则e中的存储与f中的负载同步。这意味着e中的任何(原子和非原子)存储,即在此线程中,在给定存储到x之前排序,对f中的任何操作都是可见的,即在此线程中,在给定的加载后排序。 [请注意,除了这两个以外的线程没有保证!]

因此,无法保证f 读取e存储的值,而不是例如x的较旧值。如果没有读取更新的值,那么负载与商店同步,并且对于提到的任何相关操作没有排序保证上方。

我认为原子论的记忆顺序要小于相对论的顺序一致,其中有no global notion of simultaneousness

PS:也就是说,原子载荷不能只读取任意较旧的值。例如,如果一个线程执行atomic<unsigned>变量的周期性增量(例如,具有释放顺序),初始化为0,并且另一个线程周期性地从该变量加载(例如,具有获取顺序),则除了最终包装之外,后一个线程看到的值必须单调递增。但是这遵循给定的排序规则:一旦后一个线程读取5,在从4到5的增量之前发生的任何事情都是在读取5之后的任何事物的相对过去。事实上,除了包装之外的减少是甚至不允许memory_order_relaxed但是这个内存顺序不会对访问其他变量的相对排序(如果有的话)作出任何承诺。

答案 1 :(得分:4)

发布 - 获取同步具有(至少)此保证:在此内存位置获取后,内存位置上的释放之前的副作用可见。

如果内存位置不同,则无法保证。更重要的是,没有总计(思考全球)订购保证。

查看示例,线程A使线程C退出其循环,线程B使线程D退出其循环。

然而,发布的方式可能会发布&#34;获取(或获取可能&#34;观察&#34;释放)在同一存储位置上的获取并不需要总排序。线程C可以观察A的释放,线程D可以观察B的释放,并且只能在将来C的某个地方观察B的释放和D观察A的发布。

该示例有4个线程,因为这是您可以强制执行此类非直观行为的最小示例。如果任何原子操作是在同一个线程中完成的,那么就会有一个你无法违反的命令。

例如,如果write_xwrite_y发生在同一个线程上,则需要观察y中的更改的任何线程都必须观察x中的更改}。

同样,如果read_x_then_yread_y_then_x发生在同一个帖子上,您会发现xy至少在read_y_then_x中都发生了变化。< / p>

在同一个帖子中使用write_xread_x_then_y对于练习来说毫无意义,因为很明显它没有正确同步,就像write_xread_y_then_x一样x,始终会读取最新的thread a

编辑:

  

方式,我的理由是,如果write_xx)存储到x那么它到目前为止所做的所有工作都会与读取的任何其他线程同步z获得订购。

     

(...)我无法想到任何&#39; run&#39;或者write_x从不递增的内存排序。有人可以解释我的推理存在缺陷吗?

     

另外,我知道循环读取将始终在if语句读取之前,因为获取会阻止此重新排序。

这是顺序一致的顺序,它强加了一个总订单。也就是说,它强制write_yx一个接一个地对所有线程都可见; y然后yx然后write_x,但所有主题的顺序相同。

使用release-acquire,没有总订单。仅保证在同一存储器位置上的相应获取可见对释放的影响。通过发布获取,x的效果可以保证对通知 y发生变化的人可见。

注意到某些改变是非常重要的。如果您没有注意到更改,则表示您未进行同步。因此,线程C未在x上同步,并且线程D未在{{1}}上同步。

从本质上讲,将发布获取视为更改通知系统更容易,只有在正确同步时才能使用。如果你不同步,你可能会或可能不会观察到副作用。

即使在NUMA中具有高速缓存一致性的强大的内存模型硬件体系结构,或者在总体顺序方面同步的语言/框架,也难以用这些术语进行思考,因为实际上不可能观察到这种效果。 / p>

答案 2 :(得分: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操作也位于原子变量的某个位置。原子变量的修改顺序,即使值没有变化(并且值也没有变化,因为代码始终会比较并复制完全相同的位模式)。

在修改顺序中,您可以具有释放语义(即使没有修改)。

如果您使用互斥锁保护变量,那么您将获得释放语义(即使您只是读取变量)。

如果您使用以下方法进行所有加载(至少在执行多次操作的函数中),则release-modification-loads:

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

先前证明所有操作均已有效放松的证明不再起作用,并且在至少一个read_A_then_B函数中的某些原子操作将必须在其他操作中的某些操作之前进行排序,因为它们在同一对象上进行操作。如果它们按变量的修改顺序使用acq_rel,则然后您会在其中一个发生关联之前发生(显然哪个发生在不确定性之前)。

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