轻松的排序和线程间可见性

时间:2019-07-06 08:42:11

标签: c++ multithreading atomic volatile memory-barriers

我从relaxed ordering as a signal获悉,原子变量上的存储应该在“合理的时间内”对其他线程可见。

也就是说,我很确定它应该在很短的时间内发生(大约十亿分之一秒)。 但是,我不想依靠“在合理的时间内”。

所以,这是一些代码:

std::atomic_bool canBegin{false};
void functionThatWillBeLaunchedInThreadA() {
    if(canBegin.load(std::memory_order_relaxed))
        produceData();
}

void functionThatWillBeLaunchedInThreadB() {
    canBegin.store(true, std::memory_order_relaxed);
}

线程A和B在ThreadPool之内,因此在此问题中没有线程的创建。 我不需要保护任何数据,因此这里不需要对原子存储/加载进行获取/消耗/发布顺序(我想?)。

我们肯定知道functionThatWillBeLaunchedInThreadA函数将在functionThatWillBeLaunchedInThreadB结束后启动。

但是,在这样的代码中,我们无法保证该存储将在线程A中可见,因此线程A可以读取陈旧值(false)。

这是我考虑的一些解决方案。

解决方案1:使用波动性

只需声明volatile std::atomic_bool canBegin{false};,在这里波动性就保证了我们不会看到过时的价值。

解决方案2:使用互斥或​​自旋锁

这里的想法是通过互斥锁/自旋锁保护canBegin访问,并通过释放/获取命令保证我们不会看到过时的值。 我也不需要canGo是原子的。

解决方案3:完全不确定,但是记忆屏障?

也许这段代码不起作用,所以,告诉我:)。

bool canGo{false}; // not an atomic value now
// in thread A
std::atomic_thread_fence(std::memory_order_acquire);
if(canGo) produceData();

// in thread B
canGo = true;
std::atomic_thread_fence(std::memory_order_release);

对于cpp参考,在这种情况下,写为:

  

在FB之前已排序的所有非原子存储和弛豫原子存储   在线程B中将发生-在所有非原子负载和松弛原子负载之前   从FA之后在线程A中创建的相同位置开始

您将使用哪种解决方案?为什么?

3 个答案:

答案 0 :(得分:2)

您无法更快地使商店对其他线程可见。请参阅 If I don't use fences, how long could it take a core to see another core's writes? - 障碍不会加快对其他核心的可见性,它们只会让此核心等待,直到发生这种情况。

为此,RMW 的商店部分与纯商店也没有什么不同。

(当然在 x86 上;不完全确定其他 ISA,其中宽松的 LL/SC 可能会从存储缓冲区中获得特殊处理,如果此内核可以获得缓存的独占所有权,则可能更有可能在其他存储之前提交行。但我认为它仍然必须从乱序执行中退出,因此核心知道它不是推测性的。)

评论中链接的

Anthony's answer 具有误导性; as I commented there

<块引用>

如果 RMW 在另一个线程的存储提交缓存之前运行,它不会看到该值,就像它是一个纯加载一样。这是否意味着“陈旧”?不,这只是意味着商店还没有发生。

RMW 需要保证“最新”值的唯一原因是它们本质上是在该内存位置上序列化操作。如果您希望 100 个不同步的 fetch_add 操作不相互影响并且等效于 += 100,那么这就是您所需要的,但除此之外,尽力而为/最新可用的值很好,这就是您从正常的原子负载。

如果您需要即时查看结果(一纳秒左右),那只能在单个线程中实现,例如 x = y; x += z;


另请注意,使商店在合理的时间内可见的 C/C++ 标准要求(实际上只是一个注释)是对操作顺序要求的补充。这并不意味着 seq_cst 存储可见性可以延迟到稍后加载之后。所有 seq_cst 操作都发生在所有线程的程序顺序的某种交错中。

在真实的 C++ 实现中,可见性时间完全取决于硬件内核间延迟。但是 C++ 标准是抽象的,理论上可以在需要手动刷新以使存储对其他线程可见的 CPU 上实现。然后由编译器决定,不要偷懒并将其推迟“太长时间”。


