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

时间:2021-10-15 13:34:17

作者:admin

来源:纯净之家


系统大全为您提供
 内核同步
同步介绍
同步的概念
临界区:也称为临界段,就是访问和操作共享数据的代码段。
竞争条件: 2个或2个以上线程在临界区里同时执行的时候,就构成了竞争条件。
所谓同步,其实防止在临界区中形成竞争条件。
如果临界区里是原子操作(即整个操作完成前不会被打断),那么自然就不会出竞争条件。但在实际应用中,临界区中的代码往往不会那么简单,所以为了保持同步,引入了锁机制。但又会产生一些关于锁的问题。
死锁产生的条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有资源都已被占用。所以线程相互等待,但它们永远不会释放已经占有的资源。于是任何线程都无法继续,死锁发生。
自死锁:如果一个执行线程试图去获得一个自己已经持有的锁,它不得不等待锁被释放。但因为它正在忙着等待这个锁,所以自己永远也不会有机会释放锁,死锁产生。
饥饿(starvation) 是一个线程长时间得不到需要的资源而不能执行的现象。
造成并发的原因
中断——中断几乎可以在任何时刻异步发生,也就是可能随时打断当前正在运行的代码。
软中断和tasklet ——内核能在任何时刻唤醒或调度中断和tasklet,打断当前正在执行的代码。
内核抢占——因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占。
睡眠及用户空间的同步——在内核执行的进程可能会睡眠,这就会唤醒调度程序从而导致调度一个新的用户进程执行。
对称多处理——两个或多个处理器可以同时执行代码。
避免死锁的简单规则
加锁的顺序是关键。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人能照此顺序使用。
防止发生饥饿。判断这个代码的执行是否会结束。如果A不发生,B要一直等待下去吗?
不要重复请求同一个锁。
越复杂的加锁方案越可能造成死锁。---设计应力求简单。
锁的粒度
加锁的粒度用来描述加锁保护的数据规模。一个过粗的锁保护大块数据,比如一个子系统的所有数据结构;一个过细的锁保护小块数据,比如一个大数据结构中的一个元素。
在加锁的时候,不仅要避免死锁,还需要考虑加锁的粒度。
锁的粒度对系统的可扩展性有很大影响,在加锁的时候,要考虑一下这个锁是否会被多个线程频繁的争用。
如果锁有可能会被频繁争用,就需要将锁的粒度细化。
细化后的锁在多处理器的情况下,性能会有所提升。
同步方法
原子操作
原子操作指的是在执行过程中不会被别的代码路径所中断的操作,内核代码可以安全的调用它们而不被打断。
原子操作分为整型原子操作和位原子操作。
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()系统调用使用完成变量唤醒父进程。

Seq锁(顺序锁)

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

在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是 seq 锁对写者更有利,只要没有其他写者,写锁总是能够被成功获得。挂起的写者会不断地使得读操作循环(前一个例子),直到不再有任何写者持有锁为止。

禁止抢占

由于内核是抢占性的,内核中的进程在任何时刻都可能停下来以便另一个具有更高优先权的进程运行。这意味着一个任务与被抢占的任务可能会在同一个临界区内运行。为了避免这种情况,内核抢占代码使用自旋锁作(可以防止多处理器机器上的真并发和内核抢占)为非抢占区域的标记。如果一个自旋锁被持有,内核便不能进行抢占。

实际中,某些情况(不需要仿真多处理器机器上的真并发,但需要防止内核抢占)并不需要自旋锁,但是仍然需要关闭内核抢占。为了解决这个问题,可以通过 preempt_disable 禁止内核抢占。这是一个可以嵌套调用的函数,可以调用任意次。每次调用都必须有一个相应的 preempt_enable 调用。当最后一次 preempt_enable 被调用后,内核抢占才重新占用。

顺序和屏障

对于一段代码,编译器或者处理器在编译和执行时可能会对执行顺序进行一些优化,从而使得代码的执行顺序和我们写的代码有些区别。

一般情况下,这没有什么问题,但是在并发条件下,可能会出现取得的值与预期不一致的情况,比如下面的代码:

/* 
 * 线程A和线程B共享的变量 a和b
 * 初始值 a=1, b=2
 */
int a = 1, b = 2;

/*
 * 假设线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    b = 4;
}

/*
 * 假设线程B 中对 a和b的操作
 */
void Thread_B()
{
    if (b == 4)
        printf("a = %d
", a);
}

由于编译器或者处理器的优化,线程A中的赋值顺序可能是b先赋值后,a才被赋值。

所以如果线程A中 b=4; 执行完,a=5; 还没有执行的时候,线程B开始执行,那么线程B打印的是a的初始值1。

这就与我们预期的不一致了,我们预期的是a在b之前赋值,所以线程B要么不打印内容,如果打印的话,a的值应该是5。

在某些并发情况下,为了保证代码的执行顺序,引入了一系列屏障方法来阻止编译器和处理器的优化。

为了使得上面的小例子能正确执行,用上表中的函数修改线程A的函数即可:

/*
 * 假设线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    mb(); 
    /* 
     * mb()保证在对b进行载入和存储值(值就是4)的操作之前
     * mb()代码之前的所有载入和存储值的操作全部完成(即 a = 5;已经完成)
     * 只要保证a的赋值在b的赋值之前进行,那么线程B的执行结果就和预期一样
     */
    b = 4;
}
 
  
  以上就是系统大全给大家介绍的如何使的方法都有一定的了解了吧,好了,如果大家还想了解更多的资讯,那就赶紧点击系统大全官网吧。 
 
本文来自系统大全http://www.win7cn.com/如需转载请注明!推荐:win7纯净版

系统下载排行榜71011xp

提取码
XGZS
关闭 前往下载