内核同步方法

  • ​​内核同步方法​​
  • ​​原子操作​​
  • ​​自旋锁​​
  • ​​读---写自旋锁​​
  • ​​信号量​​
  • ​​计数信号量和二值信号量​​
  • ​​P()和V()操作​​
  • ​​读---写信号量​​
  • ​​互斥体​​
  • ​​完成变量​​
  • ​​BKL:大内核锁​​
  • ​​顺序锁​​
  • ​​禁止抢占​​

本系列博客追寻《Linux内核设计与实现-Robert Love》,各个Linux机中的内核源代码不一,因此直接下载官网内核源码

参考书籍:《Linux内核设计与实现-Robert Love》

内核同步方法

原子操作

他是其他同步方法的基石。原子操作可以保证指令以原子的方式执行—执行过程不被打断

原子操作是不能被分割的指令

内核提供了两组原子操作接口-----一组针对整数进行操作,另一组针对单独的位进行操作

自旋锁

我们经常碰到这种情况:先得从一个数据结构中移出大户数据,对其进行格式转换和解析,最后再把它加入到另一个数据结构中,整个执行过程必须是原子的,在数据更新完毕之前,不能有其他代码读取这些数据。显然,简单的原子操作对此无能为力,这就需要使用更为复杂的同步方法—锁来提供保护

Linux内核中最常见的锁就是自旋锁(spin lock)。自旋锁最多只能被一个可执行线程持有。

如果一个执行线程试图获得一个被已经持有(即所谓的争用)的自旋锁,那么该线程就会一直进行忙循环-旋转-等待锁重新可用。要是锁未被争用,请求所的执行现场便能立即得到他,继续执行。在任意时间,自旋锁都可以防止多于一个的执行现场同时进入临界区。同一个锁可以用在多个位置。

一个被争用的自旋锁使得请求他的线程在等待重新可用时可自选(特别浪费处理器时间),这种行为是处理器的要点。所以自旋锁不应该被长时间持有,这也是自旋锁的初衷:在短期内进行轻量级加锁

还可以采取另外的方式来处理对锁的争用:让请求现场睡眠,直到锁重新可用时再重新唤醒他。这样处理器就不必循环等待,可以去执行其他代码

自旋锁和一般锁的区别
从实现原理上来讲,Mutex属于sleep-waiting类型的锁。例如在一个双核的机器上有两个线程(线程A和线程B),它们分别运行在Core0和Core1上。假设线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁,而此时这个锁正被线程B所持有,那么线程A就会被阻塞(blocking),Core0 会在此时进行上下文切换(Context Switch)将线程A置于等待队列中,此时Core0就可以运行其他的任务(例如另一个线程C)而不必进行忙等待。而Spin lock则不然,它属于busy-waiting类型的锁,如果线程A是使用pthread_spin_lock操作去请求锁,那么线程A就会一直在 Core0上进行忙等待并不停的进行锁请求,直到得到这个锁为止。

读—写自旋锁

有时,锁的用途可以明确的分为读取和写入两个用途,类似消费者/生产者两种类别。

Linux内核提供了专门的读—写自旋锁,一个或多个任务可以并发的持有读者锁;相反,用于写的锁最多只能被一个写任务持有,而且此时不能有并发的读操作

有时也称为共享/排斥锁,或者并发/排斥锁

自旋锁提供了一种快速简单的锁实现方法。如果枷锁时间不长并且代码不会睡眠,利用的自旋锁是最佳的。如果加锁时间可能很长或者代码在持有锁时有可能睡眠,那么最好使用信号量来完成加锁功能

信号量

Linux中的信号量是一种睡眠锁,如果有一个任务试图获得一个不可用(已经被占用)的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。这时处理器就能重获自由,从而去执行其他代码。当持有的信号量可用(被释放)后,处于等待队列中的那个任务将被唤醒,并获取该信号量。

但是,信号量比自旋锁有更大的开销,生活总是一分为二的(好有哲理啊)

具体分析:

  • 由于争用信号量的进程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间持有的情况
  • 相反,锁被短时间持有的时候,使用信号量就不太适宜了
  • 由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获得信号量锁,因为在中断上下文是不能进行调度的
  • 你可以在持有信号量时去睡眠,因为当其他进程试图互动二同一信号量时不会因此而死锁
  • 在你占用信号量的同时不能占用自旋锁。因为在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。

计数信号量和二值信号量

信号量可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有他

