并发编程系列(二)—— store, load 与 RMW

背景知识

store buffer 与 L1/L2/L3 cache

注意,store buffer 不是缓存,它属于 CPU 而不是缓存系统。

不同 CPU 都有 store buffer 吗?

现代高性能的超标量处理器几乎都配备了 Store Buffer,可能叫法不同,但都有类似的"东西"和机制。

什么是"写完成"?

store操作完成的标志是什么?如果只是写到 store buffer,那么在架构层面 上(Architecturally)对全局依然是"未发生 "的,因为store完成的标志是"写入缓存,让别人看见"。

Store-Load 重排

先看一个例子:

plain 复制代码
初始值:
x = 0, y = 0
thread 1:               thread2:
x.store(1)    -- A       y.store(1)    -- C
a = y.load()  -- B       b = x.load()  -- D

时序:

plain 复制代码
1. thread 1 对 x 执行写操作,但只是把 1 写入 store buffer
2. 同时,thread 2 对 y 执行写操作,但也只是把 1 写入 store buffer
3. thread 1 在没有把 store buffer 刷入 L1 cache 的前提下,就率先读了 y 的值
  (Store-Load 重排),而此时,thread 2 对 y 的写也还停留在 store buffer,
   对 thread 1 不可见,于是,thread 1 读到的 y 值是 0。
4. 同理,thread 2 读到的 x 值也是 0。
5. thread 1 和 thread 2 把各自的store buffer 刷到了 L1 cache,但为时已晚,因为 load
   先一步执行了,a == 0 && b == 0 的事实已经铸就。

基于代码顺序,有 A --> B 并且 C --> D。

从 thread 1 的视角看,既然它读到了y的旧值,那么有 B--> C。

从 thread 2 的视角看,既然它读到了x的旧值,那么有 D --> A。

从 thread 1 的视角看,总体顺序是 A --> B --> C --> D

从 thread 2 的视角看,总体顺序是 C --> D --> A--> B

即,不同线程看到的全局顺序不一样,并且,两者结合起来,形成了 A--> B --> C -->D --> A 这样的环。

RMW (atomic read-modify-write operation) 和 load 的区别

  1. 从"自我"的视角看,RMW / load 都是从 cache 或 main memory 中读取,至少在读到的那一刻,它们看到的都是最新的值。
  2. 如果在读的一瞬间,其它线程修改了同一个原子变量,但只写到了 store buffer,那么,从架构层面上看,这个"写"并没有完成,所以不算"最新值"。因此,这种情况不影响第1条中"两者都读到最新值"的结论。
  3. 两者的区别在于"对他人的影响"上。
    1. RMW 会先通过 RFO (Read For Ownership) 请求要求其它核心交出最新值并作废自己 cache 中的副本。
    2. 在"读"动作完成后,RMW 会锁住对应的 cache line 直到整个 RMW 操作完成,这期间,别的线程既不能读,更不能写这条 cache line。
    3. 普通 load 只是自己读,不干涉别人,它读的时候别人可能也在读,在它刚读完的一瞬间,别人就可能修改了这个值。

RMW 和 store 的区别

store可能会将数据放入 store buffer,导致延迟可见,但 RMW 一定不会。

  • 当 CPU store某个变量时,如果该变量所在的 Cache Line 不在 L1 Cache 中,或者状态不是 Exclusive / Modified(味着其他核心也缓存了该数据,需要通过总线发送 Invalidate 消息让其他核心作废缓存),CPU 必须向总线发送信号获取该缓存行的所有权。等待缓存行返回需要几百个时钟周期,为了提效,CPU 会把要写入的值暂存进 Store Buffer,然后继续执行下一条指令。此时,这个写操作对其他 CPU 是不可见的,直到 Store Buffer 最终将数据刷入 L1 缓存。这就是普通 store 导致延迟可见的物理根源。
  • RMW 在读取阶段就已经独占了 cache line,写入的数据会直接放入 cache line。RMW 执行完成,释放 cache line 锁的那一刻,数据就全局可见了。

RMW 会产生全内存屏障 (full memory barrier) 吗?

x86 会,ARM 不会。

