宽松的原子商店在发布前是否重新排序? (类似于加载/获取)

Are relaxed atomic store reordered themselves before the release? (similar with load /acquire)

我阅读了 en.cppreference.com specifications 宽松的原子操作:

"[...]only guarantee atomicity and modification order consistency."

所以,我在问自己,当你处理相同或不同的原子变量时,这样的 'modification order' 是否有效。

在我的代码中,我有一个原子树,其中一个低优先级、基于事件的消息线程填充应该更新的节点,使用 memory_order_relaxed 在红色“1”原子上存储一些数据(见图)。然后它继续使用 fetch_or 在其 parent 中写入以了解哪个 child 原子已更新。每个原子最多支持64位,所以我在红色操作'2'中填充位1。它连续继续,直到根原子也使用 fetch_or 标记,但这次使用 memory_order_release.

然后一个快速、实时、不可阻塞的线程加载控制原子(memory_order_acquire)并读取启用它的位。然后它用 memory_order_relaxed 递归地更新 childs 原子。这就是我将数据与高优先级线程的每个周期同步的方式。

由于此线程正在更新,因此可以 child 在其 parent 之前存储原子。问题是在我填充child信息之前,它存储了一个parent(填充children的位来更新)。

换句话说,就像标题说的那样,relaxed store 是在 release 之前重新排序的吗?我不介意 non-atomic 变量被重新排序。 Pseudo-code,假设 [x, y, z, control] 是原子的并且初始值为 0:

Event thread:
z = 1; // relaxed
y = 1; // relaxed
x = 1; // relaxed;
control = 0; // release

Real time thread (loop):
load control; // acquire
load x; // relaxed
load y; // relaxed
load z; // relaxed

我想知道在实时线程中是否总是这样:x <= y <=z。检查我是否写了这个小程序:

#define _ENABLE_ATOMIC_ALIGNMENT_FIX 1
#include <atomic>
#include <iostream>
#include <thread>
#include <assert.h>
#include <array>

using namespace std;
constexpr int numTries = 10000;
constexpr int arraySize = 10000;
array<atomic<int>, arraySize> tat;
atomic<int> tsync {0};

void writeArray()
{
    // Stores atomics in reverse order
    for (int j=0; j!=numTries; ++j)
    {
        for (int i=arraySize-1; i>=0; --i)
        {
            tat[i].store(j, memory_order_relaxed);
        }
        tsync.store(0, memory_order_release);
    }
}

void readArray()
{
    // Loads atomics in normal order
    for (int j=0; j!=numTries; ++j)
    {
        bool readFail = false;
        tsync.load(memory_order_acquire);

        int minValue = 0;
        for (int i=0; i!=arraySize; ++i)
        {
            int newValue = tat[i].load(memory_order_relaxed);
            // If it fails, it stops the execution
            if (newValue < minValue)
            {
                readFail = true;
                cout << "fail " << endl;
                break;
            }
            minValue = newValue;
        }

        if (readFail) break;
    }
}


int main()
{
    for (int i=0; i!=arraySize; ++i)
    {
        tat[i].store(0);
    }

    thread b(readArray);
    thread a(writeArray);

    a.join();
    b.join();
}

工作原理:有一个原子数组。一个线程以相反的顺序以宽松的顺序存储,并以释放顺序结束存储一个控制原子。

另一个线程使用控制原子的获取顺序加载,然后它使用松散的原子加载数组的其余值。由于 parents 不能在 children 之前更新,newValue 应始终等于或大于 oldValue。

这个程序我已经在我的电脑上执行了好几次,debug和release,都没有触发失败。我使用的是普通的 x64 Intel i7 处理器。

因此,假设对多个原子的宽松存储至少在与控制原子和 acquire/release 同步时确实保持 'modification order' 是否安全?

关于放松给予修改顺序一致性的引用。仅意味着所有线程可以就那个一个对象的修改顺序达成一致。即存在订单。稍后与另一个线程中的获取加载同步的发布存储将保证它是可见的。 https://preshing.com/20120913/acquire-and-release-semantics/ 有一个很好的图表。

任何时候你存储一个其他线程可以加载和取消引用的指针,如果任何指向的数据最近也被修改,至少使用mo_release 如果有必要让读者也看到这些更新。 (这包括任何间接可达的东西,比如你的树的等级。)

