【精通内核】CPU控制并发原理CPU中断控制内核解析

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

 

前言

📫作者简介小明java问道之路专注于研究计算机底层/Java/Liunx 内核就职于大型金融公司后端高级工程师擅长交易领域的高安全/可用/并发/性能的架构设计📫 

🏆 CSDN专家博主/Java优质创作者/CSDN内容合伙人InfoQ签约作者、阿里云签约专家博主、华为云专家、51CTO专家/TOP红人 🏆

🔥如果此文还不错的话还请👍关注、点赞、收藏三连支持👍一下博主~

目录

本文导读

一、CPU的中断控制

1、并行和并发

1.1 并发

1.2 并行

2、CPU中断控制原理

二、CPU的结构与缓存一致性

1、现代CPU架构

2、CPU的缓存一致性

3、CPU总线与MESI协议

三、CPU的系统屏障内核原理解析

1、编译器屏障原理解析

2、指令级屏障剖析

总结


本文导读

本文讲解CPU角度的中断控制CPU层面并行并发和中断控制的原理现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理以及CPU如何通过编译器的屏障与指令实现系统屏障经过内联汇编代码验证之后证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障指令重排加深对系统屏障的内核源码和原理的理解。

一、CPU的中断控制

1、并行和并发

1.1 并发

最初的计算机中是没有并发概念的。一台计算机只能运行一个任务该任务没有完成就不能运行下一个任务。

这有什么缺点呢

想象一下你的计算机只能运行一个任务如果你在听歌那么计算机就无法进行其他事情。是不是很抓狂让我们来想想如何让计算机同时运行多个任务让你在听歌时还能写文档、聊天等。这个在计算机中运行多个任务的操作就叫作并发高并发当然就是同时有非常多的任务在你的计算机中执行。那么问题来了:大家都知道计算机中进行计算的是CPU然而CPU个数有限任务个数又远大于CPU个数这时CPU又该如何运行这么多的任务的呢

可换个思维来考虑大家都学过数学不妨回忆什么是函数想象一个连续的曲线函数用离散的多个小块来模拟曲线当小块足够多时那么我们就说这些小块就是组成连续函数的块。

同理计算机也可以这么做。计算机中有很多任务让这些任务交替在速度够快人眼就感知不到任务间的切换只要矩形足够多就越能模拟出这个函数。下面我们对并发进行定义:在有限的COU中执行超过了CPU数量的任务任务之间交替执行。

1.2 并行

并发也就是在不同时间执行多个任务因为时间很短所以人眼看不出来是在替执行。而并行呢就是一种特殊的并发。并发是不同时间执行多个任务并行其实就是同一时间执行多个任务。下面给出计算机中并行的定义:在有限的CPU中执行任务任务的个数正好等同于CPU的个数则称之为并行。

计算机什么时候用到并发又什么时候用到并行呢

这不仅仅是CPU个数正好等于任务的个数还需要操作系统提供支持。例如操作系统如果设定只用一个CPU那么CPU个数再多也没有意义所以具体的并发与并行问题需要放在实际情况中来考量。不过现在个人计算机或者服务器CPU个数都足够多且操作系统如果不是特殊设置也不会只用一个CPU。由于实际运行的任务多于CPU核心数因此并行与并发是同时存在的。

2、CPU中断控制原理

本节从CPU来看看并发和并行下硬件层是如何处理共享资源的。

如果只有一个CPU我们肯定采用并发任务因为没有多个CPU所以无法并行也就是分时复用CPU资源即给每个任务分CPU时间片当时间片到后切换下一个任务执行。想象一下如我想在这种场景下实现 P-V 原语那么应怎么做呢

众所周知对于变量加 1 的操作分为3步加载、修改、写回如果在加载完成的时候CPU 切换了任务那么会发生什么肯定就回到了之前说的共享资源导致并发的问题肯定又要上锁那么上锁就必须要有原子性操作。

怎么解决上述问题呢

