目录
乱序处理器(Out-of-order processors)
前言
大家经常说的一个词汇叫做所见即所得,有些编程工具是所见即所得的,给程序员带来极大的方便。对于一个c程序员,我们的编写的代码能所见即所得吗?我们看到的c程序的逻辑是否就是最后CPU运行的结果呢?很遗憾,不是。
我们的“所见”和最后的执行结果隔着:
1、编译器
2、CPU取指执行
1、编译器
编译器将符合人类思考的逻辑(c代码)翻译成了符合CPU运算规则的汇编指令,编译器了解底层CPU的思维模式,因此,它可以在将c翻译成汇编的时候进行优化(例如内存访问指令的重新排序),让产出的汇编指令在CPU上运行的时候更快。然而,这种优化产出的结果未必符合程序员原始的逻辑,因此,作为程序员,作为c程序员,必须有能力了解编译器的行为,并在通过内嵌在c代码中的 memory barrier 来指导编译器的优化行为(这种memory barrier又叫做优化屏障,Optimization barrier),让编译器产出即高效,又逻辑正确的代码。
2、CPU取指执行
CPU的核心思想就是取指执行,随着计算机系统越来越复杂(多核、cache、superscalar、out-of-order),使用汇编指令这样贴近处理器的语言也无法保证汇编指令的取指和执行是严格按照顺序进行的,从而需要程序员(看,人还是最不可以替代的)告知CPU如何保证逻辑正确。
基础概念
内存乱序访问
定义
程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。
作用
内存乱序访问行为出现的理由是为了提升程序运行时的性能。
过程
内存乱序访问主要发生在两个阶段:
编译时,编译器优化导致内存乱序访问(指令重排)
运行时,多 CPU 间交互引起内存乱序访问
影响
很多时候,编译器和 CPU 引起内存乱序访问不会带来什么问题,但一些特殊情况下,程序逻辑的正确性依赖于内存访问顺序,这时候内存乱序访问会带来逻辑上的错误,例如:
此段代码中,ok 初始化为 0,线程 1 等待 ok 被设置为 1 后执行 do 函数。假如说,线程 2 对内存的写操作乱序执行,也就是 x 赋值后于 ok 赋值完成,那么 do 函数接受的实参就很可能出乎程序员的意料,不为 42。
编译时内存乱序访问
在编译时,编译器对代码做出优化时可能改变实际执行指令的顺序(例如 gcc 下 O2 或 O3 都会改变实际执行指令的顺序):
编译器优化的结果可能导致 y = 1 在 x = r 之前执行完成。首先直接编译此源文件:
g++ -S test.cpp
得到相关的汇编代码如下:
这里我们看到,x = r 和 y = 1 并没有乱序。现使用优化选项 O2(或 O3)编译上面的代码(g++ -O2 -S test.cpp),生成汇编代码如下:
经过编译器优化之后 movl $1, y(%rip) 先于 movl %eax, x(%rip) 执行。
解决方法
避免编译时内存乱序访问的办法就是使用编译器 barrier(又叫优化 barrier)。Linux 内核提供函数 barrier() 用于让编译器保证其之前的内存访问先于其之后的完成。内核实现 barrier() 如下(X86-64 架构):
define barrier() __asm__ __volatile__("": : :"memory")
现在把此编译器 barrier 加入代码中:
这样就避免了编译器优化带来的内存乱序访问的问题了(如果有兴趣可以再看看编译之后的汇编代码)。
本例中,我们还可以使用 volatile 这个关键字来避免编译时内存乱序访问(而无法避免后面要说的运行时内存乱序访问)。
volatile 关键字能够让相关的变量之间在内存访问上避免乱序,这里可以修改 x 和 y 的定义来解决问题:
ps: gcc/gcc++ -O 编译则是无编译优化;-O2/-O3 则是存在编译优化。
运行时内存乱序访问
在运行时,CPU 虽然会乱序执行指令,但是在单个 CPU 的上,硬件能够保证程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的,这时候 Memory barrier 没有必要使用(不考虑编译器优化的情况下)。
有序处理器(In-order processors)
早期的处理器为有序处理器(In-order processors),有序处理器处理指令通常有以下几步:
1. 指令获取
2. 如果指令的输入操作对象(input operands)可用(例如已经在寄存器中了),则将此指令分发到适当的功能单元中。如果一个或者多个操作对象不可用(通常是由于需要从内存中获取),则处理器会等待直到它们可用
3. 指令被适当的功能单元执行
4. 功能单元将结果写回寄存器堆(Register file,一个 CPU 中的一组寄存器)
乱序处理器(Out-of-order processors)
乱序处理器(Out-of-order processors)处理指令通常有以下几步:
1. 指令获取
2. 指令被分发到指令队列
3. 指令在指令队列中等待,直到输入操作对象可用(一旦输入操作对象可用,指令就可以离开队列,即便更早的指令未被执行)
4. 指令被分配到适当的功能单元并执行
5. 执行结果被放入队列(而不立即写入寄存器堆)
6. 只有所有更早请求执行的指令的执行结果被写入寄存器堆后,指令执行的结果才被写入寄存器堆(执行结果重排序,让执行看起来是有序的)
从上面的执行过程可以看出,乱序执行相比有序执行能够避免等待不可用的操作对象(有序执行的第二步)从而提高了效率。现代的机器上,处理器运行的速度比内存快很多,有序处理器花在等待可用数据的时间里已经可以处理大量指令了。
单cpu乱序访问
思考一下乱序处理器处理指令的过程,我们能得到几个结论:
1》对于单个 CPU 指令获取是有序的(通过队列实现)
2》对于单个 CPU 指令执行结果也是有序返回寄存器堆的(通过队列实现)
由此可知:在单 CPU 上,不考虑编译器优化导致乱序的前提下,多线程执行不存在内存乱序访问的问题。
我们从内核源码也可以得到类似的结论(代码不完全的摘录):
这里可以看到,如果是 SMP 则使用 mb,mb 被定义为 CPU Memory barrier(后面会讲到);
而非 SMP 时,直接使用编译器 barrier。
多cpu乱序访问
在多 CPU 的机器上,问题又不一样了。每个 CPU 都存在 cache(cache 主要是为了弥补 CPU 和内存之间较慢的访问速度),当一个特定数据第一次被特定一个 CPU 获取时,此数据显然不在 CPU 的 cache 中(这就是 cache miss)。此 cache miss 意味着 CPU 需要从内存中获取数据(这个过程需要 CPU 等待数百个周期),此数据将被加载到 CPU 的 cache 中,这样后续就能直接从 cache 上快速访问。
当某个 CPU 进行写操作时,它必须确保其他的 CPU 已经将此数据从它们的 cache 中移除(以便保证一致性),只有在移除操作完成后此 CPU 才能安全的修改数据。显然,存在多个 cache 时,我们必须通过一个 cache 一致性协议来避免数据不一致的问题,而这个通讯的过程就可能导致乱序访问的出现,也就是这里说的运行时内存乱序访问。这里不再深入讨论整个细节,这是一个比较复杂的问题。
范例
分析
创建了两个线程来运行测试代码(需要测试的代码将放置在 run 函数中)。我使用了 pthread barrier(区别于本文讨论的 Memory barrier)主要为了让两个子线程能够同时运行它们的 run 函数。此段代码不停的尝试同时运行两个线程的 run 函数,以便得出我们期望的结果。在每次运行 run 函数前会调用一次 start 函数(进行数据初始化),run 运行后会调用一次 end 函数(进行结果检查)。run1 和 run2 两个函数运行在哪个 CPU 上则通过 cpu_thread1 和 cpu_thread2 两个变量控制。
先编译此程序:g++ -lpthread -o test2 test2.cpp(这里-O 未优化,目的是为了避免编译器优化的干扰)。
需要注意的是,两个线程运行在两个不同的 CPU 上(CPU 0 和 CPU 1)。只要内存不出现乱序访问,那么 r1 和 r2 不可能同时为 0,因此断言失败表示存在内存乱序访问。
结果
编译之后运行此程序,会发现存在一定概率导致断言失败。为了进一步说明问题,我们把 cpu_thread2 的值改为 0,换而言之就是让两个线程跑在同一个 CPU 下,再运行程序发现断言不再失败。
解决
最后,我们使用 CPU Memory barrier 来解决内存乱序访问的问题(X86-64 架构下):
内存屏障(Memory barrier)
定义
内存屏障,也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
作用
Memory barrier 能够让 CPU 或编译器在内存访问上有序。
一个 Memory barrier 之前的内存访问操作必定先于其之后的完成。
分类
Memory barrier 包括两类:
1. 编译器屏障(compiler barrier)
2. CPU屏障(cpu barrier)
编译器屏障 Compiler Barrior
/* The "volatile" is due to gcc bugs */
#define barrier() __asm__ __volatile__("": : :"memory")
阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。
CPU内存屏障(CPU memory Barrior)
CPU级别内存屏障其作用有两个:
防止指令之间的重排序;
保证数据的可见性;
分类
通用 barrier,保证读写操作有序的,mb() 和 smp_mb()
写操作 barrier,仅保证写操作有序的,wmb() 和 smp_wmb()
读操作 barrier,仅保证读操作有序的,rmb() 和 smp_rmb()
注意点
1)所有的 CPU Memory barrier(除了数据依赖 barrier 之外)都隐含了编译器 barrier。
2)这里的 smp 开头的 Memory barrier 会根据配置在单处理器上直接使用编译器 barrier,而在 SMP 上才使用 CPU Memory barrier(也就是 mb()、wmb()、rmb(),回忆上面相关内核代码)。
3)CPU Memory barrier 中某些类型的 Memory barrier 需要成对使用;
详细来说就是:一个写操作 barrier 需要和读操作(或数据依赖)barrier 一起使用(当然,通用 barrier 也是可以的),反之依然。
使用 Memory barrier
无需考虑内存乱序的情况
我们所编写的代码会根据一定规则在与内存的交互过程中发生乱序。内存执行顺序的变化在编译器(编译期间)和cpu(运行期间)中都会发生,其目的都是为了让代码运行的更快。
就算是为了性能而乱序,但是乱序总有个度吧(总不能将指针的初始化的代码乱序在使用指针的代码之后吧,这样谁还敢写代码)。编译器开发者和cpu厂商都遵守着内存乱序的基本原则,简单归纳如下:
```
不能改变单线程程序的执行行为 -- 单线程程序总是满足Program Order(所见即所得)
```
在此原则指导下,写单线程代码的程序员不需要关心内存乱序的问题。
在多线程编程中,由于使用互斥量,信号量和事件都在设计的时候都阻止了它们调用点中的内存乱序(已经隐式包含各种memery barrier),内存乱序的问题同样不需要考虑了。
Memory barrier 常用场合
只有当使用无锁(lock-free)技术时–内存在线程间共享而没有任何的互斥量,内存乱序的效果才会显露无疑,这样我们才需要考虑在合适的地方加入合适的memery barrier。
但是如果你希望编写诸如无锁数据结构,那么 Memory barrier 还是很有用的。
Memory barrier 的范例
读内核代码进一步学习 Memory barrier 的使用。
Linux 内核实现的无锁(只有一个读线程和一个写线程时)环形缓冲区 kfifo 就使用到了 Memory barrier;
实现源码
分析
这里,索引 in 和 out 被两个线程访问。in 和 out 指明了缓冲区中实际数据的边界,也就是 in 和 out 同缓冲区数据存在访问上的顺序关系,由于未使用同步机制,那么保证顺序关系就需要使用到 Memory barrier 了。
索引 in 和 out 都分别只被一个线程修改,而被两个线程读取。__kfifo_put 先通过 in 和 out 来确定可以向缓冲区中写入数据量的多少,这时,out 索引应该先被读取后才能真正的将用户 buffer 中的数据写入缓冲区,因此这里使用到了 smp_mb(),对应的,__kfifo_get 也使用 smp_mb() 来确保修改 out 索引之前缓冲区中数据已经被成功读取并写入用户 buffer 中了。
对于 in 索引,在 __kfifo_put 中,通过 smp_wmb() 保证先向缓冲区写入数据后才修改 in 索引,由于这里只需要保证写入操作有序,故选用写操作 barrier,在 __kfifo_get 中,通过 smp_rmb() 保证先读取了 in 索引(这时候 in 索引用于确定缓冲区中实际存在多少可读数据)才开始读取缓冲区中数据(并写入用户 buffer 中),由于这里只需要保证读取操作有序,故选用读操作 barrier。