linux 内核同步

[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访问的数据结构不需要同步

[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变量的引用错误

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

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

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

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


完成量(Completion Variants)

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

 

1、上下文
一般来说,CPU在任何时刻都处于以下三种情况之一:
(1)运行于用户空间,执行用户进程;
(2)运行于内核空间,处于进程上下文;
(3)运行于内核空间,处于中断上下文。
应用程序通过系统调用陷入内核,此时处于进程上下文。现代几乎所的CPU体系结构都支持中断。当外部设备产生中断,向CPU发送一个异步信号,CPU调用相应的中断处理程序来处理该中断,此时CPU处于中断上下文。
在进程上下文中,可以通过current关联相应的任务。进程以进程上下文的形式运行在内核空间,可以发生睡眠,所以在进程上下文中,可以使作信号量(semaphore)。实际上,内核经常在进程上下文中使用信号量来完成任务之间的同步,当然也可以使用锁。
中断上下文不属于任何进程,它与current没有任何关系(尽管此时current指向被中断的进程)。由于没进程背景,在中断上下文中不能发生睡眠,否则又如何对它进行调度。所以在中断上下文中只能使用锁进行同步,正是因为这个原因,中断上下文也叫做原子上下文(atomic context)(关于同步以后再详细讨论)。在中断处理程序中,通常会禁止同一中断,甚至会禁止整个本地中断,所以中断处理程序应该尽可能迅速,所以又把中断处理分成上部和下部(关于中断以后再详细讨论)。
2、上下文切换
上下文切换,也就是从一个可执行进程切换到另一个可执行进程。上下文切换由函数context_switch()函数完成,该函数位于kernel/sched.c中,它由进程调度函数schedule()调用。
2.2、用户抢占
当内核即将返回用户空间时,内核会检查need_resched是否设置,如果设置,则调用schedule(),此时,发生用户抢占。一般来说,用户抢占发生几下情况:
(1)从系统调用返回用户空间;
(2)从中断(异常)处理程序返回用户空间。

2.3、内核抢占
内核从2.6开始就支持内核抢占,对于非内核抢占系统,内核代码可以一直执行,直到完成,也就是说当进程处于内核态时,是不能被抢占的(当然,运行于内核态的进程可以主动放弃CPU,比如,在系统调用服务例程中,由于内核代码由于等待资源而放弃CPU,这种情况叫做计划性进程切换(planned process switch。但是,对于由异步事件(比如中断)引起的进程切换,抢占式内核与非抢占式是有区别的,对于前者叫做强制性进程切换(forced process switch)。
为了支持内核抢占,内核引入了preempt_count字段,该计数初始值为0,每当使用锁时加1,释放锁时减1。当preempt_count为0时,表示内核可以被安全的抢占,大于0时,则禁止内核抢占。该字段对应三个不同的计数器(见软中断一节),也就是说在以下三种任何一种情况,该字段的值都会大于0。
(1)    内核执行中断处理程序时,通过irq_enter增加中断计数器的值;
#define irq_enter()        (preempt_count() += HARDIRQ_OFFSET)

(2)    可延迟函数被禁止(执行软中断和tasklet时经常如此,由local_bh_disable完成;
(3)    通过把抢占计数器设置为正而显式禁止内核抢占,由preempt_disable完成。
当从中断返回内核空间时,内核会检preempt_count和need_resched的值(返回用户空间时只需要检查need_resched),如查preempt_count为0且need_resched设置,则调用schedule(),完成任务抢占。一般来说,内核抢占发生以下情况:
(1)    从中断(异常)返回时,preempt_count为0且need_resched置位(见从中断返回);
(2)    在异常处理程序中(特别是系统调用)调用preempt_enable()来允许内核抢占发生;
(4)    内核任务显示调用schedule(),例如内核任务阻塞时,就会显示调用schedule(),该情况属于内核自动放弃CPU。

5、从中断返回
当内核从中断返回时,应当考虑以下几种情况:
(1)    内核控制路径并发执行的数量:如果为1,则CPU返回用户态。
(2)    挂起进程的切换请求:如果有挂起请求,则进行进程调度;否则,返回被中断的进程。
(3)    待处理信号:如果有信号发送给当前进程,则必须进行信号处理。
(4)    单步调试模式:如果调试器正在跟踪当前进程,在返回用户态时必须恢复单步模式。
(5)    Virtual-8086模式:如果中断时CPU处于虚拟8086模式,则进行特殊的处理。
4.1从中断返回
中断返回点为ret_from-intr:
从中断返回时,两种情况,一是返回内核态,二是返回用户态。
5.1.1、返回内核态
5.2、从异常返回
异常返回点为ret_from_exception:
6、从系统调用返回

 

 

二、API使用

注意,2.6内核是抢占式的。
所以在访问per-CPU变量时,应使用特定的API来避免抢占,即避免它被切换到另一个CPU上被处理。

per-CPU变量可以在编译时声明,也可以在系统运行时动态生成

实例一:
[cpp] view plain copy
编译期间创建一个per-CPU变量:  
    DEFINE_PER_CPU(int,my_percpu); //声明一个变量  
    DEFINE_PER_CPU(int[3],my_percpu_array); //声明一个数组  
  
使用编译时生成的per-CPU变量:  
    ptr = get_cpu_var(my_percpu); //  
    使用ptr  
    put_cpu_var(my_percpu); //  
  
当然,也可以使用下列宏来访问特定CPU上的per-CPU变量  
    per_cpu(my_percpu, cpu_id); //  
   
per-CPU变量导出,供模块使用:  
    EXPORT_PER_CPU_SYMBOL(per_cpu_var);  
    EXPORT_PER_CPU_SYMBOL_GPL(per_cpu_var);  

实例二:
[cpp] view plain copy
动态分配per-CPU变量:  
    void *alloc_percpu(type);  
    void *__alloc_percpu(size_t size, size_t align);  
  
使用动态生成的per-CPU变量:  
    int cpu;  
    cpu = get_cpu();  
    ptr = per_cpu_ptr(my_percpu);  
    //使用ptr  
    put_cpu();  

、实现

使用上面的API为什么就能避免抢占问题呢,看看代码实现就知道了:
[cpp] view plain copy
#define get_cpu_var(var) (*({ \  
    extern int simple_identifier_##var(void); \  
    preempt_disable(); \  
    &__get_cpu_var(var); }))  
#define put_cpu_var(var) preempt_enable()  
[cpp] view plain copy
#define get_cpu() ({ preempt_disable(); smp_processor_id(); })  
#define put_cpu() preempt_enable()  
关键就在于 preempt_disable 和 preempt_enable 两个调用,分别是禁止抢占和开启抢占
抢占相关的东东以后再看

per-cpu 变量的引入有效的解决了SMP系统中处理器对锁得竞争,每个cpu只需访问自己的本地变量。本文阐述了per-cpu变量在2.6内核上的实现和相关操作。
在系统编译阶段我们就手工的定义了一份所有的per-cpu变量,这些变量的定义是通过宏DEFINE_PER_CPU实现的:
11 #define DEFINE_PER_CPU(type, name) \
12 __attribute__((__section__(".data.percpu"))) __typeof__(type) per_cpu__##name
从上面的代码我们可以看出,手工定义的所per-cpu变量都是放在.data.percpu段的。注意上面的宏只是在SMP体系结构下才如此定义。如果不是SMP结构的计算机那么只是简单的把所有的per-cpu变量放到全局变量应该放到的地方。
单CPU的per-cpu变量定义:
27 #else /* ! SMP */
29 #define DEFINE_PER_CPU(type, name) \
30 __typeof__(type) per_cpu__##name

posted @ 2020-01-06 22:15  codestacklinuxer  阅读(242)  评论(0编辑  收藏  举报