让我们先思考是什么导致CPU下来看看任到没到达时间片呢因为任务代码里是没有检测时间片的指令的答案是中断那么什么是中断呢想象一下你在写代码当你老板找你时给你打了个电话于是你停下手里的工作接你老板的电话完毕后继续写代码那么你放下手头完成后继续写代码的过程就叫作中断恢复。

你写代码时是电话把你中断了那么CPU执行指令时是谁中断了CPU

答案是 CPU 一个针脚会检测中断而这个中断信号在 intel上是由一个芯片叫作 8259A中断控制芯片 来做的。

8259A中断控制芯片

图中有两块 8259A 芯片每块芯片可以管理8个中断源通过使用多片级联的方式那么最多可以管理64个不同的中断号排列组合芯片上只有8个中断源针脚那么8个有8个中断源的8259A芯片是多少 8*8=64图中采用了两块芯片可以管理15级中断号IR2连接了第2块的INT针脚那么还剩7个片2有RO-R7总共7个7+8=15。

这里将级联的芯片称为从芯片而将直接跟 CPU 的 INTR 针脚也就是 CPU Interrupt Request CPU中断请求针脚相连的芯片称为主芯片。

从芯片的 INT 引脚连接到主芯片的 IR2 引脚上。主芯片的端口基地址为 0x20从芯片的地址为 0xA0。我们可以在操作系统初始化时通过系统总线控制器CPU用 IN或者OUT 命令对 8259A 操作。

完成编程后首先芯片就开始工作随时响应连接到IR0-IR15针脚的信号再通过CPUINTR针脚通知给CPU。然后CPU 响应这个信号通过数据总线 D0-D7 将我们通过编程设定的中断号读出接着 CPU就可以知道是哪个中断了我最后根据这个中断号去调用响应的中断服务程序。

其实CPU就是通过检测 INTR 针脚信号来判断是否有中断问题来了什么时候检测呢在执行完一条指令当开始执行下一个指令之前检测中断信号。

回顾CPU如何执行指令 IF(instruction fetch指令提取)、ID(instruction decode指令译码)、EX(execute执行阶段)、MEM(memory访存阶段)、WB(writeback写回阶段)、IE(interrupt execute中断处理阶段)。

下面来看看Linux内核是如何操作的。Linux 内核中对于中断的宏定义:

#define local irq_disable()_asm__volatile_("cli":::"memory") // 关中断	

#define local_irq_enable()__asm__volatile_("sti":::"memory") // 开中断

回到我们之前的问题上如果在 CPU 上执行 P-V 原语呢也就是如何让 CPU 不会切换任务很简单通过一种方式让 CPU 不响应INTR针脚的中断信号就行了当我们执行完原子性的操作后再让 CPU 响应INTR 信号即可那么这两个过程就叫作:中断使能、关中断。对应着两个 CPU 指令即 STIset interrupt flag设置中断标志位和 CLIclear interrupt flag清除中断标志位。

二、CPU的结构与缓存一致性

1、现代CPU架构

现代CPU架构除了在片上继承多个核以外还有为了减少对内存访问增加了多级缓存来提高运行速度。通常是三级缓存及L1核内独享L2核内独享L3核外共享

现代CPU架构

上图描述了现代 CPU 的架构每个 CPU 独立有 L1和L2 缓存而共享 L3 缓存。假如我们有两个任务A和B它们对 counter变量的加1是这样的任务 A和B 其中一个访存通过总线总裁只有一个任务能够通过数据总线和地址总线把counter变量从内存中加载放入 L3缓存 中然后任务 A和B 分别把 counter 加载到自己的 L1、L2 缓存中接着进行操作操作完毕后只需要把结果放入缓存中即可。

来考虑会出现什么问题

如果任务A获得了锁并且在修改了 counter后放到了自己的L1缓存中同样任务B也如此那么问题来了当任务A释放了锁这时任务B获得了锁情况又当如何呢