在任何类型的树/链表/基于指针的数据结构上,几乎唯一可以使用 relaxed 的情况是在尚未 "published" 到其他节点的新分配节点中线程呢。 (理想情况下,您可以将 args 传递给构造函数,这样它们就可以被初始化,甚至根本不需要尝试成为原子;std::atomic<T>() 的构造函数本身不是原子的。因此,在发布指向新对象的指针时必须使用发布存储-构造原子对象。)


在 x86 / x86-64 上,mo_release 没有额外费用;普通 asm 存储已经具有与发布版一样强大的排序,因此编译器只需要阻止编译时重新排序即可实现 var.store(val, mo_release); 它在 AArch64 上也很便宜,特别是如果您不久之后不进行任何获取加载。

这也意味着您无法使用 x86 硬件测试 relaxed 是否不安全;编译器将在编译时为松弛存储选择一个顺序,以它选择的任何顺序将它们钉在发布操作中。 (并且 x86 原子 RMW 操作总是完全障碍,有效 seq_cst。在源代码中使它们变弱只允许编译时重新排序。一些非 x86 ISA 可以具有更便宜的 RMW 以及加载或存储较弱的订单,不过,即使 acq_rel 在 PowerPC 上也稍微便宜一些。)

遗憾的是,通过使用 x86_64 进行实验,您将对标准支持的内容知之甚少,因为 x86_64 的行为非常良好。特别是,除非您指定 _seq_cst:

  • 所有读取有效_acquire

  • 所有写入有效_release

除非它们跨越缓存行边界。并且:

  • 所有读-修改-写都是有效的seq_cst

除了编译器(也)允许重新排序_relaxed操作。

你提到使用 _relaxed fetch_or... 如果我理解正确的话,你可能会失望地发现它并不比 便宜seq_cst,并且需要 LOCK 前缀指令,承载该指令的全部开销。


但是,是的 _relaxed 就顺序而言,原子操作与普通操作没有区别。所以是的,它们可能会被重新排序为其他 _relaxed 原子操作以及非原子操作——由编译器 and/or 机器。 [尽管如前所述,在 x86_64 上,而不是在机器上。]

并且,是的,在线程 X 中的释放操作与线程 Y 中的获取操作同步的情况下,线程 X 中所有在释放之前排序的写入将在线程 Y 中的获取之前发生。所以释放操作是一个信号,表明在 X 中它之前的所有写入都是 "complete",并且当获取操作看到该信号时,Y 知道它已经同步并且可以读取 X 写入的内容(直到释放)。

现在,这里要理解的关键是简单地存储 _release 是不够的,存储的 value 必须向负载 _acquire 发出存储已发生的明确信号。否则,负载如何判断?

通常像这样的_release/_acquire对用于同步访问一些数据集合。一旦该数据为 "ready",商店 _release 就会发出信号。看到信号的任何负载 _acquire(或看到信号的所有负载 _acquire)知道数据是 "ready",它们可以阅读。当然,任何写入存储 _release 之后的数据也可能(取决于时间)被负载 _acquire 看到.我在这里想说的是,如果要对数据进行进一步更改,可能需要 另一个 信号。

你的小测试程序:

  1. tsync初始化为0

  2. 在作者中:毕竟tat[i].store(j, memory_order_relaxed),确实tsync.store(0, memory_order_release)

    所以 tsync 的值没有改变 !

  3. 在reader中:在tat[i].load(memory_order_relaxed)

    之前做tsync.load(memory_order_acquire)

    并忽略从 tsync

  4. 读取的值

我在这里告诉你 _release/_acquire 对是 not 同步-- 所有这些 stores/load 也可能是 _relaxed。 [我认为如果作者设法保持领先 reader,您的测试将 "pass"。因为在 x86-64 上,所有写入都是按指令顺序完成的,所有读取也是如此。]

为了测试_release/_acquire语义,我建议:

  1. tsync初始化为0,将tat[]初始化为全零。

  2. 作者中:运行j = 1..numTries

    在所有tat[i].store(j, memory_order_relaxed)之后,写tsync.store(j, memory_order_release)

    这表示传递已完成,所有 tat[] 现在都是 j

  3. 在reader中:做j = tsync.load(memory_order_acquire)

    通过 tat[] 应该找到 j <= tat[i].load(memory_order_relaxed)

    通过后,j == numTries 表示作者已完成。

这里writer发出的signal是刚写完j,会继续j+1,除非j == numTries。但这不能保证tat[]的书写顺序。

如果您想要编写器在每次通过后停止,并等待 reader 看到它并发出相同的信号——那么您需要另一个信号并且您需要线程等待它们各自的 "you may proceed" 信号。