0

我在en.cppreference.com 规范中阅读了关于原子的宽松操作:

“[...]只保证原子性和修改顺序 的一致性。”

所以,我在问自己,当您处理相同或不同的原子变量时,这种“修改顺序”是否有效。

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

在此处输入图像描述

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

由于该线程正在更新,因此可以将子原子存储在其父级之前。问题是在我填写子信息之前它存储了一个父项(填充要更新的子项)。

换句话说,正如标题所说,宽松的商店在发布之前是否在它们之间重新排序?我不介意对非原子变量进行重新排序。伪代码,假设 [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();
}

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

另一个线程加载控制原子的获取顺序,然后以放松的原子加载数组的其余值。由于父母不能在孩子之前更新,所以 newValue 应该总是等于或大于 oldValue。

我已经在我的电脑上多次执行了这个程序,调试和发布,它并没有触发失败。我使用的是普通的 x64 Intel i7 处理器。

那么,假设对多个原子的宽松存储至少在与控制原子同步并获取/释放时确实保持“修改顺序”是安全的吗?

4

2 回答 2

3

遗憾的是,您将通过 x86_64 的实验了解标准支持的内容很少,因为 x86_64 的表现非常好。特别是,除非您指定_seq_cst

  • 所有读取都有效地_acquire

  • 所有写入实际上都是_release

除非它们跨越缓存线边界。和:

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

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

您提到使用_relaxed fetch_or... 如果我理解正确,您可能会失望地得知它并不比seq_cst 便宜,并且需要一个LOCK前缀指令,承担全部开销。


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

而且,是的,在线程 X 中的释放操作与线程 Y 中的获取操作同步的情况下,线程 X 中的所有写入都是在释放之前发生的 - 在线程 Y 中的获取之前。所以释放操作是一个信号在 X 中它之前的所有写入都是“完成的”,并且当获取操作看到信号 Y 知道它已经同步并且可以读取 X 写入的内容(直到发布)。

现在,这里要理解的关键是仅仅执行存储_释放是不够的,存储的必须是加载_acquire存储发生的明确信号。否则,负载如何判断?

通常,像这样的_release / _acquire对用于同步对某些数据集合的访问。一旦该数据“准备就绪”,store _release 就会发出信号。任何看到信号的加载_acquire(或看到信号的所有加载_acquire)都知道数据“准备好”并且可以读取它。当然,在 store _release之后对数据的任何写入都可能(取决于时间)被 load(s) _acquire 看到。我在这里想说的是,如果要对数据进行进一步更改,可能需要另一个信号。

你的小测试程序:

  1. 初始化tsync为 0

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

    所以值tsync不会改变!

  3. 在读者:做tsync.load(memory_order_acquire)之前做tat[i].load(memory_order_relaxed)

    并忽略从中读取的值tsync

我在这里告诉你_release / _acquire没有同步——所有这些存储/加载也可能是_relaxed。[我认为如果作者设法领先于读者,您的测试将“通过”。因为在 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. 在读者:做j = tsync.load(memory_order_acquire)

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

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

writer 发送的信号是它刚刚完成写入j,并将继续j+1,除非j == numTries. 但这并不能tat[]保证写入的顺序。

如果你想要的是让作者在每次通过后停止,并等待读者看到它并发出相同的信号——那么你需要另一个信号,你需要线程等待它们各自的“你可以继续”信号。

于 2020-05-01T12:50:45.650 回答
0

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

每当您存储其他线程可以加载和取消引用的指针时,至少mo_release在最近修改了任何指向的数据时使用,如果读者也需要看到这些更新。(这包括任何间接可达的东西,比如你的树的层次。)

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


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

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

于 2020-05-01T10:27:53.703 回答