内存屏障Acquire/Release/Fence

Rust的atomic类型操作包含参数Ordering,包括Acquire、Release等,这些语义参考的是C++20规范。回想到Riscv指令集中的原子指令"A"扩展集,以及Linux kernel中也都有类似的语义。虽然它们存在于不同的层次,但是都涉及内存屏障的使用,并且在语义定义方面有相通性。

内存屏障

各个层面需要内存屏障的原因有两个:

  1. 指令重排序问题:出于性能的考虑,编译器会对产生的指令进行重新排序;CPU的指令部件同样会为提升性能,改变指令的发射顺序。重排序有可能打破编程时精心设计的操作顺序而引起混乱,这通常发生在同步方面。
  2. 多CPU(SMP)的缓存一致性:现代计算机基本都是多级缓存,通常一级缓存是每个CPU自己的本地缓存,从二级缓存开始才变为共享。CPU在本地缓存的操作并非实时同步到其它CPU上,这就影响了SMP架构下系统同步原语的实现。

引入内存屏障就是用来解决上述问题。一方面对指令重排序施加限制;另一方面在必要时同步CPU缓存。

首先来看Rust/C++语言中,atomic类型操作涉及的Acquire/Release语义。

Acquire语义

C++20规范给出的定义:(std::memory_order - cppreference.com)

memory_order_acquire - A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread (see Release-Acquire ordering below).

包括三句话:

  • 第一句,可以看出Acquire通常与load有关,正式名称应当是load-acquire
  • 第二句,我认为应该补充一下黑体字部分更确切,no reads or writes (after the acquire operation) in the current thread can be reordered before this load. 意思是位于这个load-acquire后面的指令不能被重排序放到load-acquire的前面。load-acquire的屏障作用:阻止后面的指令跨过它被排到前面,但不管前面的指令。这是解决重排序的问题。
  • 第三句,对于该原子变量,其它线程的写操作store-release必然能被本线程的load-acquire看到。现代操作系统中,线程会尽量被分散到各个CPU上并发运行。所以这个是解决SMP缓存一致性的问题。

依然很抽象,画一个直观图以帮助理解和记忆:

内存屏障load-acquire就好像是在它之后设置了一个"坑",阻止了它后面的指令"爬"到它前面去,但是对它前面的指令没有限制,它们可以"跳"下去。

Release语义

C++20规范给出的定义:

memory_order_release - A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable (see Release-Acquire ordering below) and writes that carry a dependency into the atomic variable become visible in other threads that consume the same atomic (see Release-Consume ordering below).

它的确切名称应该是store-release,与前面load-acquire对应,不再赘述,直接给出直观图:

与load-acquire相反,大"坑"挖在store-release的前面,前序指令"爬"不上去,到不了release内存屏障的后面,而后序指令不受影响,它们可以往前"跳"下去。

综合来看,无论是load-acquire还是store-release,都是单向屏障,阻止某一个方向的读写指令在重排序时跨过它们,而对另一个方向不限制。

下面看一个示例,来进一步理解它们的作用。

基于Acquire/Release的同步示例

示例同样来自于链接std::memory_order - cppreference.com,稍微改动一下。

c++ 复制代码
#include <atomic>
#include <cassert>
#include <string>
#include <thread>
 
std::atomic<int> flag = {0};
int data;
 
void producer()
{
    data = 42;
    flag.store(1, std::memory_order_release);
}
 