x86 下,RMW 会被翻译成lock前缀或者隐式lock前缀(就是不显式携带lock但有lock的功能)的指令,例如,x86-64下,fetch_add被翻译成lock xadd指令。无论是显式 lock(比如 lock xadd)还是隐式 lock,都会生成全内存屏障 (Full Memory Barrier),即,在当前 CPU core上:

  • 在 lock 指令执行之前的所有读写操作,必须在 lock 指令执行完毕前完成;
  • 会强制将当前核心的 store buffer 刷入 L1 Cache,确保 lock 之前的读写操作,对其它核心都可见。
  • 在 lock 指令之后的所有读写操作,必须等 lock 指令完成后才能开始。

ARM 采用弱内存模型,硬件有更大的自由度。比如,data.fetch_add(1, std::memory_order_release)会被翻译成ldxrstlxr的组合(详见下文),只具备 release 语义,不具备 acquire 语义。即只能保证 fetch_add前面的指令不会被重排到fetch_add后面,但不能保证fetch_add后面的指令一定不会跑到fetch_add的前面。

full memory barrier 的理解

双向的。即

plain 复制代码
=========== 前向屏障:前面的指令不能越过 ===========
会产生 full memory barrier 的指令,比如 lock xadd
=========== 后向屏障:后面的指令不能越过 ===========

即:
1. lock xadd 前的指令,不能排到 lock xadd 后面,换言之,在 lock xadd 被 CPU 执行之前,它前面
   的指令,都必须被CPU执行完,且对全局可见。
2. lock xadd 后的指令,不能排到 lock xadd 前面,换言之,在 lock xadd 被 CPU 执行之前,它后面
   的指令,都不能先于它被 CPU 执行。

store/load/ RMW 对应的汇编指令

原子变量以std::atomic<long long> data{0}为例,RMW 操作以fetch_add为例,编译器为 GCC 15.2,通过 Compiler Explorer 来观察生成的汇编代码。

指令 内存序 X86_64 ARM64 ARM64 (-mno-outline-atomics)[1]
store[2] relaxed movq str str
release stlr stlr
seq_cst xchgq
load[3] relaxed movq ldr ldr
acquire ldar ldar
seq_cst
fetch_add relaxed lock xadd __aarch64_ldadd8_relax ldxr & stxr
release __aarch64_ldadd8_rel ldxr & stlxr
acquire __aarch64_ldadd8_acq ldaxr & stxr
acq_rel __aarch64_ldadd8_acq_rel ldaxr & stlxr
seq_cst

备注:

  1. ARM 架构下默认编译成内置函数,加上-mno-outline-atomics参数后,编译成汇编指令。
  2. store只支持这3中内存序,若使用其它内存序,则是未定义行为。
  3. load只支持这3中内存序,若使用其它内存序,则是未定义行为。

x86_64 体系下的store/load/ fetch_add

store(relaxed)store(release)

为什么两者都翻译成movq

mov 指令天然的原子性

x86_64 采用 TSO (Total Store Order) 这种强内存模型,对自然对齐的(32位、64位)内存进行读写,mov 指令(movlmovq等)在硬件层面就保证了原子性。

架构天然的 release 语义

什么是 release 语义?

当前线程内,store操作前的任何读写,包括非原子变量的读写,都不能被重排到该store操作的后面。

为什么天然?

背景:先要搞懂什么是 CPU 的乱序执行(指令重排)。汇编代码中,是指令 A 在前,指令 B 在后,而 CPU 在实际执行是,可能先执行指令 B,后执行指令A,这就是所谓的 CPU 乱序执行。

如前所述,x86_64 采用 TSO 强内存模型,在硬件层面就禁止了下面这两种重排:

  • 禁止 Load-Store 重排:如果汇编代码是 读操作 A --> 写操作 B,那么,CPU在执行时,绝不可能先执行 B 后执行 A。
  • 禁止 Store-Store 重排:如果汇编代码是 写操作 A --> 写操作 B,那么,CPU在执行时,绝不可能先执行 B 后执行 A。

综上,写操作前面的读写操作,都不能跑到该写操作的后面,天然满足 release 语义。

特别注意:release 语义是由 x86 体系结构(硬件)保证的,不是由 mov 指令保证的!mov 指令只能保证原子性,但该指令本身不禁止任何重排。

两者等价吗?

不等价

如上,两者翻译成相同的汇编指令,只能说是在 CPU 层面 (硬件层面)等价。但在编译器层面(软件层面)不会等价。

