学内核之十四:三言两语说说锁

目录

一 前言

二 应用层的锁

三 互斥锁

四 信号量

五 自旋锁

六 RCU 读写更新锁

七 序列锁

八 总结


一 前言

锁是计算机编程中,一个极其重要的概念。也是计算机软件中的一个极其重要的基础设施。现代操作系统中的编程,锁的应用极其广泛。锁自身的正确性和效率,是一个涉及根本的问题。

基于锁,有一些关联的概念。比如同步、异步。阻塞和非阻塞。花开两朵,各表一枝:

先来看同步异步。如果将这两个概念整一起,我们习惯是要表达:同步为等待接口执行完成后返回;而异步是操作下发,是否完成执行,后续通过通知或回调等形式来告知,俗称异步。

再来看阻塞和非阻塞。阻塞是说,当前接口未完成时,任务不继续往下执行,直到接口完成后返回。而非阻塞是说,任务不被接口阻塞。习惯中,非阻塞也是通过回调或者主动查询状态来获取接口的完成状态。

其实,上面所述同步和异步与阻塞和非阻塞之间有一些交叉模糊地带,而非能够严格的划分开来。往往是你中有我,我中有你。要想有所区分,主要是看上下文和要表达的场景。举个例子,同步接口,往往是阻塞模式的。比如通过远程调用一个服务接口,在完成接口功能后才返回,就属于同步调用。这个等待过程,调用发起方就处于阻塞状态。

本来是说锁,怎么就引到了上面四个概念呢?是因为其中一个概念----同步,有不同的含义。同步这个词,在单独使用时,往往指代多任务之间的协同,这就高度关联锁了。我们说同步问题,往往就指代锁的问题。否则,一般会习惯表达同步接口和异步接口。

人的大脑在长期演进中,进化为了单线程模式。所以,对于并发处理,往往需要大脑主动切换思考模式,而非继续保持自然而然的思考方式。故此,无论是在内核层面还是应用层面,同步问题都是令人头大的问题。处理好同步,需要极强的逻辑思维,长久的实践锻炼(这样,就可能搞出几个困难问题,从而强化大脑意识),不断的潜意识磨练,才可能做好。而涉及同步问题时,无论是菜鸟还是大拿,精神都会高度紧张。可以说,搞微内核,很大一个原因就是可以避免锁及同步问题。

除了锁本身涉及的逻辑与大脑结构不匹配外,锁引发的问题更是不容易定位,这也是导致谈锁色变的主要原因。现在内核和应用都有了一些封装措施,来简化锁的使用或者是辅助定位锁的问题。不过,处理锁和异步类问题,主要还是靠想。一个想字,汇聚了千言万语,浓缩为千金一字。

锁的使用就不罗嗦了,常见的有互斥锁、信号量、条件变量、读写锁等。除此,还引申出一些相关概念及封装接口,比如乐观锁、悲观锁,同步函数原语等。但是,大家有没有想过,锁本身是如何实现的。其实,了解了锁的实现原理,对于锁的使用,就会更加得心应手。

二 应用层的锁

我们先从应用层来入手。对于Linux来讲,应用层的锁是基于内核的相关功能来实现的。像posix系列的锁,就是基于futex来实现的。futex是一个跨内核和应用空间的接口。名字源于此:fast user mutex。

为啥讲是跨内核和应用层空间呢?因为进入内核,需要走系统调用,这会增加消耗。如果锁暂时没有被持有,那么获取锁时,在用户空间就可以完成。否则,就需要通过系统调用陷入内核层,通过内核挂起任务,等待锁的释放。这个处理过程有点像内核里常用的一种优化措施,即快速路径。

而锁本身是否获取和释放的标记,是通过一个原子变量来完成的,至于为啥,后面介绍互斥锁时会再讲解。所谓原子变量,就是对这个变量的访问是原子的,要么完成,要么未开始,而不会出现卡在中间的情况。关于这里的原子访问(也就是应用层的原子访问)是如何实现的,我们在下一篇单独描述。读者可以先思考一下。

好了,下面,我们就直接从内核来看看,锁这个基础设施的实现。

三 互斥锁

首先,我们来看看互斥锁。需要强调一点,锁是保护资源或者说是数据的,不是保护代码的。当我们需要访问共享资源时,给资源加上一把锁,持有锁的访问者才有权访问资源,其他访问者发现锁被持有后,能做的就是等待。等到锁重新被释放后,再次争抢。

通过这里的描述,你是否忽然发现,锁本身似乎也是一种资源。如何保证对锁本身的访问是独立的?即不会出现多个访问者同时持有锁。这时候,原子访问就派上用场了。如果对锁本身的访问是原子的,那么我们可以认为锁同一时刻只可能被一个访问者持有。

