关于多线程同步的一切:乱序执行和内存屏障

系统教程10个月前发布 creeper
9 0 0

关于多线程同步的一切:乱序执行和内存屏障

程序序(Program Order)

对单线程程序而言,代码会一行行顺序执行,就像我们编写的程序的顺序那样。比如:

a = 1;
b = 2;

会先执行`a = 1`,再执行`b = 2`,从程序角度看到的代码行依次执行叫程序序,我们在此基础上构建软件,以此作为讨论的基础。

内存序(Memory Order)

与程序序相对应叫内存序,是指从某个角度观察到的对于内存的读和写所真正发生的顺序。

内存操作顺序并不唯一,在一个包含core0和core1的CPU中,core0和core1有着各自的内存操作顺序,这两个内存操作顺序不一定相同。

关于多线程同步的一切:乱序执行和内存屏障

从包含多个Core的CPU的视角看到的全局内存操作顺序(Global Memory Order)跟单core视角看到的内存操作顺序亦不同,而这种不同,对于有些程序逻辑而言,是不可接受的,例如:

程序序要求`a = 1`在`b = 2`之前执行,但内存操作顺序可能并非如此,对a赋值1并不确保发生在对b赋值2之前。

  • 如果编译器认为对b赋值没有依赖对a赋值,那它完全可以在编译期为了性能调整编译后的汇编指令顺序
  • 即使编译器不做调整,到了执行期,也有可能对b的赋值先于对a赋值执行

虽然对一个Core而言,如上所述,这个Core观察到的内存操作顺序不一定符合程序序,但内存操作序和程序序必定产生相同的结果,无论在单Core上对a、b的赋值哪个先发生,结果上都是a被赋值为1、b被赋值为2,如果单核上,乱序执行会影响结果,那编译器的指令重排和CPU乱序执行便不会发生,硬件会提供这项保证。

关于多线程同步的一切:乱序执行和内存屏障

但多核系统,硬件不提供这样的保证,多线程程序中,每个线程所工作的Core观察到的不同内存操作序,以及这些顺序与全局内存序的差异,常常导致多线程同步失败,所以,需要有同步机制确保内存序与程序序的一致,内存屏障(Memory Barrier)的引入,就是为了解决这个问题,它让不同的Core之间,以及Core与全局内存序达成一致。

乱序执行(Out-of-order Execution)

乱序执行会引起内存顺序跟程序顺序不同,乱序执行的原因是多方面的,比如编译器指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作顺序无法精确匹配程序顺序,这有可能带来混乱,既然有副作用,那为什么还需要乱序执行呢?

答案是为了性能。

我们先看看没有乱序执行之前,早期的有序处理器(In-order Processors)是怎么处理指令的?

  • 指令获取,从代码节内存区域加载指令到I-Cache
  • 译码
  • 如果指令操作数可用(例如操作数位于寄存器中),则分发指令到对应功能模块中;如果操作数不可用,通常是需要从内存加载,则处理器会stall,一直等到它们就绪,知道数据被加载到cache或拷贝进寄存器
  • 指令被功能单元执行
  • 功能单元将结果写回寄存器或内存位置

关于多线程同步的一切:乱序执行和内存屏障

乱序处理器(Out-of-order Processors)又是怎么处理指令的呢?

  • 指令获取,从代码节内存区域加载指令到I-Cache
  • 译码
  • 分发指令到指令队列
  • 指令在指令队列中等待,一旦操作数就绪,指令就离开指令队列,那怕它之前的指令未被执行(乱序)
  • 指令被派往功能单元并被执行
  • 执行结果放入队列(Store Buffer),而不是直接写入Cache
  • 只有更早请求执行的指令结果写入cache后,指令执行结果才写入cache,通过对指令结果排序写入cache,使得执行看起来是有序的。

指令乱序执行是结果,但原因并非只有CPU的乱序执行,而是由两种因素导致:

  • 编译期:指令重排(编译器),编译器会为了性能而对指令重排,源码上先后的两行,被编译器编译后,可能调换指令顺序,但编译器会基于一套规则做指令重排,有明显依赖的指令不会被随意重排,指令重排不能破坏程序逻辑。
  • 运行期:乱序执行(CPU),CPU的超标量流水线、以及预测执行、Cache-Miss等都有可能导致指令乱序执行,也就是说,后面的指令有可能先于前面的指令执行。

