0%

Linux内核同步

本文首先介绍了Linux内核中解决并发问题的意义和思路,然后介绍了Linux内核中提供的多种同步机制的原理和使用。 ## 临界区和竞争条件

名词

  1. 临界区:对共享资源进行操作的代码
  2. 竞争条件:两个线程可能在同一时刻在同一个临界区执行代码,这个就称为竞争条件,是一个bug。
  3. 同步:避免并发和发生竞争条件
  4. SMP:对称多处理器
  5. 内核抢占:Linux完整地支持内核抢占,也就是在内核任务执行的过程中可以被其他任务抢占CPU的执行权
  6. 中断安全代码:在中断处理程序中能避免并发访问的安全代码;
  7. SMP安全代码:在SMP机器中能避免并发访问的安全代码;
  8. 抢占安全代码:在内核抢占时可以避免并发访问的安全代码;

并发问题的引入

  1. 真并发:因为SMP技术的诞生,提供了同一个系统内同一时刻多个线程同时执行的能力,那么就有可能发生一个数据结构同时被多个线程访问;
  2. 伪并发:即使在单个CPU的情况下,因为Linux完整地支持内核抢占,一个线程可能在任何时刻被调度(比如在执行临界区代码时),然后共享数据结构被新线程访问。

Linux内核中可能造成并发执行的原因

  1. 中断——中断是由硬件发起,可能在任何时刻发生;
  2. 软中断和tasklet;
  3. 内核抢占;
  4. 睡眠以及用户空间的同步——在内核执行的线程可能会睡眠,从而引发调度;
  5. SMP;

加锁

什么是锁

锁提供了一种机制,这种机制可以保证同一时刻仅有一个线程对加锁的数据结构进行操作。

说明:

  1. 锁的持有对象是线程。
  2. 锁的使用是自愿的,非强制的,完全属于一种编程者自选的编程手段。
  3. 锁有不同的实现机制,这些机制的不同点在于,当无法获得锁时,线程的行为表现(忙等待/睡眠)
  4. 加锁、释放锁这个过程是原子实现的

怎么在代码中考虑同步问题

  1. 锁的是数据结构,不是代码,大多数内核数据结构都需要加锁;
  2. 用锁来保护共享资源并不困难,真正困难的地方在于判断什么数据需要加锁并划出临界区;
  3. 在编写代码开始阶段就要考虑加锁;

死锁

最简单的自死锁的例子

避免死锁的一些规则:

  1. 所有程序都按顺序加锁;
  2. 防止发生饥饿;
  3. 不要重复请求同一个锁;
  4. 设计力求简单

为什么要按顺序加锁?看这个例子:

释放锁的顺序和死锁无关,但最好也是按照顺序释放。

锁的争用和扩展性

  1. 锁的争用:多个线程试图获取同一把锁,会成为系统瓶颈;
  2. 扩展性:是对系统可扩展程度的一个量度;(锁的粒度)

现在我们来看看Linux内核为了数据同步,提供了什么工具。

概括

  1. 原子操作:处理器在物理层面会提供一些原子操作保证这些指令在执行的过程中不被打断,原子操作是其他同步工具的基石;
  2. 自旋锁:可以保证临界区的代码同一时刻只能有一个线程在执行,没拿到自旋锁的线程会忙等待(一直请求自旋锁等到拿到了为止)
  3. 信号量:可以保证临界区的代码同一时刻只能有1~n个线程在执行,没拿到信号量的线程会加入到等待队列,信号量被释放后从等待队列中取一个线程执行;
  4. 互斥体(特殊的信号量):轻量版的信号量,可以保证临界区的代码同一时刻只能有一个线程在执行,开销更小。
  5. 完成变量:一个线程需要等待另一个线程完成某一件事后才能继续执行,可以使用完成变量。
  6. 顺序锁:和自旋锁差不多,但是对写者更有利,读不用加锁但是利用序列号机制来保证读的过程没有写操作发生,写操作到来时直接上锁,写完释放锁。
  7. 禁止抢占:为了应对不需要上锁但是需要防止内核抢占的情况。

原子操作

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

内核提供了两组原子操作接口,一组针对整数进行操作,另一组针对单独的位进行操作,在Linux支持的所有体系结构上都实现了这两组接口。所以原子操作和体系结构密切相关。

原子整数操作

原子整数操作中的整数使用了atomic_t结构,为什么不使用int?原因是:

  1. 确保原子操作只和特殊的数据类型一起用;
  2. 确保这个类型不会被传递给任何非原子函数;
  3. 确保编译器不对相应的值进行优化;
  4. 在不同体系结构中间实现原子操作的时候,使用统一的数据结构可以屏蔽差异;

相关操作:

以下的这些操作在所有的体系结构中都存在,但是特定的体系结构可以定义更多的原子操作:

atomic_t在64位体系结构下也是32位的,如果需要使用64位的原子变量,请使用atomic64_t,但是仍然建议能用32位就使用32位。

原子位操作

提供了一种操作某个内存地址的某一个位的方式,第一个参数是内存地址,第二个参数是位号。

内核还提供了一组相对应的非原子位操作,不同的是不保证原子性,在函数名前缀多了两个下划线。

