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

Ary*_*yan 19 c++ multithreading memory-model memory-barriers stdatomic

我目前正在阅读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);
}
Run Code Online (Sandbox Code Playgroud)

所以我能想到的不同执行路径是这样的:

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
Run Code Online (Sandbox Code Playgroud)

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
Run Code Online (Sandbox Code Playgroud)

3)

Thread a (x is true)
Thread b (y is true)
Thread c (z is incremented) assertion cannot fire
Thread d (z is incremented)
Run Code Online (Sandbox Code Playgroud)

有人可以向我解释这个断言是如何解雇的吗?

他展示了这个小图: 图片

商店是否y也应该与加载同步read_x_then_y,并且商店要x与加载同步read_y_then_x?我很困惑.

编辑:

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

顺便说一句,我推理的是,if thread a(write_x)存储x到目前为止它所做的所有工作都与任何其他x使用获取排序读取的线程同步.一旦read_x_then_y看到这个,它就会突破循环并读取y.现在,有两件事可能发生.在一个选项中,write_y已写入y,意味着此版本将与if语句同步(加载)意义z增加且断言无法触发.另一个选项是if write_y还没有运行,意味着if条件失败并且z没有递增,在这种情况下,只有xtrue并且y仍然是false.一旦write_y运行时,read_y_then_x跳出其循环,但是双方xy为真,且z递增和断言不火.我想不出任何"运行"或内存排序z从不增加.有人可以解释我的推理存在缺陷吗?

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

Arn*_*gel 14

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

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

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

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

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

更新以响应注释:该标准没有定义一个线程看到另一个线程对原子的更改所需的时间的硬上限.有人建议实施者最终应该看到价值观.

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

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

因此,不能保证f 读取e存储的值,而不是例如某些旧的值x.如果读取更新的值,那么还负载并不能与实体店同步,并有对上述任何相关的操作的无顺序的保证.

我认为原子论的记忆顺序要小于相对论的顺序一致性,因为没有全局的同时性概念.

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

  • 别客气!"顺序一致性是让所有线程看到最新价值的一致性." - 不完全......线程之间仍然没有同步时间.什么seq.利弊.本质上意味着更新发生或变得可见的顺序在线程之间是一致的 - 就像所有原子访问在一个线程上交错一样.然而,仍然可以存在可测量的*延迟*,并且这种延迟甚至可以在线程之间不同.因此,在一个线程上的`x && y`和另一个线程上的`x &&!y`(反之亦然,但不是两者都在同一个执行中)仍然是可能的(一段时间). (2认同)

ace*_*ent 5

释放-获取同步(至少)有这样的保证:在内存位置上释放之前的副作用在该内存位置上获取之后是可见的。

如果内存位置不同,则没有这样的保证。更重要的是,没有完全(考虑全球)订购保证。

看这个例子,线程 A 使线程 C 退出其循环,线程 B 使线程 D 退出其循环。

然而,释放可以在同一内存位置上“发布”到获取的方式(或者获取可以“观察”释放的方式)不需要全排序。线程 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

在同一个线程中使用write_xread_x_then_y对于练习来说是毫无意义的,因为很明显它没有正确同步,就像拥有write_x和 一样read_y_then_x,它总是读取最新的x


编辑:

我对此的推理是,如果thread a( write_x) 存储到,x则迄今为止它所做的所有工作都与x通过获取顺序读取的任何其他线程同步。

(...)我想不出任何z永远不会增加的“运行”或内存排序。有人可以解释我的推理哪里有缺陷吗?

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

这是顺序一致的顺序,它强加了总顺序。也就是说,它强制要求write_x两者write_y都对所有线程依次可见;thenx或then ,但所有线程y的顺序相同。yx

对于release-acquire,没有总顺序。释放的效果仅保证对同一内存位置上的相应获取可见。通过release-acquire,任何注意到变化的write_x人都可以看到其效果。 x

注意到某些事情发生了变化非常重要。如果您没有注意到变化,则说明您没有同步。因此,线程 C 未在 上同步y,线程 D 也未在 上同步x

从本质上讲,将发布-获取视为仅在正确同步时才起作用的更改通知系统会更容易。如果不同步,您可能会也可能不会观察到副作用。

即使在 NUMA 中也具有高速缓存一致性的强内存模型硬件架构,或者按照总顺序同步的语言/框架,使得很难用这些术语来思考,因为实际上不可能观察到这种效果。