信号量同时允许的持有者数量可以在声明信号量时指定。这个值称为使用者数量或简单的叫数量。通常情况下,信号量和自旋锁一样,在一个时刻仅允许一个锁持有者。这时计数为1,这样的信号量成为二值信号量(因为他或者由一个任务持有,或者根本没有任务持有他)或称为互斥信号量(因为他强制进行互斥)

另一方面,初始化时也可以把数量设置为大于1的非0值。这种情况,称为计数信号量,他允许在一个时刻至多有count个锁持有者。计数信号量不能用来进行强制互斥,因为他允许多个线程同时访问临界区。

P()和V()操作

信号量支持P()和V()操作

  • P:测试操作(字面意思是探查),down
  • V:增加操作,up

down()操作通过对信号量计数减1来请求获得一个信号量。如果结果是0或大于0,获得信号量锁,任务就可以进入临界区。如果结果是负数,任务会被放入等待队列,处理器执行其他任务

其实可以看做是有没有资源
P操作:S信号量自减1,如果S小于0,阻塞当前进程状态,放到一个进程队列,此时这个进程就处于一个等待状态;否则继续运行
V操作:S信号量自加1,如果S小于等于0,阻塞当前进程状态,放到一个进程队列,此时这个进程就处于一个等待状态;否则继续运行
注意一点(可能不对,我自己想的):当信号量初始值为0是时,如果有P操作,则必定前面有一个V操作,而V操作是自加,所以前面可以没有P操作

读—写信号量

与自旋锁一样,信号量也有区分读—写访问的可能,与读—写自旋锁和普通自旋锁之间的关系差不多,读—写信号量也要比普通信号量更具优势。

// 创建读---写信号量
static DECLARE_RWSEM(name);

所有的读—写信号量都是互斥信号量—也就是说,他们的引用计数等于1,虽然他们只对写者互斥,不对读者
只要没有读者,并发持有该所的读者数不限。相反,只有唯一的写者(在没有读者时候)可以获得写者锁。

所有的读—写的睡眠都不会被信号打断,所以他只有一个版本的down()操作

读—写信号量比读—写自旋锁多一种特有的操作:可以动态的将写锁转换成读锁

互斥体

mutex,可参见C++多线程专栏

完成变量

如果在内核中一个认为需要发出信号通知另一任务发生了特定事件,利用完成变量(completion variable)是使两个任务得以同步的简单方法。

如果一个任务要执行一些工作时,另一个任务就会在该完成变量上等待。当这个任务完成工作之后,会使用完成变量去唤醒在等待的任务。

完成变量仅仅提供了代替信号量的一种简单的解决方法

BKL:大内核锁

BKL(大内核锁)是一个全局自旋锁,使用他主要是为了防备实现从Linux最初的SMP过度到细粒度加锁机制

特性:

  • 持有BKL的任务仍然可以睡眠。因为当任务无法被调度时,所加锁会自动被丢弃;当任务呗调度时,锁又会重新获得(确保不会造成死锁)
  • BKL是一种递归锁。一个进程可以多次请求一个锁,并不会像自旋锁那样产生死锁
  • BKL只可以用在进程上下文中。自旋锁不可以
  • 新的用户不允许使用BKL

BKL在被持有时间同样禁止内核抢占

  • lock_kernel():获得BKL
  • unlock_kernel():释放BKL
  • kernel_locked():如果锁被持有返回非0值,否则返回0

问题:难以判断其保护的是什么(数据?代码?)

顺序锁

简称seq锁,用于读写共享数据。

实现这种锁主要依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁,并且序列值会增加。在读取数据之前和之后,序列号都被读取。如果读取的序列号值相同,说明在读操作进行的过程中没有被写操作打断过。此外,如果读取的值是偶数。那么久表明写操作没有发生(要明白因为锁的初值是0,所以写锁会使值成奇数,释放的时候是偶数)。

写入的时候加锁并且序列号增加,读取的时候序列号都被读取,如果被写打断了,序列号增加,就可以知道被打断了。

禁止抢占

由于内核是抢占性的,内核中的进程在任何时刻都可能停下来以便另一具有更高优先级的进程运行,这意味着一个任务可能会在同一临界区内运行。为了避免这种情况,内核抢占代码使用使用自旋锁作为非抢占区的标记。如果一个自旋锁被持有,内核便不能进行抢占。

还有一种方法是利用抢占计数,如果计数是0,内核可以抢占,否则不可。

新创建了一个交流群,欢迎大家进入哈:892778879