由于counter的最新值在任务A的L1缓存中这可怎么办任务B所在的 CPU 的缓存上是旧值应如何保证程序的正确性这就是缓存一致性协议。

2、CPU的缓存一致性

来看看intel的MESI缓存一致性协议的状态描述

intel的MESI缓存一致性协议的状态

上述状态的描述非常容易理解L1、L2、L3 的缓存是一行一行排列的我们称之为缓存行那么4 种状态即modified、exclusive、shared、invalid 就是在缓存行中保留了两个状态位分别可以表示4种状态。

那么分别代表什么意思呢从上述描述中应该就能够大致猜出这是什么状态了。例如 CPU 1加载了 counter 值那么 counter 在缓存行中的状态为E状态当CPU2加载 counter 值时CPU1窥探到总线上再到内存中加载 counter 值那么就会把自己缓存行中的数据放入 CPU2 的缓存行中且把自己状态修改为S状态表明 CPU1 和 CPU2 共享 counter 变量。而当CPU1获得了锁并且修收了 counter 值时CPU 1中的 counter 缓存行状态就变为 M 状态并且 CPU 2 窥探到 counter 值已经改变可将缓存行状态变为1状态此时当CPU 2在操作 counter 值时由于CPU2的缓存行无效因此就会重新从CPU 1中将counter的最新值加载至其中。这就保证了不同 CPU 中缓存一致性。

总结下CPU缓存一致性

1一个处于 M 状态的缓存行必须时刻监听所有试图读取该缓存行对应的主存地址的操作如果监听到则必须在此操作执行前把其缓存行中的数据写回内存中或者将该值转发给需要这个值的 CPU然后将状态修改为 S。

2一个处于S状态的缓存行必须时刻监听使该缓存行无效或者独享该缓存行的请求如果监听到则必须把其缓存行状态设置为I。

3一个处于E状态的缓存行必须时刻监听其他试图读取该缓存行对应的主存地址的操作如果监听到则必须将其缓存行状态设置为S并且转发值给需需要的 CPU缓存行。

4当CPU需要读取数据时如果其缓存行的状态是 I则需要重新发起读取请求并把自己状态变成 S;如果不是 I则可以直接读取缓存中的值但在此之前必须要等待其他 CPU 的监听结果如其他CPU也有该数据的缓存且状态是M则需要等待其把缓存更新到内存或者转发后再次读取。

5当CPU需要写数据时只有在其缓存行是 M或者 E 时才能执行否则需要发出特殊的 RFO指令read for ownership这是一种总线事务通知其他CPU置缓存无效I这种情况下性能开销是相对较大的。在写入完成后修改其缓存状态为 M。

3、CPU总线与MESI协议

总线/缓存锁非常容易理解我们回顾上图中描述的 CPU 架构图。

多个 core 也就是CPU核共享一个L3然后L3后面就是内存也就是使用同一个总线我们来想想是不是可以对这根总线上锁然后当CPU执行完一条指令后再解锁呢当这个 CPU 在访存时不允许其他 CPU 再申请访问内存如 CMPXCHG 指令它是将寄存器的值和内存中的值比较并替换那么如果我们直接写 CMPXCHGmemoryr也就是将eax和memery地址的值比较如果相同则将r寄存器的内容放入 memory 地址中将交换的值放入eax 中。想想这几个步骤这将不会保证正在执行操作的CPU不会被其他CPU锁干扰也就是这几个步骤不保证原子性。

那么如何做呢如果写 LOCK CMPXCHG memory,r, 那么仅仅加一个 LOCK 前缀但是这就够了我们对这个操作上锁了,等我们这条指令操作完成后锁才释放,那么就能保证CPU这条操作满足原子性了。

我们知道 LOCK 前缀就是用来上锁的那么想想我们对什么上锁读者可能会说上述不是说了共享总线么对总线上锁不就可以了