Store Buffer

为什么需要Store Buffer?

考虑下面的代码:

void set_a()
{
a = 1;
}
  • 假设运行在core0上的set_a()对整型变量a赋值1,计算机通常不会直接写穿通到内存,而是会在Cache中修改对应Cache Line
  • 如果Core0的Cache里没有a,赋值操作(store)会造成Cache Miss
  • Core0会stall在等待Cache就绪(比如从内存加载变量a到对应的Cache Line),但Stall会损害CPU性能,相当于CPU在这里停顿,白白浪费着宝贵的CPU时间
  • 有了Store Buffer,当变量在Cache中没有就位的时候,就先Buffer住这个Store操作,而Store操作一旦进入Store Buffer,core便认为自己Store完成,当随后Cache就位,store会自动写入对应cache。

所以,我们需要Store Buffer,每个Core都有独立的Store Buffer,每个Core都访问私有的Store Buffer, Store Buffer帮助CPU遮掩了Store操作带来的延迟。

关于多线程同步的一切:乱序执行和内存屏障

Store Buffer会带来什么问题?

a = 1;
b = 2;
assert(a == 1);

上面的代码,断言a==1的时候,需要读(load)变量a的值,而如果a在被赋值前就在Cache中,就会从Cache中读到a的旧值(可能是1之外的其他值),所以断言就可能失败。

但这样的结果显然是不能接受的,它违背了最直观的程序顺序性。

问题出在变量a除保存在内存外,还有2份拷贝,一份在Store Buffer里,一份在Cache里,如果不考虑这2份拷贝的关系,就会出现数据不一致。那怎么修复这个问题呢?

可以通过在Core Load数据的时候,先检查Store Buffer中是否有悬而未决的a的新值,如果有,则取新值;否则从cache取a的副本。这种技术在多级流水线CPU设计的时候就经常使用,叫Store Forwarding。有了Store Buffer Forwarding,就能确保单核程序的执行遵从程序顺序性,但多核还是有问题,让我们考查下面的程序:

int a = 0; // 被CPU1 CACHE
int b = 0; // 被CPU0 CACHE


// CPU0执行
void x() {
a = 1;
b = 2;
}


// CPU1执行
void y() {
while (b);
assert(a == 1);
}

假设a和b都被初始化为0;CPU0执行x()函数,CPU1执行y()函数;变量a在CPU1的local Cache里,变量b在CPU0的local Cache里。

  • CPU0执行`a = 1;`的时候,因为a不在CPU0的local cache,CPU0会把a的新值1写入Store Buffer里,并发送Read Invalidate消息给其他CPU
  • CPU1执行`while (b);`,因为b不在CPU1的local cache里,CPU1会发送Read Invalidate消息去其他CPU获取b的值
  • CPU0执行`b = 2;`,因为b在CPU0的local Cache,所以直接更新local cache中b的副本
  • CPU0收到CPU1发来的读b请求,把b的新值(2)发送给CPU1;同时存放b的Cache Line的状态被设置为Shared,以反应b同时被CPU0和CPU1 cache住的事实
  • CPU1收到b的新值(2)后结束循环,继续执行`assert(a == 1);`,因为此时local Cache中的a值为0,所以断言失败
  • CPU1收到CPU0发来的Read Invalidate后,更新a的值为1,但为时已晚,程序在上一步已经崩了

怎么办?答案留到内存屏障一节揭晓。

Invalidate Queue

为什么需要Invalidate Queue

当一个变量加载到多个core的Cache,则这个CacheLine处于Shared状态,如果Core1要修改这个变量,则需要通过发送核间消息Invalidate来通知其他Core把对应的Cache Line置为Invalid,当其他Core都Invalid这个CacheLine后,则本Core获得该变量的独占权,这个时候就可以修改它了。

收到Invalidate消息的core需要回Invalidate ACK,一个个core都这样ACK,等所有core都回复完,Core1才能修改它,这样CPU就白白浪费。

事实上,其他核在收到Invalidate消息后,会把Invalidate消息缓存到Invalidate Queue,并立即回复ACK,真正Invalidate动作可以延后再做,这样一方面因为Core可以快速返回别的Core发出的Invalidate请求,不会导致发生Invalidate请求的Core不必要的Stall,另一方面也提供了进一步优化可能,比如在一个CacheLine里的多个变量的Invalidate可以攒一次做了。

