一: 乱序
乱序包含处理器的乱序执行和编译器的乱序优化;
(1)处理器的乱序
乱序执行就是说把原来 有序执行的 指令列表,在保证执行结果一致的情况下 根据 指令依赖关系及指令执行周期 重新安排执行顺序。
例如以下指令(a = 1;b=a;c=2;d=c)在CPU中就很可能被重排序成为以下的执行顺序(a=1;c=2;b=a;d=c;),这样的话,4条指令都可以高效的在流水线中运转了。
对于目前的高级处理器,乱序执行可以提高内部逻辑元件的利用率以提高运行速度。现在普遍使用的一些超标量处理器通常能够在一个指令周期内并发执行多条指令。处理器从L1 I-Cache预取了一批指令后,就会分析找出那些互相没有关
联可以并发执行的指令,然后送到几个独立的执行单元进行并发执行。比如下面这样的代码(假定编译器不做优化):
z = x + y;
p = m + n;
CPU就有可能将这两行无关代码分别送到两个算术单元去同时执行。
然而在多核的情况下,由于内部的高速缓存,乱序执行对访问指令的影响可能导致数据的变化不能及时的反映在主存上,从而导致错误的结果。
比如我们在一个核上执行数据的写入操作,并在最后写一个标记用来表示之前的数据已经准备好,然后从另一个核上通过判这个标志来判定所需要的数据已经就绪,这种做法存在风险:标记位先被写入,但是之前的数据操作却并未完成(可能是未计算完成,也可能是数
没有从处理器缓存刷新到主存当中),最终导致另一个核中使用了错误的数据。
除此之外,处理器的分支预测单元有可能直接把两条分支的指令都预取来一块并发执行掉。等到分支判断的结果出来以后,再丢弃错误分支的计算结果。比如这样的代码(假定编译器不做优化):
z = x + y; if (z < 0) then p = m + n; else p = m - n;
看上去如果z不计算出来是无法继续的。但是实际上CPU有可能先把三个加法都同时进行计算,然后根据z=x+y的结果直接挑选正确的p值。
(2):编译器的乱序优化
受到处理器预取单元的能力限制,处理器每次只能分析一小块指令的并发性,如果指令相隔比较远就无能为力了。但是从编译器的角度来看,编译器能够对很 大一个范围的代码进行分析,能够从更大的范围内分辨出可以并发的指令,并将其尽量靠近排列让处理器更容
预取和并发执行,充分利用处理器的乱序并发功能。 所以现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。并且可以对访存的指令进行进一步的乱序,减少逻辑上不必要的访存,以及尽量提高 Cache命中率和CPU的LSU(load/store unit)的工作
率。所以在打开编译器优化以后,看到生成的汇编码并不严格按照代码的逻辑顺序是正常的。比如:
int *p, *q; ......; *p = 1; *p = 2; *q = *p;
这样,编译器通常会优化掉前面一个对*p的写入(逻辑上冗余),仅对*p写入2。而对*q赋值的时候,编译器认为此时*q的结果就应该是上次*p的值,会优化掉从*p取数的过程,直接把在寄存器中保存的*p的值给*q(PowrPC汇编):
(假设r3=p,r4=q) li r5, 2 // r5赋值2 stw r5, 0(r3) // 把r5写到*p stw r5, 0(r4) // 把r5写到*q
二:内存屏障
(1)volatile关键字
从编译器的乱序优化来看,对于某些变量的获取或是取值判断,编译器会先将变量读入到某个寄存器,之后所取得实质上是寄存器的值。比如:
short flag = 1; void test() { do1(); while(flag); do2(); }
线程A执行while循环,当flag被其他线程置0的时候结束循环。可是编译器优化后,线程A首先将flag读入一个寄存器,之后等待的是寄存器的值变为0。因而,陷入了死循环。
通过将flag定义成volatile short flag 可以解决此问题。volatile关键词保持了每次编译器对变量的访问都进行一次访问操作,而不是使用寄存器中的值;除此之外,绝大多数的编译器,通常不会优化掉对volatile对象的访问,并且通常保持同一个volatile对象的一系列读写
作是有序的(但是不能保证不同的volatile对象之间有序)。
可是,volatile最大的作用是通过赋予变量“易变性“来保证每次从内存中取值,却不能保证处理器的处理有序。因此,对于需要保证执行顺序的代码,必须通过使用内存屏障来实现。
(2)内存屏障
读屏障rmb()
处理器对读屏障前后的取数指令(LOAD)能保证有序,但是不一定能保证其他算术指令或者是写指令的有序。对于读指令的执行完成时间也不能保证,即它不能保证在屏障之前的读指令一定都执行完成,只能保证屏障之前的读指令一定能在屏障之后的读指令之前完成
写屏障wmb()
处理器对屏障前后的写指令(STORE)能保证有序,但是不一定能保证其他算术指令或者是读指令的有序。对于写指令的执行完成时间也不能保证,即它不能保证在屏障之前的写指令一定都执行完成,只能保证屏障之前的写指令一定能在屏障之后的写指令之前完成。
通用内存屏障mb()
处理器保障只有屏障之前的访存操作(包括读写)都完成以后才会执行屏障之后的访存操作。即可以保障读写之间的有序(但是同样无法保证指令完成的时 间)。这种屏障对处理器的执行单元效率产生的负面影响要比单纯用读屏障或者写屏障来的大。比如对于PowerP
来说这种通用屏障通常是使用sync指令实现的,在这种情况下处理器会丢弃所有预取的指令并清空流水线。所以频繁使用内存屏障会降低处理器执行单元的效率。
rmb()和wmb()屏障都可以用mb()替代,但需要考虑到性能问题;
dpdk中的无锁环形队列中充分使用了内存屏障的概念,比如:rte_smp_rmb()函数
在X86架构中:
#define rte_compiler_barrier() do { \ asm volatile ("" : : : "memory"); \ } while(0) #define rte_smp_rmb() rte_compiler_barrier()
rte_smp_rmb()是一个简单的memory指令,
它告诉编译器:这条指令(其实是空的)可能会读取任何内存地址,也可能会改写任何内存地址。那么编译器会变得保守起来,它会防止这条fence命令上方的内存访问操作移到下方,同时防止下方的操作移到上面,也就是防止了乱序,是我们想要的结果。
但这还没完,这条命令还有另外一个副作用:它会让编译器把所有缓存在寄存器中的内存变量flush到内存中,然后重新从内存中读取这些值。
由于X86是个强有序处理器,一个编译器的内存屏障就能保证处理器有序执行;
而在ARM架构中:
#define dsb(opt) asm volatile("dsb " #opt : : : "memory") #define dmb(opt) asm volatile("dmb " #opt : : : "memory") #define rte_rmb() dsb(ld) #define rte_smp_rmb() dmb(ishld)
通过dsb/dmb指令的内存屏障+memory编译器的屏障保证了处理器的执行有序。
部分内容来自:
(1)https://blog.csdn.net/puncha/article/details/8462835
(2)https://www.cnblogs.com/yc_sunniwell/archive/2010/06/24/1764231.html