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


    程序序(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的顺序。

  • 相关阅读:
    是什么,让中国成为一台超级计算机?
    Google Chrome(谷歌浏览器)安装使用
    MySQ快速基础篇_MySQL数据库基础
    【计算机视觉 | 图像分割】arxiv 计算机视觉关于图像分割的学术速递(9 月 6 日论文合集)
    C语言 —— 初步入门知识(选择与循环语句、函数、数组、操作符、关键字和#define)
    Volatile介绍
    国家开放大学 训练题
    09-进程和计划任务管理
    C站专家圈分享-低代码构建WebAPI的原理与体验
    【Unity】使用Unity实现双屏显示
  • 原文地址:https://blog.csdn.net/m0_73311735/article/details/126557543