从源代码到程序执行(进程/线程),可以简化成下面的模型(只为说明问题,所以只列出关键节点,不要扣细节上的对错):

源代码 --- 编译器进行汇编 ---> 汇编指令 --- CPU 执行 ---> 程序跑起来(进程 / 线程)

前面说过,CPU执行指令时,可能不会严格按照开发者看到的汇编代码的顺序执行。类似的,从源代码到汇编指令这一步,编译器也可能会重排。比如:

cpp 复制代码
int data = 0;
std::atomic<int> flag{0};

// 情况 A:使用 release
void producer_release() {
    data = 42;                                // (1) 普通写
    flag.store(1, std::memory_order_release); // (2) 原子写
}

// 情况 B:使用 relaxed
void producer_relaxed() {
    data = 42;                                // (3) 普通写
    flag.store(1, std::memory_order_relaxed); // (4) 原子写
}

对于 producer_release():

编译器知道 (2) 是 release 语义,它必须 保证data = 42flag = 1之前发生。生成的 x86 汇编大概是这样:

plain 复制代码
mov DWORD PTR data[rip], 42  ; 先写 data
mov DWORD PTR flag[rip], 1   ; 后写 flag (release)

对于 producer_relaxed():

因为是 relaxed,编译器发现dataflag是没有数据依赖的两个变量,为了优化寄存器分配或者流水线效率,编译器完全有可能把代码重排成这样

plain 复制代码
mov DWORD PTR flag[rip], 1   ; 编译器擅自把写 flag 提前了!(relaxed 允许这么做)
mov DWORD PTR data[rip], 42  ; 后写 data

所以,对开发者而言,如果想要 release 语义,就老老实实指定std::memory_order_release

load(relaxed)load(acquire)

store的情况类似,两者翻译成movq的原因是 TSO 内存模型也禁止 Load-Load 重排。因此:

plain 复制代码
TSO 内存模型禁止 Load-Load 和 Load-Store 重排 -->
    读指令后面的任何读写指令,都不可能排到该读指令前面 -->
        天然满足 acquire 语义

同样地,两者只是在 CPU 层面等价,但因为编译器重排的存在,两者整体上不等价。

store(seq_cst)load(seq_cst)

为什么两者翻译成的汇编代码不一样?

先说语义:

  • seq_cst作用在store上,具有 release 语义。(store本身就不可能有 acquire语义)
  • seq_cst作用在load上,具有 acquire 语义。
  • 除此之外,seq_cst还保证:程序中所有标记为 **seq_cst**的原子操作都有一个全局唯一的执行顺序,并且所有线程看到的这个顺序都是相同的,即所谓的全局单一全序 (Single Total Order)。注意,这个约束只针对所有使用了seq_cst内存序的原子操作,而不是任意的原子操作。

前两点,x86 上已经在硬件层面天然保证了,关键看第三点。

plain 复制代码
A:  data_x.store(1, seq_cat)
  --> 假设翻译成 movq %edi, data_x(%rip)
    --> x86体系结构保证了,这个 movq 前面的读写指令都必须先于它被CPU执行(禁止 Load-Store / Store-Store重排)
        x86体系结构保证了,这个 movq 后面的写指令绝不能先于它被CPU执行(禁止 Store-Store 重排)
B:  data_y.load(seq_cst)
  --> 假设翻译成 movq data_y(%rip), %rax
    --> x86体系结构保证了,这个 movq 后面的读写指令绝不能先于它被CPU执行(禁止 Load-Load/ Load-Store重排)
        x86体系结构保证了,这个 movq 前面的读指令必须先于它被CPU执行(禁止 Load-Load 重排)

可见,光凭架构本身的约束,是不能禁止 B 跑到 A 前面(即 B 先于 A 被 CPU 执行 )的,因为 x86 本身不禁止 Store-Load 重排,这样,就违背第三点约束了。(Store-Load 重排的本质原因是 store buffer 的存在,详见前文对 Store-Load 重排的介绍)。

要保证第3点,除了强制 CPU 刷新 store buffer,避免 Store-Load 重排之外,还需要保证"不同原子操作的结果,被所有线程以相同的顺序看到",比如原子操作 A 和 B,所有线程要么都先看到 A 的结果,后看到 B 的结果,要么反过来。不能有的线程先看到 A 的结果,有的线程先看到 B 的结果。比如典型的非多副本原子性 (Non-Multi-Copy Atomicity) 问题
非多副本原子性 (Non-Multi-Copy Atomicity) 问题

