天天看点

Linux内核中的锁

1. 为什么要保证原子性

处理器分两种:cisc(复杂指令集,可以直接在内存上进行操作,如x86,一条汇编指令可以原子的完整读内存、计算、写内存)和rics(精简指令集,所有操作都必须是在CPU内部进行。所以你想给内存某个变量做加法,你要先用load指令把内存load到CPU的寄存器、再执行add,再执行store把结果放到内存中)。

因此a++这句话在rics上并不是原子的,必须翻译成一个rmw序列(读、修改、写)。那么就有可能在中途被打断,执行结果就可能不符合预期了。又例如,CPU去修改某个寄存器,也是这样的rmw过程,因此如果多个线程像修改这个寄存器也会有潜在问题。

所以一些芯片把寄存器分类成了set和clear两个寄存器。那么在修改寄存器的时候,想把某个寄存器的某个bit写1,就去写它的set寄存器,想把某位清0,就去写它的clear寄存器,这样就只有写操作。然后硬件会保证修改寄存器内容及其原子性,你就不用做了。例如,你想把寄存器的bit9写1,则就把1>>9写到set寄存器就好了,无需知道寄存器原来的值是什么。

有的芯片使用bitband技术:一个寄存器有32位,就有32个影子寄存器,对应寄存器的每一位。同上,你想改某一位就写这个bit的影子寄存器,硬件会给把寄存器修改成正确的值。这样,你写的代码就无需考虑原子性,提高了代码的performance。

硬件上保证原子性的一些手段:

排他性(或独占性)的load和store,对于ARM,读的时候调用ldrex,写的时候调用strex:当两个线程同时做load或store(使用ex),就会把并行的序列变成串行,只有第一个store的人会成功,第二个失败(指令会有返回值),第二个store的代码要写成死循环,判断返回值,失败后就重新从load开始再次执行序列。注意,编译器无法产生idrex/strex指令,因此要用它们的话必须手写内嵌汇编。

atomic_add/atomic_sub/atomic_inc/atomic_dec/atomic_set/atomic_read等都是通过上述排他性的wmx序列来完成,因此给一个整数变量加减可以使用这几个api。并且定义变量要使用atomic_t,虽然只是int的typeof,但还是写规范点。

atomic_xxx只能用来对整型变量保证原子性。对于其他类型或语义通常是通过加锁或禁用中断来保证原子性的。

2. Linux内核锁

临界区:就是可能存在多个线程同时访问临界资源的代码片段,而临界资源在一个时间点只能被一个线程访问。这种情况下,我们通过加锁的方式对临界区加以保护(在用户态还可以通过同步信号量或条件变量等方式,但内核中一般只用锁)。

拿到一把锁,要把相关的语义事物全部做完,再解锁。具体哪些部分是一个语义整体,就得你根据实际场景自己分析了,看哪些语义是相互关联的,就一起加锁。另外,要做到语义最小,不能说为了安全,不管三七二十一对整段代码加锁。

另外一点要注意的就是,语义关联的东西必须要共同加锁。这句话的意思是,加锁的对象一定要是一个语义,不能是其中的一部分,例如,一个结构体实例,里面有性别和姓名两个成员,多个线程会修改它,那么这时的语义就是整个结构体,你就要给整个结构体加锁。如果你给姓名和性别单独加锁的话,就可能出现在某个时刻小明的性别变成了女性。

一般那些某个地方代码的错误导致了其他代码的bug都是两个原因:1.内存越界,指针乱踏。2.锁没加对,导致偶然性的bug,可能好几天才会挂。

2.1 spinlock

自旋锁是用在多处理器环境中的锁:如果内核控制路径发现自旋锁开着,就获取锁并继续执行,相反,如果内核控制路径发现锁由运行在另一个CPU上的内核控制路径锁着,就在周围“旋转”,直到锁被释放。这个“旋转”就是在忙等,这期间正在等待的内核控制路径除了浪费时间,无事可做。

spin_lock()的实现逻辑是,他执行的是一个 核内锁调度,核间自旋 的过程。多核处理器里面,任何一个核拿到了spinlock,这个核内的调度器就被锁住了,也就是这个核上的其他线程就不可能被调度执行了,核内是通过直接把调度器锁住来实现的。而核间才是真正自旋,因此spinlock中的”spin”在多核上才有意义。在单核情况下,spin_lock()就只是简单的去锁住调度器(preempt_disable,即禁掉内核抢占)。

spinlock适合锁住那些时间特别短且不睡眠的区间。这包括了两方面:

  1. 核1锁住临界区,核2上某线程在等待进入临界区,那么核2线程可以选择睡眠让其他线程运行,等核1线程退出临界区唤醒自己后再继续运行,也可以原地自旋忙等。如果一个临界区的时间很短,核1的线程很快执行完临界区了,这种情况下,核2线程与其睡眠进行两次上下文切换,还不如原地死等(while循环去检查一个变量的值),因为可能前者的开销更大。
  2. spinlock的区间不能睡眠(不能调用可睡眠函数),这个好理解,因为不能调度了,而睡眠会引发调度。

在定义一个spinlock的时候要将锁初始化为“未锁住”的状态,确保第一次可以获得锁,定义一个自旋锁用DEFINE_SPINLOCK(x)宏即可,x是锁的名字。

虽然spinlock之后这个核不能进行调度了,但这个核上的中断还可能来,spin_lock挡不住中断,如果中断处理程序也要访问临界资源,则spinlock就起不到作用了。这时要用spinlock的修改版本spin_lock_irqsave,即既拿spinlock,也把这个核上的中断关掉。并且,这时线程中必须使用spin_lock_irqsave,要不然线程在spin_lock的时候被中断,中断处理中又调用spin_lock就死锁了。

