学内核之七:问题三,全局变量加锁与每CPU变量

发布于:2023-01-18 ⋅ 阅读:(470) ⋅ 点赞:(0)

接着上一篇:

学内核之六:问题二,原子操作与锁_龙赤子的博客-CSDN博客

代码中如果有一个全局整形变量,是否需要加锁保护?每CPU变量是如何避免锁的?

为什么我会将这两个问题放在一起?等看完下面的讨论,就会明白。

如果代码里有一个全局的int型变量,多线程访问时,是否需要加锁?网上搜一搜,就会发现,有的人说需要,有的人说基本类型变量不需要。显然,大家有一个共识,那就是如果这个变量是类似一个结构体的多基本变量构成的数据,肯定需要加锁。因为这种情况下,CPU不能一条指令搞定。

自然,回到 int 变量,说不需要加锁的人,显然是认为该变量的修改,只需要一条指令就可以了。我想,大部分做这一回答的,都是这样想的。当然,还有一些细分的,比如说区分读和写,如果只是读,就不需要加锁。这显然就是废话了。

好了,那我们看看,类似 int i=1;这样一条修改整形变量的指令,是否是一条汇编语句完成。我估计X86有可能,但是ARM好像是不行的。因为对于RISC架构的处理来讲,访问内存的操作好像只有读取和写回,修改都是在读取到寄存器中进行的。我写了一个简单的程序,反汇编看来一下,的确是读-修改-写的三条指令,而不是一条指令。

如果是上述三条指令,我们来看锁的必要性。

首先,考虑任务执行的打断。这一点跟上一节的锁本身的实现有相似地方。当前的流程,可能被中断打断,可能被其他核心抢占,也可能被当前核心上的其他高优先级任务抢占。

其次,抢占会造成执行的中断。不是终止,只是会停下来的意思。这样,极端情况下, 在多个任务访问变量时,大家都可能到了修改寄存器的这一步。

最后,大家都开始写回。结果呢?每个任务都将自己的结果写回,虽然变量被加了多次,但反馈到内存上,看看好像只修改了一次。

这会产生什么情况?举个例子。任务1可能将变量修改为了5,接着任务2将其修改成了7,紧接着任务3又将其修改为了9,之后任务1再读取修改后的值,发现不是自己改的5,而成了奇怪的9。显然,这种情况下,必须要加锁的。

那么,再看每CPU变量。为什么每CPU变量就可以避免加锁了?首先,相对于我们前面讲的,多了一个约束,就是不存在多核心访问。关于每CPU变量如何实现,在另外一个文档中整理。

如果只在自己的核心上,那就假设有两个任务,1和2,是否存在上面描述的情况。想想,好像也不能避免。只要读-修改-写的过程可以被打断,就有出问题的可能。任务1写的值被任务2覆盖,导致任务1再次读取时,不再匹配自己的写值。这里,我们提到说是可能,而非一定。因为任务总是会被中断打断,如果只有一个任务使用该变量,那么即使被打断,也没有问题,所以条件还要加上在存在竞争的情况下,执行流程可以被打断。

现在我们看每CPU变量的情况。这个不存在多核心问题。多任务似乎是存在的。是否被中断访问,有待研究代码实现。不过,这一点似乎是可控的。可以要求对每CPU变量的中断访问提供限制条件。

那么现在,就需要考虑在多任务访问的情况下,如果避免读-修改-写的过程被打断。显然,禁止任务的抢占就可以了。如果任务1不被任务2抢占或打断,那么读-修改-写的过程就不会被打断。但是,这里面有一条隐藏路线,那就是被中断打断,并且中断返回时,调度了别的任务。这个跟被其他任务打断,本质上是一致的。

所以,回过来,就是对每CPU变量的访问,既不能被其他任务抢占,也不能被中断打断。所以内核中,读每CPU变量的访问,都是关闭抢占的。这一点还需要进一步核实,还可能是这样的,只是关闭抢占,但是抢占的定义被扩大,这样即使被中断打断,那么中断返回的时候,还是回到被打断的任务,而不会调度其他任务,因为关闭了抢占。

