有了memory_order_relaxed,原子变量的总修改顺序如何在典型体系结构上得到保证

With memory_order_relaxed how is total order of modification of an atomic variable assured on typical architectures?

本文关键字:典型 结构上 修改 relaxed order memory 有了 变量 顺序      更新时间:2023-10-16

据我所知,memory_order_relaxed是为了避免在特定体系结构上进行更严格的排序时可能需要的昂贵的内存围栏。在这种情况下,原子变量的总修改顺序是如何在流行的处理器上实现的?

编辑:

atomic<int> a;
void thread_proc()
{
int b = a.load(memory_order_relaxed);
int c = a.load(memory_order_relaxed);
printf(“first value %d, second value %dn, b, c);
}
int main()
{
thread t1(thread_proc);
thread t2(thread_proc);
a.store(1, memory_order_relaxed);
a.store(2, memory_order_relaxed);
t1.join();
t2.join();
}

什么可以保证输出不会是:

first value 1, second value 2
first value 2, second value 1

多处理器通常使用MESI协议来确保某个位置的总存储订单。信息以缓存行粒度传输。该协议确保在处理器修改缓存行的内容之前,所有其他处理器都放弃其缓存行的副本,并且必须重新加载修改行的副本。因此,在处理器将x和y写入同一位置的示例中,如果任何处理器看到x的写入,则它必须从修改后的行重新加载,并且必须在写入器写入y之前再次放弃该行。

通常有一组特定的汇编指令对应于std::atomics上的操作,例如x86上的原子加法是lock xadd

通过指定memory order relaxed,你可以从概念上认为它告诉编译器"你必须使用这种技术来增加值,但我没有在标准之外施加其他限制,就好像优化规则是最重要的一样"。因此,在宽松的排序约束下,仅用lock xadd替换add可能就足够了。

还要记住,"memory_order_relaxed"指定了编译器必须遵守的最低标准。一些平台上的一些内部会有隐含的硬件障碍,这不会因为过于约束而违反约束。

所有原子操作都符合[intro.races]/14:

如果修改原子对象M的操作A发生在修改M的操作B之前,则在M的修改顺序中,A应早于B。

主线程中的两个存储需要按该顺序进行,因为这两个操作是在同一个线程中排序的。因此,它们不能在该秩序之外发生。如果有人在原子中看到值2,那么第一个线程必须已经执行过值设置为1的点,根据[interro.rises/4:

对特定原子对象M的所有修改都以某种特定的总顺序发生,称为M的修改顺序。

这当然只适用于特定原子对象上的原子操作;当使用relaxed排序时,不存在相对于其他事物的排序(这就是重点)。

这是如何在真正的机器上实现的?无论编译器认为合适的方式是什么。编译器可以决定,因为你覆盖了刚刚设置的变量的值,所以它可以根据假设规则删除第一个存储。根据C++内存模型,从来没有人看到值1是完全合法的实现。

但除此之外,编译器需要发出使其工作所需的任何内容。请注意,乱序处理器通常不允许乱序完成相关操作,所以这通常不是问题。

线程间通信有两部分:

  • 一个可以进行加载和存储的核心
  • 由连贯缓存组成的存储系统

问题在于CPU核心中的推测性执行

处理器加载和存储单元总是需要比较地址,以避免对同一位置的两次写入进行重新排序(如果它对写入进行了重新排序)或预取刚刚写入的过时值(当读取提前完成时,在前一次写入之前)。

如果没有该功能,任何可执行代码序列都有可能导致其内存访问完全随机化,看到以下指令写入的值等。所有内存位置都将以疯狂的方式"重命名">,程序无法连续两次引用同一(最初命名的)位置

所有程序都会中断。

另一方面,潜在运行代码中的内存位置可以有两个"名称":

  • L1d中可以保存可修改值的位置
  • L1i中可解码为可执行代码的位置

在执行特殊的重新加载代码指令之前,这些指令不会以任何方式连接,不仅L1i,而且指令解码器都可以在缓存中具有可修改的位置。

[另一个复杂情况是,当两个虚拟地址(由推测加载或存储使用)引用相同的物理地址(别名)时:这是另一个需要处理的冲突。]

摘要:在大多数情况下,CPU自然会提供对每个数据内存位置的访问顺序。

编辑:

而核心需要跟踪使推测执行无效的操作,主要是对推测指令稍后读取的位置的写入。读取彼此不冲突,CPU核心可能希望在推测读取后跟踪缓存内存的修改(使读取提前明显发生),并且如果读取可以无序执行,可以想象,稍后的读取可能在较早的读取之前完成;关于为什么系统会先开始稍后的读取,一个可能的原因是地址计算是否更容易且先完成。

因此,一个系统可以无序地开始读取,一旦缓存提供了一个值,就会认为它们已经完成,并且只要同一内核的写入没有与读取冲突,就有效,并且不会监控另一个CPU想要修改关闭的内存位置(可能是一个位置)导致的L1i缓存无效,这样的顺序是可能的:

  • 将即将执行的指令分解为序列A,序列A是以r1结尾的有序操作列表,序列B是以r2结尾的较短序列
  • 两者并行运行,B更早产生结果
  • 推测性地尝试load (r2),注意写入该地址可能会使推测无效(假设该位置在L1i中可用)
  • 那么另一个CPU窃取了(r2)的缓存线保存位置,这让我们很恼火
  • A完成了使r1值可用,并且我们可以推测地执行load (r1)(恰好与(r2)是相同的地址);它会暂停,直到我们的缓存返回其缓存行
  • 最后完成的负载的值可能与第一个不同

A和B的推测都不会使任何内存位置无效,因为系统不认为缓存线的丢失或上次加载时返回的不同值是推测的无效(这很容易实现,因为我们在本地拥有所有信息)。

在这里,系统将任何读取视为与任何非本地写入的本地操作不冲突,并且加载的顺序取决于a和B的复杂性,而不是程序顺序中最先出现的顺序(上面的描述甚至没有说程序顺序发生了变化,只是猜测忽略了它:我从来没有描述过哪个加载是程序中的第一个)。

因此对于一个松弛的原子负载,在这样的系统上需要一个特殊的指令

缓存系统

当然,缓存系统不会改变请求的顺序,因为它的工作方式就像一个由核心临时拥有所有权的全局随机访问系统。