那内核如何实现原子访问呢?关于内核层原子变量的实现,可以参考另一篇博文:学内核之六:问题二,原子操作与锁_龙赤子的博客-CSDN博客

通过上文,我们发现,真正的原子操作,需要硬件层面的支持。原子操作是非常重要的基础,可以说是基础的基础。后面,我们介绍其他锁的实现时,大家会越来越深刻的感受到这一点。

好了,现在基于上述讨论,我们看看互斥锁如何实现。首先,有一把锁,但是只能原子访问。因此同一时刻,只有一个访问者可能拿到锁。其他访问者去拿锁,都会得到失败的结果。得到锁的访问者,就可以继续对保护资源进行访问。而其他拿锁失败的访问者需要等待。注意,这里虽然表达为等待,但是,就计算机内部的实现来讲,其他访问者是被放到了睡眠队列,休息去了。因为等在这里其实没有必要,还白白占用CPU资源。当锁的持有者释放锁时,就可以通知内核,去叫醒那些等待锁的睡眠者,让他们再来抢锁。因此,互斥锁的内核实现关键是原子变量加等待队列。

如上图所示,开始时,1、2、3任务抢占资源,队列为空。之后,任务1原子的抢到资源,此后,任务2和任务3的抢占失败会导致内核将它们放到睡眠队列中。当后续某一个时刻,任务1释放资源后,内核会让任务2和任务3醒来,再次运行,加入到锁的争抢中。

这里只是原理性的描述。虽然接近了代码语言,但是还有很多的细节没有表达。这些就需要看代码来理解了。

四 信号量

接着,来讲讲信号量。信号量和互斥锁是很类似的。区别主要在于,互斥锁保护的资源,通常认为其数目为1,这样当有一个访问者占用资源时,其他所有访问者都需要等待。而信号量则可以用于保护资源数目不止1的情况。当然1的情况也是可以使用的。除此,还有一个细微差别,互斥锁通常用于一个线程内,加锁和释放是成对出现的;而信号量可能出现A线程Wait,B线程Signal的情况。这一点通过它们的名称就可以理解。互斥锁是lock和unlock,而信号量一般是wait和signal。

因为信号量面对的资源数目可能有多个,所以信号量的实现里,原子变量不再是0-1这样的情况,而是0-N,具体这个N可以在创建信号量时根据资源数目来设定。除此,信号量很多方面跟互斥锁是一样的,访问者无法拿到资源时,同样需要睡眠等待。而资源重新可用时,内核同样需要唤醒睡眠等待者,让其再次进入争抢状态。

五 自旋锁

上面讲互斥锁和信号量时,都提到了,资源争抢失败者就会进入睡眠等待状态。那么,是否有场景,争抢失败者不能进入睡眠等待,否则会出错?这是有可能的。典型的例子,就是在中断中需要争抢锁时,显然,我们不应该让中断处理停下来。那这时候应该怎么办呢?要么就放弃锁的争抢,要么就等待。不过,这个等待不是睡眠等待,而是死等。即CPU在锁处理逻辑中死循环,直到拿到锁。这就是自旋锁的基本原理。这里的死等被形象的表示为自旋,像个陀螺一样,原地打转。

自旋本身的实现并不困难,无非就是检查锁变量,如果没有拿到,就再次循环检查。这里的关键是,自旋时,需要注意什么?一个是中断。因为中断如其名,并不会按预期来,所以在自旋锁自旋时,新来中断可能会打断当前自旋。解决这个问题,就需要在自旋锁中关中断。这里的关中断,是自旋逻辑所在的CPU核心的中断,其他核心的中断并不会影响该核心的自旋。但是,其他核心跟当前核心,可能存在任务抢占。比如,内核发现某个CPU核心负载过大,将其上的任务调度到其他核心执行,这也可能影响自旋。所以,在自旋锁的处理中,需要关闭抢占。满足上述条件,自旋就可以安全进行了。补充一点,自旋过程中,对于锁的状态修改可以通过原子变量实现,进一步保障了整个处理逻辑的完备。

所谓知己知彼,百战不殆。了解了自旋锁的实现后,我们看看使用自旋锁时需要注意什么?因为自旋时,会让CPU空转,并不会让出CPU资源,所以保护区的处理时间不应该过长。其次,上面也讲到了,自旋锁获取后,中断和抢占是关闭的,所以不应该在保护区有睡眠相关的操作和函数调用,比如sleep类接口调用。也基于同样的原因,自旋锁不应该嵌套调用。

