指令重排序与内存屏障

简介: 指令重排序与内存屏障

从老版glibc的一个bug说开来


之前我在公众号中,发表过这么一篇文章:


QQ图片20220528200424.png


这篇文章里面提到了老版本的glibc(2.13以前)中的排序函数qsort()有一个在并发时会出现core dump的bug。那段代码如下:


void qsort_r(void* b, size_t n, size_t s, __compar_d_fn_t cmp, void* arg) {
    size_t size = n * s;
    char* tmp = NULL;
    struct msort_param p;
    /* For large object sizes use indirect sorting.  */
    if (s > 32) {
        size = 2 * n * sizeof(void*) + s;
    }
    if (size < 1024)
        /* The temporary array is small, so put it on the stack.  */
    {
        p.t = __alloca(size);
    } else {
        /* We should avoid allocating too much memory since this might
        have to be backed up by swap space.  */
        static long int phys_pages;
        static int pagesize;
        if (phys_pages == 0) {
            phys_pages = __sysconf(_SC_PHYS_PAGES);
            if (phys_pages == -1) {
                phys_pages = (long int)(~0ul >> 1);
            }
            phys_pages /= 4;
            pagesize = __sysconf(_SC_PAGESIZE);
        }
        /* If the memory requirements are too high don't allocate memory.  */
        if (size / pagesize > (size_t) phys_pages) {
            _quicksort(b, n, s, cmp, arg);
            return;
        }
... ...


core dump原因就是用到了两个static的变量:


static long int phys_pages;
      static int pagesize;


static变量默认会初始化成0。


if (size / pagesize > (size_t) phys_pages) {


这种除法操作并发的时候会出现除0,导致coredump。也就是pagesize = 0,而单看串行逻辑,上述代码没有问题。因为前面pagesize已经被赋值了:


pagesize = __sysconf(_SC_PAGESIZE);


这个就是读取系统配置,获取页的大小赋值给pagesize。


那么为什么会core呢?首要原因当然是pagesize的赋值的if其判断条件不是pagesize为0,而是phys_pages 是否为0。因此存在race condition,phys_pages被赋值,但是pagesize还没走到赋值的位置的时候,其他线程开始做qsort排序,导致跳过了这个if,直接去做了那个除法操作。


所以见过一些老代码在服务初始化的时候,先用qsort()给随机数做一下排序,目的就是给这两个static变量初始化。


为此,有人给老版的glibc提过修复的merge request:


https://sourceware.org/bugzilla/show_bug.cgi?id=11655


主要改动的diff是:


-      if (phys_pages == 0)
+      if (phys_pages == 0 || pagesize == 0)
        {
          phys_pages = __sysconf (_SC_PHYS_PAGES);


修改判断条件,将两个静态变量是否为0都判断了一遍。很简单易懂是吧,看着也能解决这个并发的bug,但是最终glibc没有合入这个修改。而这其中的原因呢,就要引出今天我们的议题了:编译器和CPU会对指令进行重排序!


先看下最终glibc的修改版(glibc 2.13开始)是这样:


if (pagesize == 0) {
      phys_pages = __sysconf (_SC_PHYS_PAGES);
      if (phys_pages == -1)
        phys_pages = (long int) (~0ul >> 1);
      phys_pages /= 4;
      /* Make sure phys_pages is written to memory.  */
      atomic_write_barrier ();
      pagesize = __sysconf (_SC_PAGESIZE);
    }


这段代码和之前版本的主要diff有二,第一是if条件中改为直接判断pagesize,没有用 || 去判断两个static变量是否为0;第二呢就是在pagesize真正被赋值之前加入了一个atomic_write_barrier() 后面会讲到。


剧透一下,这段代码的含义就是用汇编语言,在这里加入了一个内存屏障。好了,开始讲讲什么是指令重排序,什么是内存屏障吧!


指令重排序


编译器为了提高程序的性能,有时不会按照程序代码对应的指令顺序来执行,而是乱序执行(Out-of-order execution)。比如我们用gcc编译器都用过O2参数。当然了说乱序有点夸张,它是在保证程序结果不变的情况下,对看似没有关联的语句进行重排序。然而它的重排序有个弊病,就是它仅能从单线程的串行逻辑角度去判断两个语句有没有依赖关系。不能判断多线程环境下的依赖关系。因而会导致问题。


当然不仅编译器,CPU也会对程序进行优化,从而导致指令的重排。


前文所述的那个没被合入的merge request,如果合入则最终代码如下:


if (phys_pages == 0 || pagesize == 0) {
            phys_pages = __sysconf(_SC_PHYS_PAGES);
            if (phys_pages == -1) {
                phys_pages = (long int)(~0ul >> 1);
            }
            phys_pages /= 4;
            pagesize = __sysconf(_SC_PAGESIZE);
        }
        if (size / pagesize > (size_t) phys_pages) {
            _quicksort(b, n, s, cmp, arg);
            return;
        }


在第一个if块中,其实phys_pages和pagesize是没有依赖关系的,所以直接可能被优化成这样执行:


if (phys_pages == 0 || pagesize == 0) {
            pagesize = __sysconf(_SC_PAGESIZE);
            phys_pages = __sysconf(_SC_PHYS_PAGES);
            if (phys_pages == -1) {
                phys_pages = (long int)(~0ul >> 1);
            }
            phys_pages /= 4; 
        }
        if (size / pagesize > (size_t) phys_pages) {
            _quicksort(b, n, s, cmp, arg);
            return;
        }


这样又会产生一种新的race condition,那就是某个线程中的qsort其pagesize和phys_pages都通过__sysconf()赋值完成了,但是phys_pages /=4;还没有被执行,彼时另外一个线程又在执行qsort,导致它判断pagesize和phys_pages都不等于0了,就跳过了if直接执行:


if (size / pagesize > (size_t) phys_pages) {


这个时候的phys_pages是还没有除以4的,所以这个除法虽然不core了,但整个表达式的逻辑也不正确!


内存屏障


内存屏障(memory barrier)又叫内存栅栏(memory fence),其目的就是用来阻挡CPU对指令的重排序。我们再看下glibc最终修改后的代码。


if (pagesize == 0) {
      phys_pages = __sysconf (_SC_PHYS_PAGES);
      if (phys_pages == -1)
        phys_pages = (long int) (~0ul >> 1);
      phys_pages /= 4;
      /* Make sure phys_pages is written to memory.  */
      atomic_write_barrier ();
      pagesize = __sysconf (_SC_PAGESIZE);
    }


atomic_write_barrier(),顾名思义就是加一个”写类型“的内存屏障,其实它是一个宏,展开为:


__asm("":::"memory")


这个就是通过嵌入汇编代码的方式加了一个内存屏障。让phys_pages成功写入之后再去给pagesize赋值(根据注释也可见一斑)。


此外前面我有提到,编译器和CPU都会导致指令的重排序。这里的 __asm("":::"memory") 其实加的是编译器的内存屏障(也叫优化屏障),也就是说它能阻止编译器不会对这段代码重排序,并不会阻止CPU的重排序。那么CPU不需要管吗?


X86的内存模型


在谈及CPU时,通常会把变量的读操作称为load,变量的写操作称为store。两两组合因而会出现4类读写操作:


  • LoadLoad屏障:保证前面的Load在后面的Load之1前完成


  • StoreStore屏障:保证前面的Store在后面的Store之前完成


  • LoadStore屏障:保证前面的Load在后面的Store之前完成


  • StoreLoad屏障:保证前面的Store在后面的Load之前完成。


对于我们常见的x86 架构的CPU来说,它有一个相对强大的内存模型。它能直接保证前面三种屏障,也就是说不需要去写汇编指令去阻止CPU对前面三种类型读写操作的重排。但x86 CPU无法保证StoreLoad类型的屏障。对于我前面所讲的qsort的例子,这个场景并不属于StoreLoad。貌似是StoreStore,先后对两个变量进行写入。所以不需要给CPU加内存屏障。


当然如果要加的话,也有办法是这样写:


__asm volatile ("mfence" ::: "memory")


mfence是针对CPU的内存屏障。


内存屏障与MESI


看完前面的内容,相信你已经认识到内存屏障对于阻止编译器和CPU指令重排序的作用,但其实CPU的内存屏障却不止如此,还记得本系列的上一篇文章介绍了CPU的缓存一致性协议MESI吗?


QQ图片20220528200558.png

其实内存屏障与MESI也有关系。


CPU的内存屏障如果只是保证指令顺序不会乱,也未必会让程序执行符合预期。因为MESI为了提升性能,引入了Store BufferInvalidate Queue。所以内存屏障还有其他功能:


写类型的内存屏障还能触发内存的强制更新,让Store Buffer中的数据立刻回写到内存中。读类型的内存屏障会让Invalidate Queue中的缓存行在后面的load之前全部标记为失效。


顺带一提,X86 CPU是没有实现Invalidate Queue的。

相关文章
|
5月前
汇编语言(第四版) 实验一 查看CPU和内存,用机器指令和汇编指令编程
汇编语言(第四版) 实验一 查看CPU和内存,用机器指令和汇编指令编程
|
4月前
|
Java 编译器
Java面试题:Java内存模型深度剖析,Java内存模型中的重排序(Reordering)现象,Java内存模型中的happens-before关系
Java面试题:Java内存模型深度剖析,Java内存模型中的重排序(Reordering)现象,Java内存模型中的happens-before关系
28 0
|
6月前
|
存储 程序员 数据处理
【汇编】mov和add指令、确定物理地址的方法、内存分段表示法
【汇编】mov和add指令、确定物理地址的方法、内存分段表示法
711 1
【汇编】mov和add指令、确定物理地址的方法、内存分段表示法
|
6月前
|
存储
【汇编】数据在哪里?有多长、div指令实现除法、dup设置内存空间
【汇编】数据在哪里?有多长、div指令实现除法、dup设置内存空间
136 1
|
缓存 安全 Java
volatile底层的实现原理:volatile关键字的作用、内存模型、JMM规范和CPU指令
volatile底层的实现原理:volatile关键字的作用、内存模型、JMM规范和CPU指令
163 0
持久内存指令(PMDK)简介
持久内存指令(PMDK)简介
325 0
|
存储 安全
4.3 x64dbg 搜索内存可利用指令
发现漏洞的第一步则是需要寻找到可利用的反汇编指令片段,在某些时候远程缓冲区溢出需要通过类似于`jmp esp`等特定的反汇编指令实现跳转功能,并以此来执行布置好的`ShellCode`恶意代码片段,`LyScript`插件则可以很好的完成对当前进程内存中特定函数的检索工作。在远程缓冲区溢出攻击中,攻击者也可以利用汇编指令`jmp esp`来实现对攻击代码的执行。该指令允许攻击者跳转到堆栈中的任意位置,并从那里执行恶意代码。
187 0
|
存储 缓存 索引
通过地址和索引实现数组、CPU指令执行过程、内存概述及内存物理结构
通过地址和索引实现数组、CPU指令执行过程、内存概述及内存物理结构
104 0
|
前端开发 rax
实验一:查看CPU和内存,用机器指令和汇编指令编程
实验一:查看CPU和内存,用机器指令和汇编指令编程
202 0
|
存储 缓存 数据库
PPC902AE101 3BHE010751R0101 访问指令来增加CPU内存子系统的带宽
PPC902AE101 3BHE010751R0101 访问指令来增加CPU内存子系统的带宽
118 0
PPC902AE101 3BHE010751R0101 访问指令来增加CPU内存子系统的带宽