一、背景
之前在做 rt-linux之防止优先级反转-CSDN博客 里的优先级反转的实验的验证时,在模拟长时间占锁的代码里使用了死循环死等一个标志位的方式,遇到了这篇博客里说的这个不加volatile导致的代码运行与编写预期不一致的情况。我觉得是一个比较典型的情况,所以有必要单独写一篇来博客来做个记录并重点强调一下,也期望大家在看到这个例子以后,编写代码时也能注意到这类的问题,按需增加volatile或者其他原子变量结构的声明,防止这一类问题发生。
volatile的这个例子的描述和分析在第二章展开,如果把volatile的这个例子衍生一下,就是编译器优化,再衍生一下,就是SMP下的数据一致性问题,说到一致性问题,可能有些做底层的同学会想到说缓存一致性,注意,这两者还是有一些区别的,本文不会提及缓存一致性以及cache的分层及cache的底层实现等细节,这些内容后面会单独写一篇来介绍,作为体系结构专栏里的内容,这篇博客主要围绕这个volatile问题展开,从编译器优化到多核间的数据如何从代码编写角度来做到数据一致性。缓存一致性更多的是体系结构及底层cache的实现及MESI机制,这些其实对开发人员来说可以是无感的,只需要了解里面的一些基本机理即可,而数据一致性或者说数据有序的可见性,是开发人员尤其底层开发人员一定需要关注和熟悉的概念,对性能和一些corner case而言,它就显得非常重要了。关于它,我们会在第三章中展开。
二、volatile有关问题及编译器优化
首先声明一下,这篇博客里讨论的内容是限定在C/C++语言的volatile范围(非java的volatile,也非某些mcu的芯片平台如xtensa里的volatile,不同的平台实现不一样,这俩平台会在使用volatile修饰符时按情况在前或在后加入内存屏障),讨论的平台范围限定在x86_64或arm64两个主流平台。
这一章里,我们先把博客的里的问题描述一下并给出原因分析,然后再针对这个问题,拓展一下范围,拓展到编译器优化,继而谈及内存屏障,并在 2.2 一节里做相关的实验,确认C/C++语言的volatile在x86平台里的编译出汇编的情况,会不会带上内存屏障。有关内存屏障及编译器优化和内存屏障组合使用的linux的常用宏的介绍都会在第三章里展开。
2.1 问题描述和原因分析
下面是当时测试优先级反转例子时的一个内核模块ko的源码里的出现与预期不一致问题的源码部分:
上图中红色框框出来的部分是一个判断,判断__neta_test_mutext.bquit是否是1来决定是否退出
这个bquit函数会由另外一个线程/进程来去修改这个标志位:
但是,如果我们不把这个bquit标志成volatile变量,那么编译器会对函数逻辑进行优化,而出现如下的internal_neta_dev_testioctl_lowprio_entermutex的编译结果:
看汇编代码可以看出,这个while循环只会去读一次bquit的值,接下来就不会再去读bquit了,而是直接用w0这个寄存器来做判断,从而导致这个循环永远不会因为bquit的值更新成1而退出。
所以,总结一下原因,如果不给这个标志位变量增加volatile的声明,那么编译器会对标志位死循环等状态这样的逻辑进行编译器优化,导致会把一些没有标志volatile的变量给存到寄存器上,而在接下来的逻辑里就直接用寄存器上的值进行判断。
增加volatile声明后问题恢复
其实,由于编译器优化导致的不期望的结果发生的现象,不仅仅是上面描述的循环体内死等标志位的情形,再描述两个典型的情形:
1)重复或者相似逻辑的编译优化
先做了一次检查,检查完后处理其他的逻辑后,考虑到多线程因素,需要在后面的时间再做这样的检查。这两次检查如果逻辑上一致或者相似的话,且用到的变量没有标记成volatile或者原子变量,也没有加内存屏障,编译器可能会把这两次相同或者相似的检查提炼成一次操作,在寄存器资源还算充裕的情形下,就直接拿寄存器的上一次判断的结果直接作为下一次判断的结果
2)逻辑前后顺序的优化
代码里写的是完成动作A和动作B后,最后置上标志位C,如果没有用内存屏障的话,编译器优化就可能会颠倒逻辑,先置上标志位C再去完成动作A和动作B。
注意,上面讲的都还是编译器的优化,除了编译器优化以外,还有一个一致性问题,是数据可见性问题。与之有关的是内存屏障,这个会在第三章里详细展开。
在描述内存屏障前,我们在下面 2.2 一节里先基于x86平台做一下这个volatile的相关实验,确认它并没有内存屏障,另外,关于x86平台这种内存强一致性模型的平台,linux里的很多通用的内存屏障的宏也都被改用成编译器防优化屏障,而非芯片底层的内存屏障(如x86的sfence/lfence/mfence),关于内存屏障以及内核里的常用宏的介绍见第三章。
2.2 x86平台volatile内核态和用户态的汇编只是避免编译器优化(包含内存屏障的实验)
在前面的逻辑中已经讲到,volatile变量会避免编译器优化,但是volatile会不会做其他的行为呢,比如芯片平台实现相关的内存屏障的动作呢?——从x86_64这个主流平台的实验结果来看,它并不会。
虽然我们在使用上,常常会把防编译器优化的如 __asm__ __volatile__("": : :"memory") 与内存屏障的语句如 x86平台的 mfence/lfence/sfence 或 arm64平台的 dmb ishst/ishld/ish/... 结合起来用如下:
由于::: "memory"表示的是防止编译器优化(或者说编译器屏障,前后的内存操作隔开),所以翻译到汇编后,就是下图中的mfence汇编
虽然我们在使用上,常常会把防编译器优化的如 __asm__ __volatile__("": : :"memory") 与内存屏障的语句结合起来使用,但是防编译器优化和内存屏障从本质上来说就应该被视为两个不同的东西,要分开去理解。
这一节,我们就从内核态代码里加volatile和用户态代码加volatile两种情况在x86_64平台下做实验验证,确认C/C++里的volatile只影响了编译器优化,并不会增加内存屏障的mfence/lfence/sfence/lock 这样的汇编。
2.2.1 x86平台内核态代码增加volatile及其他常用内存屏障的实验
下面这个.c文件里不仅包含了volatile,还包含了atomic_add_return,wmb/rmb/mb,smp_wmb/smp_rmb/smp_mb,会在下面一个个做一下说明,关于这些内存屏障里的详细细节放到第三章里
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/sched.h>
#include <linux/slab.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Memory Barrier Example Module");
atomic_t test = ATOMIC_INIT(0);
static int __init memory_barrier_init(void) {
printk(KERN_INFO "Memory Barrier Example Module Loaded\n");
// 示例变量
int var1 = 0;
int var2 = 0;
atomic_add_return(1, &test);
var1 = 3;
//dma_mb();
//printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
var1 = 2;
dma_rmb();
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
var1 = 4;
dma_wmb();
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
smp_store_release(&var2, 2);
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
var1 = smp_load_acquire(&var1);
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
// 使用 wmb()
//var1 = 1;
wmb(); // Write memory barrier
var2 = 1;
*((volatile int*)&var1) = 3;
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
*((volatile int*)&var1) = 2;
// 使用 rmb()
rmb(); // Read memory barrier
printk(KERN_INFO "After rmb: var1 = %d, var2 = %d\n", var1, var2);
// 使用 mb()
mb(); // Memory barrier
printk(KERN_INFO "After mb: var1 = %d, var2 = %d\n", var1, var2);
// 使用 smp_wmb()
var1 = 2;
smp_wmb(); // SMP Write memory barrier
var2 = 2;
printk(KERN_INFO "After smp_wmb: var1 = %d, var2 = %d\n", var1, var2);
// 使用 smp_rmb()
smp_rmb(); // SMP Read memory barrier
printk(KERN_INFO "After smp_rmb: var1 = %d, var2 = %d\n", var1, var2);
// 使用 smp_mb()
smp_mb(); // SMP Memory barrier
printk(KERN_INFO "After smp_mb: var1 = %d, var2 = %d\n", var1, var2);
*((volatile int*)&var1) = 3;
return 0;
}
static void __exit memory_barrier_exit(void) {
printk(KERN_INFO "Memory Barrier Example Module Unloaded\n");
}
module_init(memory_barrier_init);
module_exit(memory_barrier_exit);
make出ko以后,使用objdump -S把elf导出到文件:
objdump -S memory_barrier.ko > elf.txt
可以从下图种看到volatile的操作并没有在翻译成汇编时加上任何内存屏障的汇编语句
x86的内存屏障语句就mfence/lfence/sfence/lock这四个。
完整的elf内容:
memory_barrier.ko: file format elf64-x86-64
Disassembly of section .init.text:
0000000000000000 <init_module>:
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Memory Barrier Example Module");
atomic_t test = ATOMIC_INIT(0);
static int __init memory_barrier_init(void) {
0: e8 00 00 00 00 call 5 <init_module+0x5>
5: 55 push %rbp
printk(KERN_INFO "Memory Barrier Example Module Loaded\n");
6: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
static int __init memory_barrier_init(void) {
d: 48 89 e5 mov %rsp,%rbp
printk(KERN_INFO "Memory Barrier Example Module Loaded\n");
10: e8 00 00 00 00 call 15 <init_module+0x15>
*
* Atomically adds @i to @v and returns @i + @v
*/
static __always_inline int arch_atomic_add_return(int i, atomic_t *v)
{
return i + xadd(&v->counter, i);
15: b8 01 00 00 00 mov $0x1,%eax
1a: f0 0f c1 05 00 00 00 lock xadd %eax,0x0(%rip) # 22 <init_module+0x22>
21: 00
//dma_mb();
//printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
var1 = 2;
dma_rmb();
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
22: 31 d2 xor %edx,%edx
24: be 02 00 00 00 mov $0x2,%esi
29: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
30: e8 00 00 00 00 call 35 <init_module+0x35>
var1 = 4;
dma_wmb();
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
35: 31 d2 xor %edx,%edx
37: be 04 00 00 00 mov $0x4,%esi
3c: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
43: e8 00 00 00 00 call 48 <init_module+0x48>
smp_store_release(&var2, 2);
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
48: ba 02 00 00 00 mov $0x2,%edx
4d: be 04 00 00 00 mov $0x4,%esi
52: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
59: e8 00 00 00 00 call 5e <init_module+0x5e>
var1 = smp_load_acquire(&var1);
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
5e: ba 02 00 00 00 mov $0x2,%edx
63: be 04 00 00 00 mov $0x4,%esi
68: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
6f: e8 00 00 00 00 call 74 <init_module+0x74>
// 使用 wmb()
//var1 = 1;
wmb(); // Write memory barrier
74: 0f ae f8 sfence
var2 = 1;
*((volatile int*)&var1) = 3;
printk(KERN_INFO "After wmb: var1 = %d, var2 = %d\n", var1, var2);
77: ba 01 00 00 00 mov $0x1,%edx
7c: be 03 00 00 00 mov $0x3,%esi
81: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
88: e8 00 00 00 00 call 8d <init_module+0x8d>
*((volatile int*)&var1) = 2;
// 使用 rmb()
rmb(); // Read memory barrier
8d: 0f ae e8 lfence
printk(KERN_INFO "After rmb: var1 = %d, var2 = %d\n", var1, var2);
90: ba 01 00 00 00 mov $0x1,%edx
95: be 02 00 00 00 mov $0x2,%esi
9a: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
a1: e8 00 00 00 00 call a6 <init_module+0xa6>
// 使用 mb()
mb(); // Memory barrier
a6: 0f ae f0 mfence
printk(KERN_INFO "After mb: var1 = %d, var2 = %d\n", var1, var2);
a9: ba 01 00 00 00 mov $0x1,%edx
ae: be 02 00 00 00 mov $0x2,%esi
b3: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
ba: e8 00 00 00 00 call bf <init_module+0xbf>
// 使用 smp_wmb()
var1 = 2;
smp_wmb(); // SMP Write memory barrier
var2 = 2;
printk(KERN_INFO "After smp_wmb: var1 = %d, var2 = %d\n", var1, var2);
bf: ba 02 00 00 00 mov $0x2,%edx
c4: be 02 00 00 00 mov $0x2,%esi
c9: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
d0: e8 00 00 00 00 call d5 <init_module+0xd5>
// 使用 smp_rmb()
smp_rmb(); // SMP Read memory barrier
printk(KERN_INFO "After smp_rmb: var1 = %d, var2 = %d\n", var1, var2);
d5: ba 02 00 00 00 mov $0x2,%edx
da: be 02 00 00 00 mov $0x2,%esi
df: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
e6: e8 00 00 00 00 call eb <init_module+0xeb>
// 使用 smp_mb()
smp_mb(); // SMP Memory barrier
eb: f0 83 44 24 fc 00 lock addl $0x0,-0x4(%rsp)
printk(KERN_INFO "After smp_mb: var1 = %d, var2 = %d\n", var1, var2);
f1: ba 02 00 00 00 mov $0x2,%edx
f6: be 02 00 00 00 mov $0x2,%esi
fb: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
102: e8 00 00 00 00 call 107 <init_module+0x107>
*((volatile int*)&var1) = 3;
return 0;
}
107: 31 c0 xor %eax,%eax
109: 5d pop %rbp
10a: 31 d2 xor %edx,%edx
10c: 31 f6 xor %esi,%esi
10e: 31 ff xor %edi,%edi
110: e9 00 00 00 00 jmp 115 <__UNIQUE_ID_vermagic118+0x82>
Disassembly of section .exit.text:
0000000000000000 <cleanup_module>:
static void __exit memory_barrier_exit(void) {
0: 55 push %rbp
printk(KERN_INFO "Memory Barrier Example Module Unloaded\n");
1: 48 c7 c7 00 00 00 00 mov $0x0,%rdi
static void __exit memory_barrier_exit(void) {
8: 48 89 e5 mov %rsp,%rbp
printk(KERN_INFO "Memory Barrier Example Module Unloaded\n");
b: e8 00 00 00 00 call 10 <cleanup_module+0x10>
}
10: 5d pop %rbp
11: 31 c0 xor %eax,%eax
13: 31 ff xor %edi,%edi
15: e9 00 00 00 00 jmp 1a <_note_9+0x2>
2.2.2 实验里包含了内核里其他常见的内存屏障等操作
除了volatile以外,其他的如atomic_add_return,wmb/rmb/mb,smp_wmb/smp_rmb/smp_mb分别如下实验结果(下面表格里是精简的含义,详细的含义见第三章):
内核里的宏/函数 | 含义 | x86下对应汇编 |
atomic_add_return | 原子加操作 | lock xadd %eax,0x0(%rip) |
wmb | 指令前的所有store动作不会在该wmb指令后完成(不强制排空store buffer)(保序角色范围是full system)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("sfence" ::: "memory") |
rmb | 指令后的所有load动作不会在该rmb指令前完成(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("lfence":::"memory") |
mb | 指令会等到该mb指令前的所有读写操作对全局可见后再执行该mb指令后的操作(最强的屏障,性能影响最大)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("mfence":::"memory") |
smp_wmb | 指令前的写操作对全局可见后才会让指令后的写操作对全局可见(指令只强调顺序,并不保证该smp_wmb指令执行完后该指令前的写操作已经对全局可见了)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_rmb | 指令前的读操作全部完成后才会执行指令后的读操作(指令只强调顺序,并不保证该smp_rmb指令执行完后该指令前的读操作已经全部完成)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_mb | 指令会等到该smp_mb指令前的所有读写操作对全局可见后再执行该smb_mb指令后的操作(保序角色范围是cpu之间)(保序内容范围是内存数据) (比mb的性能较好) | asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc") |
| ||
dma_rmb | 指令前的读操作全部完成后才会执行指令后的读操作(指令只强调顺序,并不保证该smp_rmb指令执行完后该指令前的读操作已经全部完成)(保序角色范围是cpu/GPU/DMA之间即outer shareable)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
dma_wmb | 指令前的写操作对全局可见后才会让指令后的写操作对全局可见(指令只强调顺序,并不保证该dma_wmb指令执行完后该指令前的写操作已经对全局可见了)(保序角色范围是cpu/GPU/DMA之间即outer shareable)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_load_acquire | 用于修饰内存读取指令,禁止它后面的内存操作指令被提前执行 | __asm__ __volatile__("": : :"memory") |
smp_store_release | 用于修饰内存写指令,禁止它上面的内存操作指令被乱序到写指令后执行 | __asm__ __volatile__("": : :"memory") |
上面这些常用内存屏障的含义都是经过深思熟虑后综合比较后找到的相对较为完整准确的说法,可以牢记于心。注意,store buffer处于cache/memory与cpu core之间,core写入store buffer就被视为完成了store指令。关于x86和arm64都有的store buffer以及x86但arm64有的invalidate queue的细节会在第三章里介绍。
要注意的是,上面提及的是内核里的抽象出来的宏,与具体平台如x86平台里的具体的汇编命令lfence/mfence/lock等还是有区别的,就讲一个例子,lfence的功能就要比内核定义的rmb功能要强不少,相关细节,我们会在第三章里逐步展开。这里先起个头:
某线程执行lock前缀指令集时,会去争抢全局锁,拿到锁后其他线程的读取操作会被阻塞,直到清空该线程的本地的store buffer后,才释放锁。这个清空本地的store buffer的逻辑和mfence的逻辑类似,mfence指令用于清空本地store buffer,将数据刷到主内存里,mfence在硬件层面搞了一个队列,cpu之间本身就竞争,所以比lock的方式效率低。另外,从intel p6系列处理器开始,如果访问的内存区域位于一个缓存行内,即没有跨缓存行,lock信号不会被发送,即不会锁定总线,而是使用缓存锁定,依赖于缓存一致性协议来保证原子性,很显然这时候的效率肯定要比mfence高出更多了。
上表格中,提到了保序角色范围和保序内容范围的概念,有关这两个范围在arm64里有详细分类和细节,保序角色范围分为cpu之间(inner shareable即ish)、GPU/DMA/CPU之间(outer即osh)、全系统包括特殊寄存器(fullsystem即不带ish或osh),保序内容范围分为内存数据(dmb)、内存数据和寄存器数据(dsb)、内存数据和寄存器数据和运行的指令流水线数据(isb),这个话题在后面的博客里介绍。
2.2.3 x86平台用户态代码增加volatile
用户态代码:
#include <stdio.h>
int main()
{
volatile int a;
a = 1;
printf("a=%d\n", a);
a = 2;
printf("a=%d\n", a);
return 1;
}
vs2019等于linux机器进行gdb调试,断点设在下面的位置(关于vs2019的ide方式进行登录linux机器进行代理gdb调试调试效率非常高,肯定是远高于直接进行gdb命令操作的调试,详细步骤我计划后面写一篇博客来介绍使用):
可以从下图中看到,虽然声明了volatile,但是汇编里并没有出现x86上有关内存屏障的mfence/lfence/sfence/lock这些字样:
三、SMP下的数据一致性问题及内存屏障的原理及使用
这一章会顺着在 2.2.1 里做的实验结果展开一一讲述概念,讲述概念时会涉及两个常用平台x86和arm64,arm64的内存屏障的细节特别多,会在后面的博文里单独展开介绍,这里先只涉及x86和arm64的公共的概念部分,具体的内存屏障指令的细节介绍以x86为主。
先解释一下这一章的标题,SMP是Symmetric Multiprocessing 的缩写,多个处理器核心共享相同的内存和I/O设备。
数据一致性,也可以说是数据有序的可见性。在SMP架构下,多个核心相互之间的逻辑是有依赖的,有时候需要有时序上的保障。举个例子,对于load-to-load的时序要求,如果在单核上,这样的要求是没有任何实际理由的,意思就是,单核上,你加载的时序如果没有强依赖的话,没有任何实际的理由需要处理器在加载不相干的A和B时一定得按照编程的时序去加载。如果是SMP系统的话,由于是多线程,这样的需求是普遍存在的,最典型的就是data和flag场景,写完data后再置flag,读的线程在读的时候,编程肯定会先读flag再读data,如果处理器这时候打乱了读的顺序,先读data,再读flag,就可能导致数据与编程模型的不一致。
数据的一致性从底层实现角度依赖缓存一致性,也就是cache coherency。缓存一致性有个著名的mesi协议,mesi协议有一些变种,但是原理上差不多,这里不展开,如第一章里提到,我们软件开发者就算是底层软件开发其实也没必要了解mesi里的每一处细节,只要一个大致cache的invalidate和独占和更新的时序就可以了,更多的我们需要去用架构提供的现有的内存屏障的汇编实现封装出跨平台的内核里的那些通用宏。另外一方面,这些内核里的通用宏一般也已经封装成熟了,我们更多的是理解各个内存屏障宏的区别,在不同的场景下选用最贴切的最合适的内存屏障宏,确保功能正常并高效。对于arm64的场景,由于处理器架构提供了非常多的细分的内存屏障的指令供我们选择,所以,如果理解了这些细分的指令的具体含义和差异,那么在一些指定的场景下,我们可以直接嵌入汇编,用arm64的这些细分的内存屏障指令替代linux系统默认自带的那些颗粒度较高的内存屏障指令,以实现内存屏障指令使用上的进一步优化,这块会在之后的博客中展开。
这一章,我们会由 2.2.2 一节里的包含实验结果的总结的表格来展开,一一说明相关的概念,讲概念时会不局限于x86或者arm64,但细节上还是以x86为主,具体内存屏障的指令在这篇博客里也只会涉及x86上的内存屏障指令里的细节,说明其具体底层的含义和实现原理及应用的场合。
我们回顾一下 2.2.2 一节里的这张表格:
内核里的宏/函数 | 含义 | x86下对应汇编 |
atomic_add_return | 原子加操作 | lock xadd %eax,0x0(%rip) |
wmb | 指令前的所有store动作不会在该wmb指令后完成(不强制排空store buffer)(保序角色范围是full system)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("sfence" ::: "memory") |
rmb | 指令后的所有load动作不会在该rmb指令前完成(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("lfence":::"memory") |
mb | 指令会等到该mb指令前的所有读写操作对全局可见后再执行该mb指令后的操作(最强的屏障,性能影响最大)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("mfence":::"memory") |
smp_wmb | 指令前的写操作对全局可见后才会让指令后的写操作对全局可见(指令只强调顺序,并不保证该smp_wmb指令执行完后该指令前的写操作已经对全局可见了)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_rmb | 指令前的读操作全部完成后才会执行指令后的读操作(指令只强调顺序,并不保证该smp_rmb指令执行完后该指令前的读操作已经全部完成)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_mb | 指令会等到该smp_mb指令前的所有读写操作对全局可见后再执行该smb_mb指令后的操作(保序角色范围是cpu之间)(保序内容范围是内存数据) (比mb的性能较好) | asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc") |
| ||
dma_rmb | 指令前的读操作全部完成后才会执行指令后的读操作(指令只强调顺序,并不保证该smp_rmb指令执行完后该指令前的读操作已经全部完成)(保序角色范围是cpu/GPU/DMA之间即outer shareable)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
dma_wmb | 指令前的写操作对全局可见后才会让指令后的写操作对全局可见(指令只强调顺序,并不保证该dma_wmb指令执行完后该指令前的写操作已经对全局可见了)(保序角色范围是cpu/GPU/DMA之间即outer shareable)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_load_acquire | 用于修饰内存读取指令,禁止它后面的内存操作指令被提前执行 | __asm__ __volatile__("": : :"memory") |
smp_store_release | 用于修饰内存写指令,禁止它上面的内存操作指令被乱序到写指令后执行 | __asm__ __volatile__("": : :"m |
暂且忽略掉atomic_add_return和dma_rmb/dma_wmb和smp_load_acquire/smp_store_release这些,先重点看下面这6个,也就是两套api之间的区别:
内核里的宏/函数 | 含义 | x86下对应汇编 |
wmb | 指令前的所有store动作不会在该wmb指令后完成(不强制排空store buffer)(保序角色范围是full system)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("sfence" ::: "memory") |
rmb | 指令后的所有load动作不会在该rmb指令前完成(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("lfence":::"memory") |
mb | 指令会等到该mb指令前的所有读写操作对全局可见后再执行该mb指令后的操作(最强的屏障,性能影响最大)(保序角色范围是full system)(保序内容范围是内存数据和寄存器数据) | asm volatile("mfence":::"memory") |
smp_wmb | 指令前的写操作对全局可见后才会让指令后的写操作对全局可见(指令只强调顺序,并不保证该smp_wmb指令执行完后该指令前的写操作已经对全局可见了)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_rmb | 指令前的读操作全部完成后才会执行指令后的读操作(指令只强调顺序,并不保证该smp_rmb指令执行完后该指令前的读操作已经全部完成)(保序角色范围是cpu之间)(保序内容范围是内存数据) | __asm__ __volatile__("": : :"memory") |
smp_mb | 指令会等到该smp_mb指令前的所有读写操作对全局可见后再执行该smb_mb指令后的操作(保序角色范围是cpu之间)(保序内容范围是内存数据) (比mb的性能较好) | asm volatile("lock; addl $0,-4(%%" _ASM_SP ")" ::: "memory", "cc") |
我们从三个角度来展开分析:
1)smp_wmb/smp_rmb/smp_mb这套api和wmb/rmb/mb这套api的差异是什么?
2)smp_wmb/smp_rmb/smp_mb这几个api究竟在做什么,以及底层是如何实现的,为什么x86平台,smp_wmb/smp_rmb等价于__asm__ __volatile__("": : :"memory"),smp_mb的实现解决的是StoreStore/StoreLoad/LoadLoad/LoadStore里的哪一类屏障的问题,smp_mb的实现为什么没用mfence而是用的lock方式?
3)既然x86已经是一个TSO(total store order)模型的内存一致性平台(还有哪些其他内存一致性模型?),为什么x86下wmb/rmb还是有对应的汇编实现,其底层实现原理是什么,wmb/rmb对应的sfence和lfence有哪些实际用途,lfence的实际的功能效果是不是要强于rmb的功能定义本身,mb也就是mfence是不是x86平台下最强的内存屏障,它相比x86平台的smp_mb的lock实现方式,它能多做哪些?哪些情况lock是做不了的,需要用mfence?
3.1 smp_wmb/smp_rmb/smp_mb这套api和wmb/rmb/mb这套api的差异是什么?
上面的表格里可以看到相关描述上的差异,smp_xx系列的这套api的保序角色范围是cpu之间,保序内容范围是内存数据;而不带smp_开头的这套api的保序角色范围是full system,保序内容范围是内存数据和寄存器数据。
保序角色范围和保序内容范围的概念,在arm64里有详细分类和细节,保序角色范围分为cpu之间(inner shareable即ish)、GPU/DMA/CPU之间(outer即osh)、全系统包括特殊寄存器(fullsystem即不带ish或osh),保序内容范围分为内存数据(dmb)、内存数据和寄存器数据(dsb)、内存数据和寄存器数据和运行的指令流水线数据(isb),这个话题在后面的博客里介绍。
这里,我们只需要理解保序角色指的是谁和谁保序,一般来说,我们的操作都是内存数据,保序的也是cpu与cpu之间的数据的可见的有序性,所以,正好对应于的是smp_xx系列的这套api的保序角色范围和保序内容范围。
3.1.1 x86上smp_wmb/smp_rmb/smp_mb这套api和wmb/rmb/mb这套api的差异是什么?
对于x86平台,我们在 3.3 里会讲到,它是一个tso架构,会默认确保store-store和load-load的保序角色之间的有序性,也就是在x86平台,我们并不需要考虑store-store和load-load这俩个case时的保序角色之间的有序性。所以,在x86,smp_xx系列的这套api和不带smp_开头的这套api的差异就在于保序内容范围上的差异,smp_xx系列保序内容范围是内存数据,而不带smp_开头的这套api保序内容范围是内存数据和寄存器数据。
另外,需要强调的是,本节讲的差异是从内核这套api的定义上出发来说的,实际在对应于x86平台下,比如wmb/rmb/mb对应的sfence/lfence/mfence在功能上并不完全等于内核的这套api的定义,关于sfence/lfence/mfence的实现细节和功能会在 3.3 一节中展开。
3.1.2 x86上dma_wmb/dma_rmb和smp_wmb/smp_rmb从实现上来说是一样的
为什么说x86上dma_wmb/dma_rmb和smp_wmb/smp_rmb从实现上来说是一样的?
因为dma操作的数据里需要保序的仍然是内存数据范围,不包含特殊寄存器,所以dma_xx就和smp_xx这套的保序内容范围一致,所以,也就看到了上面完整版的表格里dma_rmb和dma_wmb,和smp_rmb和smp_wmb,在x86上的实现是一样的,至于为什么可以等于防编译器乱序的__asm__ __volatile__("": : :"memory")的原因会在 3.2 一节里介绍。
3.2 smp_wmb/smp_rmb/smp_mb的x86下的实现
我们先再来看一下与这一节相关的这些问题:
smp_wmb/smp_rmb/smp_mb这几个api究竟在做什么,以及底层是如何实现的,为什么x86平台,smp_wmb/smp_rmb等价于__asm__ __volatile__("": : :"memory"),smp_mb的实现解决的是StoreStore/StoreLoad/LoadLoad/LoadStore里的哪一类屏障的问题,smp_mb的实现为什么没用mfence而是用的lock方式?
我们从 2.2.2 的实现里可以看到,x86平台下smp_wmb/smp_rmb等价于__asm__ __volatile__("": : :"memory"),为什么会这样呢?有以下几个原因:
1)x86平台的store buffer是FIFO的,不存在乱序,写入顺序就是刷入cache的顺序,无论写的数据在cache line的情况是怎样,都按照先写的数据先刷入cache的顺序进行
2)x86平台不存在invalid queue,也就保证了在从store buffer刷入cache的那一刻,其他核上就能够读到最新的修改,对于存在invalid queue的cpu来说,写入完成并不能确保其他核能读到
x86平台下smp_mb的实现并不是__asm__ __volatile__("": : :"memory"),而是lock方式,这是为了解决哪一类屏障,smp_mb的实现为什么没用mfence而用lock方式,这点我们在 3.2.3 里介绍。
3.2.1 关于store buffer
store buffer是cpu core和cache/内存中间的一个存储单元,用于缓存因为cache coherency的协议操作导致数据不能及时完成刷入的数据。store buffer可以把它理解成是cpu core在完成写入操作时必经的一个模块,你可以理解成“你可以快速经过它,但是你不能不经过它”。
有了store buffer,是为了提升处理器存储的性能,cpu在完成数据向store buffer的写入以后,就可以处理接下来的指令。
在一个TSO内存一致性模型的系统上,store buffer的写入顺序是严格保障的,后写入的数据就算再cache line里有更好的能更快完成写入的状态也不能提早与前一笔数据完成写入。
有了TSO内存一致性模型,store-store类可见性问题就天然的保障了。这也是为什么x86平台下smp_wmb的实现和防止编译器乱序的__asm__ __volatile__("": : :"memory")一致。
3.2.2 关于invalid queue
invalid queue用于缓存cache line的失效信息,也就是说,当cpu0写入x成1时,并从store buffer把1刷到x的地址上时,cpu1仍然可以读到x之前的数值,是因为从store buffer把1写入到x的地址上时出发的别的核如cpu1核的相关cache line的invalid的消息被缓存到cpu1核的invalid queue里了。
invalid queue的出现是为了性能的进一步优化,虽然store buffer已经能有一个缓存提速流水线了,但是store buffer的空间是有限的。store buffer满了之后,cpu还是会卡在处理store buffer中的条目时等对应的invalidate ACK回来的事件上。invalidate ACK耗时的主要原因是cpu要讲对应的cache line置为invalid后再返回invalidate ACK。有了invalid queue就可以把这个要invalid的cache line保存到队列里,然后不等invalid完成直接返回invalidate ACK,实现提速。
x86上并没有invalid queue,也就不需要smp_rmb了。
3.2.3 关于load-store场景和store-load场景
这一节讲到的概念与内存一致性模型有关,内存一致性模型对于内存读写load/store有以下4中存储顺序的定义:
StoreStore(写写):先执行写,后执行写。
LoadLoad(读读):先执行读,后执行读。
LoadStore(读写):先执行读,后执行写。
StoreLoad(写读):先执行写,后执行读。
前两者场景在之前的小节里说明在x86平台里对内存数据范围并不需要内存屏障。
对于loadstore场景,x86平台也并不需要,是因为没有invalid queue,所以cpu从cache里肯定读不到旧值,就是store前的load动作因为load是同步的,所以无需屏障。
但对于storeload场景,因为x86下也有store buffer,所以store也是分步,后面跟进load动作的时刻并不能保证store的动作被全局可见(cpu读不到非本核以外其他核的store buffer)
而smp_mb就是为了解决storeload这种场景。
3.2.4 x86下的smp_mb的实现
如上一节所说,x86下仍然要解决storeload这种场景,所以也必须要去实现smp_mb这api。
smp_mb的实现为什么用lock方式,毕竟mfence肯定能包裹住所有的场景,smp_mb用lock的方式,有以下几个方面:
1)smp_mb只需要针对保序内容是内存数据这个范围,不需要包含寄存器范围,mfence从内存数据扩大到包含寄存器范围,对于smp_mb的场景并不需要
2)虽然保序角色在 3.1.1 里已经提到因为x86是tso架构,store-store和load-load是天然保序的,但是smp_mb还要考虑store-load的情况。考虑保序角色范围也没有问题,因为smp_mb针对的是inner shareable场景,mfence针对的是full system场景,smp_mb不需要这么大范围
3)lock方式的效率比mfence高,那么,lock方式的效率为什么比mfence高呢?这在 3.2.4.1 里介绍。
3.2.4.1 lock方式比mfence方式高的原因
上面 2.2.2 里已经讲到,从intel p6系列处理器开始,如果访问的内存区域位于一个缓存行内,即没有跨缓存行,lock信号不会被发送,即不会锁定总线,而是使用缓存锁定,依赖于缓存一致性协议来保证原子性,很显然这时候的效率肯定要比mfence高出更多了。
有关lock前缀,如何用只锁定cache line的功能实现比锁定总线小很多的开销见下图,当然这是有条件要求的,如果不满足条件,还是会降级到锁定总线:
3.3 TSO内存一致性模型及x86平台的内存屏障指令详解
与这一节相关的问题如下:
既然x86已经是一个TSO(total store order)模型的内存一致性平台(还有哪些其他内存一致性模型?),为什么x86下wmb/rmb还是有对应的汇编实现,其底层实现原理是什么,wmb/rmb对应的sfence和lfence有哪些实际用途,lfence的实际的功能效果是不是要强于rmb的功能定义本身,mb也就是mfence是不是x86平台下最强的内存屏障,它相比x86平台的smp_mb的lock实现方式,它能多做哪些?哪些情况lock是做不了的,需要用mfence?
3.3.1 有哪些内存一致性模型?
内存一致性,即 memory consistency,包括硬件层面的模型和语言的内存模型。这里只讨论硬件层面的模型。
在3.2.3 一节里我们已经说明了内存一致性模型对内存读写有以下4中存储顺序的定义:
StoreStore(写写):先执行写,后执行写。
LoadLoad(读读):先执行读,后执行读。
LoadStore(读写):先执行读,后执行写。
StoreLoad(写读):先执行写,后执行读。
我们分别介绍有哪些内存模型,以及该内存模型对于上面列的4中存储顺序是否会出现乱序。
3.3.1.1 顺序一致性(SC: Sequential Consistency)模型
同一cpu上严格按照编程顺序来执行。这种情况,上面这4中存储顺序肯定都不会乱序
3.3.1.2 完全存储定序(TSO: Total Store Order)模型
写操作不会将数据立即写入内存,而是先写道严格先入先出的store buffer队列里。只会在StoreLoad时乱序,其他情况保序
3.3.1.3 部分存储定序(PSO: Part Store Order)模型
允许StoreLoad和StoreStore乱序,其他情况保序
3.3.1.4 宽松存储(RMO:Relax Memory Order)模型
允许所有4种操作乱序,arm64就是RMO模型
3.3.2 x86下wmb/rmb对应的汇编实现sfence/lfence底层实现原理是什么?什么时候需要?
先回答一个问题,既然x86是一个tso模型的平台,smp_wmb和smp_rmb刚才也都说了仅仅做了防编译器优化的动作,而为什么wmb/rmb要有对应的x86的实现?
其实在之前也零零碎碎提到了原因,smp_wmb和smp_rmb的保序内容范围是内存数据,不包含寄存器数据,内存数据可以通过x86天然的tso内存模型来保证store-store和load-load的顺序,除了防编译器优化以外,其他都不用加,这是可以的,但是对于寄存器数据,这是做不到的。
比如,sfence前有tlb的写入动作,sfence可以保证tlb的写入生效前不执行后面的语句。
比如,lfence后有rdtsc这个tsc时间获取指令,lfence可以保证lfence前的指令完成之后再执行rdtsc
但是要强调的是sfence+lfence并不能禁止store-load的乱序,有需要的时候,或者性能并不影响的时候还是用mfence更加靠谱安全。
截取几段关于lfence和sfence和mfence的spec上的描述:
其实,从上图中可以看到,lfence要比rmb这个api本身的含义做了更多,lfence可以阻止后面的指令提前运行(预取并不阻止),但是要注意,store动作是在写入store buffer后就算完成,所以lfence并不能保证在lfence这个指令前写入store buffer的数全局可见了,它只能保证局部可见或者说本核可见。如下截图:
说到lfence,就需要提到load-load的保序的底层实现。
补充一点,其实x86虽然是tso模型,要求的load-load的order,但是实际上硬件并没有按照严格的load-load的顺序来执行。因为这样做效率太低了。比如第一次load是cache miss甚至L2/L3都没有miss要访问DRAM,第二次load没有任何依赖,且是cache hit的,硬件上完全没有必要一定要等前一条load结束后再执行下一条load。
在第三章的一开头,我们也提到load-to-load的时序要求,在单核上是没有任何实际理由的,在多核上则会有像先store data再store flag,另一个核先load flag后load data这样的保序要求。所以,本质是,我们要保证这类情形,多核情况下的store保序。
回到硬件上底层的实现,只要我们不违背两个核看到的store顺序不一致这个原则,那么硬件上的加速是可以允许的。所以,再实际流水线的实现中,load与load之间是乱序执行,会有一个load-ordering-buffer的结构,在load commit之前检测到其地址是否被其他核写过,如果没有写过,就认为这种乱序是安全的,如果有写过,则需要取消这种乱序并flush流水线。
所以,实际运行的情况是,硬件实现并不会严格保证load-to-load的顺序,只要乱序执行的结果和顺序执行的结果是一致的就行。如果要一定要确保不乱序,那么只能借助lfence这种指令。
3.3.3 mfence作为x86平台下最强的内存屏障(mb的实现),相比smp_mb的lock的实现方式,多做了哪些?哪些情况是lock做不了的,必须要用mfence的?
这一疑问其实在之前的 3.2.4 里也回答了,mfence是针对full system的保序角色范围,所有类型数据(内存数据和寄存器数据),屏障了所有乱序可能,并确保了mfence前的内容的store buffer清空,也就是全局可见。其中与lock相比,把类型数据从内存数据拓展到了包含寄存器数据。
标签:__,00,smp,指令,volatile,内存,使用,保序 From: https://blog.csdn.net/weixin_42766184/article/details/143244770