基于上面的描述,我们会发现,谁能抢到自旋锁不完全是一个随机事件(前面所述其他锁也是类似的)。如果CPU核心的性能不同,那么获取锁的能力是不一样的。高性能的核心,代码执行速率可能更高,因此查锁频率也可能会更高,自然,获取锁的概率就可能大一些。针对类似这些问题,内核的自旋锁实现也是一直在演变的。博主了解的一种演变版本是带序号的版本。虽然无法让任务像互斥锁和信号量那样,排队到等待队列上睡眠,但是仍然可以让获取自旋锁的任务排队,这是通过序号检查来实现的。每一个自旋锁都有两个序号,一个是获取者序号,一个是持有者序号。每一个获取自旋锁的任务在检查锁时,会拿到当前自旋锁的获取者序号,并将自旋锁的获取者序号更新。这样,当下一个任务获取自旋锁时,拿到的就是更新的获取者序号。同时,该新任务也将自旋锁的获取者序号再次更新。当自旋锁释放时,会将其持有者序号更新。各个任务此时再争抢自旋锁时,就不再是无序的状态了,而是需要检查自己拿到的获取者序号跟锁当前的持有者序是否号一致。如果一致,则可以拿锁,否则,仍然自旋等待。

如此一来,锁被获取的顺序就会与请求获取锁时的先后顺序严格一致。举个例子,比如,任务1获取锁时,锁的获取者序号为1,持有者序号为1,此时任务1获取到锁,并将锁的获取者序号自增为2。任务2获取锁时,锁的获取者序号就是2,持有者序号为1。因为二者不一致,所以任务2此时无法获取到锁,但是任务2会将锁的获取者序号增加为3。后续任务的处理以此类推。当锁释放时,其持有者序号自增1,对于当前情况,持有者序号会变为2,如此,与持有者序号相同的获取者只能是任务2。这样,任务2会先于其他等待该锁的任务拿到锁,因为任务2先于其他任务请求锁并修改了锁的持有者序号,其他任务只能拿到更加靠后的持有者序号,自然要更靠后的获取到锁。基于该机制,自旋锁的抢占从无序变成了先请求先获得的有序状态。

以上是序号版本,这部分据了解也一直在演进,感兴趣的读者可以查看最新内核里的实现方式。

六 RCU 读写更新锁

了解了自旋锁后,我们再来看看RCU锁----读写更新锁。这个锁的本质意图是避免读操作之间的锁争抢,比如读操作不加锁,只是在有写操作时,才上锁。显然,这种锁要发挥作用,场景得是读多写少那种。恰巧,内核里有大量这种应用场景。比如,各种队列的查询。查询相比修改而言,是一种更加高频的动作。另外,像文件的读取,也是比修改更加高频的动作。

关于这个锁的实现,可参考博主针对该主题的一篇博文:学内核之十三:关于RCU锁的一些思考_龙赤子的博客-CSDN博客

七 序列锁

最后,我们再来看看内核的序列锁。序列锁有点像乐观锁的概念。同样是读数据时不加锁,写数据需要上锁。读者获取数据的前后会检查序号,如果没有发生变化,那么说明数据没有发生改变,自然,就可以拿来使用。但是,读者也明显的看出来了,这不保险啊,万一数据改变了咋办?这就涉及序列锁的精髓了。如果读取数据过程中,数据发生了改变,那么就重新再读一次。进一步的,如何知道数据发生了改变?这就该序号发挥作用了。读数据过程中,不对序号进行修改,只在进出过程中,判断序号是否一致,不一致,那么就说明读任务在数据保护区停留的时候,数据被写者修改了。这时候就需要再重新读,直到前后序号一致,此时读取的数据才是完整的。而对写者而言,每次进保护区的时候,将序号加1,出的时候再加1,这样就可以完成对数据的修改标记。

通过上面的流程,我们可以注意到,数据是否被写者访问,可以通过序号的奇偶特性来快速判断。

至此,我们就简单了解了内核中有关锁的实现。上面只是概述了相关的原理,具体到实现,还涉及很多细节和优化措施,而且随着版本的迭代,具体实现也在演进中,感兴趣的读者可以进一步的阅读源码来了解。

八 总结

现在,我们简单总结一下。锁的基础首先是原子操作。原子操作,依赖硬件的特定指令来保证。基本的锁是互斥锁,其他各种锁,都是基于互斥锁,根据不同应用场景,衍生出来的。之所以有这么多种锁,无非还是要提高软件的执行效率,减少相互之间的依赖和钳制。不单是锁,就是原子操作指令,CPU核心层面也在持续优化,指令也在变化中。计算机软硬件到现在的发展程度,每前进一步都显得更加艰难。往往很多都是微创新,真正是点滴汇聚,以成江河,梧桐并闾,极望成林。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值