void consumer()
{
    while (flag.load(std::memory_order_acquire) < 1)
        ;
    assert(data == 42); // never fires
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

对于producer,设置data之后,执行store-release操作设置flag为1;而consumer通过load-acquire等待flag变1后,确定能够得到由producer发布的data值。

这个程序的意图通过内存屏障贯彻到编译器进而CPU,确保指令顺序满足同步的要求。

  1. 第12行,原子变量flag的store参数是release,根据上面Release的语义,它确保前序指令也就是data = 42;不会越过它,必然在它之前发生。
  2. 第12行和第17行操作同一个原子变量flag,producer线程的store-rlease确保了consumer线程的load-acquire能够看到对flag的更新。
  3. 第17行,原子变量flag的load参数是acquire,根据上面Acquire的语义,它确保后续指令也就是对data数值的访问不会越过它跑到前面去,也就是说,只有load相关指令满足条件,才会去执行读data的指令。

这三者形成一个保障链,保证了对data变量同步的意图。

Acquire/Release组合建立临界区

如果把前面给出的Acquire/Release的示意图组合起来:

可以看出在load-acquire和store-release构成一个"坑",在"坑里"的指令被限制在两个原子操作之间。如果再结合上面同步示例中设置和等待flag原子变量的方式,我们就可以构建出临界区,进而以此为基础构建出各种锁。

其实这个也是Acquire/Release这一对奇怪名字的由来,Acquire代表获得锁,进入临界区;Release代表释放锁,脱离临界区。

AcqRel和Relaxed语义

AcqRel是acquire/release的组合,如下图:

这个真正像一个屏障,它把前后指令流一分为二,前面的跳不到后面,后面的爬不到前面。

至于Relexed 语义,就是对内存访问顺序无约束,仅仅是原子指令操作本身,没有内存屏障的作用。

Fence语义

对C++/Rust而言,fence的参数还是Acquire/Release之类,且它在编程语言方面并不常用。但在Riscv指令集和Linux kernel中,fence是常用的操作,形式可以描述为:

python 复制代码
fence r, rw
fence rw, w
... ...

相对于原子操作附加的Acquire/Release,Fence就是单纯的内存屏障,逗号前后分别是前序集合,后续集合,集合中r表示读内存,w代表写内存。意义是,以fence为界,在fence前后的指定类型的操作必须保持先后顺序。例如上面第1行,约束:fence后面的任何指令(读/写),不能跑到fence前面的读指令之前,但对写无限制,同时也可以说成,fence前面的读指令不能跑到fence后面的任意指令之后。

因此,相对于Acquire/Release,Fence是双向语义。

Acquire/Release与Fence

The RISC-V Instruction Set Manual Volume I: User-Level ISA Document Version 2.2中第7.3节提到:

The AMOs were designed to implement the C11 and C++11 memory models efficiently. Although the FENCE R,RW instruction suffices to implement the acquire operation and FENCE RW, W suffices to implement release, both imply additional unnecessary ordering as compared to AMOs with the corresponding aq or rl bit set.

Acquire可以被Fence R, RW加上Load实现,伪码表示

Load XXX
Fence R, RW

可以这样理解:fence r, rw前面紧挨着load,而load是个读指令,所有后续的指令都不可能越到load的前面去,这与上面的load-acquire的语义是对应的。

相应的,Release可以被Fence RW, W加上Store实现,伪码表示

Fence RW, W
Store XXX

这样理解:fence r, rw后面紧跟着store,而store就是个写指令,所有前序的指令都不可能越过store到它的后面去,同样起到了store-release内存屏障的作用。

相关推荐
SomeB1oody1 天前
【Rust自学】4.1. 所有权:栈内存 vs. 堆内存
开发语言·后端·rust
SomeB1oody2 天前
【Rust自学】4.2. 所有权规则、内存与分配
开发语言·后端·rust
SomeB1oody2 天前
【Rust自学】4.5. 切片(Slice)
开发语言·后端·rust
编码浪子2 天前
构建一个rust生产应用读书笔记6-拒绝无效订阅者02
开发语言·后端·rust
baiyu332 天前
1小时放弃Rust(1): Hello-World
rust
baiyu332 天前
1小时放弃Rust(2): 两数之和
rust
Source.Liu2 天前
数据特性库 前言
rust·cad·num-traits
编码浪子2 天前
构建一个rust生产应用读书笔记7-确认邮件1
数据库·rust·php
SomeB1oody2 天前
【Rust自学】3.6. 控制流:循环
开发语言·后端·rust
Andrew_Ryan2 天前
深入了解 Rust 核心开发团队:这些人如何塑造了世界上最安全的编程语言
rust