什么是内存乱序访问?

文章目录

  • ​​什么是内存乱序访问?​​
  • ​​1. 编译乱序​​
  • ​​1.1 编译优化等级​​
  • ​​1.2 使用volatile​​
  • ​​1.3 编译器屏障​​
  • ​​1.4 加锁​​
  • ​​2. 运行乱序​​
  • ​​3. 总结​​


不断深挖计算机底层的原理越发觉得有趣,今天聊聊内存乱序执行的话题。


首先问个问题:我们写得程序会按照既定的顺序执行么?


这似乎毫无疑问。但是了解编译、链接原理的“底层”知识,则不会轻易下定论。特别是在用到多线程涉及到内存共享没有加锁的时候,也会暴露这个问题。


所以很遗憾,在某些情况下,程序指令的执行顺序会发生改变,这就产生了我们所说的

内存乱序问题

乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化.

但是也很幸运,我们可以采取手将“乱序”纠正为“顺序”。
内存乱序访问一般分为两种:编译乱序和执行乱序。下面我们分别举例说明现象并介绍规避乱序的方法。

1. 编译乱序

之所以出现编译器乱序优化其根本原因在于处理器每次只能分析一小块指令,但编译器却能在很大范围内进行代码分析,从而做出更优的策略。

我们来写两行简单的程序复现一下编译乱序的表现。

int x, y, z;
void fun(){
x = y;
z = 1;
}

通过gcc查看编译成的汇编指令,这里我们采用O3优化等级:

​gcc -S demo.c -O3​

截取一段我们重点关注的代码:

fun:
.LFB0:
.cfi_startproc
endbr64
movl $1, z(%rip) " z = 1
movl y(%rip), %eax
movl %eax, x(%rip) " x = y
ret
.cfi_endproc

显然,编译器调换了​​x = y; z = 1;​​两条语句的执行顺序。

那么如何解决编译乱序带来的麻烦呢?解决方案有如下几种:

  • 编译优化等级
  • volatile
  • 编译器屏障
  • 加锁

1.1 编译优化等级

我们将编译优化等级调整为O0,观察效果。
​​​gcc -S demo.c -O0​

fun:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl y(%rip), %eax " x = y
movl %eax, x(%rip)
movl $1, z(%rip) " z = 1
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc

一般地硬件设备编译时采用-Os的优化级别,介于-O2与-O3之间。区别如下:

  • -Os在-O2的基础上尽量降低目标代码的大小;
  • -O3会想尽办法提高运行速度,即使增加目标代码的大小

1.2 使用volatile

volatile关键字我们不陌生,访问被volatile修饰的变量时,强制访问内存中的值,而不是缓存中的。用volatile声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化,以免出错
​​​volatile官方描述​​ 所以,使用volatile修饰变量,即使用O3等级优化也不会改变语句的顺序。

volatile int x, y, z;
void fun(){
x = y;
z = 1;
}

编译结果:

fun:
.LFB0:
.cfi_startproc
endbr64
movl y(%rip), %eax
movl %eax, x(%rip)
movl $1, z(%rip)
ret
.cfi_endproc

1.3 编译器屏障

Linux内核提供了函数barrier(),用于让编译器保证其之前的内存访问先于其之后的内存访问完成。这样可以防止编译屏障之前的code和编译屏障之后的code出现编译乱序。

#define barrier() _asm_ _volatile_("": : :"memory")

继续改写源程序:

int x, y, z;
void fun(){
x = y;
__asm__ __volatile__("": : :"memory");
z = 1;
}

编译结果:

fun:
.LFB0:
.cfi_startproc
endbr64
movl y(%rip), %eax
movl %eax, x(%rip)
movl $1, z(%rip)
ret
.cfi_endproc

1.4 加锁

对共享内存加锁是必要的,这样可以省去很多烦恼。

#include <pthread.h>
pthread_mutex_t m;

int x, y, z;
void fun(){
pthread_mutex_lock(&m);
x = y;
pthread_mutex_unlock(&m);
z = 1;
}

编译结果:

fun:
.LFB1:
.cfi_startproc
endbr64
subq $8, %rsp
.cfi_def_cfa_offset 16
leaq m(%rip), %rdi
call pthread_mutex_lock@PLT
movl y(%rip), %eax
leaq m(%rip), %rdi
movl %eax, x(%rip)
call pthread_mutex_unlock@PLT
movl $1, z(%rip)
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc

2. 运行乱序

运行时,CPU本身是会乱序执行指令的。
早期的处理器为有序处理器(in-order processors),总是按开发者编写的顺序执行指令,如果指令的输入操作对象(input operands)不可用(通常由于需要从内存中获取),那么处理器不会转而执行那些输入操作对象可用的指令,而是等待当前输入操作对象可用。

