linux源码解析16- Linux内核常用锁机制总结

时间:2023-02-23 12:06:26

首先看看Linux内核中的并发场景;

单CPU多进程系统,产生并发访问因素有:

  • 中断处理程序可以打断软中断,tasklet和进程上下文;
  • 软中断和tasklet之间不会并发,但可以打断进程上下文;
  • 在支持抢占的内核中,进程上下文之间会并发;
  • 在不支持抢占的内核中,进程上下文不会并发;

在多CPU和多进程系统中,产生并发访问因素:

  • 同一类型的中断处理程序不会并发,但不同类型的中断有可能分发到不同CPU上响应,可能产生并发;
  • 同一类型的软中断会在不同CPU上并发;
  • 同一类型的tasklet是串行执行的,不会在多个CPU并发;
  • 不同CPU上的进程上下文会并发;

使用锁的原则:

保护资源或者数据,而不是保护代码;

Linux内核中的锁机制

1.自旋锁

特点:临界区不允许调度和睡眠,禁止抢占;解锁恢复抢占,可以用于进程上下文,中断上下文;

自旋锁主要用途是多处理器之间的并发控制,适用于竞争不太激烈的场景。如果竞争激烈,大量时间浪费在自旋锁,导致整体性能下降。

内核提供API:

spin_lock(lock);//加锁,成功后返回,否则自旋等待

//当锁进程和中断,有并发访问时,关闭中断
spin_lock_irqsave(lock, flags);	//加锁,并关闭中断
spin_lock_bh(lock);				//加锁,并关闭软中断

spin_unlock(lock);//解锁

spin_unlock_irqrestore(lock, flags);	//解锁,打开硬中断
spin_unlock_bh(lock);				   //解锁,打开软中断

ps:为什么有的代码用spin_lock(),有的代码使用raw_spin_lock()?

实时补丁RT-patch, spinlock变成可抢占和睡眠的锁; 在绝对不允许被抢占和睡眠的临界区,使用raw_spin_lock,否则使用spinlock;

驱动工程师应该谨慎使用自旋锁:

  • 自旋锁实际上是忙等待,当临界区很大时,或有共享设备的时候,需要较长时间占用锁,会降低系统的性能;
  • 自旋锁可能导致系统死锁,一般是由递归使用一个自旋锁引起;
  • 持有自旋锁期间,不能调用可能引起进程调度的函数; copy+from/to_user(),kmalloc()msleep等;
  • 在单核下编程,也要考虑多核场景。 比如进程持有spin_lock_irqsave(),单核情况下中断不调用spin_lock也没问题,因为spin_lock_irqsave()保证了这个CPU中断服务程序不会执行。 但是在多核环境,spin_lock_irqsave()不能屏蔽另外一个核的中断,另外一个核就可能造成并发问题;

2.读写自旋锁:rwlock_t

允许多个读者同时持有锁; 只允许一个写者同时持有锁;

读写锁适合读者多,写者少的应用场景;

内核支持API:

DEFINE_RWLOCK(lock);

rwlock_init(lock);

read_lock(lock);
write_lock(lock);
read_unlock(lock);
write_unlock(lock);

3.信号量:

信号量是多值的,当其用作二值信号时,类似于锁,一个值代表未锁,另一个代表已锁;

原理: 获取锁的过程中,若不能立即得到,会发生调度,进入睡眠;

特点: 锁的竞争不是忙等,信号量临界区允许调度和睡眠而不会导致死锁; 锁的竞争者会转入睡眠,让出CPU,因此锁的竞争不会影响系统整体性能;

内核执行路径释放锁时,唤醒等待该锁的执行路径;

自旋锁vs信号量:

  • 自旋锁问题: 持有自旋锁的临界区不允许调度和睡眠,竞争激烈时整体性能不好;

  • 信号量缺点: 中断上下文要求整体运行时间可预测(不能太长),而信号量临界区允许睡眠,可能发生调度,因此不能用于中断上下文;

如果抢锁的过程很短,那么信号量不划算,因为进程睡眠加上唤醒代价太大,消耗CPU资源可能远大于短时间忙等待;

内核的semaphore实现:

struct semaphore{
	raw_spinlock_t lock;
	unsigned int count;
	struct list_head wait_list;
}

wait_list字段当信号量为忙时,所有等待信号量的进程列表,而lock则是保护wait_list的自旋锁;

信号量的API:

DEFINE_SEMPHORE(sem); //静态定义sem信号量
void sema_init(struct semaphore *sem, int val);  ///初始化一个信号量sem,计数初值为val
void down(struct semaphore *sem);				//减少信号量sem计算器,如果失败(count已经为0),转入睡眠,不能被信号唤醒(TASK_UNINTERRUPTIBLE),并把当前进程挂到wait_list;被唤醒后继续尝试获取锁
void up(struct semaphore *sem); 				//增加信号量sem计数器(类似释放锁),然后唤醒wait_list里的第一个进程(如果有的话);

int down_interruptible(struct semaphore *sem);//可以被信号唤醒,驱动推荐使用
int down_trylock(struct semaphore *sem);//立即返回
void down_timeout(struct semaphore *sem,long timeout);//timeout超时返回

4.读写信号量

类似读写自旋锁,为了区分不同的竞争者,比如允许读者共享,而写者互斥

struct rw_semaphore{
	long count;
	struct list_head wait_list;
	raw_spinlock_t wait_lock;
}

DECLARE_RWSEM(sem);//静态声明sem变量
init_rwsem(sem);//初始化一个sem

down_read(struct rw_semaphore *sem);	 //读者减少信号量sem计算器,类似获取锁
down_write(struct rw_semaphore *sem);	//写者减少sem计数器
up_read(struct rw_semaphore *sem);	   //增加sem计数器
up_write(struct rw_semaphore *sem);	  //增加sem计数器

5.互斥体:

本质上是二值的信号量;

struct mutex{
	atomic_t count;
	spinlock_t wait_lock;
	struct list_head wait_list;
}

DEFINE_MUTEX(mutex);//静态定义mutex互斥量
mutex_init(mutex);//初始化mutex,初始未锁;

void mutex_lock(struct mutex *lock);	//加锁,若失败,转入D状态(TASK_UNINTERRUPTIBLE),并把当前进程挂到wait_list
void mutex_unlock(struct mutex *lock);  //解锁,并唤醒wait_list里第一个进程

mutex特点:

  • 同一个时刻,只有一个线程可以持有mutex;
  • 只有锁持有者可以解锁,因此mutex不适合多进程负载同步场景;
  • 不允许递归加锁或解锁;
  • 当进程持有mutex时,进程不可以退出;
  • mutex必须使用官方API初始化;
  • mutex可以睡眠,所以不能用在中断处理程序,或中断下半部,比如tasklet,定时器等;

自旋锁和互斥体: 自旋锁属于更底层实现,互斥体基于自旋锁是实现;

  • 互斥体的开销是进程上下文的切换,自旋锁的开销是等待获取锁。若临界区较小,用自旋锁,若临界区较大,应该用互斥体;
  • 互斥体保护的临界区可能包含睡眠的代码,而自旋锁不允许阻塞,睡眠,因为睡眠意味着要进行进程切换,如果切换出去,另一个进程试图获取本自旋锁,就死锁了。
  • 互斥体存在于进程上下文,所以在中断,软中断环境,只能用自旋锁。如果一定要使用互斥体,应该使用mutex_trylock(),不能获取锁立即返回,避免阻塞;

6.RCU机制:

提高读锁的性能; 原理,没搞懂