但是例如我通过一根控制总线拉高电平这时其他 CPU 将不能够访存这就可以了可是就像我们上述操作一样仅仅对一个变量的地址上锁

那么有没有办法来缩小锁的力度查看上图我们的变量可是被加载到了CPU的高速缓存中而且我们有 MESI协议呢这就够了我们可以看看如果变量在高速缓存中那么我们通过 MESI协议 将其他CPU缓存的值变为 I 状态这时其他 CPU 就无法再读取这个变量于是它们就需要去访存但是由于 MESI 规定了当有 CPU 在对这个变量操作的时候其他 CPU 不能操作直到 CPU 将值修改完成后置为 M 状态因此可以通过 Forward 机制将修改的值转发给其他 CPU这时状态变为 S这也避免了其他 CPU 再去访问地址因而极大地提高了效率。而且我们也将锁细粒度到了变量级别就不用再去对总线上锁了。

三、CPU的系统屏障内核原理解析

由于我们的汇编代码是由编译器产生的而我们的编译器是知道流水线的因此编译器当然能够重排序汇编代码来更进一步的优化指令流水线不仅是CPU可以乱序执行而且我们的编译器也可以重排序代码据此这里的屏障就被分为了两种即编译器屏障和指令级屏障。

1、编译器屏障原理解析

上述讨论到CPU为了高效执行代码 引用了多级流水线而我们的编译器也会面向CPU编译代码所以也会导致指令重排序我们先来看看以下代码和它的汇编代码。

代码声明了4个变量即 a、b、c、d并初始化为 0, 然后在func_1函数体内修改a为1并将d的值赋值给a随后判断d是否为真(C语言非零即真) 如果为真则输出c的值同样 func_2

    int a = 0, b = 0, c = 0, d = 0;
    void func_1() {
        a = 1;
        b = d;
        if (d) {
            printf("%d", c);
        }
    };
    void func_2() {
        c = 1;
        d = a;
        if (a) {
            printf("%d", b);
        }
    } ;

也是如此。我们来看看func 1未经优化的汇编代码。

func_1:                    // func_1 函数
        push    rbp        // 保存 rbp
        mov     rbp, rsp   // 将 rsp 的值 赋值给 rbp
        
        mov     DWORD PTR a[rip], 1    // 将 1 赋值给变量 a
        mov     eax, DWORD PTR d[rip]  // 将 eax 中 d 赋值给 b
        
        mov     DWORD PTR b[rip], eax  // 将 eax 中的 d 赋值 b
        mov     eax, DWORD PTR d[rip]  // 将 d 放入 eax
        test    eax, eax    // eax 值取and 看是否为0
        je      .L3         
        
        mov     eax, DWORD PTR c[rip]   // 将 c 放入 eax 通过 rip来做相对偏移寻址等于 %rip+c
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0  // 将 %d 的地址放入 edi
        mov     eax, 0                 // 把调用好 0 方法 eax 中
        call    printf                 // 调用 printf 输出c

可以看到未经优化编译器所生成的汇编代码是按照我们的代码书写顺序来执行的那么如果发生重排序也是CPU的指令重排序而不是编译器的因为这里编译器编译的代码和我们的C代码的语义是一样的同理func2的代码和func 1一样这里就不粘贴出来了只不过不同的是变量变为了c和d。那么我们来看看编译器优化过后的汇编代码基于x86-64 gcc 12.2编译器。

优化后编译出来的代码和我们写的代码有所不同。这就是编译器面向 CPU 编译更合理地贴近于 CPU 的流水线架构了不难发现当把 d 读取放入eax寄存器中时由于赋值给a=1的指令和 b=d 的指令没有数据依赖因此不如先把 d 读入然后指令流水线就会在读入 d 时执行赋值操作

func_1:                    // func_1 函数
        mov     eax, DWORD PTR d[rip]  // 将 eax 中放入 d 
        mov     DWORD PTR a[rip], 1    // 将 1 赋值给变量 a
        test    eax, eax               // eax 值取and 看是否为0
        mov     DWORD PTR b[rip], eax  // 将 eax 中的 d 赋值 b
        jne      .L4
        
        rep ret  // 优化 CPU 分支预测器