(即使全是读操作且不重排,不同观察者看到的世界也会不同)

这其实就是 IRIW(Independent Reads, Independent Writes)现象的根源。我们明确规定没有任何线程发生指令重排

初始:x = 0, y = 0

线程 1 (Core 1) 线程 2 (Core 2) 线程 3 (Core 3, 观察者) 线程 4 (Core 4, 观察者)
x = 1; y = 1; while(x == 0); while(y == 0);
r1 = y; r2 = x;

假设硬件拓扑结构是:Core 1 和 Core 3 物理距离近(比如共享 L2 缓存),Core 2 和 Core 4 物理距离近。

严格按序执行的过程:

  1. 线程 1 写入 x=1。这个消息很快传到了离它近的 Core 3,但还需要一段时间才能传到 Core 4。
  2. 线程 2 写入 y=1。这个消息很快传到了离它近的 Core 4,但还需要一段时间才能传到 Core 3。
  3. Core 3(线程 3)严格按序执行 :它先看到了 x=1,跳出循环执行 ②。此时 y=1 的消息还没传到 Core 3,所以它读到 r1 = y = 0。
  4. Core 4(线程 4)严格按序执行 :它先看到了 y=1,跳出循环执行 ④。此时 x=1 的消息还没传到 Core 4,所以它读到 r2 = x = 0。

结果: 同样出现了 r1 == 0 且 r2 == 0。

分析:
注意!线程 3 绝对没有把 ① 和 ② 重排,线程 4 也绝对没有重排 ③ 和 ④。
它们看到的分歧,纯粹是因为在多核系统中,数据在总线/缓存网络中的传播速度不一样 (这在 ARM 和 PowerPC 等架构中是真实存在的)。

  • 线程 3 认为:x=1 发生在了 y=1 之前。
  • 线程 4 认为:y=1 发生在了 x=1 之前。
    它们又一次对"全局顺序"产生了分歧。

解决:

想要解决这个问题,系统必须保证,一个写操作要么对所有核心都不可见,一旦对某个核心可见,就必须瞬间对所有核心可见。不能出现"离得近的先看到,离得远的后看到"的情况。具体的,就是需要强迫缓存一致性协议(比如 MESI 等)进行昂贵的同步。

要解决上面的问题,就需要加 full memory barrier。一个显而易见的方法是在 A 和 B 上都加一个 full memory barrier,但这是冗余的,影响效率,因为只在某一个上面加就行了(详见前面对 full memory barrier 的解读)。

那么,加在谁上面呢?经过统计,多数程序中,都是读操作多于写操作,因此,加到写操作上,对效率的影响更小。所以,store(seq_cst)被翻译成了带隐式lock的 exchange 指令(xchglxchgq等),而load(seq_cst)依然被翻译成 mov 指令。

fetch_add

在 x86 上,fetch_add被统一翻译成lock xadd,即采用了最严格的内存序,加了 full memory barrier,为的就是保证"读-改-写"这一套流程的整体原子性,以及"结果对所有线程立即可见"(详见背景知识部分对 RMW 的介绍)。

所以,如果光从 CPU 的角度看,采用哪种内存序都是一样的。但是,如前所述,在编译器层面,不同的内存序依然是不一样的。

ARM64 体系下的store/load/ fetch_add

storeload

store(relaxed)load(relaxed)

两者不要求任何同步或者顺序约束,只要求原子性,因此翻译成普通的str((St ore R egister))和ldr((L oad Register))指令即可。

store(release)/store(seq_cst)/load(acquire)/load(seq_cst)

为什么store(release)/store(seq_cst)都翻译成stlr(St ore-Rel ease R egister),load(acquire)/load(seq_cst)都翻译成ldar(L oad -A cquire Register)呢?

显然,两者分别满足 Release 语义和 Acquire 语义,所以用于store(release)load(acquire)是没问题的,那为什么可以用于store(seq_cst)load(seq_cst)呢?seq_cst要求的"全局单一全序 (Single Total Order)"如何来保证呢?