相比之下,乱序处理器(out-of-order processors)会先处理那些有可用输入操作对象的指令(而非顺序执行)从而避免了等待,提高了效率。现代计算机上,处理器运行的速度比内存快很多,有序处理器花在等待可用数据的时间里已可处理大量指令了。即便现代处理器会乱序执行,但在单个CPU上,指令能通过指令队列顺序获取并执行,结果利用队列顺序返回寄存器堆(详情可参考http:// http://en.wikipedia.org/wiki/Out-of-order_execution),这使得程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的, 因此内存屏障是没有必要使用的(前提是不考虑编译器优化的情况下)。

下面举个例子阐明现象及解决方案。

/*=============================================================================
*
* Author: Terrance[wangtao_27@qq.com]
*
* 公众号:嵌入式孤岛
*
* Last modified: 2021-11-13 23:02
*
* Filename: cpuchaos.c
*
* Description: 内存乱序执行访问与预防
*
=============================================================================*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>

int x, y, p, q;
int runtime = 0;

static pthread_barrier_t barrier_start;
static pthread_barrier_t barrier_end;

static void *thread1(void *args)
{
for (; ;){
pthread_barrier_wait(&barrier_start);
x = 1;
#ifdef CPU_MEM_FENCE
__asm__ __volatile__("mfence":::"memory"); // CPU内存屏障
#endif
p = y;
pthread_barrier_wait(&barrier_end);
}
return NULL;
}

static void *thread2(void *args)
{
for (; ;){
pthread_barrier_wait(&barrier_start);
y = 1;
#ifdef CPU_MEM_FENCE
__asm__ __volatile__("mfence":::"memory");
#endif
q = x;
pthread_barrier_wait(&barrier_end);
}
return NULL;
}
void start(void)
{
x = y = p = q = 0;
}

void end(void)
{
++runtime;
printf("[%d] %d %d\n", runtime, p, q);

/* 乱序发生,终止程序 */
if (p == 0 && q == 0){
puts("chaos coming!");
exit(-1);
}

}

int main(int argc, char *argv[])
{
int err;
pthread_t t1, t2;

err = pthread_barrier_init(&barrier_start, NULL, 3);
if (err != 0){
perror("pthread_barrier_init");
exit(-1);
}

err = pthread_barrier_init(&barrier_end, NULL, 3);
if (err != 0){
perror("pthread_barrier_init");
exit(-1);
}

/* create thread */
err = pthread_create(&t1, NULL, thread1, NULL);
if (err != 0){
perror("pthread_create");
exit(-1);
}

err = pthread_create(&t2, NULL, thread2, NULL);
if (err != 0){
perror("pthread_create");
exit(-1);
}
/* 线程1绑定到CPU0执行 */
cpu_set_t cst;
CPU_ZERO(&cst);
CPU_SET(0, &cst);
err = pthread_setaffinity_np(t1, sizeof(cst), &cst);
if (err != 0){
perror("pthread_setaffinity_np");
exit(-1);
}

/* 线程2绑定到CPU1执行 */
CPU_ZERO(&cst);
CPU_SET(1, &cst);
err = pthread_setaffinity_np(t2, sizeof(cst), &cst);
if (err != 0){
perror("pthread_setaffinity_np");
exit(-1);
}

for (;;){
start();
pthread_barrier_wait(&barrier_start);
pthread_barrier_wait(&barrier_end);
end();
}

return 0;
}
# linux @ ubuntu in ~/codelab/c/Nov [21:35:52] 
$ gcc cpuchaos.c -o chaos -lpthread

# linux @ ubuntu in ~/codelab/c/Nov [21:35:53]
$ ./chaos
[1] 1 0
[2] 1 0
[3] 1 0
[4] 1 0
[5] 1 0
[6] 1 0
[7] 0 1
......
[6000] 0 1
[6001] 1 0
[6002] 1 0
[6003] 1 0
[6004] 1 0
[6005] 0 0
chaos coming!

发生了乱序,程序终止。

# linux @ ubuntu in ~/codelab/c/Nov [21:35:58] C:255
$ gcc cpuchaos.c -o chaos -lpthread -DCPU_MEM_FENCE

# linux @ ubuntu in ~/codelab/c/Nov [21:37:54]
$ ./chaos
[1] 1 0
[2] 1 0
[3] 1 0
[4] 1 0
[5] 1 0
[6] 1 0
[7] 0 1
......
[405185] 0 1
[405186] 0 1
[405187] 0 1
[405188] 0 1
[405189] 0 1
[405190] 0 1
[405191] 0 1
[405192] 0 1
[405193] 0 1
^C

跑了40多次万次仍未发生乱序,内存屏障生效。

不过,如果硬件产品如果是单核则无须担心执行乱序。

3. 总结

本文浅谈了内存乱序现象,包括编译乱序和执行乱序。所以针对共享数据,该上锁上锁基本可以规避内存优化问题。

什么是内存乱序访问?_编译器