自旋锁

  1. Linux内核最常见的锁是自旋锁,自旋锁的实现和体系结构密切相关;
  2. 自旋锁最多只能被一个线程持有;
  3. 自旋的意思就是在争用不到锁时继续检查锁的状态,所以特别浪费处理器时间;
  4. 由于上面的原因,自旋锁不应当被长时间持有,自旋锁的目的是短时间,轻量级加锁;
  5. 如果需要长时间持有锁,那么使用下面会讲到的信号量;

自旋锁的使用方法

基本使用形式

使用注意事项:

  1. DEFINE_SPINLOCK(mr_lock)静态定义了自旋锁
  2. 自旋锁不可重入(递归获取)!否则就会产生死锁
  3. 自旋锁可以使用在中断处理程序中,这种情况下需要首先禁止本地中断(当前处理器上的中断请求)
  4. 中断处理程序中使用自旋锁

  1. 如果在中断处理程序中可以确定本地中断在加锁之前是激活的,可以无条件的在解锁后激活本地中断

其他自旋锁操作

自旋锁和下半部:如果执行的进程上下文和下半部共享数据,那么在加自旋锁的同时还要禁止下半部执行:spin_lock_bh()

读-写自旋锁

Linux专门提供了读-写自旋锁,一个或者多个读任务可以同时持有读者锁,但是同一时刻只能有一个写者锁,并且不能有读者锁。

  • 读锁:共享锁、并发锁
  • 写锁:排斥锁

使用方式:

注意事项:

  1. 读锁可以递归获取;
  2. 这种自旋锁对写者不友好,大量的读者会导致写者饥饿;

读-写自旋锁方法列表:

信号量

Linux中的信号量是一种睡眠锁。如果有一个任务试图获得一个不可用(已经被占用)的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。信号量比自旋锁拥有更好的处理器利用率。

概况

  1. 信号量适合锁会被长时间持有的情况;
  2. 睡眠、维护等待队列、唤醒的花销也会很长,短时间的锁依然考虑自旋锁;
  3. 只能在进程上下文中使用信号量,因为中断上下文不能被调度;
  4. 进程可以在持有信号量的时候睡眠;
  5. 占用信号量的同时不能占用自旋锁,占用信号量的时候可能会睡眠,而自旋锁不允许睡眠;
  6. 使用信号量的代码可以被抢占;

计数信号量和二值信号量

  1. 信号量可以同时允许任意数量的锁持有者,这个数量可以在声明信号量的时候指定;
  2. 使用者数量:信号量最多允许同时持有锁的使用者数量;
  3. 二值信号量、互斥信号量:使用者数量等于1的信号量;
  4. 计数信号量:非二值信号量的信号量,内核中使用的机会不多;
  5. down()操作通过对信号量减1来请求获得一个信号量,大于等于0就进入临界区,负数就去等待队列;
  6. up()操作用来释放信号量,去队列里面唤醒一个任务;

使用信号量

1
2
3
4
5
6
7
// 创建普通信号量
struct semaphore name;
sema_init(&name, count);
// 创建互斥信号量
static DECLARE_MUTEX(name);
// 动态创建的信号量
sema_init(sem,count);

读-写信号量

所有的读写信号量都是互斥信号量。

互斥体(mutex)

  1. 互斥体可以看成一种轻量级的互斥信号量;
  2. 任何时刻只有一个任务可以持有mutex;
  3. 给mutex上锁的人必须要负责给其解锁;
  4. 递归的上锁和解锁是不被允许的;
  5. 当持有一个mutex时,进程不允许退出;
  6. mutex不能在中断和下半部中使用;
  7. mutex只能通过官方API管理

互斥体的使用

和信号量以及自旋锁的区别

  1. 除非 mutex的某个约束妨碍你使用,否则相比信号量要优先使用 mutex。当你写新代码 时,只有碰到特殊场合(一般是很底层代码)オ会需要使用信号量。因此建议首选 mutex。
  2. 在中断上下文中只能使用自旋锁,而在任务睡眠时只能使用互斥体。表10-8回顾了一下各种锁的需求情况。
image-20220106160613135
image-20220106160613135

完成变量

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

完成变量由结构completion表示,定义在<linux/completion.h>中;

静态创建并初始化:

1
DECLEAR_COMPLETION(mr_comp);

顺序锁

  1. 简称seq锁,2.6版本内核引进
  2. 对写操作友好,是一种写优先的锁
  3. 读操作无需获取锁,而是通过读取前后序列计数器的值是否变化进行读取
  4. 读多写少的场景很适合
  5. jiffies是一个Linux内核中使用顺序锁的经典案例
  6. 使用方法:

禁止抢占

在需要禁止内核抢占但不需要自旋锁的时候,可以使用preempt_disable()禁止内核抢占,可以嵌套使用,每使用一次,都需要有一个对应的preempt_enable()

内核为每一个进程维护了一个preempt_count计数器,当计数器为0的时候,内核可以进行抢占,否则不会抢占。

顺序和屏障

处理器或者编译器为了提高效率可能会对读写进行重排序,有时候这会引起一些意想不到的错误,为了告诉编译器和处理器不要对某些指令进行重排序,我们可以手动地加入屏障。屏障前后的指令不会跨越屏障执行。

  1. rmb()提供了“读”内存屏障,在rmb()之前的读操作不会重排序到rmb()之后,rmb()之后的也不会重排序到rmb()之前。
  2. wmb()提供“写”内存屏障,同理;
  3. mb()提供“读”也提供“写”屏障