这种实现,就要求中断里不能开抢占!!!

上面所述过程,没有提及CPU中的Cache一致性。这一点是被CPU硬件保证,对软件来讲,是透明的。后面我们专门来看Cache的一致性。当前,前提是反复提到的,没有DMA等其他可直接访存设备的参与。

这里确定一下每CPU变量的禁止抢占代码实现

查看代码,注意到:

分别有关闭抢占和打开抢占的操作。

查看上述关闭和打开抢占的实现

 

 

我来看抢占的实现,可以看到,首先处理抢占计数

 

 

可以看到,计数最终是操作的当前线程的preempt_count变量。该变量被volatile修饰,编译器每次都会从内存中获取该变量进行修改。

这里是否需要通过原子操作来操作该变量?是否有这个必要?用原子操作的话,就可以避免在修改变量的时候,被别的任务打断。但是我们注意到,不同于每CPU变量,当前线程上的这个变量应该属于一个局部变量,只有当前线程才会有这个堆栈,切换任务就切换堆栈了,所以,不像每CPU变量,属于全局变量。所以,不需要采用原子操作。

那就引入另一个问题,如果只是当前任务堆栈修改该变量,那么如何保证系统的抢占被关闭?问题就在于抢占的实现了。所谓抢占,就是系统调度另外的任务替换当前任务的执行。这个是在调度函数里实现的。除了中断和异常打断当前任务的执行,CPU是不会主动中断当前任务的执行的。当当前任务被中断后,调度执行过程中就会考虑抢占问题,然后在满足抢占条件时,调度其他任务抢占当前任务。所以,如果我们再当前任务中记录不抢占标记,也就是这里的计数值大于零,那么当调度执行的时候,就不会调度其他任务,自然,当前任务就会继续执行,实现所谓的关闭抢占的效果

上面,我们一直在讨论当前CPU,或者说默认是当前CPU的情景。对于SMP系统,其他CPU的干扰是否存在?需要注意到,执行上述代码时,肯定是在某一个CPU上,所以,该CPU的调度,不会调度该CPU队列上的其他任务,也不会调度其他CPU队列上的任务。那么,别的CPU上的任务调度会不会调度到当前任务呢?显然也不会,因为当前任务是处于运行状态的。

最后一个细节,该变量可以认为是一个局部变量,会不会存在多个任务的该变量都被增加的情况?显然,从每CPU来看,是存在这种情况的,但是如上述对抢占的说明,我们可以看到,这不会影响什么,CPU之间不会相互影响。那,对于当前CPU呢?显然也不会,因为按照上述逻辑,当前CPU上,只有当前运行任务的抢占计数会大于零,不会存在多个任务的抢占计数大于零的情况。因为只有当前任务的计数减少为零后,才有机会调度别的任务运行,所以当前任务的计数不减少,CPU一直会执行当前任务

虽然,使用栈变量规避了锁的情况,但是可能存在一种情况,就是在修改该变量的过程中,比如读-更新-写的过程中,发生中断,导致任务被调度出去。这也不会产生问题,因为对真正需要访问数据的修改,是在抢占计数生效后才进行的,所以这个过程是可重入的。

总结一句,看似简单的一行代码,背后包含了很多的东西。

抢占关闭里的第二行代码是一个barrier。这个在内核里是大量运用的,我们看看这个调用做了什么。

从上面的代码来看,barrier的实现是一段嵌入汇编。

这段汇编做的主要工作是避免编译器优化,保存内存的有序访问(相比于乱序访问)。这里有一个细节,那就是CPU的乱序执行呢?因为本质上,对于单个核心来讲,其最终的执行结果是依赖指令序列的,乱序并不产生问题,所以通过上面的代码,就可以保证抢占计数变量最终是完成了更新。这之后,才访问的每CPU变量。

到这里,我们就清楚了每CPU变量为啥需要禁止抢占以及如何做到禁止抢占。


网站公告

今日签到

点亮在社区的每一天
去签到