您现在的位置是:首页 > 高性能编程高性能编程
Memory Barrier Mechanism
比目鱼2023-07-22【高性能编程】人已围观
简介本文转载,原创地址http://www.biscuitos.cn/blog/MEMORY-BARRIOR-SUBSCR/内存屏障预备知识内存屏障: 在多核并行处理过程中,让其他 CPU 看到对共享资源的访问顺序. 内存屏障之
本文转载
原创地址:http://www.biscuitos.cn/blog/MEMORY-BARRIOR-SUBSCR/
目录
Memory Barrier 使用
Memory Barrier 进阶研究
内存屏障预备知识
内存屏障: 在多核并行处理过程中,让其他 CPU 看到对共享资源的访问顺序. 内存屏障之所以难学难懂,因为其涉及很多硬件和软件的内容,其边界很难确认。所谓边界就是最基础的东西,万物都是有边界的,内存屏障也不例外,只要了解内存屏障的边界,那么本节用于介绍学习内存屏障需要了解哪些基础知识:
编译乱序: 编译器对程序指令的重排原理
指令乱序: CPU 重排指令执行顺序原理
CACHE Coherent: CACHE 一致性原理
Store Buffer: 写缓存硬件原理
内存一致性模型: 软件层面内存访问的模型
以上便是学习内存屏障需要了解的基础知识,可以不用懂每个原理的细节,但需要知道每个原理都描述和解决了什么问题。那么接下来对每个原理进行讲解.
指令乱序
乱序执行: 程序里代码执行顺序可能被编译器、CPU 根据某种策略调整(俗称乱序), 但乱序执行不影响执行的结果. 程序为什么需要乱序呢? 只要原因是 CPU 内部采用流水线技术,抽象且简化看, 一个 CPU 指令的执行过程可以分成 4 个阶段: 取指、译码、执行、写回.
这四个阶段分别由 4 个独立物理执行单元来完成,在这种情况下,如果指令之间没有依赖关系,后一条指令并不需要等到前一条指令完成执行再开始执行,而是前一条指令完成取指之后,后一条指令边可以开始执行取指操作.
理想状态下,Instruct A-D 四条指令之间没有依赖,那么可以使流水线的并行度最大化。第一条指令完成取指,第二条指令就可以开始取指; 第一条指令完成译码之后,第二条指令可以开始执行译码, 那么第三条指令可以开始取指, 依次类推.
在按顺序执行的情况下,一旦遇到指令依赖的情况,流水线就会停滞,例如 InstructionB 依赖 InstructionA 的结果,那么在 InstructionA 执行完之前 InstructionB 无法执行,这会让流水线的执行效率大大降低. 例如上图由于 InstructionB 依赖 InstructionA 的结果,导致剩余三条流水线多出现多个空闲周期.
InstructionC 和 InstructionD 指令对其他指令并没有依赖,可以考虑将 InstructionC 和 InstructionD 乱序 到指令 InstructionB 之前,那么流水线执行单元就可以尽可能处于工作状态。通过上面的案例大概了解了系统为什么需要乱序执行,通过乱序执行合理调整指令执行顺序,可以提高流水线的运行效率,让指令的执行能尽可能地并行起来.
编译乱序
编译乱序(Out-of-Order Execution at Compile Time): 指的是编译器为了优化程序执行效率,可能会更改代码的执行顺序。编译器做这种优化的主要原因是,尽管代码的执行顺序改变了,但是从编程语义的角度看,更改后的代码和原始代码是等价的,即不会影响程序的结果。然而,这种优化在多线程或并发环境中可能会引发问题。可以通过一个实践案例进行了解,其在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] Out-of-Order Execution at Compile Time ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default Source Code on Gitee
该案例来自 Peter P. Pacheco 的书籍 “An Introduction to Parallel Programming”, 这是一个使用 Peterson’s algorithm 实现的两线程互斥的程序。它应该保证两个线程不会同时进入它们的关键区域。然而,如果编译器或硬件决定重新排序 “flag[my_rank] = 1” 和 “turn = other_rank” 的执行顺序,那么这个程序就会出现问题,因为它们的顺序对于 Peterson’s algorithm 是至关重要的。注意,尽管这个程序可能会在一些系统上运行良好,但是它依赖于没有乱序的假设,因此它在某些系统上可能无法正常运行。这是因为编译器和现代硬件可能会对代码进行乱序以优化性能。那么接下来在 BiscuitOS 运行两个程序:
BiscuitOS 启动之后运行两个程序,BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default-O2 是经过 O2 优化的程序, 其优化包括指令重排,而 BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default-O0 是采用 O0 编译,即不优化. 两者运行之后可以看到 O0 优化之后的程序 Thread1 和 Thread2 同时进入了关键区域,与预期的不符合,而 O2 优化的程序只有一个线程可以进入关键区域。解决乱序问题的一种方法是使用内存屏障(memory barrier) 或原子操作(atomic operations)。例如,C11 中引入了 stdatomic.h 头文件,提供了一些可以用于编写并发代码的原子类型和操作。使用这些原子操作可以帮助确保代码的正确顺序,避免乱序问题, 可以查看 main-atomic.c 源码:
在 C11 和更高版本的 C 语言标准中,为了确保内存顺序和同步,引入了原子操作(
BiscuitOS 启动之后运行 BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default-atomic,可以看到只有一个线程进入了关键区,因此原子操作可以保证不被编译器优化乱序。另外还可以通过添加内存屏障解决该问题,可以查看 main-barrier.c 源码:
在这个代码中,我在设置 flags[rankA] 和 data 之后以及在检查它们的值之前都插入了内存屏障 __sync_synchronize()。这确保了这些操作在内存中的顺序与源代码中的顺序一致,从而避免了编译器和处理器的乱序执行。需要注意的是,使用 __sync_synchronize() 的版本比使用原子操作的版本性能可能稍差,因为 __sync_synchronize() 阻止了所有的内存操作重排,而不仅仅是特定的几个操作。因此,如果可能,使用原子操作通常是更好的选择. 那么在 BiscuitOS 上实践该案例:
BiscuitOS 启动之后运行 BiscuitOS-MEMORY-BARRIER-OUT-COMPILER-default-barrier,可以看到只有一个线程进入了关键区,因此内存屏障可以保证不被编译器优化乱序。通过上面的分析,大概了解了编译器优化时对指令的重排,在多线程场景下会造成非预期的结果,其中可以通过内存屏障来解决这个, 接下来可以查看 OBJDUMP 文件,查看编译器优化指令顺序:
在 X86 下查看其 O0、O2 和 Barrier 下的汇编,可以看到 Barrier 和 O0 的差不多,属于没有优化的代码,”flag[my_rank] = 1” 和 “turn = other_rank” 的执行顺序没有变。但对于 O2 的优化,其代码逻辑别颠倒,那么其执行逻辑就被改变. 综上所述,在 GCC 编译过程中: O0 不做优化,O2/O3 编译器可能会重编排指令. 因此要特别注意在多线程对共享资源的访问时的优化.
CACHE 一致性
在 CACHE 专题 对 CACHE 一致性进行过详细讲解,CACHE 一致性是解决多核 CPU 看到共享数据是一致的,这里再次对 CACHE 一致性协议 MESI 进行简要的回顾:
A: WriteBack Message, CACHE Line 回写到内存,该 CPU 保留了副本且有修改它的权利
B: CPU 无需发任何消息,直接对独占的 CACHE Line 执行写操作
C: CPU 没有副本,但想独占读因此发送 Read Invalidate 消息获得 Data
D: CPU 独占的情况下,收到 Read Invalidate 消息将其置为 Invalidate
E: CPU 有只读副本,但收到了 Invalidte 信息之后将其置为 Invalidate
F: CPU 没有副本,但想共享读因此发送 Read Message 之后获得副本
G: CPU 有只读副本,但要进行写操作因此发送 Invalidate 消息
H: CPU 独占的情况下,收到了 Read Message 将其置为 Shared
I: CPU 没有副本,然后执行以此 RMW,因此发送 Read Invalidate 消息
J: CPU 独占的情况下,收到 Read Invalidate 消息将其置为 Invalidate
K: CPU 有只读副本,但将要对其写操作因此发送 Invalidate Message
L: CPU 有独占副本,但收到 Read Message 之后将其置为 Shared
通过上面的 MESI 协议状态的该表,可以看到有的是通过向 RingBus 发送 Message,其他 CPU 收到 Message 之后会修改本地 CACHE 的状态,那么这些 Message 包括如下:
Read Message: 以只读形式获得 CACHE 副本
Read Response: CPU 收到 Read Message 之后将副本发送给请求方并修改为 Shared
Invalidate Message: 通知其他 CPU 本地 CACHE 无效
Invalidate Acknowledge: 收到其他 CPU 处理 Invalidate Message 应答
Read Invalidate: 希望从其他 CPU 处获得 CACHE 内容并其本地 CACHE 无效
WriteBack: 将 Modify 状态的 CACHE 写会到内存并修改状态为 E
Store Buffer
在讨论 Store Buffer 之前先看一个 CACHE 一致性例子,假如 CPU0 想对 A 内存地址进行写操作,但此时 A 在 CPU1 的 CACHE 中有副本,且 CACHE Line 状态为 Shared,那么 CPU0 会执行一下操作:
A: CPU0 在 CACHE 中查询 A 是否被缓存,结果发现 CACHE Miss
B: CPU0 要对 A 进行 RMW 操作,那么向 RingBus 发送 Read Invalidate 消息
C: CPU0 在没有收到所有 CPU Invlidate ACK 之前进入 Stall 状态
D: RingBus 收到消息之后,发现没有需要仲裁的消息,于是将消息转发给其他 CPU
E: CPU1 收到 Read Invalidate 消息之后,立即将 A 对应的 CACHE Line 置为 Invlidate,并将 A 对应的 CACHE Line 副本和 Read Invalidate ACK 发送给 RingBus
F: RingBus 将收到的 ACK 和 CACHE Line 副本转发给 CPU0
G: CPU0 将收到的 CACHE Line 副本放到自己的 CACHE Line 里,并将 CACHE Line 状态修改为 Exclusive
H: 待其他 CPU 都回复了 ACK 之后 CPU0 结束 Stall,然后从 CACHE Line 中读取到寄存器进行修改
I: 由于此时 CACHE Line 的状态是 Exclusive,CPU0 无需通知其他 CPU,直接将修改的值写入到自己的 CACHE Line 里,并将 CACHE Line 的状态修改为 Modify
从上面的流程可以看出,CPU0 从 “C” 发出 Read Invalidate Message 之后进入 Stall 状态,到 “H” 收到其他 CPU 的 Invlidate ACK 之后才结束 Stall 状态,这期间 CPU0 只能被阻塞不能做其他动作,硬件工程师看到之后说他们可以努力一把,把这个 Stall 给搞掉,于是就诞生了 Store Buffer.
Store Buffer 是一个位于 CPU0 和 CACHE 之前的硬件,其主要目的是解决 CPU 因为 Store 操作无法继续执行导致的 Stall(阻塞),并缓存 Store 操作的硬件. 可以将刚刚的问题搬到上图的硬件架构上,其处理流程会发生变化:
A: CPU0 需要对 A 地址执行 RMW 操作,查询本地 CACHE 发生 CACHE Miss.
B: CPU0 向 RingBus 发送 Read Invalidate Message
C: CPU0 发送消息完毕之后,将 Store 操作缓存到 Store Buffer 中,继续执行其他指令
D: RingBus 收到消息之后,发现没有需要仲裁的消息,于是将消息转发给其他 CPU
E: CPU1 收到 Read Invalidate 消息之后,立即将 A 对应的 CACHE Line 置为 Invlidate,并将 A 对应的 CACHE Line 副本和 Read Invalidate ACK 发送给 RingBus
F: RingBus 将收到的 ACK 和 CACHE Line 副本转发给 CPU0
G: CPU0 将收到的 CACHE Line 副本放到自己的 CACHE Line 里,并将 CACHE Line 状态修改为 Exclusive
H: CPU0 从 Store Buffer 中取出之前缓存的 Store 指令执行
I: 由于此时 CACHE Line 的状态是 Exclusive,CPU0 无需通知其他 CPU,直接将修改的值写入到自己的 CACHE Line 里,并将 CACHE Line 的状态修改为 Modify.
可以看到 Store Buffer 的加入之后,Store 操作不再阻塞 CPU 的执行,并在等待过程中执行其他指令,但这也是有问题的,这将会导致 Store 指令的执行顺序改变,也就是通常所说的指令乱序, 这里不展开讲,先给大家感受一下 Store Buffer 的存在和作用.
内存一致性模型
在CACHE 专题已经对 CACHE Coherent 进行了深入的研究,CACHE 一致性主要解决: 多个 CPU 看到同一内存地址的数据是一致的, 那么 CACHE 一致性是否可以确保并行程序的数据一致了呢? 答案是远远不够,在单核处理器系统中,内存操作的顺序通常是明确且一致的,因为有一个唯一的全局时间点。然而在多核处理器或者多线程系统中,不同处理器或线程可能会看到不同的内存操作顺序,这是由于各种因素导致的,比如 Store Buffer、指令重排、多线程等。这就带来了一个问题: 怎么才能确保不同的处理器或者线程操作共享数据时,能看到一致的数据状态和操作顺序?
为了解决这个问题,提出了内存一致性模型。内存一致性模型是一个协议,定义了在什么情况下,一个处理器的内存操作对其他处理器是可见的,以及不同处理器看到的内存操作顺序,这样无论底层硬件如何优化,只要遵循内存一致性模型,就能确保程序的正确性。内存一致性模型对于编程者而言非常重要,它使得编程者能够对并发的内存操作有一个明确的理解,知道在什么情况下一个操作会看到另一个操作的结果,从而避免潜在的并发问题。对于硬件设计者而言,内存一致性模型定义了他们必须遵循的规则,以确保多处理器系统的正确运行。常见的内存一致性模型有:
线性一致性(Linearizability)或严格一致性(Strict consistency)
原子一致性(Atomic consistency)
缓存一致性(Cache Coherence)
静态一致性(Quiescent consistency)
处理器一致性(Processor consistency)/PRAM一致性(PRAM consistency,P指pipeline)
释放一致性(Release consistency)
因果一致性(Causal consistency)
PSO一致性(Partial store ordering)
弱序一致性(Weak-ordering consistency)
最终一致性(Eventual consistency)
以上便是内存一致性模型,有的过于严苛只存在于理论上,有的则可运用于实际的架构中。本节只对其中几种常用的内存一致性模型进行讲解,更多内存一致性模型,开发者可以参考:
A Primer on Memory Consistency and Cache Coherence – Danie1J.Sorin
SC(Sequential Consistency) 内存一致性模型
Sequential Consistency(SC) 内存一致性模型可以说是最直观的内存一致性模型,SC 是由 Leslie Lamport 在 1979 年首次定义的一个内存一致性模型。在顺序一致性模型中,内存操作(例如,load 和 store)必须满足以下两个条件:
操作顺序: 对于单个处理器,内存操作必须按照其在程序中的顺序执行。也就是说,程序中先发生的操作必须先于后发生的操作
一致性视图: 所有处理器必须看到一个一致的操作顺序。也就是说,如果我们把所有处理器的操作按照它们的执行顺序排列,那么这个顺序必须对所有处理器都是一样的。
这意味着,在顺序一致性模型中,内存操作不能被重新排序。例如,一个处理器不能先执行后发生的操作,然后再执行先发生的操作。此外,所有处理器必须看到相同的操作顺序。例如,如果处理器 A 见到了先是操作 1,然后是操作 2,那么处理器 B 也必须见到先是操作 1,然后是操作 2。顺序一致性为多处理器编程提供了一个简单且直观的模型,程序员可以理解为所有的操作都是在一个全局和唯一的顺序下发生的。然而,由于它限制了很多能提高性能的优化(例如,操作的重排序和缓存),因此在实践中往往会使用更弱的一致性模型,如 Total Store Order (TSO) 或 Relaxed Consistency Models.
上图左边是 CoreC1 的程序指定的顺序,右边是 CoreC2 的程序指定的顺序,中间是实际的内存访问顺序。简而言之,不管是从哪个核心的角度,内存访问的顺序是遵循程序指定的顺序的. 所以对于上述程序而言,只要服从顺序一致性内存模型,最后程序的结果寄存器 r2 都能拿到值 NEW,唯一不能确定的是执行指令 L1 的次数.
上图 CoreC1 要执行 S1 和 L1 两条指令,而 CoreC2 需要执行 S2 和 L2 两条指令,x 和 y 的初始值均为 0,根据 SC 模型可以出现以下几种情况:
S1->L1->S2->L2, 且 (r1,r2) = (0, NEW)
S2->L2->S1->L1, 且 (r1,r2) = (NEW, 0)
S1->S2->L1->L2, 且 (r1,r2) = (NEW, NEW)
S1->S2->L2->L1, 且 (r1,r2) = (NEW, NEW)
S2->S1->L1->L2, 且 (r1,r2) = (NEW, NEW)
S2->S1->L2->L1, 且 (r1,r2) = (NEW, NEW)
综上所述,这 6 种内存执行顺序都符合顺序一致性模型,而其他的组合是不符合的.
L1->L2->S1->S2, 且 (r1,r2) = (0, 0) 等剩余多种组合都不符合顺序一致性模型. 不符合模型都有一个特点,就是要么 S1 与 L1 的顺序反了,要么就是 S2 与 L2 的顺序反了。因此可以发现顺序一致性模型的特点就是程序的顺序一定和内存访问顺序一致. 通过上面的案例分析,SC 内存一致性模型具有如下优点:
简单明了: 顺序一致性为程序员提供了一个非常直观和易于理解的模型。程序员可以认为所有的内存操作都在一个全局的、一致的顺序下发生
易于编程: 由于顺序一致性的模型简单,它使得并发程序的编程和推理变得相对容易。程序员不需要担心在不同处理器之间复杂的内存操作重排序
虽然顺序一致性模型具有这些优点,但在当今硬件急速发展的时代,对性能极致的最求是软件开发者和硬件开发者所追求的,因此其具有如下缺点:
性能限制: 顺序一致性可能限制硬件和编译器优化。由于所有内存操作必须严格按照某种全局顺序执行,这就限制了可能的指令并行化和重新排序。这可能导致性能低下,尤其是在处理器的缓存、预取和流水线等技术无法充分发挥作用的情况下
实现复杂性: 在硬件层面上,保证全局操作顺序的一致性可能需要复杂的同步机制,这可能导致硬件实现变得复杂,增加了设计和验证的难度
可扩展性问题: 在大规模多处理器系统中,实现顺序一致性可能面临严重的可扩展性问题。因为它要求所有处理器对内存操作达成一致的顺序,这可能需要大量的通信和同步,从而成为系统扩展的瓶颈
由于这些缺点,许多现代的多处理器系统采用了更宽松的内存一致性模型,如弱序一致性或释放一致性,这些模型允许更多的操作重排序,从而能够更好地利用硬件和编译器优化,提高性能。但是,这也使得并发程序的编程和推理变得更加困难。顺序一致性模型要求每一个 LOAD 和 STORE 都需要按照程序描述的顺序执行下去:
每个处理器上的读操作(LOAD)和写操作(STORE)必须按照它们在程序中出现的顺序来执行。不允许对它们进行任何形式的重排序。也就是说,LOAD-LOAD、STORE-STORE、LOAD-STORE、STORE-LOAD,四种情况都不能乱序。
所有处理器必须以同一全局顺序看到所有的内存操作。这意味着,如果处理器 P1 先于处理器 P2 执行了对同一内存位置的写操作,那么所有的处理器都应该按照这个顺序来观察这两个操作。
因此,顺序一致性模型为并发程序员提供了一个相对简单直观的内存模型: 程序中的指令顺序就是它们执行的顺序,所有处理器都能看到相同的操作顺序。这种模型虽然在编程上容易理解,但是由于限制了可能的优化,所以在实践中可能会导致性能开销。
TSO(Total Store Ordering) 内存一致性模型
前面了解了顺序一致性模型要求所有处理器的所有内存操作的顺序都是全局一致的,这意味这任何处理器中,读和写操作都必须按照他们在程序中出现的顺序来执行,不运行重排序,那么可能会导致性能降低,例如下面的案例:
假如 CPU0 想对 A 内存地址进行写操作,但此时 A 在 CPU1 的 CACHE 中有副本,且 CACHE Line 状态为 Shared,A 在 CPU0 的 CACHE 中没有副本,那么 CPU0 会执行一下操作:
A: CPU0 在 CACHE 中查询 A 是否被缓存,结果发现 CACHE Miss
B: CPU0 要对 A 进行 RMW 操作,那么向 RingBus 发送 Read Invalidate 消息
C: CPU0 在没有收到所有 CPU Invlidate ACK 之前进入 Stall 状态
D: RingBus 收到消息之后,发现没有需要仲裁的消息,于是将消息转发给其他 CPU
E: CPU1 收到 Read Invalidate 消息之后,立即将 A 对应的 CACHE Line 置为 Invlidate,并将 A 对应的 CACHE Line 副本 和 Read Invalidate ACK 发送给 RingBus
F: RingBus 将收到的 ACK 和 CACHE Line 副本转发给 CPU0
G: CPU0 将收到的 CACHE Line 副本放到自己的 CACHE Line 里,并将 CACHE Line 状态修改为 Exclusive
H: 待其他 CPU 都回复了 ACK 之后 CPU0 结束 Stall,然后从 CACHE Line 中读取到寄存器进行修改
I: 由于此时 CACHE Line 的状态是 Exclusive,CPU0 无需通知其他 CPU,直接将修改的值写入到自己的 CACHE Line 里,并将 CACHE Line 的状态修改为 Modify
从上面的流程可以看出,CPU0 从 “C” 发出 Read Invalidate Message 之后进入 Stall 状态,到 “H” 收到其他 CPU 的 Invlidate ACK 之后才结束 Stall 状态,这期间 CPU0 只能被阻塞不能做其他动作,如果按顺序内存一致性模型,加入后面是一条读指令,并且这条读指令与前一条写指令没有任何依赖,但为了保持顺序一致,那么读指令还是会被阻塞. 对于这种情况可以采用更宽松的内存一致性模型(注意: 这里这对读被阻塞的情况进行讨论,而如果是写指令还是需要遵循顺序一致性模型).
TSO(Total Store Order) 是一种比顺序一致性(Sequential Consistency,SC)模型更宽松的内存一致性模型,主要用于提升多处理器系统的性能。TSO 和 SC 的主要区别在于处理器执行内存操作的顺序:
在顺序一致性(SC)模型中,所有处理器看到的所有内存操作(读和写)的顺序都是全局一致的。这就意味着在任何处理器中,读和写操作都必须按照它们在程序中出现的顺序来执行,不允许重排序, 也就是说,LOAD-LOAD、STORE-STORE、LOAD-STORE、STORE-LOAD,四种情况都不能乱序。
在总存储顺序(TSO)模型中,保留了 LOAD-LOAD、STORE-STORE 和 LOAD-STORE 不能乱序。然而, 读操作可以在稍后的写操作之前完成,即 STORE-LOAD 操作乱序。这意味着处理器可以将写操作缓存在存储缓冲区中,直到必要时才将其刷新到内存中.
上图 CoreC1 要执行 S1 和 L1 两条指令,而 CoreC2 需要执行 S2 和 L2 两条指令,x 和 y 的初始值均为 0,根据 SC 模型可以出 现以下几种情况:
S1->L1->S2->L2, 且 (r1,r2) = (0, NEW)
S2->L2->S1->L1, 且 (r1,r2) = (NEW, 0)
S1->S2->L1->L2, 且 (r1,r2) = (NEW, NEW)
S1->S2->L2->L1, 且 (r1,r2) = (NEW, NEW)
S2->S1->L1->L2, 且 (r1,r2) = (NEW, NEW)
S2->S1->L2->L1, 且 (r1,r2) = (NEW, NEW)
L2->L1->S2->S1, 且 (r1,r2) = (0, 0), 对于 TSO 模型这种情况是支持的,而 SC 模型 是不支持这种情况的。该情况的特点就是 STORE-LOAD 乱序. 当开发者知道了一个 CPU 的内存模型,就可以根据具体的模型考虑问题,而不同纠结硬件实现机制,也不用关系硬件做了什么导致的乱序。开发者只需关系内存模型,所以内存模型更像一种理论,一种标准,CPU 设计之初需要遵循的法则。常见的 PC 处理器 X86-64 就是采用 TSO 模型. TSO 模式的 STORE-LOAD 乱序在大多情况下都是没有问题的,但如果需要 STORE-LOAD 保序,其中要求 Write Buffer(这里默认为 Store Buffer,就是它提供了乱序的能力)是一个 FIFO 的结构,开发者可以通过显示的 FENCE 指令 防止 STORE-LOAD 指令之间的乱序.
Relaxed Memory Order: RMO 模型
RMO(Relaxed Memory Order) 是一种内存一致性模型,最早由 Sun Microsystems 在其 SPARC 处理器中实现。RMO 属于松弛(relaxed)内存一致性模型,它比 Sequential Consistency(SC) 模型和 Total Store Order(TSO) 模型都要更松弛。在 RMO 模型中,不同处理器上的内存操作(loads 和 stores)可以在一定程度上自由乱序。包括 LOAD-LOAD、LOAD-STORE、STORE-STORE 甚至 STORE-LOAD 操作都有可能乱序,这使得 RMO 模型的实现比 SC 和 TSO 更高的性能。然而,RMO 模型的这种高度乱序性也使得编程变得更复杂,需要额外的同步操作(如内存屏障)来保证正确的行为。
需要注意的是,尽管 RMO 模型允许大部分操作的乱序执行,但它仍然需要满足某些一致性约束,例如,一个处理器不能提前看到其他处理器尚未完成的写操作的结果。此外,对同一内存位置的读-写操作必须满足特定的顺序要求,例如,一个处理器不能在写入一个值之后立即读取到一个旧的值。总的来说,RMO 模型在提高性能的同时,也增加了编程的复杂性。程序员需要更深入地理解内存一致性和同步操作,才能有效地使用 RMO 模型。ARM64 架构目前采用这种内存一致性模型.
Memory Barrier
在讲解 Memory Barrier 之前,开发者先从一个问题开始了解为什么要引入 Memory Barrier 这个技术。在 在 CACHE Coherent 了解了 CACHE 一致性协议在各 CPU 之间发送的消息种类,以及 在 指令乱序 了解了指令为什么要乱序,那么接下来通过一个案例引入 Memory Barrier, 假如 CPU0 想对 A 内存地址进行写操作,但此时 A 在 CPU0 的 CACHE 中没有副本,而在 CPU1 的 CACHE 中有副本,且 CACHE Line 状态为 Shared,那么 CPU0 会执行一下操作:
A: CPU0 在 CACHE 中查询 A 是否被缓存,结果发现 CACHE Miss
B: CPU0 要对 A 进行 RMW 操作,那么向 RingBus 发送 Read Invalidate 消息
C: CPU0 在没有收到所有 CPU Invlidate ACK 之前进入 Stall 状态
D: RingBus 收到消息之后,发现没有需要仲裁的消息,于是将消息转发给其他 CPU
E: CPU1 收到 Read Invalidate 消息之后,立即将 A 对应的 CACHE Line 置为 Invlidate,并将 A 对应的 CACHE Line 副本 和 Read Invalidate ACK 发送给 RingBus
F: RingBus 将收到的 ACK 和 CACHE Line 副本转发给 CPU0
G: CPU0 将收到的 CACHE Line 副本放到自己的 CACHE Line 里,并将 CACHE Line 状态修改为 Exclusive
H: 待其他 CPU 都回复了 ACK 之后 CPU0 结束 Stall,然后从 CACHE Line 中读取到寄存器进行修改
I: 由于此时 CACHE Line 的状态是 Exclusive,CPU0 无需通知其他 CPU,直接将修改的值写入到自己的 CACHE Line 里,并>将 CACHE Line 的状态修改为 Modify
从上面的流程可以看出,CPU0 从 “C” 发出 Read Invalidate Message 之后进入 Stall 状态,到 “H” 收到其他 CPU 的 Invlidate ACK 之后才结束 Stall 状态,这期间 CPU0 只能被阻塞不能做其他动作,硬件工程师看到之后说他们可以努力一把,把这个 Stall 给搞掉,防止这种不必要的写操作延时的一种方法是在每个 CPU 和 CACHE 之间添加了一个 Write Buffer,也就是 Store Buffer, 如下图所示。有了 Write Buffer,CPU0 可以简单地将其他写操作记录在 Write Buffer 中并继续执行,当缓存行最终从 CPU1 转移到 CPU0 时,数据将从 Write Buffer 移动到 CACHE Line 里,然而还需要解决一些复杂性,接下来慢慢讨论.
Store Forwarding
上图中在 CPU 和 CACHE 之间添加了一个 Write Buffer,这里称为 Store Buffer,上图的架构不存在真实的架构中,只是用于对问题的讲解. 当添加 Store Buffer 之后可以将 Store 操作缓存,然后 CPU 继续执行其他指令,虽然解决了 Stall 问题,但这样的设计违背了自身一致性(Self-consistency). 采用上图的架构进行讲解:
案例代码很简单,变量 a 和 b 的初始值都为 0,CPU1 独自拥有变量 a 的 CACHE Line,CPU0 独自拥有变量 b 的 CACHE Line。开发者应该觉得 b 最终等于 2,但如果直接采用上图的架构,那么会感到惊讶,b 最后不等于 2,那么其运行过程如下:
A: CPU0 开始执行 “a = 1”
B: CPU0 在本地 CACHE 中查找 a,但没有找到,于是 CACHE Miss
C: CPU0 为了执行 RMW 独占 a,那么发送 Read Invalidate Message
D: CPU0 将 “a=1” 存储到 Store Buffer
E: CPU1 收到 Read Invalidate 消息,通过传输缓存行和将缓存行从本地移除作为应答
F: CPU0 开始执行 “b = a + 1”
G: CPU0 收到 CPU1 传输过来的 CACHE Line,并将其放到自己缓存,此时 a 为 0
H: CPU0 从缓存中加载 a,此时 a 的值为 0
I: CPU0 将 Store Buffer 中存储的条目更新到 CACHE 里,此时缓存中的 a 置为 1
J: CPU0 继续执行 “b = a + 1”,此时 a 为 0,那么 b 的结果 1
K: 由于 b 只存在 CPU0 中,CPU0 直接将结果存储到本地 CACHE 里而无需进行通过
L: CPU0 接着执行 “b == 2” 的判断,此时可以断定断言已经失败.
出现断言失败的根本原因是 CPU0 有两份 a 的副本,一个来自 CACHE Line,另外一个来自 Store Buffer。这个例子破坏了一个非常重要的保证,即每个 CPU 总是看到其自身的操作就像它们按程序顺序执行一样. 破坏这个保证对软件类型来说是极其反直觉的,所以硬件实现了 Store Forwaring, 即每个 CPU 在执行加载(LOAD)时会先在 Store Buffer 中进行查找,如果命中直接从 Store Buffer 中获取,否则需要到 CACHE 中进行查找.
Store Buffer 的硬件架构进行改进,当每次 CPU 执行 LOAD 操作时,CPU0 首先到 Store Buffer 中进行查找数据,如果命中,那么直接从 Store Buffer 中取值; 反之 Store Buffer 中没有命中,那么再去 CACHE 中进行查找. 那么回到前面的案例,其代码执行逻辑变成如下:
A: CPU0 开始执行 “a = 1”
B: CPU0 在本地 CACHE 中查找 a,但没有找到,于是 CACHE Miss
C: CPU0 为了执行 RMW 独占 a,那么发送 Read Invalidate Message
D: CPU0 将 “a=1” 存储到 Store Buffer
E: CPU1 收到 Read Invalidate 消息,通过传输缓存行和将缓存行从本地移除作为应答
F: CPU0 开始执行 “b = a + 1”
G: CPU0 需要在加载 a,于是现在 Store Buffer 进行查找
H: CPU0 从 Store Buffer 中找到了 a,然后直接加载该值
I: CPU0 收到 CPU1 传输过来的 CACHE Line,并将其放到自己缓存,此时 a 为 0
J: CPU0 将 Store Buffer 中存储的条目更新到 CACHE 里,此时缓存中的 a 置为 1
K: CPU0 继续执行 “b = a + 1”,此时 a 为 1,那么 b 的结果 2
L: 由于 b 只存在 CPU0 中,CPU0 直接将结果存储到本地 CACHE 里而无需进行通过
M: CPU0 接着执行 “b == 2” 的判断,此时可以断定断言成功.
在添加了 Store Forwarding 功能之后,Store Buffer 的自身一致性(Self-consistency) 问题已经解决. 但 Store Buffer 还有其他问题,例如接下来要将讨论的违背了全局内存序:
假设 a 和 b 的初始值为 0,CPU0 执行 Func() 函数,CPU1 执行 BAR() 函数,a 缓存在 CPU1 的 CACHE 里,而 CPU0 独占 b, 那么执行逻辑如下:
A: CPU0 开始执行 “a=1”, 但 a 不在 CPU0 的本地 CACHE,于是将 a 存储在本地 Store Buffer 里,并发起 Read invalidate 消息.
B: CPU1 开始执行 “while(b==0) continue”, 但 b 不在 CPU1 本地缓存,于是发起 Read Message
C: CPU0 继续执行 “b=1”, b 已经在 CPU0 本地缓存,且 CACHE Line 状态是 Modify 或者 Exclusive, 所以 CPU0 直接将新值存储到 CACHE 里,无需通知其他 CPU
D: CPU0 收到 “Read Message”,然后将最新的 b(b=1) 的值传输出去并将 CACHE Line 状态置为 Shared,以此作为 Read Message 的应答.
E: CPU1 收到 Read Message 应答和 b 的值,并将 b 的值存储到 CPU1 的本地缓存.
F: CPU1 此时可以继续执行 “while(b==0) continue” 指令,条件符合可以继续执行下一条指令.
G: CPU1 接着执行 “assert(a==1)”, 此时 CPU1 在本地 CACHE 中找打了 a,但此时 a 的值还是 0,因此断言失败.
H: CPU1 此时才收到 “Read Invalidate Message”, 然后将 a 的值传送出去并将对应的 CACHE Line 置为无效,但一切都晚了.
I: CPU0 收到了应答和 a 的值,将其存储在本地缓存里,然后将 Store Buffer 的条目更新到缓存,此时 a 的值为 1.
从全局内存序的角度来看,CPU0 的两条指令之间发生了乱序,但在 SC 内存一致性模型 里规定了,程序的 STORE-STORE 是不能乱序的,这会导致程序结果出错. 这个时候硬件工程师跑来和你说他们也无能为力,他们压根不知道两个指令什么时候是相互依赖的,什么时候不是依赖的,但硬件工程师说可以提供 Memory Barrier 指令允许软件告诉 CPU 什么地方需要保序,例如添加了内存屏障指令之后的代码片段如下:
Memory Barrier: smp_mb()
smp_mb() 函数确保该条指令之前的 Store 操作没有完成之前,该条指令之后的所有 Store 操作都必须等待前面的完成之后,才能继续执行. CPU 可以将后续的 Store 缓存到 Store Buffer 里,如果 Store Buffer 满了,那么 CPU 进行简单的 Stall,待前面的 Store Entry 清空后继续运行. 那么之前的流程就会变成如下:
A: CPU0 开始执行 “a=1”, 当 a 不在 CPU0 的本地 CACHE,于是将 a 存储到本地 Store Buffer 里,并发起 Read Invalidate Message.
B: CPU1 开始执行 “while(b==0) continue”, 但 b 不在 CPU1 本地缓存,于是发起 Read Message
C: CPU0 开始执行 “smp_mb()”, 将 Store Buffer 所有的 Entry 进行标记
D: CPU0 继续执行 “b=1”, b 已经在 CPU0 本地缓存,且 CACHE Line 状态是 Modify 或者 Exclusive, 但是由于 Store Buffer 里存在已经标记的 Entry,因此 CPU0 只能将 “b=1” 存储到 Store Buffer 里, 但不会被 Store Buffer 标记.
E: CPU0 收到 “Read Message”,然后将原始 b(b=0) 的值传输出去并将 CACHE Line 状态置为 Shared,以此作为 Read Message 的应答.
F: CPU1 收到 “Read Message” 的应答和 b 的值,并存储到 CPU1 本地 CACHE 中.
G: CPU1 再次执行 “while(b==0) continue”, 从 CPU1 本地 CACHE 中读取 b 的值,但此时 b 的值为 0. 此时 b 的新值还安全隐藏在 CPU0 的 Store Buffer 里.
H: CPU1 收到 Read Invalidate Message,然后传递 a 的值并将本地的 CACHE Line 标记为无效,以此作为应答.
I: CPU0 收到了 Read Invalidate Message 的应答信息,且包含了 a 的值,然后到 Store Buffer 里将 a 的新值写入到 CPU0 的 CACHE 里,并将对应的 CACHE Line 标记为 Modify
J: 在 Store Buffer 里只有 a 是被 smp_mb() 进行标记的,目前已经没有被标记的 Entry,那么其余未被标记的 Entry 可以将值写入到 CACHE,这里包括 b 的新值, 但是此时 b 在 CACHE Line 的状态是 Shared.
K: CPU0 针对 b 地址向其他 CPU 发送 Invalidate Message
L: CPU1 收到 Invalidate Message 之后,将 b 对应的 CACHE Line 标记为 Invalidate,然后发送 ACK.
M: CPU1 再次执行 “while(b==0) continue”, 此时 b 对应的 CACHE Line 为 Invalidate,于是向其他 CPU 发送 Read Message.
N: CPU0 收到针对 b 地址的 Invalidate ACK 消息,首先将对应的 CACHE Line 状态由 Shared 切换成 Exclusive,CPU0 接着将 b 的新值写入到 CACHE 里,且无需通知其他 CPU,并将 CACHE Line 状态修改为 Modify.
O: CPU0 收到针对 b 地址的 Read Message,然后将 b 的最新值连同应答信号一同发给出去, 最后将 CACHE Line 的状态修改为 Shared.
P: CPU1 收到 Read Message 的应答和 b 的值,将其存储到 CPU0 的本地缓存, 并将 CACHE Line 状态修改为 Shared
Q: CPU1 再次执行 “while(b==0) continue”, 从 CACHE 中读取 b 的值,此时 b 为 1 结束 while 循环
R: CPU1 接着执行 “assert(a==1)” 指令,但 CPU1 的 CACHE 里没有缓存 a 的副本,因此发起 Read Message
S: CPU0 收到针对 a 的 Read Message,然后传输 a 的内容并将对应的 CACHE Line 标记为 Shared 作为应答.
T: CPU1 收到针对 a 的 Read Message 应答,并包括 a 的值,然后将其存储在 CPU1 CACHE 里,将对应的 CACHE Line 标记为 Shared.
U: CPU1 最后执行 “assert(a==1)” 指令,从 CPU1 本地 CACHE 中读取 a 的值,此时 a 为 1,断言成真.
从全局内存序的角度来看,因为添加 smp_mb() 的缘故,CPU0 的两条指令之间保证了 “STORE-STORE” 的序,这样也符合 SC 内存一致性模型 和 PSO 内存一致性模型的要求,因为在两种内存模型中都一致规定 “STORE-STORE” 是不能乱序的,最后验证的结果也符合预期. 但为了解决该问题,可以发现 CPU 出现了多次等待,这些等待大部分发生在 Invalidate Message 应答等待.
从上面案例也可以看出 smp_mb() 函数的作用是将 Store Buffer 里面的 Entry 标记,只有 Store Buffer 里面有标记的 Entry,那么该 CPU 接下来的 STORE 操作必须缓存在 Store Buffer里,当 Mark 的 Entry 收到 ACK 之后,首先处理对应的标记 Entry; 相反如果 Store Buffer 里缓存了多条 Entry 但都没有被标记,那么 CPU 新的 Store 可以不被阻塞。这里只能做这些分析,更多的细节分析不利于软件的理解.
Invalidate QUEUE
通过前面的分析,使用 Store Buffer 和 smp_mb() 函数软硬结合的方式解决了很多问题,但不幸的是,每个 Store Buffer 大小必须相对小,这意味着一个 CPU 执行一系列的 STORE 操作可能会填满 Store Buffer,当被填满之后,CPU 必须再次 Stall 并等待 Invalidate 完成,以便在继续执行前清空 Store Buffer。在内存屏障之后理解出现这种情况也是如此,此时所有后续 STORE 指令必须等待 Invalidate 操作(Marked Entry)完成,无论等待的 STORE 的操作数是否在 CACHE 中命中. 通过上一个案例可以知道,大部分等待都是因为 Invalidate ACK 回来的太慢,进一步原因是 CPU 收到 Invlidate Message 之后将对应的 CACHE Line 设置为 Invalidate 需要的时间很长. 未解决这个办法引入了 Invalidate QUEUE 硬件.
Invalidate Message 或者 Read Invalidate Message 花费如此长时间的一个原因是它们必须确保对应的 CACHE Line 确实已被无效. 如果 CACHE Line 处于忙碌,例如,CPU 在密集地 STORE 和 LOAD 数据,所有数据都在 CACHE 里,这种无效操作会被延迟。另外,如果大量的 Invalidate 消息在短时间内到达,某个 CPU 可能会在处理他们上落后,从而可能阻塞所有其他的 CPU. 然而,事实上 CPU 在发送 Invalidate ACK 之前,其实并不需要实际上让 CACHE Line 切换为 Invalidate。相反,它可以将 Invaildate Message 放入到队列中,理解为在 CPU 发送关于那个 CACHE Line 的任何进一步消息之前,消息将会被处理.
上图是 Invalidate QUEUE(无效队列) 的硬件架构,当有 Invalidate Message 发送到 CPU 之后,Invalidate QUEUE 立即将 Invalidate Message 加入队列,然后立即发送 Invalidate ACK,且不必等待对应 CACHE Line 置为 Invalidate. Invalidate QUEUE 还规定了当 CPU 要发送 Invalidate Message 时,必须先去 Invalidate QUEUE 里进行确认,如果要无效的 CACHE Line 已经在无效队列中,CPU 不能立即发送 Invalidate Message,其必须等待无效队列的条目被处理完毕之后才能发送 Invalidate Message; 相反如果发送的 Invalidate Message 对应的 CACHE Line 不在 Invalidate QUEUE 里,那么可以发送 Invalidate Message. Invalidate QUEUE 承诺将 Invalidate Message 放到 Invlidate QUEUE 里,当发送任何关那个被无效 CACHE Line 的 MESI 协议消息之前,Invalidate QUEUE 会处理这条 Entry. 接下来看看加入 Invalidate QUEUE 之后对之前的案例有什么改进:
接着讨论添加了 smp_mb() 函数的场景,再回顾一下前置条件: a 与 b 初始值为 0,a 变量替换为 Read-only 即对应的 CACHE Line 状态为 Shared, 而 b 被 CPU0 独占,即 b 对应的 CACHE Line 状态是 Modify 或者 Exclusive. 然后 CPU0 执行 Func() 函数,CPU1 执行 BAR() 函数,那么处理顺序如下:
A: CPU0 开始执行 “a=1”, 但是由于 a 对应的 CACHE Line 状态为 Shared,因此向其他 CPU 发送 Invalidate Message,然后将 a 的新值存储到 Store Buffer 里.
B: CPU1 执行 “while(b==0) continue”, 但 b 不在 CPU1 的 CACHE 里,因此发送 Read Invalidate 消息
C: CPU1 收到针对 a 的 Invalidate Message,于是 Invalidate Message 加入到 Invalidate QUEUE 队列了,并理解做出 Invalidate ACK
D: CPU0 执行 smp_mb() 函数,其将 Store Buffer 里面的 Entry 进行标记
E: CPU0 收到 CPU1 发送针对 a 的 Invalidate ACK,然后将 Store Buffer 里标记的 Entry 全部写到 CACHE 里,其中包括 a 的新值写到了 CACHE Line,并标记为 Modify.
F: CPU0 开始执行 “b=1”, 由于此时 Store Buffer 里没有标记的 Entry,且 b 在 CPU0 本地 CACHE Line 的状态为 Exclusive 或者 Modify,那么直接将新值写到 CACHE Line 里,且无效通知其他 CPU.
G: CPU0 收到了针对 b 的 Read Message,于是将 b 在 CPU0 的 CACHE Line 状态修改为 Shared,并将 b 最新值传输以此作为应答.
H: CPU1 收到了针对 b 的 Read Message 应答,然后将 b 的新值存储到 CPU1 的 CACHE Line,且状态为 Shared
I: CPU1 执行 “while(b==0) continue”,此时 b 对应的 CACHE Line 是 Shared,while 循环结束,继续执行下一条指令
J: CPU1 执行 “assert(a==1)”, 此时 a 在 CPU1 的本地缓存中,并且 CACHE Line 状态为 Shared 状态(旧的状态),因此读取 a 的值,此时断言失败.
K: 尽管断言失败了,CPU1 开始处理 Invalidate QUEUE 队列中的消息,并迟钝的使 a 对应的 CACHE Line 无效.
显然,如果加速 Invalidate Message ACK 导致内存屏障实际上被忽视,那么加速就没有太大意义了. 然而内存屏障可以与 Invalidate QUEUE 交互,这样当给定的 CPU 执行内存屏障,它会标记其 Invalidate QUEUE 中的所有条目(Entry), 并强制任何后续的加载 (LOAD) 等待,直到 Invalidate QUEUE 里所有标记的条目都应用到 CPU 的 CACHE,因此可以将案例代码修改成如下:
A: CPU0 开始执行 “a=1”, 但是由于 a 对应的 CACHE Line 状态为 Shared,因此向其他 CPU 发送 Invalidate Message,然>后将 a 的新值存储到 Store Buffer 里.
B: CPU1 执行 “while(b==0) continue”, 但 b 不在 CPU1 的 CACHE 里,因此发送 Read Invalidate 消息
C: CPU1 收到针对 a 的 Invalidate Message,于是 Invalidate Message 加入到 Invalidate QUEUE 队列了,并理解做出 Invalidate ACK
D: CPU0 执行 smp_mb() 函数,其将 Store Buffer 里面的 Entry 进行标记
E: CPU0 收到 CPU1 发送针对 a 的 Invalidate ACK,然后将 Store Buffer 里标记的 Entry 全部写到 CACHE 里,其中包括 a 的新值写到了 CACHE Line,并标记为 Modify.
F: CPU0 开始执行 “b=1”, 由于此时 Store Buffer 里没有标记的 Entry,且 b 在 CPU0 本地 CACHE Line 的状态为 Exclusive 或者 Modify,那么直接将新值写到 CACHE Line 里,且无效通知其他 CPU.
G: CPU0 收到了针对 b 的 Read Message,于是将 b 在 CPU0 的 CACHE Line 状态修改为 Shared,并将 b 最新值传输以此作 为应答.
H: CPU1 收到了针对 b 的 Read Message 应答,然后将 b 的新值存储到 CPU1 的 CACHE Line,且状态为 Shared
I: CPU1 执行 “while(b==0) continue”,此时 b 对应的 CACHE Line 是 Shared,while 循环结束,继续执行下一条指令
J: CPU1 执行 “smp_mb()” 指令,那么 CPU1 Stall 直到本地 Invalidate QUEUE 清空.
K: CPU1 现在开始处理 Invalidte QUEUE 中的消息,其中包括针对 a 的 Invalidate 操作,因此 a 对应的 CACHE Line 切换成 Invalidate.
L: CPU1 执行 “assert(a==1)”, 发现 a 不在 CPU1 的 CACHE 里,于是发送 Read Message
M: CPU0 收到针对 a 的 Read Message,于是传送 a(a==1) 的值并将对应的 CACHE Line 标记为 Shared 作为应答
N: CPU1 收到针对 a 的 Read Message 应答,于是将 a 存储在本地 CACHE Line,并标记为 Shared
O: CPU1 从 CACHE 中读取 a 的值并进行断言,此时 a 为 1,断言为真.
在大量的 MESI 消息传递下,CPU1 获得了正确的结果,以上案例说明了 CPU 设计者为什么在 CACHE 一致性优化上必须非常小心. 另个通过内存屏障和 Invalidate QUEUE 的结合,MESI 消息的传递变得更少,一来缓解了总线压力,二来也可以保证结果的正确性.
Read and Write Memory Barriers
在前面两个例子里内存屏障 smp_mb() 被用来标记 Store Buffer 和 Invalidate QUEUE 里面的项目(Entry). 但在案例代码片段里,Func() 并没有理由区处理 Invalidate QUEUE, BAR() 同样没有理由去处理 Store Buffer. 因此,许多 CPU 架构提供了较弱的内存屏障指令,这些指令只对其中一个队列进行操作。譬如读内存屏障只标记 Invalidate QUEUE, 而写内存屏障只标记 Store Buffer, 而一个全功能的内存屏障则同时处理这两个缓冲区.
对之前的案例进行修改,这样的效果是,读内存屏障只对执行它的 CPU 上的加载(LOAD)进行排序,因此,所有在读内存屏障之前的加载看起来都已在读内存屏障之后的任何加载之前完成。类似地,写内存屏障只对存储(STORE)进行排序,同样只对执行它的 CPU 有效,并且所有在写内存屏障之前的存储看起来都已在写内存屏障之后的任何存储之前完成。全功能的内存屏障既对加载(LOAD)也对存储(STORE)进行排序,但仍然只对执行内存屏障的 CPU 有效.
SMP RMB/WMB 使用
在 Linux 里 ‘smp_rmb()’ 函数是一个读内存屏障(Read Memory Barrier), 其防止编译器和处理器对其前后的读操作进行重新排序(乱序),’smp_rmb()’ 函数确保了其在前面的所有读操作在内存中的完成(即结果对其他处理器可见)都会早于在它后面的任何读操作的完成. 与此类似 ‘smp_wmb()’ 函数会影响 CPU 的指令顺序,确保函数调用之前的写操作在函数调用之后的写操作之前完成。那么接下来通过一个实践案例了解 smp_rmb() 和 smp_wmb() 如何使用,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] SMP Read/Write Barrier ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-SMP-RWMB-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-SMP-RWMB-default Source Code on Gitee
实践案例由一个内存模块构成,其核心是一个 RingBus 环形缓存,由数据结构 struct RingBuffer 进行描述,其包括环型缓存的 Entry 部分和头尾指针。模块通过创建两个内核线程,一个作为生产者,另外一个作为消费者。producer_thread() 是生成者运行的函数,其代码逻辑是在 31-45 行执行一个 for 循环 100 次,32 行首先检查头尾指针是否相对来判断 RingBuffer 是否满了,如果满了那么就延时 10 ms; 如果 RingBuffer 没有满,那么执行 37 行代码,向 RingBuffer 写入最新的数据,然后在 43 行更新 RingBuffer 的头指针,并且在 42 行插入了 smp_wmb() 函数确保其他 CPU 看到的代码逻辑是: 先更新数据再更新头指针, 然后生产者延时 50ms 在进行下一次循环. consumer_thread() 是消费者运行的函数,其现在 54 行对 RingBuffer 的头和尾进行比较,以此确认 RingBuffer 是否有新数据,如果没有那么延时 10ms; 反之如果有新数据,那么 62 行读出最新的数据,这里在 61 行插入了 smp_rmb() 函数先看到更新数据再更新头指针,即头指针指向最新的数据. 消费者在读取完数据之后更新尾指针. 那么接下来在 BiscuitOS 上实践该案例:
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本,可以看到消费者读取的数据是生产者生产的,顺序没有被改变。那么接下来分析 smp_rmb() 函数和 smp_wmb() 函数是如何保持执行顺序是一致的. 假设上面的案例中没有添加 smp_rmb() 函数和 smp_wmb() 函数,然后 CPU0 执行 producer_thread() 函数作为生产者, CPU1 执行 consumer_thread() 函数作为消费者,然后通过 struct RingBuffer 的定义可以知道 data[] 数组在一个 CACHE Line 里,head 和 tail 在同一个 CACHE Line 里。由于 62 行的逻辑 data[] 成员在 CPU1 里面是只读,即其在 CPU1 的 CACHE Line 状态为 Shared. 然后由于 37 行代码逻辑 CPU0 对 head 和 tail 所在的 CACHE Line 有独占权,那么其在 CPU0 CACHE Line 的状态为 Exclusive 或者 Modify. 那么会有如下逻辑:
A: CPU0 开始执行 “buf.data[buf.head] = i” 代码,由于 CPU0 对 “buf.data[]” 所在的 CACHE Line 状态为 Shared,那么 CPU0 发起针对 “data[]” 的 Invalidate Message, 然后将 “buf.data[buf.head]” 新值写入到 Store Buffer 里.
B: CPU1 开始执行 “buf.tail == buf.head”, 由于 tail 和 head 不在 CPU1 的 CACHE 里,因此 CPU1 发送针对 tail/head 的 Read Message
C: CPU1 收到针对 “data[]” 的 Invalidate Message,那么将该 Invalidate Message 加入到 Invalidate QUEUE 之后,立即发送针对 “data[]” 的 Invalidate ACK.
D: CPU0 执行 “buf.head = (buf.head + 1) % RING_SIZE” 指令,由于 Store Buffer 里没有 Marked 的 Entry,因此此次 STORE 操作不会被 Store Buffer 卡住,另外由于 head 和 tail 在 CPU0 的 CACHE 里,并且对应的 CACHE Line 状态为 Exclusive 或者 Modify,那么 CPU0 可以直接执行 STORE 操作,无需向其他 CPU 发送消息,那么 buf.head 的新值写入到 CPU0 的 CACHE Line 里.
E: CPU0 收到针对 head/tail 的 Read Message,于是 CPU 将 head/tail 的最新值发送出去并将其在 CPU0 里 CACHE Line 状态切换成 Shared.
F: CPU0 收到针对 “data[]” 的 Invalidate ACK,那么 CPU0 将 Store Buffer 里面 “data[]” 最新的值写入到 CACHE 里,并将 CACHE Line 标记为 “Modify”.
F: CPU1 收到针对 head/tail 的 Read Message ACK,然后将 head/tail 的新值存储到自己的 CACHE Line,并将 CACHE Line 修改为 Shared.
G: CPU1 执行 “buf.tail == buf.head”, 此时 tail/head 在自己的 CACHE Line 且为 Shared,那么 CPU1 直接加载(LOAD) 执行,条件不符合,那么 RingBuffer 有新的值,此时 CPU1 继续执行下一指令.
H: CPU1 执行 “printk(“Consumed: %d\n”, buf.data[buf.tail]);”, 由于针对 “data[]” 的 Invalidate Message 还在 Invalidate QUEUE 里,那么 CPU1 看到的是 “data[]” 在自己的本地 CACHE 里,且对应 CACHE Line 的状态为 Shared,那么 CPU 直接直接读取 “data[]” 的数据,但此时数据还是旧的,新的值还在 CPU0 的 CACHE 里.
I: 尽管 CPU1 读到旧的值,CPU1 还是开始处理 Invlidate QUEUE 里面的消息,其中包括将 “data[]” 在 CPU1 CACHE Line 标记为 Invalidate.
通过上面的案例,可以看到 CPU1 消费者最后读到一个旧值, 与预期不符合。从 CPU1 角度看 CPU0 的两条指令乱序,CPU 先更新了 head 在更新 “data[]”,而 CPU1 因为 Invalidate QUEUE,都已经 Invalidate 的内容还被 LOAD. 那么为了解决这个问题,首先需要让 CPU0 在其他 CPU 看来是保序的,其次是 CPU1 不能 LOAD 已经 Invalidate 数据, 于是在案例的 42 行添加了 smp_wmb() 函数可以保证每次写操作先写 ‘data[]’ 再写索引,另外 61 行添加的 smp_rmb() 函数确保了 CPU1 可以保证每次读操作先读到最新的索引值再读到最新的数据, 那么处理逻辑变成如下:
A: CPU0 开始执行 “buf.data[buf.head] = i” 代码,由于 CPU0 对 “buf.data[]” 所在的 CACHE Line 状态为 Shared,那么 CPU0 发起针对 “data[]” 的 Invalidate Message, 然后将 “buf.data[buf.head]” 新值写入到 Store Buffer 里.
B: CPU1 开始执行 “buf.tail == buf.head”, 由于 tail 和 head 不在 CPU1 的 CACHE 里,因此 CPU1 发送针对 tail/head 的 Read Message
C: CPU0 执行 “smp_wmb()” 指令,其将 Store Buffer 里面的 Entry 全部进行标记.
D: CPU0 继续执行 “buf.head = (buf.head + 1) % RING_SIZE” 指令,就算 head/tail 位于 CPU0 的本地 CACHE 里,且对应的 CACHE Line 状态为 Modify/Exclusive, 但由于 Store Buffer 里有标记的 Entry,那么这次 STORE 操作只能放到 Store Buffer 里.
E: CPU1 收到了针对 “data[]” 的 Invalidate Message,于是将其放到 Invalidate QUEUE 里并立即返回针对 “data[]” 的 Invalidate ACK
F: CPU0 收到了针对 “data[]” 的 Invalidate Message ACK, 于是将 Store Buffer 里面的 Entry 进行 Flush 动作,此时 CPU0 对 “data[]” 独占,于是将新值直接写到 CACHE 里,并将 “data[]” 对应的 CACHE Line 标记为 Modify.
G: CPU1 继续处理 Store Buffer 里面的 Entry,由于此时 CPU0 对 head/tail 的 CACHE Line 独占,因此 Store Buffer 将 head/tail 的新值写到 CACHE Line 里,并将 CACHE Line 标记为 Modify.
H: CPU0 收到了针对 head/tail 的 Read Message,那么传送 head/tail 最新值作为 ACK,并将对应的 CACHE Line 修改为 Shared.
I: CPU1 收到针对 head/tail 的 Read Message ACK 之后,将其值存储在 CPU1 的 CACHE Line 里,并标记为 Shared.
J: CPU1 执行 “buf.tail == buf.head”, 此时 head/tail 位于 CPU1 的 CACHE 里,并且 CACHE Line 状态为 Shared,那么 CPU1 直接读取其值,然后判断为假,结束循环执行下一条指令 “smp_rmb”, smp_rmb() 将 CPU1 Invalidate QUEUE 里的所有 Entry 进行标记
K: CPU1 继续执行 “printk(“Consumed: %d\n”, buf.data[buf.tail])” 读操作,此时 CPU1 看到 “data[]” 在其 CACHE 里,并且对应的 CACHE Line 状态为 Shared,但由于 Invalidate QUEUE 里有 mark 的 Entry,因此 CPU1 不得不先处理 Invalidate QUEUE 里的消息,其中包括使 “data[]” 无效.
L: CPU1 处理完 Invalidate QUEUE 里的消息之后,CPU1 再次加载 “data[]”, 但其对应的 CACHE Line 状态为 Invalidate,那么其向其他 CPU 发送针对 “data[]” 的 Read Message.
M: CPU0 收到针对 “data[]” 的 Read Message 之后,将 data[] 的新值作为 ACK 发送出去,并将 data[] 对应的 CACHE Line 标记为 Shared.
N: CPU1 收到针对 “data[]” 的 Read Message ACK 之后,将 data[] 的新值存储在 CPU1 的 CACHE 里,并标记为 “Shared”
O: CPU1 继续执行 “printk(“Consumed: %d\n”, buf.data[buf.tail])” 读操作,此时读到 “data[]” 的最新值.
通过添加 smp_rmb() 函数和 smp_wmb() 函数之后,最后计数的结果正确。整体来看,针对两个 CACHE Line 之前存在一定的读写耦合,可以通过添加内存屏障来保证并行编程时的读写顺序.
VMALLOC 分配器 vmallocinfo 场景
VMALLOC 分配器为用户空间提供了 “/proc/vmallocinfo” 接口,用于查看 VMALLOC 分配器分配情况。在内核里 VMALLOC 分配器维护一段独立的虚拟内存,申请者可以从 VMALLOC 分配器分配一段连续的虚拟内存,但这段虚拟内存会映射到离散的物理页上,以此形成虚拟连续物理不连续的内存。VMALLOC 分配器使用 struct vm_struct 数据结构维护其分配的一段内存:
struct vm_struct 数据结构的 addr 成员指向这段内存的起始虚拟地址,size 成员表示这段内存的大小,pages 成员用于存储这段内存映射的离散物理页,nr_pages 成员指明这段内存映射了多少物理页,caller 指明申请这段内存的拥有者. next 成员用于加入到全局的 vmlist, VMALLOC 分配器将所有的 struct vm_struct 维护在 vmlist 链表里; VMALLOC 分配器是 struct vmap_area 数据结构描述 VMALLOC 分配器分配的一段内存的虚拟内存区域,其维护在 VMALLOC 的 vmap_area_root 红黑树里,其 list 成员维护在 vmap_area_list 链表里。当上层调用 “/proc/vmallocinfo” 接口时,内核会遍历 vmap_area_list 链表上的所有节点,然后通过遍历节点获得对应的 struct vm_struct 结构体,然后从中获得相关的信息进行显示。以上便是本节讨论的内存屏障背景:
当上层调用 “/proc/vmallocinfo” 接口时,show_numa_info() 函数会被调用,其 4070 行存在一个 smp_rmb() 读内存屏障, 其对应的 smp_wmb() 位于 clear_vm_uninitialized_flag() 函数:
在 vmallocinfo() 中使用 vmap_area_list 在 SMP 系统中引入了排序问题。在 s_show() 中,从 vm_struct 中检索一些值, 当 va->vm 被赋值时,即取出一个 VMALLOC 内存区域,vm_struct 的值并没有完全初始化,也就某个 CPU 正在新分配一段 VMALLOC 内存还没有初始化完毕就被另外一个 CPU 遍历到了。完全设置的通知是通过在不持有锁的情况下移除 struct vm_struct 数据结构 flags 成员的 VM_UNINITIALIZED 标志来实现的。当我们看到 VM_UNINITIALIZED 被移除时,并不能确保 vm_struct 在其他 CPU 的视角下有合适的值. 那么接下来分析一下,在不加内存屏障的情况下如何导致数据出错的: 假设 CPU0 执行 __vmalloc_node_range() 函数,而 CPU1 执行 show_numa_info() 函数,另外 struct vm_struct 数据结构的 pages[] 成员与 flags 成员分别在两个 CACHE Line 里,由 4075 行的代码逻辑假设 pages[] 在 CPU1 中只读,即其在 CPU1 的 CACHE Line 状态为 Shared, pages[] 的初始化为空; 接着由 2461 行的代码逻辑假设 CPU0 对 flags 有独占权,即其对应的 CACHE Line 在 CPU0 CACHE 里的状态是 Exclusive 或者 Modify, flags 初始值为 VM_UNINITIALIZED. 那么在没有内存屏障的场景下其代码逻辑如下:
A: CPU0 开始执行 __vmalloc_node_range(), 其中包括对 pages[] 的写操作,但是此时 pages[] 在 CPU0 的 CACHE 里的状态是 Shared,于是 CPU0 发送针对 pages[] 的 Invalidate 消息,并将 pages[] 写操作存储在 Store Buffer 里.
B: CPU1 开始执行 show_numa_info() 函数,其中首先判断 “v->flags & VM_UNINITIALIZED”, 于是 CPU1 在本地 CPU 中没有找到,因此于是发起针对 flags 的 Read Message.
C: CPU0 继续执行 clear_vm_uninitialized_flag() 函数的 “vm->flags &= ~VM_UNINITIALIZED”, 由于 CPU0 对 flags 有独占权,且 Store Buffer 里没有 Marked 的 Entry,那么 CPU0 直接将 flags 的新值写入到 CACHE 里,此时无需通知其他 CPU.
D: CPU1 收到了针对 pages[] 的 Invalidate 消息,CPU1 将 Read Invalidatre 消息存储到 Invalidate QUEUE 里,然后直接发送针对 pages[] 的 Invalidate ACK
E: CPU0 收到针对 flags 的 Read Message,于是将 flags 最新的值发送应答,并将 flags 在 CPU0 中的 CACHE Line 状态修改为 Shared.
F: CPU0 收到针对 pages[] 的 Invalidate ACK 之后,从 Store Buffer 里将 pages 最新的值写入到 CACHE 里,并将 pages 在 CPU0 对应的 CACHE Line 修改为 Modify.
G: CPU1 收到针对 flags 的 Read Message 应答之后,将 flags 存储到 CPU1 CACHE 里,并将对应的 CACHE Line 状态修改为 Shared.
H: CPU1 从 CACHE Line 读取 flags 的值,此时 flags 已经没有 VM_UNINITIALIZED,那么 CPU1 继续执行 4075 行代码,需要读取 pages[] 的值,此时由于 Invalidate QUEUE 里 Entry 还没有处理,那么 CPU1 看到本地 CACHE 里有 pages[], 且对应的 CACHE Line 状态为 Shared,那么直接读取值,此时值是旧的值。
I: CPU1 处理 Invalidate QUEUE 里的 Entry,其中包括将 pages[] 对应的 CACHE Line 标记为无效,可是为时已晚.
通过上面场景的分析,可以看到 CPU1 读到 pages[] 旧值,与预期不符合. 从其他 CPU 角度来看,CPU0 的两条指令乱序了,CPU0 先更新了 flags 然后再更新 pages[], 这会让 CPU1 读到 flags 符合条件的情况下读到了一个旧的值. 另外 CPU1 里 pages[] 对应的 CACHE Line 已经无效还是当做 Shared 状态进行使用,两个错误最终导致获得错误的结果。那么对于 CPU0 需要加入 smp_wmb() 写内存屏障,确保其他 CPU 看到 CPU0 先写 pages[] 再写 flags; 另外对于 CPU1 需要加入 smp_rmb() 读内存屏障,确保其先读到 flags 的值,再读到 pages[] 的值. 因此同样的场景假设,加了内存屏障之后的代码逻辑如下:
A: CPU0 开始执行 __vmalloc_node_range(), 其中包括对 pages[] 的写操作,但是此时 pages[] 在 CPU0 的 CACHE 里的状态为 Shared,于是 CPU0 发送针对 pages[] 的 Invalidate 消息,并将 pages[] 写操作存储在 Store Buffer 里.
B: CPU1 开始执行 show_numa_info() 函数,其中首先判断 “v->flags & VM_UNINITIALIZED”, 于是 CPU1 在本地 CPU 中没有找到,因此于是发起针对 flags 的 Read Message.
C: CPU0 执行 smp_wmb() 写内存屏障,于是将 Store Buffer 里全部 Entry 进行标记.
D: CPU0 继续执行 clear_vm_uninitialized_flag() 函数的 “vm->flags &= ~VM_UNINITIALIZED”, 由于此时 CPU0 对 flags 有独占权,但是 Store Buffer 里有被标记的 Entry,因此此时写操作只能存储到 Store Buffer 里.
E: CPU1 收到针对 pages[] 的 Invalidate Message,于是将消息存储到 Invalidate QUEUE 里,然后理解发送针对 pages[] 的 Invalidate ACK.
F: CPU0 收到针对 pages[] 的 Invalidate ACK 之后,将 Store Buffer 进行处理,其将 pages[] 的新值写入到 CACHE 里,并将对应的 CACHE Line 修改为 Modify.
G: CPU0 继续将 Store Buffer 里的 flags 写入到 CACHE 里,由于此时 CPU0 独占 flags,因此无效通知其他 CPU 直接将 flags 的新值写入到 CACHE
H: CPU0 收到针对 flags 的 Read Message,于是传送 flags 的新值作为应答,并将对应的 CACHE Line 标记为 Shared
I: CPU1 收到针对 flags 的 Read ACK,然后将新值存储到 CPU 的 CACHE 中,并将 CACHE Line 标记为 Shared
J: CPU1 继续执行 “v->flags & VM_UNINITIALIZED”,此时 flags 在本地缓存且为 Shared,那么直接读取,此时条件满足 CPU1 继续执行下面的代码
K: CPU1 执行 smp_rmb() 读内存屏障,将 CPU1 的 Invalidate QUEUE 全部 Entry 进行标记
L: CPU1 继续执行 4075 行代码,此时 CPU1 看到 pages[] 在本地缓存,且 CACHE Line 状态为 Shared,但是由于 Invalidate QUEUE 里有标记的 Entry,于是 CPU1 必须先处理 Invalidate QUEUE 里面的信息,其中包括将 pages[] 对应的 CACHE Line 标记为 Invalidate
M: CPU1 处理完 Invalidate QUEUE 之后继续执行 4075 行代码,但是 CPU1 发现 pages[] 对应的 CACHE Line 状态为 Invalidate,于是发送针对 pages[] 的 Read Message
N: CPU0 收到针对 pages[] 的 Read Message 之后,传送 pages[] 的新值作为应答,并将其对应的 CACHE Line 修改为 Shared.
O: CPU1 收到针对 pages[] 的 Read ACK 之后,将其值存储到 CPU1 的本地 CACHE,并将对应的 CACHE Line 修改为 Shared
P: CPU1 从 CACHE 中读取 pages[] 的值,该值为最新的值.
通过添加 smp_rmb() 函数和 smp_wmb() 函数之后,最后计数的结果正确. 从整体来看,可以使用 smp_r[w]mb 内存屏障实现无锁同步的功能,内存屏障可以读写的正确序.
内存屏障实现无锁同步
在 Linux 里同步原语有很多技术,例如各种锁机制,锁机制可以在并行编程中对共享资源起到保护和同步作用,是多核架构开发必不可少的技术,但不同的锁实现的方式不同,另外因为共享资源被上锁导致等待给性能带来了折损,那么有没有一种不需要锁也可以实现的同步呢? 还真别说,本节将介绍如何使用内存屏障实现无锁同步, 首先通过一个实践案例介绍如何实现无锁同步,其在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] NO-LOCKING Synchronization ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-NOLOCKING-SYNC-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-NOLOCKING-SYNC-default Source Code on Gitee
实践案例采用内核模块进行讲解,其逻辑是建立了一个 struct node 的单链表(表头为 nodelist), 模块创建了两个内核线程,一个为生产者线程,其调用 producer_thread(), 其逻辑是动态创建一个 struct node 数据结构的新节点,首先将新节点插入到 nodelist 头部,然后再进行初始化,新节点初始化完毕会调用 53 行逻辑添加 NODE_INITIALIZED; 另外一个内核线程用于遍历 nodelist 上的所有节点,其逻辑是先获得单链表,然后依次遍历已经初始化好的节点,如果节点没有初始化好,那么直接跳过遍历下一个节点. 那么接下来在 BiscuitOS 上进行实践:
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 启用测试,可以看到 consumer_thread 可以成功遍历 nodelist 链表,且没有遍历到正在初始化的节点,实践符合预期。那么接下来回到今天讨论的正题,内存屏障是如何实现无锁同步的,首先分析存在竞态的地方: producer_thread() 函数在 46-47 行在对节点进行初始化,consumer_thread() 函数在 78 行访问节点信息,这两个地方存在竞态,即一个内核线程在初始化一个节点,而另外一个内核线程在访问节点,这样会导致内核线程访问到未初始化的数据。首先抛开内存屏障不谈,看看原始案例怎么处理: producer_thread() 在 53 行向节点的 flags 添加 NODE_INITIALIZED 标志,以此表示节点已经初始化完毕,而 consumer_thread() 在 73 行判断节点的 flags 是否包含 NODE_INITIALIZED 标志,以此判断节点是否已经初始化完毕并可以访问,如果没有初始化完毕就直接遍历下一个,反之对节点直接访问. 在单核系统上以上逻辑确实可以保证 consumer_thread 遍历全部已经初始化的节点,但到了多核架构下,以上的逻辑可能被打破,首先假设一种场景进行分析: 假设 CPU0 执行 producer_threder() 函数,CPU1 执行 consumer_thread() 函数,其中 node->data 指向的内存是动态分配的,因此 node->data 指向的内存与 node->flags 不在同一个 CACHE Line 上,那么 node->flags 对应的 CACHE Line 在 CPU0 里独占,因此 node->flags 在 CPU0 里的 CACHE Line 状态为 Exclusive 或者 Modify,而 node->data 指向的内存在 CPU1 里只读,因此 node->data 指向的内存在 CPU1 的 CACHE Line 状态为 Shared. node->flags 的初始值为 0,那么在没有内存屏障的场景下其处理逻辑如下:
A: CPU0 开始执行 producer_thread() 函数,其中包括对 node->data 写操作,但此时 node->data 在 CPU0 的 CACHE Line 状态为 Shared,于是发送针对 node->data 的 Invalidate Message,然后将写操作存储到 Store Buffer 里.
B: CPU1 开始执行 consumer_thread() 函数,其中包括 “np->flags & NODE_INITIALIZED” 的逻辑,由于 CPU1 本地没有 node->flags 的备份,于是发送针对 node->flags 的 Read Message.
C: CPU0 继续执行 ‘node->flags |= NODE_INITIALIZED’, 由于 node->flags 在 CPU0 CACHE Line 的状态为 Exclusive 或者 Modify,并且 Store Buffer 里没有被 Marked 的 Entry,于是直接将 node->flags 的新值更新到 CACHE 里,并且无需通知其他 CPU.
D: CPU1 收到针对 node->data 的 Invalidate Message,CPU1 将该消息存储在 Invalidate QUEUE 里,然后理解发送针对 node->data 的 Invalidate ACK
E: CPU0 收到针对 node->flags 的 Read Message,于是将 node->flags 的值打包发送作为应答,并将 node->flags 所在的 CACHE Line 标记为 Shared
F: CPU0 收到针对 node->data 的 Invalidate ACK 之后,将 Store Buffer 里的 node->data 值更新到 CACHE 里,并将 CACHE Line 的状态标记为 Modify.
G: CPU1 收到针对 node->flags 的 Read Message ACK 之后,将 node->flags 的值存放在 CACHE Line 里,CPU 继续执行 “np->flags & NODE_INITIALIZED” 判断,此时从 CACHE 中读取 node->flags 的值,此时 node->flags 已经包含了 NODE_INITIALIZATION, 于是条件满足继续执行下一条指令: “*np->data”, 此时 CPU1 发现 node->data 位于自己的 CACHE 里,并且对应的 CACHE Line 为 Shared,于是直接从 CACHE Line 读取 node->data 的值并进行打印,但殊不知该值是旧的.
H: CPU1 接下来将 Invalidate QUEUE 里的 Entry 进行处理,其中包括将 node->data 对有的 CACHE Line 标记为 Invalidate,但为时已晚,CPU1 已经读到错误的值.
通过上面的场景分析,可以看到 CPU1 读到了 node->data 旧值,就是未初始化的值,与预期不符合。另外从其他 CPU 角度来看,CPU0 的两条写操作乱序了,CPU0 线更新了 node->flags, 然后再写 node->data, 这会让 CPU1 读到 node->flags 符合条件的情况下读到一个错误的值。另外 CPU1 的 node->data 已经收到 Invalidte Message 之后还是继续当做 Shared 继续使用,也是一个错误。通过该场景的分析,可以看到多核架构引入的问题措不及防。那么接下来看看内存屏障如何处理该问题,正如上图的代码所示,在 52 行添加了 smp_wmb() 写内存屏障,在 76 行添加了 smp_rmb() 读内存屏障,其处理逻辑如下:
A: CPU0 开始执行 producer_thread() 函数,其中包括对 node->data 写操作,但此时 node->data 在 CPU0 的 CACHE Line 状态为 Shared,于是发送针对 node->data 的 Invalidate Message,然后将写操作存储到 Store Buffer 里.
B: CPU1 开始执行 consumer_thread() 函数,其中包括 “np->flags & NODE_INITIALIZED” 的逻辑,由于 CPU1 本地没有 nod e->flags 的备份,于是发送针对 node->flags 的 Read Message.
C: CPU0 继续执行 smp_wmb 写屏障,其将 Store Buffer 里面的 Entry 全部进行标记
D: CPU1 继续执行 node->flags |= NODE_INITIALIZED’, 由于 node->flags 在 CPU0 CACHE Line 的状态为 Exclusive 或者 Modify,但 Store Buffer 里有被 Marked 的 Entry,于是直接将 node->flags 的新值存储到 Store Buffer.
E: CPU1 收到针对 node->data 的 Invalidate Message,于是将该信息直接放到 Invalidate QUEUE 里,然后直接发送应答信息
F: CPU0 收到针对 node->data 的 Invalidate ACK 之后,将 Store Buffer 标记的 node->data 写操作写到 CACHE 里,并将对应的 CACHE Line 标记为 Modify
G: CPU0 继续将 Store Buffer 的 node->flags 写操作更新到 CACHE 里,由于此时 CPU0 独占 node->flags,那么直接写到 CACHE 里,并将对应的 CACHE Line 标记为 Modify,并且无需通知其他 CPU.
H: CPU0 收到针对 node->flags 的 Read Message,于是将 node->flags 的新值打包发送作为应答,并将对应的 CACHE Line 标记为 Shared
I: CPU1 收到针对 node->flags 的 Read 应答,并将 node->flags 新值存储到 CPU1 CACHE 里,并将对应的 CACHE Line 标记为 Shared, CPU 继续执行 “np->flags & NODE_INITIALIZED” 判断,此时从 CACHE 中读取 node->flags 的值,此时 node->flags 已经包含了 NODE_INITIALIZATION, 于是条件满足继续执行下一条指令: “*np->data”, 此时 CPU1 发现 node->data 位于自己的 CACHE 里,并且对应的 CACHE Line 为 Shared,但是此时 Invalidate QUEUE 里有未完成的 Entry,于是 CPU1 对 Invalidate QUEUE 进行处理
J: CPU1 处理 Invalidate QUEUE 包括将 node->data 标记为 Invalidate,于是 node->data 在 CPU1 的 CACHE Line 的状态变成了 Invalidate.
K: CPU1 处理完 Invalidate QUEUE 之后,继续执行 “*np->data”, 此时 CPU1 发现 node->data 对应的 CACHE Line 状态为 Invalidate,于是发送针对 node->data 的 Read Message
L: CPU0 收到针对 node->data 的 Read Message,于是将 node->data 的新值打包发送作为应答,并将其对应的 CACHE Line 标记为 Shared
M: CPU1 收到针对 node->data 的 Read ACK, 将 node->data 的新值存储到 CACHE 里,然后 CPU1 继续执行 “*np->data”, 此时 CPU1 发现 node->data 对应的 CACHE Line 状态为 Shared,于是从 CACHE 里直接读取新值.
通过添加 smp_rmb() 和 smp_wmb() 函数之后,两个内核线程可以获得正确的结果,并且从其他 CPU 看来也可以按正常的顺序执行。在内核里这种运用挺多了,例如内存管理里,某个内核线程正在使用分配器正在分配新的节点,另外一个内核线程就在遍历该内存分配器的所有节点,如果直接使用锁的化,那么竞态太多,此时采用内存屏障来保证标记的正确性,即与标志前后的资源的状态得到保证,例如上面的案例,NODE_INITIALIZATION 标志之前的 node 节点一定是没有初始化完毕的,标志之后的 node 节点一定是初始化完毕的,那么无需天添加锁就可以实现对共享资源的访问.
TTU: Isoloation Dirty Page 场景
在 try_to_unmap_one() 函数中存在上图的逻辑,其代码逻辑是函数调用 folio_test_swapbacked() 函数检查物理页是否已经在 SWAP 缓存里,如果不再那么进入 1665 分支进行处理,其首先读取物理页的 _refcount 和 _mapcount 两个引用计数,然后在 1687 行判断该物理页是否为孤立的物理页,所谓孤立的物理页就是只有被一个或者多个地址空间映射,没有被任何其他引用,例如被加入到 LRU 链表等,那么对于孤立物理页且不是脏页,那么该物理页可以直接被丢弃,这里的丢弃就是 umap 掉; 反之物理页要么不是孤立的,要么就是孤立的但是脏页,那么这个物理页不能被 umap,因此重新映射.
第一个内存屏障是 smp_mb(), 通过注释可以知道其确保 gup_pte_range() 函数的两种操作的序,第一种是 gup_pte_range() 函数如果清除了 PTE,那么内存屏障可以确保 “Clear PTE” 和 “Read Refcount” 的序,也就是说如果看到 PTE Entry 已经被清除,那么读取 Refcount 应该反应这个事实; 另一种是 gup_pte_range() 函数增加 Refcount,那么内存屏障可以确保 “Inc Refcount” 和 “Read PTE” 的序, 也就是说如果看到引用计数增加,那么正在读取 PTE. 另外 gup_pte_range() 函数的作用是遍历一个用户空间地址范围内所有 PTE,并为每个映射到该范围的物理页增加引用计数. 换个角度来看,加入没有内存屏障在这里保护,那么编译器和处理器可能会重新排序内存操作,即使这些操作在代码中以特定的顺序出现,因此缺少内存屏障可能会导致数据的不一致和竞态条件,具体如下:
clear PTE 操作可能会被重新排序,使其在 read refcount 之后发生,这可能会导致错误的引用计数,因此这个值可能在 clear PTE 之后被修改.
inc refcount 操作可能会在 read PTE 之后发生,这可能会导致错误的 PTE 值,因为这个值可能在 inc refocunt 之后被修改.
因此这里需要添加 ‘smp_mb()’ 来确保 PTE 和 refcount 之间的序,那么在回到上图的代码,smp_mb() 函数的存在,可以通过 folio_ref_count() 的返回值确认 PTE 的行为,其中在 1687 行的 “ref_count == 1 + map_count” 可以准确判断该 PTE 是一个孤立的 PTE,没有被其他额外引用.
接下来存在另外一个读内存屏障,有了这个内存屏障用于确保读取 _refcount 和读取脏页标记 两个操作的顺序的顺序,这段代码中,先进行对 folio_ref_count 和 folio_mapcount 的读取,然后是内存屏障,之后在读取 folio 的 dirty 标志. 如果没有这个内存屏障,处理器可能会先读取 folio 的 dirty 标志,然后在读取另外两个值。这会导致基于不正确信息的决策,因为 dirty 标志的读取实际上依赖于 folio_ref_count 和 folio_mapcount 的值. 那么详细分析一下为什么没有内存屏障导致判断出错呢?假设 CPU0 执行对 folio page 的 Dirty 进行置位操作,然后 CPU1 执行上图的代码,起初 CPU0 和 CPU1 对 folio page 对应的 CACHE Line 只读,即使 CACHE Line 状态为 Shared, 那么接下来处理逻辑如下:
A: CPU1 首先调用 folio_ref_count() 函数读取 folio page 的 _refcount 值,此时 CPU1 CACHE 里只有对 _refcount 的只读权限,于是将 _refcount 最新值存储在 ref_count 变量里,同理将 _mapcount 最新值存储在 map_count 变量里,此时 ref_count 和 map_count 属于同一个 CACHE Line,此时对应的 CACHE Line 状态为 Modify.
B: CPU0 将 folio page 标记为脏页,因此会向其他 CPU 发送针对 folio 的 Invalidate Message
C: CPU1 收到针对 folio 的 Invalidate Message,于是将 Message 放到 Invalidate QUEUE,并立即回复 ACK,但此时 folio 在 CPU1 的 CACHE Line 状态仍然是 Shared
D: CPU0 收到针对 folio 的 Invalidate ACK 之后,将 folio 的 _refcount 加 1 并将 flags 标记为脏页
E: CPU1 执行判断条件 “ref_count == 1 + map_count”, 由于 ref_count 和 map_count 在同一个 CACHE Line,并且被 CPU1 独占,那么 CPU1 直接从 CACHE 里读取两者的值,此时符合条件,那么 CPU1 继续执行 “!folio_test_dirty(folio)” 的判断,由于 CPU1 没有及时处理 Invalidate QUEUE,因此 CPU1 看到的是 folio 在自己的 CACHE 里,并且 CACHE Line 对应的状态是 Shared,那么 CPU1 直接读取 folio 的 flags,结果就是 folio 不是脏页, 那么进入 1689 行继续执行.
F: CPU1 继续处理 Invalidate QUEUE 里的 Entry,其中包括将 folio 对应的 CACHE Line 标记为 Invalidate,但此时为时已晚.
从上面的案例可以看出明明页已经被标记为脏页了,但还是被判断为非脏页. 从其他 CPU 角度来看,CPU1 执行的的两条读操作发生了乱序,即 CPU1 先读取脏页的信息,再读取 ref_count 和 map_count,对于标记脏页的 CPU,其会先增加 _refcount 的引用,然后在标记脏页,因此正确的逻辑会先判断有没有其他 CPU 正在访问该页,如果有那么就不用检查脏页了。为此这里需要添加一个 smp_rmb() 读内存屏障确保读顺序,那么逻辑会变成如下:
A: CPU1 首先调用 folio_ref_count() 函数读取 folio page 的 _refcount 值,此时 CPU1 CACHE 里只有对 _refcount 的只读权限,于是将 _refcount 最新值存储在 ref_count 变量里,同理将 _mapcount 最新值存储在 map_count 变量里,此时 ref_count 和 map_count 属于同一个 CACHE Line,此时对应的 CACHE Line 状态为 Modify.
B: CPU0 将 folio page 标记为脏页,因此会向其他 CPU 发送针对 folio 的 Invalidate Message
C: CPU1 收到针对 folio 的 Invalidate Message,于是将 Message 放到 Invalidate QUEUE,并立即回复 ACK,但此时 folio 在 CPU1 的 CACHE Line 状态仍然是 Shared
D: CPU0 收到针对 folio 的 Invalidate ACK 之后,将 folio 的 _refcount 加 1 并将 flags 标记为脏页
E: CPU1 执行 smp_rmb() 读内存屏障,将 Invalidate QUEUE 里所有 Entry 进行标记
F: CPU1 执行判断条件 “ref_count == 1 + map_count”, 由于 ref_count 和 map_count 在同一个 CACHE Line,并且被 CPU1 独占, 但是由于 Invalidate QUEUE 里有标记的 Entry,于是 CPU1 只好去处理 Invalidate QUEUE 里的 Entry,其中就包括将 folio 对应的 CACHE Line 标记为 Invalidate.
G: CPU1 处理完 Invalidate QUEUE 之后,继续从 CACHE 里读取 ref_count 和 map_count 两者的值,此时符合条件,那么 CPU1 继续执行 “!folio_test_dirty(folio)” 的判断,由于 folio 此时不在 CPU1 的 CACHE 里,于是发送针对 folio 的 Read Message.
H: CPU0 收到针对 folio 的 Read Message,于是将 folio 最新值打包发送作为应答,并将对应的 CACHE Line 修改为 Shared
I: CPU1 收到针对 folio 的 Read Message 应答之后,将 folio 值存储在 CPU1 CACHE 里,并将对应的 CACHE Line 状态修改为 Shared, CPU1 接着从 CACHE 里读取 folio 的值,此时 folio 的值已经被标记为脏页,因此不会进入1689 行代码逻辑.
页表安装并发访问场景
在 Linux 中,无论是用户空间进程还是内核线程,其直接使用的内存都是虚拟内存,虚拟内存需要与物理内存建立页表之后才能真正使用,否则会触发缺页异常而建立所需的页表。页表是贯彻内存管理的核心组件,在不同架构中,可能支持 3、4、5 级页表,每一级页表内包含了多个 Entry,Entry 里面包含了下一级页表或这物理页的地址信息和访问权限,因此硬件 MMU 可以通过页表 Entry 的内容控制每次内存访问。今天要讨论的是页表建立过程中内存屏障的使用,在单核架构中,一般只会出现先建立页表,然后在访问页表,但到了多核架构中,可能是一个线程在建立页表,另外一个线程就要访问页表,此时页表成了竞争的资源,如果不处理好多个线程并行访问页表将出现问题,另外过重的资源保护会出现性能降低的问题,因此需要同时考虑两个问题,那么可以考虑使用内存屏障实现无锁同步的功能,再介绍实际案例之前再次整理一下页表创建需要两个过程:
准备页表的内容(包括准备页表属性和分配物理页)
填充页表 Entry
P4D PageTable with PGD Entry
例如上图 __p4d_alloc() 函数用于 P4D 页表的填充,其首先需要准备一个 PGD Entry, PGD Entry 需要包括 P4D 页表的基地址和 P4D 页表的属性. 函数在 5187 行调用 p4d_alloc_one() 函数分配一个 P4D 页表页,然后在 5192 行调用 pgd_present() 函数判断此时 PGD Entry 是空的,也就是 P4D 页表不存在,那么进入 5195 分支继续执行,此时调用 smp_wmb() 函数确保 P4D 页表项被放入到 PGD Entry 之前,所有的设置都已经完成,这样可以避免其他 CPU 在 P4D 页表设置完之前访问它。接着就是调用 pgd_populate() 函数将 P4D 页表项放入到 PGD Entry 里. 函数在安装 P4D 页项过程中对 mm->page_table_lock 上锁,这样防止对页表的并发访问.
这里可能会有开发者会提出疑问,为什么函数已经对 mm->page_table_lock 上锁,该锁可以防止对页表的并发访问,那么怎么会出现 P4D 页表项没有安装好就被访问呢? 这个确实是一个好的问题,在 Linux 里,许多函数都可以在不锁定页表的情况下访问页表的,例如,当处理器执行内存访问时,硬件会自动遍历页表以解析虚拟内存,这种情况下,硬件并不会获取任何锁. 此外,内核的某些部分,例如页表遍历代码,也可能在不获取锁的情况下访问页表,例如函数 “walk_page_range” 和 “apply_to_page_range” 可以遍历页表并对每个页表执行一些操作,而不需要获取锁。当 “mm->page_table_lock” 被锁定时,这些操作仍然可以进行,因为它们并不需要修改页表,只是读取页表项。这就是为什么在放置新的 P4D 页表项之前需要使用 “smp_wmb” 内存屏障: 这确保了当这些读取操作发生时,页表项的所有设置都已经完成. 需要注意的是,虽然这些读操作不需要获取锁,但在修改页表时通常需要获得 “mm->page_table_lock” 锁,以防止并发修改造成的数据不一致.
PUD PageTable with P4D Entry
例如上图 __pud_alloc() 函数用于 PUD 页表的填充,其首先需要准备一个 P4D Entry, P4D Entry 需要包括 PUD 页表的基地址和 PUD 页表的属性. 函数在 5210 行调用 pud_alloc_one() 函数分配一个 PUD 页表页,然后在 5215 行调用 p4d_present() 函数判断此时 P4D Entry 是空的,也就是 PUD 页表不存在,那么进入 5216 分支继续执行,此时调用 smp_wmb() 函数确保 PUD 页表项被放入到 P4D Entry 之前,所有的设置都已经完成,这样可以避免其他 CPU 在 PUD 页表设置完之前访问它。接着就是调用 p4d_populate() 函数将 PUD 页表项放入到 P4D Entry 里. 函数在安装 PUD 页项过程中对 mm->page_table_lock 上锁,这样防止对页表的并发修改. 从其他 CPU 角度来看该 CPU 先准备好 P4D Entry 之后才安装页表,那么安装好之后就可以访问 PUD 页表.
PMD PageTable with PUD Entry
例如上图 __pmd_alloc() 函数用于 PMD 页表的填充,其首先需要准备一个 PUD Entry, PUD Entry 需要包括 PMD 页表的基地址和 PMD 页表的属性. 函数在 5234 行调用 pud_alloc_one() 函数分配一个 PMD 页表页,然后在 5239 行调用 pud_present() 函数判断此时 PUD Entry 是空的,也就是 PMD 页表不存在,那么进入 5240 分支继续执行,此时调用 smp_wmb() 函数确保 PMD 页表项被放入到 PUD Entry 之前,所有的设置都已经完成,这样可以避免其他 CPU 在 PMD 页表设置完之前访问它。接着就是调用 pud_populate() 函数将 PMD 页表项放入到 PUD Entry 里. 函数在安装 PMD 页项过程中对 mm->page_table_lock 上锁,这样防止对页表的并发修改. 从其他 CPU 角度来看该 CPU 先准备好 PMD Entry 之后才安装页表,那么安装好之后就可以访问 PMD 页表.
PTE PageTable with PMD Entry
例如上图 __pte_alloc_kernel() 函数用于 PTE 页表的填充,其首先需要准备一个 PMD Entry, PMD Entry 需要包括 PTE 页表的基地址和 PTE 页表的属性. 函数在 480 行调用 pte_alloc_one_kernel() 函数分配一个 PTE 页表页,然后在 485 行调用 pmd_present() 函数判断此时 PMD Entry 是空的,也就是 PTE 页表不存在,那么进入 486 分支继续执行,此时调用 smp_wmb() 函数确保 PTE 页表项被放入到 PMD Entry 之前,所有的设置都已经完成,这样可以避免其他 CPU 在 PTE 页表设置完之前访问它。接着就是调用 pmd_populate_kernel() 函数将 PTE 页表项放入到 PMD Entry 里. 函数在安装 PTE 页项过程中对 mm->page_table_lock 上锁,这样防止对页表的并发修改. 从其他 CPU 角度来看该 CPU 先准备好 PTE Entry 之后才安装页表,那么安装好之后就可以访问 PTE 页表.
与 __pte_alloc_kernel() 函数相似逻辑的 __pte_alloc() 函数用于填充 PTE 页表,只是其不仅针对用户空间的虚拟地址,也针对内核空间的虚拟地址,而 __pte_alloc_kernel() 函数只针对内核空间的虚拟地址构建 PTE 页表. 两者都有同样的逻辑,也都存在 smp_wmb() 函数,都是确保先在填充页表之前所有的页表设置已经完毕.
Split Huge PageTable
同理在 HugePage 也存在同样的策略,在某些情况下,内核使用大页可以提供性能,但大页存在一个问题,如果只需要分配 4KiB 大小的内存,大页会一次性分配 2M 或者更大的大页,那么这就造成内存浪费,因此内核中存在将大页拆分成小页的逻辑。说道拆分,实则将一个大页的页表修改为多个小页表,那么该场景也涉及到一个内核线程在在修改页表,另外一个线程在无锁访问页表,因此上图的 2020 行会调用 smp_wmb() 写屏障告诉其他 CPU 修改是否完成,同时也可以看到在 smp_wmb() 函数之前就存在很多对共享的页表写操作,smp_wmb() 都可以保证让其他 CPU 看到写操作已经完成,其他 CPU 可以安全访问已修改的页表.
Split Huge vmemmap PageTable
在 HugeTlb 大页极致优化场景中,也存在将一个大页拆分成小页,所谓的拆分就是将大页原先的页表拆分成下一级页表,以此映射到拆分之后的小页上,那么同样会存在这个典型的场景: 一个内核线程在拆分 HugeTlb 的大页,另外一个内核线程在无锁的访问 HugeTlb 大页,那么如何确保两个内核线程对共享资源 HugeTlb 大页的访问呢?这里还是使用了 smp_wmb() 写内存屏障,71 行的 smp_wmb() 函数确保了 PMD 页表修改之前,对 PMD 的修改已经完成,换句话来说就是只要其他内核线程看到 72 行的 pmd_populate_kernel() 函数执行完成,那么 72 行之前的页表修改也已经完成,那么另外的内核线程只要检查 72 行之前修改的内容就可以同步 HugeTlb 大页最新信息,因此实现无锁访问的同时也做好了共享数据的同步.
smp_wmb 实现无锁并发资源访问
在 Linux 内核里存在很多这样的场景,对于共享资源的写操作就加锁,而对于共享资源的读操作就不加锁,那么多核架构下如何同步修改呢? 一个比较经典的场景就是一个线程在加锁修改页表,而另外一个线程在无锁读页表,那么如果保证读者读到的内容是已经修改完毕呢? 对于这个场景我门在把它扩大化,对于这类共享资源,可以采用读写锁来实现同步,但对于频繁访问的数据就算加读锁也会在竞态时产生极大的开销,另外这类共享资源还存在软件和硬件同时访问,硬件是无法看到软件的锁存在的,因此对于这种场景可以采用内存屏障,其可以让其他 CPU 看到页表已经修改完毕之后再去访问,那么将问题进行总结,可以同质化处理,这里可以通过一个实践案例进行讲解,然后进行归纳总结, 实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] NO-LOCKING Synchronization with smp_wmb ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-NOLOCKING-SYNC-WMB-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-NOLOCKING-SYNC-WMB-default Source Code on Gitee
实践案例通过一个内核模块实现,其创建两个内核线程,一个内核线程用于创建新的节点并加入到 nodelist 链表里,另外一个内核线程用于遍历 nodelist 的所有节点。在案例中 nodelist 和新建立的节点称为了竞态资源. 对于新建节点的初始化包括很多步骤,但在 41 行添加了 smp_wmb() 写内存屏障,那么就代表只要节点的 flags 标志里含有 NODE_INITIALIZED 标志,那么这个节点就是初始化好的可以访问的,那么 61 行的 for 循环就可以正常访问新建节点. 接下来在 BiscuitOS 上实践案例:
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本实践案例自动运行,可以看到 consumer_thread() 线程遍历的节点都是已经初始化完毕的,实践符合预期. 那么对该案例进行分析,producer_thread() 线程在新建立一个节点时会对该节点进行很多初始化动作,案例只是简单的给了几个步骤,实际场景中可能会更多,另外 41 行的 smp_wmb() 写内核屏障告诉其他内核线程,只要看到节点的 flags 包含了 NODE_INITIALIZED 标志,那么节点其他所有的初始化动作已经完成,那么 consumer_thread() 线程在遍历 nodelist 上的节点时,只要节点 flags 中不包含 NODE_INITIALIZED 标志,那么该节点是没有初始化完成的,因此无法使用。对于硬件访问该节点的数据时,由于 smp_wmb() 的存在,它从硬件角度确认 flags 包含 NODE_INITIALIZED 标志之后,其他写操作都已经完成,那么硬件可以安全访问。以上的场景还存在另外一个需要注意的地方,就是多个线程对节点进行修改时,此时无法通过内存屏障保证同步,只能通过锁进行同步,同一时间只运行一个线程修改竞态区数据. 至此案例分析完毕,开发者可以根据实际需求使用 smp_wmb() 来无锁同步数据.
无锁 Tail Page 剥离场景
在 Linux 内存管理中,内核使用 struct page 数据结构描述一个物理的页的整个生命周期,其中 flags 成员用于描述物理页的性质,其中可以描述页的类型,内核支持的物理页类型如上图 enum pageflags 描述. 但开发者有没有发现一个问题,在内核中对 struct page 的 flags 成员访问很多情况下都是无锁的,那么在 SMP 系统上就会出现一个典型的场景,一个内核线程在修改 struct page 的 flags 成员,而另外一个内核线程在无锁的访问 flags 成员,那么内核是如何确保多个内核线程访问的数据是一致的呢? 接下来通过一个例子进行了解:
__split_huge_page_tail 函数的作用是将一个大页中的尾页(Tail Page) 从原先的大页中分离出来,使其称为一个独立的页。上图为代码逻辑,在分离 Tail Page 过程中可以看到,函数先将 Tail Page 的 flags 标志进行修改,其复制了原先大页的标志,然后再加上一些特有的标志,在修改完毕之后调用 smp_wmb() 写屏障,最后在调用 clear_compound_head() 函数, 以此完成剥离. 这里使用 smp_wmb() 标志的含义是告诉其他 CPU 或者内核线程,该 Tail Page 的 flags 修改完毕之后才调用 clear_compound_head() 函数,clear_compound_head() 函数执行完 Tail Page 剥离完成,其他内核线程此时如果访问 Tail page 的 page->compound_head 会发现其为 0, 因此该 Page 已经变成了一个独立的物理页。从这个案例可以看出 smp_wmb() 写屏障对一段写操作进行了无锁保序,其他内核线程只要看到这段写操作完成,那么可以无锁访问到最新的共享资源.
内核线程可以无锁调用 PageCompound() 函数判断一个页是否属于 Tail Page,其需要对页掉 flags 标志进行检测,以及对 compound_head 成员进行检测,因此之前的 smp_wmb() 函数已经确保了在剥离 Tail Page 时先清除了 flags,然后在修改 compound_head 成员。这样就可以无锁确保 PageCompound() 函数正确检测到一个页是否为 Tail Page.
MemoryPool 场景
在 Linux 内存管理中存在一种名为内存池(Memory Pool)的机制,其也被称为对象池(Memory Object Pool), 它是预先在系统启动时或者在模块加载时分配的一大块内存区域,然后在程序运行期间根据需要进行分配和释放。在内核中,有许多地方都使用了内存池,比如 slab 分配器和 mempool.
slab 分配器: 是一种内存管理机制,它通过将一种特定类型的数据结构的对象进行缓存,从而减少了频繁的内存分配和释放带来的开销。当需要一个新的对象时,可以直接从 slab 中获取,当对象不再使用时,可以将其放回 slab,而不是立即释放内存.
mempool: 是为了解决一些特殊情况下的内存分配问题而设计的一种机制,比如在低内存压力下,需要保证一些关键系统操作 (比如 I/O 操作)可以进行。mempool 会预先保留一部分内存,确保在内存紧张的情况下,这些关键操作仍能获取到内存.
总的来说,内存池的主要目的是为了提高内存分配的效率,减少内存碎片,并在内存压力下保证关键系统操作的进行。结合本文的主题,本节重点讨论 mempool 场景下如何使用内存屏障,在讨论之前先通过一个实践案例了解问题的场景,然后再深入了解,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] MEMORY Barrier Scene: Memory Pool ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-MEMPOOL-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-MEMPOOL-default Source Code on Gitee
实践案例通过一个内核模块实现,其创建里两个内核线程,模块在初始化时创建了一个内存池 pool,两个内核线程调用 mempool_alloc() 函数和 mempool_free() 函数从内存池 pool 里分配和释放内存. 接下来在 BiscuitOS 上实践该案例:
BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本调用实践所需的内容,可以看到模块加载之后两个内核线程从内存池子里分配和释放内存的情况,总体来说 KB 线程的评率是 KA 的 2-3 倍. 那么开发者考虑上图的案例如何将 KA 和 KB 的分配评率随机化以及延长观察的时间,那么就会出现一种场景: KA 线程从内存池子 pool 中分配内存,KB 正好从内存池子 pool 中分配,那么此时内存池子 pool 成了竞态共享资源,那么接下来分析一下在该场景下内存屏障如何保障对共享资源的访问:
内核使用 struct mempool_s 数据结构描述一个内存池子,其成员 min_nr 表示内存池子中最少内存对象的数量,curr_nr 成员表示当前可用的内存对象的数量,elements 成员用于指向内存对象. 通过上面的分析可以发现 min_nr 和 curr_nr 成员位于同一个 CACHE Line,而 elements 指向的内存位于其他 CACHE Line.
mempool_alloc() 函数用于从内存池子里分配可用的内存对象,其在 396 行进行上锁操作,可以防止多个内核线程修改内存池子,397 行检测到内存池子的 curr_nr 不为 0,那么代表内存池子里有可用的内存对象,那么进入 398 分支调用 remove_element() 函数从 pool->elements[] 数组中读取一个对象,接下来进行解锁操作,最后在 401 行调用 smp_wmb() 写屏障,并返回分配的内存对象 element.
mempool_free() 函数用于释放内存对象回内存池子,函数在 472 行调用 smp_rmb() 读内存屏障,然后在 491 行首先检查内存池子的 curr_nr 当前可用的内存对象是否小于 min_nr 最小可用内存对象的值,如果小于代表需要向内存池子中新增内存对象,那么进入 492 分支, 反之则则直接释放内存对象到专用高速缓存. 分析完源码之后,那么来分析如果没有内存屏障,内存池子的使用会存在哪些问题:
场景假设
在内存池子分配和回收中存在两个 CACHE Line,首先是内存池子 curr_nr 和 min_nr 位于同一个 CACHE Line,另外一个是内存池子 elements[] 数组里的内存对象,每次分配和回收的内存对象来自该数组,因此内存对象 element 位于一个独立的 CACHE Line 里。假设内核现在有两个内核线程在 CPUA 和 CPUB 上运行,CPUA 在执行内存池分配内存,CPUB 在执行内存池子回收内存. 初始状态为:
CPUA 对 curr_nr 只读,因此其 CACHE Line 状态为 Shared
CPUA 对 element 独占,因此其 CACHE Line 状态为 Modify/Exclusive
CPUB 对 curr_nr 只读,因此其 CACHE Line 状态为 Shared
异常场景分析
首先对 mempool_alloc() 函数和 mempool_free() 函数进行简化: mempool_alloc() 的核心是 29 行,其先更新 curr_nr 的值,然后从 pool 的 elements[] 数组中读取 curr_nr 对应的对象存储到 element 成员里, 然后在返回 element; mempool_free() 函数首先在参数里先读取 element,然后是 42 行判断 pool 的 curr_nr 是否小于 min_nr 成员。如果对代码再进一步简化,那么 CPUA 执行了两次写操作,W1 表示对 curr_nr 写操作, W2 表示对 p 进行写操作; 而 CPUB 则执行两次读操作,分别是 R1 获得 p, 然后 R2 是读取 curr_nr, 有了这个简化的代码,分析问题就比较简单,那么当没有内存屏障的时候,其处理流程如下:
A: CPUA 执行 mempool_alloc() 函数,其首先是执行 “–curr_nr”, 于是其在本地 CACHE 里查找 curr_nr, 发现对应的 CACHE Line 状态为 Shared,于是发送针对 curr_nr 的 Invalidate Message,于是将 “–curr_nr” 写操作存储到 Store Buffer 里。
B: CPUB 在某个循环里调用了 mempool_free() 函数,其首先获得一个对象 p,此时 p 不在 CPUB 的 CACHE 里,于是发送针对 p 的 Read Message.
C: CPUB 收到了针对 curr_nr 的 Invalidte Message 之后,将其存储到本地 Invalidte QUEUE 之后,立即发送针对 curr_nr 的应答.
D: CPUA 收到了针对 p 的 Read Message,于是将 p (element) 的旧值发送出去作为应答,然后将其对应的 CACHE Line 修改为 Shared
E: CPUA 收到针对 curr_nr 的 Invalidate ACK,于是将 Store buffer 里面 curr_nr 的新值直接写到 CACHE 里,并将对应的 CACHE Line 状态修改为 Modify.
F: CPUB 收到针对 p 的 Read Message ACK 之后,将 p 的值存储到 CPUB 本地 CACHE 里,并将对应的 CACHE Line 状态修改为 Shared. CPUB 继续执行 mempool_free() 的代码,接下来是 “curr_nr < min_nr”, 此时 CPUB 查看本地缓存发现 curr_nr 存在,并且对应的 CACHE Line 状态为 Shared,于是直接读取了 curr_nr 的值并进行计算.
G: CPUA 继续执行 mempool_alloc() 的代码,从 elements[] 数组中获得了对应的 element,然后执行代码 “p = element”, 将 element 的新值写入到 p,但此时 p 对应的 CACHE Line 状态为 Shared,那么发送针对 p 的 Invalidate Message
H: CPUB 收到针对 p 的 Invalidte Message,将其存储到 Invalidte QUEUE 里,然后发送针对 p 的 Invalidate ACK
I: CPUA 收到针对 p 的 Invalidte ACK 之后,将 element 新值写入到 p
J: CPUB 开始处理 Invalidtae QUEUE 的 Entry,其中包括将 curr_nr 和 p 标记为 Invalidate.
通过上面的场景分析,按预期 curr_nr 的值是与 element 在 elements[] 里一一映射,但可以看到 CPUB 读到了 curr_nr 的旧值和 element 的旧值,导致结果与预期不符合. 从 CPUB 的角度看,CPUA 的两条写指令乱序了,CPUA 先更新了 element 的值,然后再更新 curr_nr 的值,这会让 CPUB 读到 curr_nr 旧值. 另外从 CPUA 的角度来看,CPUB 的两条读指令乱序了,CPUB 先读了 curr_nr 的旧值,在读到 element 的旧值. 两种情况下结果与预期不符合.
内存屏障修复场景
通过上面的分析,添加内存屏障的目的是让其他 CPU 能看到某个 CPU 对共享资源的内存访问顺序。例如对于 mempool_alloc() 函数 CPUA 存在两次写操作,那么需要添加一个 smp_wmb() 写屏障,让其他 CPU 看到 CPUA 对共享资源的写顺序是先写 curr_nr, 再写 element; 同理对于 mempool_free() 函数 CPUB 存在两次读操作,那么需要添加一个 smp_rmb() 读屏障,让其他 CPU 看到 CPUB 对共享资源的读顺序是先读 element,在读 curr_nr,那么接下来看看添加内存屏障之后的处理逻辑. 再对上面的代码进行简化,CPUA 发起的两次写操作 W1 和 W2 之间插入了 smp_wmb() 写屏障,这样可以确保 curr_nr 只要更新 p 就更新; 同理 CPUB 发起了两次写操作 R1 和 R2 之间插入了 smp_rmb() 操作,这样可以保证先读 p 再读 curr_nr, 那么接下来分析代码处理逻辑:
A: CPUA 执行 mempool_alloc() 函数,其首先是执行 “–curr_nr”, 于是其在本地 CACHE 里查找 curr_nr, 发现对应的 CACHE Line 状态为 Shared,于是发送针对 curr_nr 的 Invalidate Message,于是将 “–curr_nr” 写操作存储到 Store Buffer 里。
B: CPUB 在某个循环里调用了 mempool_free() 函数,其首先获得一个对象 p,此时 p 不在 CPUB 的 CACHE 里,于是发送针对 p 的 Read Message.
C: CPUA 继续执行 smp_wmb() 写屏障,然后将 Store Buffer 里面的 Entry 进行标记. CPUA 继续执行指令 “p = element” 写操作,但是由于 Store Buffer 里有 Marked 的 Entry,因此该写操作存储到了 Store Buffer 里.
D: CPUB 收到了针对 curr_nr 的 Invalidte Message 之后,将其存储到本地 Invalidte QUEUE 之后,立即发送针对 curr_nr 的应答.
E: CPUA 收到针对 curr_nr 的 Invalidte ACK 之后,将 Store Buffer 里面的 curr_nr 最新值写入到 CACHE 里并将 CACHE Line 的状态标记为 Modify,另外将 p 的新值也写入到 CACHE 里,因为其对应的 CACHE Line 也是 Modify,因此无需通知其他 CPU 直接写.
G: CPUA 收到了针对 p 的 Read Message,于是将 p (element) 的新值发送出去作为应答,然后将其对应的 CACHE Line 修改为 Shared
H: CPUB 收到针对 p 的 Read Message ACK 之后,将 p 的值存储到 CPUB 本地 CACHE 里,并将对应的 CACHE Line 状态修改为 Shared. CPUB 继续执行 mempool_free() 的代码,smp_rmb() 读屏障,其将 CPUB 本地 Invalidate QUEUE 里的 Entry 全部进行标记,接下来是 “curr_nr < min_nr”, 由于 Invalidte QUEUE 有 Marked 的 Entry,因此 CPUB 现在 Invalidate QUEUE 里查看 curr_nr 的内容,结果发现里面有,于是将 curr_nr 对应的 CACHE Line 标记为 Invalidate,然后发送针对 curr_nr 的 Read Message.
I: CPUA 收到针对 curr_nr 的 Read Message,于是将 curr_nr 最新值发送出去作为应答,并将 curr_nr 对应的 CACHE Line 修改为 Shared.
J: CPUB 收到针对 curr_nr 的 Read Message ACK 之后,将其存储到本地 CACHE 里,并将 CACHE Line 状态修改为 Shared. CPUB 继续执行 mempool_free() 里的 “curr_nr < min_nr” 判断.
从上面的流程可以看出,代码逻辑已经按预期结果进行处理,smp_wmb/smp_rmb 的存在确保了一个无锁绑定关系,curr_nr 与 elements[curr_nr] 是一一绑定的,此时无需添加任何锁也可以有这样的保证。
X86 架构内存屏障指令
在 x86 架构中,与内存屏障相关的指令主要有以下几个:
MFENCE: 这是一个全能型内存屏障(全内存栅),它保证在该指令之前的所有内存读写操作在它之前完成,并且在该指令之后的所有内存读写操作在它之后开始。这是一个很强的顺序一致性保证。
LFENCE: 这是一个加载内存屏障(加载内存栅),它保证在该指令之前的所有内存加载(读取)操作在它之前完成,而在该指令之后的所有内存加载操作在它之后开始
SFENCE: 这是一个存储内存屏障(存储内存栅),它保证在该指令之前的所有内存存储(写入)操作在它之前完成,而在该指令之后的所有内存存储操作在它之后开始。
这些指令可以在需要的地方显式地插入到代码中,以保证内存操作的顺序。但在实践中,通常不需要直接使用这些底层的指令,而是通过高级语言提供的并发原语(如互斥量、原子操作、内存顺序模型等)来实现所需的内存访问顺序。需要注意的是,x86 架构的内存模型本身就提供了很强的顺序一致性,大多数情况下,除了一些特殊的用途(如写驱动或操作系统代码)外,我们不需要手动插入内存屏障。
MFENCE
MFENCE 是一个内存栅指令,它主要在多处理器环境中用于确保内存的顺序一致性。它在 MFENCE 之前的所有内存读取和写入操作会在它之前完成,而 MFENCE 之后的所有内存读取和写入操作会在它之后开始。下面是一个使用 MFENCE 的简单例子:
在这个例子中,两个线程分别在 shared1 和 shared2 上执行写操作。我们使用 MFENCE 来保证这两个写操作的顺序。在第一个线程中,MFENCE 保证了对 shared1 的写入在对 shared2 的写入之前发生。在第二个线程中,MFENCE 保证了读取 shared2 的值在读取 shared1 的值之前。这就保证了,当第二个线程看到 shared2 为 2 时,shared1 必须为 1.
LFENCE
LFENCE 是一种加载栅,也就是一种确保加载内存顺序的指令。它会确保在 LFENCE 之前的所有内存读取(加载)操作在它之前完成,并且在该指令之后的所有内存读取(加载)操作在它之后开始。一个 LFENCE 的使用案例可能会涉及到内存映射的 I/O 操作。在这种情况下,LFENCE 可以用于确保 I/O 设备更新的数据在使用之前已经被正确地加载到内存中。这是一个简单的例子,但请注意这不是真实的硬件操作,只是为了演示 LFENCE 的使用:
在这个例子中,wait_for_device 函数中的 while 循环会等待一个设备通过内存映射的寄存器来发出一个信号(例如设备已经准备好数据)。LFENCE 确保在我们使用从设备加载的数据之前,设备寄存器的读取已经完成。这就保证了我们看到的设备数据是最新的。然而,通常情况下,我们不需要在编程中直接使用 LFENCE,因为高级语言和库已经提供了更易于使用的并发原语和内存模型。直接使用 LFENCE 通常需要对内存模型有深入的理解,并且使用不当可能会引起难以发现的问题。
SFENCE
SFENCE 是一种在 Intel x86 架构中用于创建存储内存栅(Store Fence)的指令。SFENCE 指令确保在其之前的所有存储(写)操作完成后,再开始执行其之后的任何存储(写)操作。SFENCE 常常用于写入内存映射的 I/O 空间,在数据已经被正确地写入到内存后,再允许 I/O 设备进行访问。请注意,以下是一个非常简化的例子,旨在说明 SFENCE 指令的使用,而不是反映实际的硬件操作:
在这个例子中,write_to_device 函数通过内存映射的寄存器向设备写入数据。SFENCE 确保了在设备开始读取数据之前,数据已经被正确地写入到寄存器中。然而,通常情况下,我们在编程时并不直接使用 SFENCE,因为高级编程语言和库已经提供了更易于使用且安全的并发原语和内存模型。直接使用 SFENCE 需要对内存模型有深入的理解,且使用不当可能导致难以发现的错误。
Memory Barrier 实践
BiscuitOS 目前支持对 Memory Barrier 场景的实践,本节用于描述如何在 BiscuitOS 实践内存屏障。在实践之前,开发者需要准备一个 Linux 6.0 X86 架构实践环境,可以参考:
部署完毕之后,针对内存屏障实践,需要 BiscuitOS 使用 make menuconfig 选择如下配置:
cd BiscuitOS make menuconfig [*] DIY BiscuitOS/Broiler Hardware ---> (4) CPU Number [*] Package ---> [*] Memory Barriers Mechanism ---> [*] MEMORY Barrier Scene: Memory Pool ---> # 源码目录 # Module cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-BARRIER-MEMPOOL-default/ # 部署源码 make download # 在 BiscuitOS 中实践 make build
BiscuitOS-MEMORY-BARRIER-MEMPOOL-default Source Code on Gitee
上图是实践案例使用的源码,开发者可以根据需要自行修改,接下来在 BiscuitOS 上实践案例,直接运行 ‘make build’ 命令既可:
BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本调用实践所需的内容,可以看到模块加载之后两个内核线程从内存池子里分配和释放内存的情况, 以上便是实践内存屏障案例方法,其他实践以此类似.
Tags:
很赞哦! ()
上一篇:服务器主板北桥南桥的发展
下一篇:返回列表
随机图文
深入理解 Cache 工作原理
大家好,今天给大家分享一篇关于 Cache 的硬核的技术文,基本上关于Cache的所有知识点都可以在这篇文章里看到。linux中报文从网卡到用户态recv的架子
分享一篇后台服务器性能优化之网络性能优化,希望大家对Linux网络有更深的理解。曾几何时,一切都是那么简单。网卡很慢,只有一个队列。当数据包到达时,网卡通过DMA复制数据包并发深入理解DPDK程序设计|Linux网络2.0
移动互联网不断发展,网络流量徒增,推动着网络技术不断地发展,而CPU的运行频率基本停留在10年前的水平,为了迎接超高速网络技术的挑战,软件也需要大幅度创新,结合硬件技术的发展,DPDK,一个以软件优化为主的数据面技术应时而生,它为今天NFV技术的发展提供了绝佳的平台可行性。 作为技术人员,我们可以从中DPDK学习大量的高性能编程技巧和代码优化技巧,包括高性能软件架构最佳实践、高效数据结构设计和内存优化技巧、应用程序性能分析以及网络性能优化的技巧。