现在的位置: 首页 > 综合 > 正文

Linux Kernel同步机制

2018年05月08日 ⁄ 综合 ⁄ 共 3961字 ⁄ 字号 评论关闭

1、内核如何为不同的请求提供服务

[1]内核抢占

如果进程在执行内核函数时允许发生内核切换(被替换的进程是正执行内核函数的进程),这个内核就是抢占的。

抢占内核的主要特点 是:一个在内核态运行的进程,可能在执行内核函数期间被另外一个进程取代

使内核可抢占的目的是减少用户态进程的分配延迟(即从进程变为可执行状态到它实际开始运行之间的时间间隔)。内核抢占对执行及时被调度的任务(如电影播放器)的进程确实是有好处的,因为它降低了这种进程被另一个运行在内核态的进程延迟的风险。内核抢占会引起不容忽视的开销。因此Linux2.6独具特色地允许用户在编译内核时通过设置选项来禁用或启用内核抢占。

什么时候内核是可抢占的?

有几种情况Linux内核不应该被抢占,除此之外Linux内核在任意一点都可被抢占。这几种情况是:
内核正进行中断处理;
内核正在进行中断上下文的Bottom Half(中断的底半部)处理。硬件中断返回前会执行软中断,此时仍然处于中断上下文中;
内核的代码段正持有spinlock自旋锁、writelock/readlock读写锁等锁,处干这些锁的保护状态中;
内核正在执行调度程序Scheduler。抢占的原因就是为了进行新的调度,没有理由将调度程序抢占掉再运行调度程序;
内核正在对每个CPU“私有”的数据结构操作(Per-CPU date structures)。在SMP中,对于per-CPU数据结构未用spinlocks保护,因为这些数据结构隐含地被保护了(不同的CPU有不一样的per-CPU数据,其他CPU上运行的进程不会用到另一个CPU的per-CPU数据)。但是如果允许抢占,但一个进程被抢占后重新调度,有可能调度到其他的CPU上去,这时定义的Per-CPU变量就会有问题,这时应禁抢占。

为保证Linux内核在以上情况下不会被抢占,抢占式内核使用了一个变量preempt_ count,称为内核抢占锁。这一变量被设置在进程的PCB结构task_struct中。每当内核要进入以上几种状态时,变量preempt_ count就加1,指示内核不允许抢占。每当内核从以上几种状态退出时,变量preempt_ count就减1,同时进行可抢占的判断与调度。

从中断返回内核空间的时候,内核会检查need_resched和preempt_count的值。如果need_ resched被设置,并且preempt count为0的话,这说明可能有一个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。如果preempt-count不为0,则说明内核现在处干不可抢占状态,不能进行重新调度。这时,就会像通常那样直接从中断返回当前执行进程。如果当前进程持有的所有的锁都被释放了,那么preempt_ count就会重新为0。此时,释放锁的代码会检查need_ resched是否被设置。如果是的话,就会调用调度程序。

内核抢占发生的时机

内核抢占可能发生在:

当从中断处理程序正在执行,且返回内核空间之前。
当内核代码再一次具有可抢占性的时候,如解锁及使能软中断等。
如果内核中的任务显式的调用schedule()
如果内核中的任务阻塞(这同样也会导致调用schedule())


2]什么时候同步是必需的

当计算的结果依赖于两个或两个以上的交叉内核控制路径的嵌套方式时,可能出现竞争条件。临界区是一段代码,在其他的内核控制路径能够进入临界区前,进入临界区的内核控制路径必须全部执行完这段代码。

交叉内核控制路径使内核开发者的工作变得复杂:他们必须特别小心地识别出异常处理程序、中断处理程序、可延迟函数和内核线程中的临界区。一旦临界区被确定,就必须对其采用适当的保护措施,以确保在任何时刻只有一个内核控制路径处于临界区。

如果是单CPU的系统,可以采取访问共享数据结构时关闭中断的方式来实现临界区,因为只有在开中断的情况下,才可能发生内核控制路径的嵌套。

另外,如果相同的数据结构仅被系统调用服务例程所访问,而且系统中只有一个CPU,就可以非常简单地通过在访问共享数据结构时禁用内核抢占功能来实现临界区。

正如你们所预料的,在多处理器系统中,情况要复杂得多。由于许多CPU可能同时执行内核路径,因此内核开发者不能假设只要禁用内核抢占功能,而且中断、异常和软中断处理程序都没有访问过该数据结构,就能保证这个数据结构能够安全地被访问。

[3]什么时候同步是不必需的

所有的中断处理程序响应来自PIC的中断并禁用IRQ线。此外,在中断处理程序结束之前,不允许产生相同的中断事件。

中断处理程序、软中断和tasklet既不可以被抢占也不能被阻塞,所以它们不可能长时间处于挂起状态。在最坏的情况下,它们的执行将有轻微的延迟,因此在其执行的过程中可能发生其他的中断(内核控制路径的嵌套执行)