但写Store Buffer的方式其实是Write Invalidate,它并非立即写入内存,如果其他核此时从内存读数,则有可能不一致。

关于多线程同步的一切:乱序执行和内存屏障

内存屏障

那有没有方法确保对b的赋值一定先于对a的赋值呢?有,内存屏障被用来提供这个保障。

内存屏障(Memory Barrier),也称内存栅栏、屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,同步点之前的所有读写操作都执行后,才可以开始执行此点之后的操作。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。

内存屏障,其实就是提供一种机制,确保代码里顺序写下的多行,会按照书写的顺序,被存入内存,主要是解决StoreBuffer引入导致的写入内存间隙的问题。

void x() {
a = 1;
wmb();
b = 2;
}

像上面那样在a=1后、b=2前插入一条内存屏障语句,就能确保a=1先于b=2生效,从而解决了内存乱序访问问题,那插入的这句smp_mb(),到底会干什么呢?

回忆前面的流程,CPU0在执行完`a = 1`之后,执行smp_mb()操作,这时候,它会给Store Buffer里的所有数据项做一个标记(marked),然后继续执行`b = 2`,但这时候虽然b在自己的cache里,但由于store buffer里有marked条目,所以,CPU0不会修改cache中的b,而是把它写入Store Buffer;所以CPU0收到Read消息后,会把b的0值发给CPU1,所以继续在`while (b);`自旋。

简而言之,Core执行到write memory barrier(wmb)的时候,如果Store Buffer还有悬而未决的store操作,则都会被mark上,直到被标注的Store操作进入内存后,后续的Store操作才能被执行,因此wmb保障了barrier前后操作的顺序,它不关心barrier前的多个操作的内存序,以及barrier后的多个操作的内存序,是否与Global Memory Order一致。

a = 1;
b = 2;
wmb();
c = 3;
d = 4;

wmb()保证`a = 1; b = 2;`发生在`c = 3; d = 4;`之前,不保证`a = 1`和`b = 2`的内存序,也不保证`c = 3`和`d = 4`的内部序。

Invalidate Queue的引入的问题

关于多线程同步的一切:乱序执行和内存屏障

就像引入Store Buffer会影响Store的内存一致性,Invalidate Queue的引入会影响Load的内存一致性:

因为Invalidate queue会缓存其他核发过来的消息,比如Invalidate某个数据的消息被delay处置,导致core在Cache Line中命中这个数据,而这个Cache Line本应该被Invalidate消息标记无效。

如何解决这个问题呢?

  • 一种思路是硬件确保每次load数据的时候,需要确保Invalidate Queue被清空,这样可以保证load操作的强顺序。
  • 软件的思路,就是仿照wmb()的定义,加入rmb()约束。rmb()给我们的invalidate queue加上标记。当一个load操作发生的时候,之前的rmb()所有标记的invalidate命令必须全部执行完成,然后才可以让随后的load发生。这样,我们就在rmb()前后保证了load观察到的顺序等同于global memory order。

所以,我们可以像下面这样修改代码:

//============
a = 1;
wmb();
b = 2;

//=============
while(b != 2) {};
rmb();
assert(a == 1);

系统对内存屏障的支持

gcc编译器在遇到内嵌汇编语句`asm volatile(“” ::: “memory”);`将以此作为一条内存屏障,重排序内存操作,即此语句之前的各种编译优化将不会持续到此语句之后。

Linux 内核提供函数 barrier()用于让编译器保证其之前的内存访问先于其之后的完成。

```c
#define barrier() __asm__ __volatile__("" ::: "memory")
```

CPU内存屏障:

  • 通用barrier,保证读写操作有序, mb()和smp_mb()
  • 写操作barrier,仅保证写操作有序,wmb()和smp_wmb()
  • 读操作barrier,仅保证读操作有序,rmb()和smp_rmb()

小结

为了提高处理器的性能,SMP中引入了store buffer(以及对应实现store buffer forwarding)和invalidate queue。

store buffer的引入导致core上的store顺序可能不匹配于global memory的顺序,对此,我们需要使用wmb()来解决。

invalidate queue的存在导致core上观察到的load顺序可能与global memory order不一致,对此,我们需要使用rmb()来解决。

由于wmb()和rmb()分别只单独作用于store buffer和invalidate queue,因此这两个memory barrier共同保证了store/load的顺序。

© 版权声明

相关文章