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

内核同步


同步介绍


同步的概念

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

竞争条件: 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锁(顺序锁)

这样的锁提供了一种非常easy的机制,用于读写共享数据。

实现这样的锁主要依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁。而且序列值会添加。

在读取数据之前和之后,序列号都被读取。假设读取的序列号值同样,说明在读操作进行的过程中没有被写操作打断过。

此外,假设读取的值是偶数。那么就表明写操作没有发生(要明确由于锁的初值是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。

 

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

方法

描写叙述

rmb()

阻止跨越屏障的加载动作发生重排序

read_barrier_depends()

阻止跨越屏障的具有数据依赖关系的加载动作重排序

wmb()

阻止跨越屏障的存储动作发生重排序

mb()

阻止跨越屏障的加载和存储动作又一次排序

smp_rmb()

在SMP上提供rmb()功能,在UP上提供barrier()功能

smp_read_barrier_depends()

在SMP上提供read_barrier_depends()功能。在UP上提供barrier()功能

smp_wmb()

在SMP上提供wmb()功能,在UP上提供barrier()功能

smp_mb()

在SMP上提供mb()功能,在UP上提供barrier()功能

barrier()

阻止编译器跨越屏障对加载或存储操作进行优化

为了使得上面的小样例能正确运行,用上表中的函数改动线程A的函数就可以:

/*
 * 如果线程A 中对 a和b的操作
 */
void Thread_A()
{
    a = 5;
    mb(); 
    /* 
     * mb()保证在对b进行加载和存储值(值就是4)的操作之前
     * mb()代码之前的所有加载和存储值的操作所有完毕(即 a = 5;已经完毕)
     * 仅仅要保证a的赋值在b的赋值之前进行,那么线程B的运行结果就和预期一样
     */
    b = 4;
}

总结:

下图来自http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

 

 參考:

http://www.cnblogs.com/wang_yb/archive/2013/05/01/3052865.html

http://www.cnblogs.com/pennant/archive/2012/12/28/2833383.html

Linux内核设计与实现

原文地址:https://www.cnblogs.com/jzssuanfa/p/6719481.html