优化和内存屏障

1,837 阅读6分钟

我正在参加「掘金·启航计划」

注:

  • 本文读书笔记,出自《深入理解LINUX内核》
  • 千万不要将文章前面提到的volatile关键字与Java中的该关键字混淆

概述

      当使用优化的编辑器时,千万不要认为指令会严格的按照ta们在源码中出现的顺序执行。例如,编译器可能重新安排汇编语言指令以使寄存器以最优方式使用。此外,现代CPU通常并行执行若干条指令,且可能重新安排内存访问。这种重新排序可以极大地加速程序执行。

      然而处理同步场景时,必须避免指令重排序。如果放在同步原语之后的一条指令在同步原语本身之前执行,事情很快变得失控。事实上,所有同步原语起到优化和(或)内存屏障的作用。

优化屏障

      优化屏障(optimization barrier)原语保证编译程序不会混淆放在原语操作之前的汇编语言指令和放在原语之后的汇编语言指令,这些汇编语言指令在C中都有对应的语句。在Linux中,优化屏障就是barrier()宏,ta展开为:

__asm__ volatile ("" : : : "memory");

      指令asm告诉编译程序要插入汇编语言片段(这种情况下为空)。volatile关键字禁止编译器把asm指令与程序中其他指令重新组合。memory关键字强制编译器假定RAM中所有内存单元已经被汇编语言指令修改;因此,编译器不能使用存放在CPU寄存器中的内存单元的值来优化asm指令前的代码。注意,优化屏障并不保证不使当前CPU把汇编语言指令混在一起执行--这是内存屏障的工作。

笔者注:

  • GCC支持嵌入汇编代码的模板,不同于其它C编译器支持嵌入汇编代码的方式,为了优化用户代码,GCC设计了一种特有的嵌入方式,它规定了汇编代码嵌入的形式和嵌入汇编代码需要由哪几个部分组成
__asm__ volatile(代码部分:输出部分列表: 输入部分列表:损坏部分列表);
  • 前面的部分都是固定的,括号中的内容如下:
    • 汇编代码部分,这里是实际嵌入的汇编代码
    • 输出列表部分,让GCC能够处理C语言左值表达式与汇编代码的结合
    • 输入列表部分,也是让GCC能够处理C语言表达式、变量、常量,让它们能够输入到汇编代码中去
    • 损坏列表部分,告诉GCC汇编代码中用到了哪些寄存器,以便GCC在汇编代码运行前,生成保存它们的代码,并且在生成的汇编代码运行后,恢复它们(寄存器)的代码
  • 括号前面部分固定内容,笔者已经见过三种形式
/* 《深入理解LINUX内核》 */
asm volatile

/* JVM源码 */
__asm__ volatile

/* 其他资料 */
__asm__ __volatile__
  • 关于多种形式的问题,很多资料都解释的模棱两可,我请教了一个相关方面的大佬他的解释:因为这些并不是C语言的一部分,而是编译器的一部分,GCC,MSVC等,用了各自的语法,不过后面大家也在互相参考和兼容,有些就变成了事实标准 image.png

内存屏障

      内存屏障(memory barrier)原语确保,在原语之后的操作开始之前,原语之前的操作已经完成。因此,内存屏障类似于防火墙,让任何汇编语言指令都不能通过。

      在80x86处理器中,下列种类的汇编指令是"串行的",因为ta们起到内存屏障的作用:

  • 对I/O端口进行操作的所有指令
  • lock前缀的所有指令
  • 写控制寄存器,系统寄存器或调试寄存器的所有指令(例如:cli、sti)
  • 在Pentium 4微处理器中引入的汇编语言指令lfence sfence mfence,ta们分别有效的实现读内存屏障、写内存屏障、读-写内存屏障
  • 少数专门的汇编语言指令,终止中断处理程序或异常处理程序的iret指令就是其中的一个

笔者注:

  • CLI汇编指令全称为Clear Interupt,该指令的作用是禁止中断发生
  • STI汇编指令全称为Set Interupt,该指令的作用是允许中断发生
  • 该对指令的原理就是修改eflag寄存器的IF标志位状态,IF标志、CLI和STI指令对异常和NMI中断的产生没有影响

      Linux使用六个内存屏障原语。这些原语也具有优化屏障的作用,因为我们必须保证编译程序不在屏障前后移动汇编语言指令。"对内存屏障"仅仅作用于从内存读的指令,而"写内存屏障"仅仅作用于写内存指令。

      内存屏障既用于多处理器系统,也用于单处理器系统。 当内存屏障应该放置仅出现于多处理器系统上的竞争条件时,就使用smp_xxx()原语;在单处理系统上,ta们什么也不做。其他的内存屏障防止出现在单处理器和多处理器系统上的竞争条件。

说明
mb()适用于MP和UP的内存屏障
rmb()适用于MP和UP的读内存屏障
wmb()适用于MP和UP的写内存屏障
smp_mb()仅适用于MP的内存屏障
smp_rmb()仅适用于MP的读内存屏障
smp_wmb()仅适用于MP的写内存屏障

架构决定实现

      内存屏障原语的实现依赖于系统的体系结构。在80x86微处理器上,如果CPU支持lfence汇编语言指令,就把rmb()宏展开为:

__asm__ volatile ("lfence");

      否则就展开为:

__asm__ volatile ("lock;addl $0,0(%%esp)":::"memory")

      或者

__asm__ volatile ("lock;addl $0,0(%%rsp)":::"memory")

      asm指令告诉编译器插入一些汇编指令并起优化屏障的作用。addl $0,0(%%esp)汇编指令把0加到栈顶的内存单元,这条指令毫无意义本身没有任何价值,但是加上lock前缀就使得该指令成为CPU的一个内存屏障。

笔者注:

  • 32位、64位机器的栈顶寄存器名字稍微有所差别,分别叫esp、rsp
  • 查阅Intel官方手册得知:LOCK前缀只能加在以下指令前面,也只能加在目标操作数是内存操作数的指令形式后面:ADD、ADC、and、BTC、BTR、BTS、CMPXCHG、CMPXCH8B、CMPXCHG16B、DEC、INC、NEG、NOT、OR、SBB、SUB、XOR、XADD和XCHG
  • addl $0,0(%%esp)这句指令没有任何特殊之处,仅仅是因为满足了上述条件,然后执行指令本身的消耗足够小
  • JVM实现内存屏障也是上述方式(lock前缀 + 空操作),笔者总结原因有两个:
    • 并不是所有的CPU都支持fence家族汇编指令
    • JVM源码注释:总是使用lock addl,因为mfence有时很昂贵

      Inter上的wmb()宏实际上更加简单,因为他直接展开为barrier(),这是因为Inter处理器从不对写内存访问重排序,因此,没有必要在代码中插入一条串行化汇编指令,不过因为barrier()这个宏上文已经提到禁止编译器重新组合指令。

引用