Linux内核设计与实现——内核同步方法

主要内容

  1. 原子操作
  2. 自旋锁
  3. 读写自旋锁
  4. 信号量
  5. 读写信号量
  6. 互斥锁
  7. 完成变量
  8. 大内核锁
  9. 顺序锁
  10. 禁止抢占
  11. 顺序和屏障

1. 原子操作

原子操作可以保证指令以原子的方式执行,不会被打断
内核提供了对整数,对位的原子操作接口
特殊的atomic_t类型,32位int的低8位嵌入了一个锁

2. 自旋锁

原子操作只能用于临界区只有一个变量的情况,实际应用中,临界区的情况要复杂的多。
对于复杂的临界区,linux内核中也提供了多种同步方法,自旋锁就是其中一种。

自旋锁的特点就是当一个线程获取了锁之后,其他试图获取这个锁的线程一直在循环等待获取这个锁,直至锁重新可用。
由于线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。

注意:

  1. 自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。
  2. 线程获取自旋锁之前,要禁止当前处理器上的中断。(防止获取锁的线程和中断形成竞争条件,导致死锁)
    比如:当前线程获取自旋锁后,在临界区中被中断处理程序打断,中断处理程序正好也要获取这个锁,
    于是中断处理程序会等待当前线程释放锁,而当前线程也在等待中断执行完后再执行临界区和释放锁的代码。

中断处理下半部:

  1. 下半部处理和进程上下文共享数据时,由于下半部的处理可以抢占进程上下文的代码,
    所以进程上下文在对共享数据加锁前要禁止下半部的执行,解锁时再允许下半部的执行。
  2. 中断处理程序(上半部)和下半部处理共享数据时,由于中断处理(上半部)可以抢占下半部的执行,
    所以下半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再允许中断的执行。
  3. 同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。
  4. 不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况
  5. 同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况

3. 读写自旋锁

用于生产者消费者类型的读写自旋锁

读锁之间共享,写锁之间互斥

4.信号量

信号量也是一种锁,和自旋锁不同的是,线程获取不到信号量的时候,不会像自旋锁一样循环的去试图获取锁,而是进入睡眠,直至有信号量释放出来时,才会唤醒睡眠的线程,进入临界区执行。

由于使用信号量时,线程会睡眠,所以等待的过程不会占用CPU时间。所以信号量适用于等待时间较长的临界区。
信号量消耗的CPU时间的地方在于使线程睡眠和唤醒线程,如果 (使线程睡眠 + 唤醒线程)的CPU时间 > 线程自旋等待的CPU时间,那么可以考虑使用自旋锁。

信号量有二值信号量和计数信号量2种,其中二值信号量比较常用。

  1. 二值信号量表示信号量只有2个值,即0和1。信号量为1时,表示临界区可用,信号量为0时,表示临界区不可访问。
    二值信号量表面看和自旋锁很相似,区别在于争用自旋锁的线程会一直循环尝试获取自旋锁,
    而争用信号量的线程在信号量为0时,会进入睡眠,信号量可用时再被唤醒。
  2. 计数信号量有个计数值,比如计数值为5,表示同时可以有5个线程访问临界区。

5. 读写信号量

读写信号量和信号量之间的关系 与 读写自旋锁和普通自旋锁之间的关系 差不多。

6. 互斥锁

互斥体也是一种可以睡眠的锁,相当于二值信号量,只是提供的API更加简单,使用的场景也更严格一些,如下所示:

  1. mutex的计数值只能为1,也就是最多只允许一个线程访问临界区
  2. 在同一个上下文中上锁和解锁
  3. 不能递归的上锁和解锁
  4. 持有个mutex时,进程不能退出
  5. mutex不能在中断或者下半部中使用,也就是mutex只能在进程上下文中使用
  6. mutex只能通过官方API来管理,不能自己写代码操作它

如何选择?

在面对互斥体和信号量的选择时,只要满足互斥体的使用场景就尽量优先使用互斥体。
在面对互斥体和自旋锁的选择时,参见下表:

需求 建议的加锁方法
低开销加锁 优先使用自旋锁
短期锁定 优先使用自旋锁
长期加锁 优先使用互斥体
中断上下文中加锁 使用自旋锁
持有锁需要睡眠 使用互斥体

7. 完成变量

如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。
实现机制类似于信号量。

8. 大内核锁

不再使用,只存在于一些遗留代码

9. 顺序锁

为读写共享数据提供了一种简单的实现机制。

前提到的读写自旋锁和读写信号量,在读锁被获取之后,写锁是不能再被获取的,也就是说,必须等所有的读锁释放后,才能对临界区进行写入操作。
顺序锁则与之不同,读锁被获取的情况下,写锁仍然可以被获取。
使用顺序锁的读操作在读之前和读之后都会检查顺序锁的序列值,如果前后值不符,则说明在读的过程中有写的操作发生,那么读操作会重新执行一次,直至读前后的序列值是一样的。

10. 禁止抢占

其实使用自旋锁已经可以防止内核抢占了,但是有时候仅仅需要禁止内核抢占,不需要像自旋锁那样连中断都屏蔽掉。

这时候就需要使用禁止内核抢占的方法了:

方法 描述
preempt_disable() 增加抢占计数值,从而禁止内核抢占
preempt_enable() 减少抢占计算,并当该值降为0时检查和执行被挂起的需调度的任务
preempt_enable_no_resched() 激活内核抢占但不再检查任何被挂起的需调度的任务
preempt_count() 返回抢占计数

11. 顺序和屏障

一种同步机制(又称栅栏,关卡),用于对一组线程进行协调,所有线程到达一个汇合点后再一起向前推进

总结

Linux内核设计与实现——内核同步方法_第1张图片
同步方法总结.png

你可能感兴趣的:(Linux内核设计与实现——内核同步方法)