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


内核同步

同步介绍

 

同步的概念

临界区:也称为临界段,就是访问和操作共享数据的代码段。

竞争条件: 2个或2个以上线程在临界区里同时执行的时候,就构成了竞争条件。

所谓同步,其实防止在临界区中形成竞争条件。

如果临界区里是原子操作(即整个操作完成前不会被打断),那么自然就不会出竞争条件。但在实际应用中,临界区中的代码往往不会那么简单,所以为了保持同步,引入了锁机制。但又会产生一些关于锁的问题。

死锁产生的条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有资源都已被占用。所以线程相互等待,但它们永远不会释放已经占有的资源。于是任何线程都无法继续,死锁发生。

自死锁:如果一个执行线程试图去获得一个自己已经持有的锁,它不得不等待锁被释放。但因为它正在忙着等待这个锁,所以自己永远也不会有机会释放锁,死锁产生。

饥饿(starvation) 是一个线程长时间得不到需要的资源而不能执行的现象。

 Linux内核设计与实现(原书第3版) 清晰中文PDF 下载见

造成并发的原因

中断——中断几乎可以在任何时刻异步发生,也就是可能随时打断当前正在运行的代码。

软中断和tasklet ——内核能在任何时刻唤醒或调度中断和tasklet,打断当前正在执行的代码。

内核抢占——因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占。

睡眠及用户空间的同步——在内核执行的进程可能会睡眠,这就会唤醒调度程序从而导致调度一个新的用户进程执行。

对称多处理——两个或多个处理器可以同时执行代码。

避免死锁的简单规则

加锁的顺序是关键。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人能照此顺序使用。

防止发生饥饿。判断这个代码的执行是否会结束。如果A不发生,B要一直等待下去吗?

不要重复请求同一个锁。

越复杂的加锁方案越可能造成死锁。---设计应力求简单。

锁的粒度

加锁的粒度用来描述加锁保护的数据规模。一个过粗的锁保护大块数据,比如一个子系统的所有数据结构;一个过细的锁保护小块数据,比如一个大数据结构中的一个元素。

在加锁的时候,不仅要避免死锁,还需要考虑加锁的粒度。

锁的粒度对系统的可扩展性有很大影响,在加锁的时候,要考虑一下这个锁是否会被多个线程频繁的争用。

如果锁有可能会被频繁争用,就需要将锁的粒度细化。

细化后的锁在多处理器的情况下,性能会有所提升。

Linux基础篇之内存管理机制

Linux内核——进程管理与调度

Linux内核——内存管理

Linux内存管理之高端内存

Linux内存管理之分段机制

Linux内存管理伙伴算法   

同步方法

 

原子操作

原子操作指的是在执行过程中不会被别的代码路径所中断的操作,内核代码可以安全的调用它们而不被打断。

原子操作分为整型原子操作和位原子操作。

spinlock自旋锁

自旋锁的特点就是当一个线程获取了锁之后,其他试图获取这个锁的线程一直在循环等待获取这个锁,直至锁重新可用。

由于线程实在一直循环的获取这个锁,所以会造成CPU处理时间的浪费,因此最好将自旋锁用于能很快处理完的临界区。

自旋锁使用时有2点需要注意:

1.自旋锁是不可递归的,递归的请求同一个自旋锁会自己锁死自己。

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

中断处理下半部的操作中使用自旋锁尤其需要小心:

1. 下半部处理和进程上下文共享数据时,由于下半部的处理可以抢占进程上下文的代码,所以进程上下文在对共享数据加锁前要禁止下半部的执行,解锁时再允许下半部的执行。

2. 中断处理程序(上半部)和下半部处理共享数据时,由于中断处理(上半部)可以抢占下半部的执行,所以下半部在对共享数据加锁前要禁止中断处理(上半部),解锁时再允许中断的执行。

3. 同一种tasklet不能同时运行,所以同类tasklet中的共享数据不需要保护。

4. 不同类tasklet中共享数据时,其中一个tasklet获得锁后,不用禁止其他tasklet的执行,因为同一个处理器上不会有tasklet相互抢占的情况

5. 同类型或者非同类型的软中断在共享数据时,也不用禁止下半部,因为同一个处理器上不会有软中断互相抢占的情况

读-写自旋锁

如果临界区保护的数据是可读可写的,那么只要没有写操作,对于读是可以支持并发操作的。对于这种只要求写操作是互斥的需求,如果还是使用自旋锁显然是无法满足这个要求(对于读操作实在是太浪费了)。为此内核提供了另一种锁-读写自旋锁,读自旋锁也叫共享自旋锁,写自旋锁也叫排他自旋锁。

读写自旋锁是一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有一个写进程,在读操作方面,同时可以有多个读执行单元,当然,读和写也不能同时进行。

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

信号量

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

1)由于争用信号量的过程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间持有的情况;相反,锁被短时间持有时,使用信号量就不太适宜了。因为睡眠、维护等待队列以及唤醒所花费的开销可能比锁被占用的全部时间还要长。

2)由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,因为中断上下文中是不能进行调度的。
3)你可以在持有信号量时去睡眠,因为当其他进程试图获得同一信号量时不会因此而死锁(因为该进程也只是去睡眠而已,最终会继续执行的)。

4)在你占用信号量的同时不能占用自旋锁。因为在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。

5)信号量同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。原因是信号量有个计数值,比如计数值为5,表示同时可以有5个线程访问临界区。如果信号量的初始值始1,这信号量就是互斥信号量(MUTEX)。对于大于1的非0值信号量,也可称为计数信号量(counting semaphore)。对于一般的驱动程序使用的信号量都是互斥信号量。

信号量支持两个原子操作:P/V原语操作(也有叫做down操作和up操作的):

P:如果信号量值大于0,则递减信号量的值,程序继续执行,否则,睡眠等待信号量大于0。

V:递增信号量的值,如果递增的信号量的值大于0,则唤醒等待的进程。

down操作有两个版本,分别对于睡眠可中断和睡眠不可中断。

读-写信号量

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

读写信号量都是二值信号量,即计数值最大为1,增加读者时,计数器不变,增加写者,计数器才减一。也就是说读写信号量保护的临界区,最多只有一个写者,但可以有多个读者。

所有读-写锁的睡眠都不会被信号打断,所以它只有一个版本的down操作。

了解何时使用自旋锁和信号量对编写优良代码很重要,但是多数情况下,并不需要太多考虑,因为在中断上下文只能使用自旋锁,而在任务睡眠时只能使用信号量。

 

完成变量

建议的加锁方法

低开销加锁

优先使用自旋锁

短期加锁

优先使用自旋锁

长期加锁

优先使用信号量

中断上下文��锁

使用自旋锁

持有锁需要睡眠

使用信号量

完成变量

如果在内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量(completion variable)是使两个任务得以同步的简单方法。如果一个任务要执行一些工作时,另一个任务就会在完成变量上等待。当这个任务完成工作后,会使用完成变量去唤醒在等待的任务。例如,当子进程执行或者退出时,vfork()系统调用使用完成变量唤醒父进程。

更多详情见请继续阅读下一页的精彩内容:

  • 1
  • 2
  • 下一页

相关内容