多核的竞态有哪些情况呢?一个最严重的并发网:CPU0(有t1,t2两个线程和中断irq1)和CPU1(有t3,t4两个线程和中断irq2),这6个例程相互之间都可能产生竞态(访问相同的资源)。

解决竞态的简单做法:在线程里面统一调用spin_lock_irqsave,在中断里面统一调用spinlock。这样就避免了核内和核间的所有竞态,(核间的竞态是通过spin解决的,核内通过禁抢占和中断解决)。如果你知道只有线程才访问临界区,那线程里只用spinlock即可。

注:Linux 2.6.32以后就不支持中断嵌套了,因此中断里spinlock就好了,而老版内核版本,中断里面也要调用spin_lock_irqsave。

中断的几个API:

local_irq_save或local_irq_disable,关闭本CPU的中断。local_irq_save在关中断的同时会保存当前开关中断的状态,可以在restore的时候恢复。local_irq_disable/save是直接去改cprs寄存器,让CPU不响应中断。spin_lock_irqsave,是spinlock加local_irq_save的合体。

irq_disable(iqr_desc),屏蔽某号中断,它该的是描述符,让这个中断不发给CPU了。

我们说spin_lock核内锁调度,核间自旋。而local_irq_save是锁住了本核的中断,但在核间是没有任何作用的(Linux没有任何API能关其他核的中断或调度器)。由于我们平时写的驱动都是跨核的,不要假设自己代码肯定是单核上运行,local_irq_save起不到锁住多核的作用,如果另一个核要访问你这个核上线程的资源就产生竞态了,因此写代码的时候不要用local_irq_save,你自己写的代码基本不会存在只需要使用local_irq_save的情况,建议都改用spin_lock_irqsave来锁中断。当然local_irq_disable就更不要用了。

因此,spin_lock和spin_lock_irqsave是常用的。

注意kmalloc可能睡眠,如果在spinlock申请内存,可以加GFP_ATOMIC的flag,也可以直接用alloc_page系列函数。

还有其他的变种例如local_bh_disable()是锁下半部(锁抢占)的,相应的spin_lock_bh()是多核中锁下半部的。

2.2 mutex和信号量

mutex原理很简单,一个线程拿到了mutex,另一个线程运行时得不到mutex就睡觉,调度出去。mutex适合时间比较长或需要睡眠的临界区间。这里再多说一句,如果你的临界区里面需要调用睡眠函数就不要用spin_lock,因为代码如何运行不可预料,kernel里面有个选项CONFIG_DEBUG_ATOMIC_SLEEP,打开这个选项,在spinlock里睡就会有oops。

以前内核里还有信号量,现在基本被淘汰了,因为太复杂,实现成本太高,已经不建议用了。

加锁的原则:同一把锁,语义整体,粒度最小。因此包括三方面:1.锁一个资源用同一把锁,2.保证语义完整,3.但语义范围尽量小,使加锁粒度最小。

其中1很好理解,一个资源如果用不同的锁是锁不住的。2直接影响到功能的正确性了,而3则会影响程序的并行性能。

如果发现一个语义太大,可能是你的设计或数据结构定义有问题,本来没有互斥的语义给放到一个大的语义里了,就要尝试做语义分解。

补充一下同步锁和互斥锁:

互斥锁: 用来保护临界区,确保两个线程不能同时访问同一个资源。但是不在乎这两个线程访问这个资源的先后顺序。例如mutex,内核中的spinlock。

同步锁: 用来保证两个线程有序地访问某个资源,也有互斥在里面。例如信号量、用户态的条件变量。好像直接提及“同步锁”这个名字的时候不多。

3. 调试Linux死锁

Linux被hang住通常是由于spinlock和锁中断导致的,因为他俩都把CPU堵住了。一个调试hang死的工具是Linux自带的lockup detector。

soft lockup: 锁住调度器。

hard lockup: 锁住了中断。

kernel/watchdog.c就是用来实现lockup detector的(开启内核选项CONFIG_LOCKUP_DETECTOR),它使能了一个高优先级的rt线程,周期性的跑,给某个计数器+1,有个定时器中断定期检查这个计数器。如果定时器发现一定时间内都没+1,则说明调度器锁死了,定时器中断处理程序就打印backtrace(中断服务程序是运行在当前线程的栈,因此打印backtrace就能看到最新被调度的线程的栈)。

hard lockup:需要CPU支持NMI(不可屏蔽中断,通常是通过CPU里的PMU单元实现的),如果PMU发现长时间(这个cycle是借助NMI来计算的,因为定时器可能不工作了)一个中断都不来,就知道发生了hard lockup,这时(触发NMI中断,中断处理函数中)分析栈就知道在哪里锁住中断的。需要把CONFIG_HARDLOCKUP_DETECTOR打开。

由于ARM里面没有NMI,因此内核不支持ARM的hard lockup detector。但有一些内核patch可以用,比如用FIQ模拟MNI(如果FIQ用于其他地方了,这里就用不了了),或者用CPU1去检测CPU0是否被hard lockup(但CPU1没办法获得线程的栈,只能知道lockup了),但这两个patch都没在主线上。FIQ在Linux中基本不用的(一般只做特殊的debugger,常规代码不用)。

注意别跟drivers/watchdog/弄混了,这是看门狗,不是一回事。

继续阅读