原因在于,ARMv8 的硬件内存模型设计得非常强, **stlr** **ldar**指令的组合,不仅能满足 Acquire-Release 语义,在硬件层面本身就直接提供了 Sequential Consistency (SeqCst) 的保证

  • 硬件层面保证了:如果代码中先执行了一个stlr指令,随后又执行了一个ldar指令,CPU 硬件绝对不会把它们重排(No Store-Load Reordering)。详见官网文档
  • 另外,ARMv8 内存模型具备多副本原子性 (Multi-copy atomicity) ,即当一个stlr写入的值被某一个观察者(线程)看到时,它必然同时被所有观察者看到。详见官网文档

综合以上两点,ARM 硬件在执行全是stlr和ldar的代码时,天然就形成了一个所有线程一致认同的 Single Total Order。换言之,此处 ARM 硬件提供了比C++ 标准更强的保证。

xchgq: exch ange,q 表示 Quadword,即64位。

该命令自带隐式 lock 语义。当 xchg 的操作数之一是内存地址时,硬件会自动为它加上 lock 语义。注意,如果 xchg 只在两个寄存器之间进行,则没有 lock 语义,只是普通的寄存器操作。

fetch_add

原子性的实现

data.fetch_add(1, std::memory_order_relaxed)为例,实际生成的汇编指令是下面这样:

plain 复制代码
.L6:
    ldxr    x2, [x1]
    add     x3, x2, 1
    stxr    w4, x3, [x1]
    cbnz    w4, .L6

ARM 架构没有类似 x86 的lock xadd单条原子指令,而是使用 独占加载/存储(Load-Exclusive / Store-Exclusive,即 LL/SC 机制) 配合循环来实现"读-改-写"的原子操作。

  • ldxr x2, [x1]: L oad Ex clusive R egister。从x1指向的地址(即data)加载 64 位数据到x2。Exclusive 表示标记该内存地址的"独占监视器",为后续的写操作做准备。
  • add x3, x2, 1: 计算x2 + 1,结果存入x3(即准备写入的新值)。
  • stxr w4, x3, [x1]: St ore Ex clusive R egister。尝试x3(新值)写入x1指向的地址。写入的结果(成功或失败)会存入w4(32位寄存器):成功写入为 0,失败为 1。如果在此期间其他线程修改了该地址,独占监视器会被清除,导致写入失败。
  • cbnz w4, .L6: C ompare and B ranch if N ot Z ero。如果w4不为 0(说明写入失败,发生了并发竞争),则跳回.L6重新执行"加载-修改-写入"的循环。

内存序的实现

不难看出,内存序的实现,是通过替换 load / store 指令来试下的。例如,要实现 release 语义,就将stxr指令换成stlxr(St ore Rel ease Ex clusive R egister);要实现 acquire 语义,就将ldxr指令换成ldxar(L oad A cquire Ex clusive R egister);要实现seq_cst语义,就同时替换两者。

与 x86 下fetch_add的区别

x86 下的fetch_add,所有内存序都与seq_cst等价,是最严格的,会产生 full memory barrier。

而 ARM 下,实现的是对应的内存序,不存在"越级"。

相关推荐
山甫aa2 小时前
多叉树定义与遍历-----从零开始的数据结构
开发语言·c++·二叉树·多叉树
永远睡不够的入2 小时前
C++11新特性(2):深入 C++ 参数传递黑盒:从引用折叠到完美转发,再到可变参数模板
开发语言·c++
无限进步_2 小时前
【C++】寻找数组中出现次数超过一半的数字:三种解法深度剖析
开发语言·c++·git·算法·leetcode·github·visual studio
咸鱼翻身小阿橙2 小时前
C++ 与 QML 交互入门笔记
c++·笔记·交互
南境十里·墨染春水2 小时前
C++ 笔记 ——STL deque
开发语言·c++·笔记
j_xxx404_2 小时前
我用 Codex 做了一个智能围棋机器人系统:从 AI 引擎接入到前后端联调的完整实战
c++·人工智能·python·机器人·软件工程·团队开发·react
minji...2 小时前
Linux 网络套接字编程(五)TCP 回声服务器的实现(单进程(单线程)/多进程/多线程/线程池四个版本)
linux·服务器·开发语言·网络·c++·tcp/ip·算法
Hello!!!!!!2 小时前
C++基础(十二)——标准库算法
c++·算法
故事还在继续吗2 小时前
C++内存模型
开发语言·c++·内存