有些人可能会认为这没什么关系反正没有数据依赖再排序就是为了快最终结果没问题但是你想想如果有两个任务同时进入两个 CPU 中任务A执行了 func_1方法任务B执行了 func_2 方法会发生什么

从我们的代码中可以看到我们的本意是当d=1时c 应该为1。但是想想如果发生了重排序那么 d 的值会优先被加载而 a 的赋值操作却是在 d 之后任务B同时执行 func_2 方法同理也发生了重排序即d的值先被赋值为a的值然后c才等于1这就造成了与我们预期结果不符的现象。。如何让编译器禁止这种优化呢答案是采用编译器屏障

    volatile int a = 0, b = 0, c = 0, d = 0;
    void func_1() {
        a = 1;
        b = d;
        if (d) {
            printf("%d", c);
        }
    };

重新编译后与我们的预期是一样的先赋值 1然后读入 d 赋值给 b 确实起到了阻止编译器优化的作用。

接下来,我们来看看在 Linux 内核中是否可以用 volatile 关键字来实现阻止编译器优化呢查看下列源码。

#define barrier()_asm__volatile_("":::"memory" )

可以看到就是通过内联汇编来做这个事的核心代码是 "":::"memory" 无汇编但是有 clobber 的指令确切来说它不包含汇编指令所以不能叫作指令只是起到提示编译器的作用。简而言之这里的操作会影响内存不可任意优化。下面来看看加 "":::"memory" 将是什么样的效果先查看下列修改的代码。

    volatile int a = 0, b = 0, c = 0, d = 0;
    void func_1() {
        a = 1;
        _asm__volatile_("":::"memory" )
        b = d;
        if (d) {
            printf("%d", c);
        }
    };

可以看到加了 _asm__volatile_("":::"memory") 是相同的效果。 我们可以通过 volatile 关键字和内联汇编 _asm__volatile_("":::"memory") 提示编器不可任意排序。

2、指令级屏障剖析

在上述内容已详细介绍了发生指令集排序是由于 CPU 指令流水线造成的那么有没有办法在处理器中禁止发生重排序呢我们来看看 Linux 内核源码。

#define mb() alternative("lock;  addl $0,0(%%esp)","mfence", X86_FEATURE_XMM2)

#define rmb() alternative("lock; addl $00(%%esp)","lfence", X86_FEATURE_XMM2)

很简单这里先解释 alternative 宏定义这是一个选择宏通过让 CPU 在运行时根据自己支持的指令集选择并调用相应的指令所以起到指令重排序作用的指令为 "lock; addl $0, 0(%%esp)", "mfence"。其中 "mfence" 为新的指令因为在 intel 之前的 CPU 可以通过 lock 前缀对栈上指令加0操作来作为指令屏障但后面新出了 mfence和lfence其中sfence保证了全屏障、读屏障和写屏障的功能;而 "lock; addl S0,0(%%esp)" 指令对于任何 x86 平台都支持所以这里通过 alternative 宏定义让 CPU 来选择执行哪个。

至于这里的 mfence、lfence、sfence 这里就不进行详述因为屏障阻止的就是 loadload、storeload、storestore、loadstore 等重排序这3个指令也是针对这些不同的场景来选择使用的。读者现在只需要记住能通过这几个指令提供 CPU指令集屏障即可不用深究否则容易陷入泥潭。

总结

本文讲解CPU角度的中断控制CPU层面并行并发和中断控制的原理现代CPU的缓存结构和架构图、CPU缓存一致性的源码原理以及CPU如何通过编译器的屏障与指令实现系统屏障经过内联汇编代码验证之后证明上述所说的 Linux 内核用 volatile 关键字实现系统屏障指令重排加深对系统屏障的内核源码和原理的理解。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6