volatile atomic<T> 没用;编译器已经没有优化 atomic<T>,所以抽象机完成的每个 atomic 访问都已经发生在 asm 中。 (Why don't compilers merge redundant std::atomic writes?)。这就是 volatile 的全部作用,因此 volatile atomic<T> 编译为与 atomic<T> 相同的 asm,可以使用原子进行任何操作。

定义“过时”是一个问题,因为在不同内核上运行的不同线程无法立即看到彼此的操作。在现代硬件上需要数十纳秒才能从另一个线程查看商店。

但是你不能从缓存中读取“陈旧”的值;那是不可能的because real CPUs have coherent caches。 (这就是为什么在 C++11 之前可以使用 volatile int 来滚动你自己的原子,但不再有用。)你可能需要一个比 relaxed 更强的排序来获得你想要的语义一个值比另一个旧(即“重新排序”,而不是“陈旧”)。但是对于单个值,如果您没有看到存储,则意味着您的加载在另一个核心获得缓存行的独占所有权以提交其存储之前执行。即商店还没有真正发生。

在正式的 ISO C++ 规则中,有关于您被允许看到的值的保证,这有效地为您提供了您期望从单个对象的缓存一致性中获得的保证,就像在读者看到商店之后,进一步此线程中的负载不会看到一些较旧的商店,然后最终会返回到最新的商店。 (https://eel.is/c++draft/intro.multithread#intro.races-19)。

(注意对于 2 个写入者 + 2 个读取者进行非 seq_cst 操作,读者可能会不同意存储发生的顺序。这称为 IRIW 重新排序,但大多数硬件不能这样做;只有一些PowerPC。Will two atomic writes to different locations in different threads always be seen in the same order by other threads? - 所以它并不总是像“商店还没有发生”那么简单,它在其他线程之前对某些线程可见。但是你不能加速可见性仍然是真的,只有示例减慢了读取器的速度,因此他们都不会通过“早期”机制看到它,即使用 hwsync 来让 PowerPC 加载首先排空存储缓冲区。)

答案 1 :(得分:0)

由于@CuriouslyRecurringThoughts的评论和Anthony Williams的回答,我想处理这种情况的唯一方法是使用CAS操作(这是“读取修改写入”操作)

所以我们最后得到了

std::atomic_bool canBegin{false};
void functionThatWillBeLaunchedInThreadA() {
    bool expected = true;
    canBegin.compare_exchange_strong(expected, true, std::memory_order_relaxed);
    if(expected)
        produceData();
}

void functionThatWillBeLaunchedInThreadB() {
    canBegin.store(true, std::memory_order_relaxed);
}

那终于很复杂了...

答案 2 :(得分:0)

  

我们肯定知道functionThatWillBeLaunchedInThreadAfunction   将在   functionThatWillBeLaunchedInThreadB

首先,如果是这种情况,那么您的任务队列机制很可能已经在进行必要的同步。

答案...

到目前为止,最简单的方法是获取/发布订单。您提供的所有解决方案都更糟。

std::atomic_bool canBegin{false};

void functionThatWillBeLaunchedInThreadA() {
    if(canBegin.load(std::memory_order_acquire))
        produceData();
}

void functionThatWillBeLaunchedInThreadB() {
    canBegin.store(true, std::memory_order_release);
}

顺便说一句,这不应该是while循环吗?

void functionThatWillBeLaunchedInThreadA() {
    while (!canBegin.load(std::memory_order_acquire))
    { }
    produceData();
}
  

我不需要保护任何数据,因此获取/使用/发布   不需要在原子存储/负载上排序(我想?)

在这种情况下,需要进行排序,以防止编译器/ CPU /内存子系统在先前的读取/写入完成之前对canBegin存储区true进行排序。并且它实际上应该使CPU停止运行,直到可以保证程序顺序之前的所有写入将在存储到canBegin之前传播。在加载方面,它防止在将canBegin读为true之前读取/写入内存。如果我没记错的话,由于数据依赖性,这种重新排序实际上不会在此特定实例中的任何现代硬件上发生。 (我不确定这与推测执行如何配合)。宽松的内存顺序不能保证这些顺序。

  

但是,在这样的代码中,我们无法保证该商店   将在线程A中可见,因此线程A可以读取旧消息   值(假)。

你说自己:

  

原子变量上的存储应该对对象中的其他线程可见   “在合理的时间内”。

即使使用宽松的内存顺序,也可以保证写操作最终到达其他内核,并且所有内核最终都将在任何给定变量的存储历史记录上达成一致,因此没有陈旧的值。只有尚未传播的值。关于它的“放松”是相对于其他变量的存储顺序。因此,memory_order_relaxed解决了过时的读取问题(但并未解决如上所述的排序要求)。

请勿使用volatile。它没有提供C ++内存模型中原子所需的所有保证,因此使用它将是未定义的行为。请参阅底部的https://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering进行阅读。

您可以使用互斥或​​自旋锁。原子操作比获取/发布要昂贵得多。自旋锁将执行至少一个原子的读-修改-写操作...并且可能执行许多操作。互斥锁绝对是多余的。但是两者都有简单性的好处。大多数人都知道如何使用锁,因此更容易证明正确性。

内存隔离栅也可以使用,但是您的隔离栅位于错误的位置(这是违反直觉的),并且线程间通信变量应为std::atomic。 (在玩这些游戏时要小心……!很容易出现不确定的行为)由于栅栏的原因,轻松的订购还可以。

std::atomic<bool> canGo{false}; // MUST be atomic

// in thread A
if(canGo.load(std::memory_order_relaxed))
{
    std::atomic_thread_fence(std::memory_order_acquire);
    produceData();
}

// in thread B
std::atomic_thread_fence(std::memory_order_release);
canGo.store(true, memory_order_relaxed);

实际上,内存隔离区比std::atomic加载/存储区上的获取/发布顺序更严格,因此这毫无用处,而且可能会更昂贵。

似乎您真的想避免信令机制的开销。这正是发明std::atomic获取/释放语义的原因!您太担心过时的价值了。是的,原子RMW将为您提供“最新”价值,但它们本身也是非常昂贵的运营。我想让您了解获取/发布的速度。您最有可能瞄准的是x86。 x86具有总的存储顺序,字大小的加载/存储是原子的,因此加载获取仅编译为常规加载,而发布存储则编译为常规存储。因此,事实证明,在这篇冗长的文章中,几乎所有内容都可能会编译为完全相同的代码。