执行中断处理的内核控制路径不能被执行可延迟函数或系统调用服务例程的内核控制路径中断

软中断和tasklet不能在一个给定的CPU上交错执行

同一个tasklet不可能同时在几个CPU上执行。

简化的例子:

中断处理程序和tasklet不必编写成可重入的函数
仅被软中断和tasklet访问的每CPU变量不需要同步
仅被一种tasklet访问的数据结构不需要同步

2、同步原语

我们考察一下在避免共享数据之间的竞争条件时,内核控制路径是如何交错执行的。

[1]每CPU变量

事实上每一种显式的同步原语都有不容忽视的性能开销。

最简单也是最重要的同步技术包括把内核变量声明为每CPU变量(per-cpuvariable)每CPU变量主要是数据结构的数组,系统的每个CPU对应数组的一个元素。

一个CPU不应该访问与其他CPU对应的数组元素,另外,它可以随意读或修改它自己的元素而不用担心出现竞争条件,因为它是唯一有资格这么做的CPU,但是,这也意味着每CPU变量基本上只能在特殊情况下使用,也就是当它确定在系统的CPU上的数据在逻辑上是独立的时候。

每CPU的数组元素在主存中被排列以使每个数据结构存放在硬件高速缓存的不同行,因此,对每CPU数组的并发访问不会导致高速缓存行的窃用和实效

虽然每CPU变量为来自不同CPU的并发访问提供保护,但对来自异步函数(中断处理程序和可延迟函数)的访问不提供保护,在这种情况下需要另外的同步原语。

总的来看每CPU变量的特点有:

用于多个CPU之间的同步,如果是单核结构,每CPU变量没有任何用处。
每CPU变量不能用于多个CPU相互协作的场景。(每个CPU的副本都是独立的)
每CPU变量不能解决由中断或延迟函数导致的同步问题
访问每CPU变量的时候,一定要确保关闭进程抢占,否则一个进程被抢占后可能会更换CPU运行,这会导致每CPU变量的引用错误

[2]原子操作

所谓原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它的最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。

若干汇编语言指令具有“读-修改-写”类型--也就是说,它们访问存储器单元两次,第一次读原值,第二次写新值。假定运行在两个CPU上的两个内核控制路径试图通过执行非原子操作来同时“读-修改-写”同一存储器单元。首先,两个CPU都试图读同一单元,但是存储器仲裁器(对访问RAM芯片的操作进行串行化的硬件电路)插手,只允许其中的一个访问而让另一个延迟。然而,当第一次读操作已经完成后,延迟的CPU从那个存储器单元正好读到同一次被存储器仲裁器串行化,最终,两个写操作都成功。但是,全局的结果是不对的,因为两个CPU写入同一(新)值。因此,两个交错的“读-修改-写”操作成了一个单独的操作。

避免由于“读-修改-写”指令引起的竞争条件的最容易的办法,就是确保这样的操作在芯片级是原子的。任何一个这样的操作都必须以单个指令执行,中间不能中断,且避免其他的CPU访问同一存储器单元。这些很小的原子操作可以建立在其他更灵活机制的基础之上以创建临界区。

在你编写C代码程序时,并不能保证编译器会为a=a+1或甚至像a++这样的操作使用一个原子指令。这是因为原子操作需要硬件的支持,它是架构相关的,其API和原子类型的定义都定义在内核源码树的include/asm/atomic.h文件中,它们都使用汇编语言实现,而C语言并不能实现这样的操作。

原子操作主要用于实现资源计数,很多引用计数(refcnt)就是通过原子操作实现的。


自旋锁、信号量和互斥量的对比

我们在之前已经比较过了自旋锁和信号量,在这一节里我们主要比较自旋锁和互斥量,以及信号量和互斥量。

对于自旋锁和互斥量,在大部分情况下我们其实别无选择,比如只有自旋锁可以用于中断的处理,而当一个任务允许休眠时则只能使用互斥量。对于其他情况总的来说:低开销、短时间持有的锁一般使用自旋锁,而长时间持有的锁一般使用互斥量。

对于信号量和互斥量,信号量是互斥量的升级版,信号量比互斥量多的功能只有允许多个任务进入临界区这一点。所以在开发的过程中,建议首先使用互斥量,在发现互斥量无法完成指定功能的时候,在将其改为信号量。由于两者的API非常相似,所以这种改动的代价是非常小的,而使用互斥量则可以避免信号量过于灵活而带来的弊端。


完成量(Completion Variants)

Kernel还支持一种锁叫完成量,其用于两个任务间的同步,当某个事件发生需要一个任务通知另一个任务时,可以使用完成量。一般来说,一个任务等待在完成量上,当另一个任务向该完成量发送信号时,等待在完成量上的任务被唤醒并继续执行。完成量在Kernel中一般用于vfork()系统调用,父进程在调用fork时等待在某个完成量上,当自进程完成初始化或退出后向父进程发送完成通知。

【上篇】
【下篇】

抱歉!评论已关闭.