linux 内核 同步原理

中断分为同步中断和异步中断。

  • 同步中断是由CPU控制单元产生的,“同步”是指只有在一条指令执行完毕后,CPU才会发出中断,比如系统调用
  • 异步中断是由其他硬件设备依照CPU时钟信号产生的,即意味着中断能够在指令之间发生,例如键盘中断

按照Intel的微处理器手册,同步中断和异步中断也分别称为异常(或者软件中断)和中断, 中断为设备硬件产生的,异常的产生源有两种,

  • 一种是程序错误,内核发送一个信号
  • 另一种内核发生缺页,或对内核服务的一个请求(系统调用,通过一条int指令 、sysenter指令)【内核态能够触发的唯一异常就是缺页异常,其他的都是用户态触发的】

 

每个进程的thread_info 描述符与thread_union 中内核栈紧邻,如果thread_union 结构大小为8KB。那么当前进程的内核栈被用于所有类型 的内核控制路径:

异常、中断、和可延迟函数,如果thread_union结构的大小为4Kb,对于常规的内核工作以及IRQ处理例程所需空间来说,4KB并不是总够用,所以内核引入三种类型的内核栈:

异常栈:拥有处理异常包括系统调用;

硬中断请求栈:用于处理中断包括硬中断请求栈,每个栈都占用一个单独页框;

软中断请求栈:用于处理科研此函数,系统中的每个CPU都有一个软中断请求栈而且每个栈单独占用一个单独的页框;

硬中断请求存放在hardirq_stack中。软中断存放在softirq_stack数组中;都是irq_ctx类型联合体。

/*
 * per-CPU IRQ handling contexts (thread information and stack)
 */
union irq_ctx {
    struct thread_info      tinfo;
    u32                     stack[THREAD_SIZE/sizeof(u32)];
} __attribute__((aligned(THREAD_SIZE)));
 
static DEFINE_PER_CPU(union irq_ctx *, hardirq_ctx);
static DEFINE_PER_CPU(union irq_ctx *, softirq_ctx);

中断初始化
-------------------------------------------------------------

a、实模式:将中断向量表中的每一项都初始化为默认的中断服务例程ignore_int
b、保护模式,基本流程如下:
start_kernel
  trap_init()
    陷阱门直接将IDT表项初始化为异常处理handler
    init_IRQ()

      中断门初始化统一的入口:common_interrupt

 设备驱动程序调用request_irq注册中断
----------------------------------------------------------------

硬中断、软中断、异常之间的抢占关系

硬中断可以被另一个优先级比自己高的硬中断“中断”,不能被同级(同一种硬中断)或低级的硬中断“中断”,更不能被软中断“中断”

软中断可以被硬中断“中断”,但是不会被另一个软中断“中断”。在一个CPU上,软中断总是串行执行

对于单处理器的情况,在单处理器上不存在竞争条件,这是因为可延迟函数(软中断)的执行总是在一个CPU上串行执行--也就是说,一个可延迟函数不会被另一个可延迟函数中断,但是

可能被硬中断“中断”,而硬中断最后还是要执行到软中断,

因此还是会形成对资源的临界区访问。所以在保护软中断时,应该关闭本地软中断,比如用local_bh_disable???

实际结果是:硬件中断执行完毕 执行 软件段时,执行到do_softirq 会检查是否处于中断上下文,所以单处理不存在竞争条件(一个可延迟函数不会被另一个可延迟函数中断)

开始处理软中断的场景有:
1、中断退出执行的irq_exit
2、内核线程ksoftirqd
3、local_bh_enable

硬中断和软中断都可以抢占(或者称为中断)异常(最典型的是系统调用),但是异常不能抢占硬中断和软中断

硬中断和软中断(只要是中断上下文)执行的时候都不允许内核抢占,换句话说,中断上下文中永远不允许进程切换

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

有几种情况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())

 

什么时候同步是必需的

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

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

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

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

什么时候同步是不必需的

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

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

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

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

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

所以:

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

 

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()来允许内核抢占发生;preempt_enable-----可能调用schedule

#ifdef CONFIG_PREEMPT
#define preempt_enable() \
do { \
    barrier(); \
    if (unlikely(preempt_count_dec_and_test())) \
        __preempt_schedule(); \
} while (0)


(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、从系统调用返回

 

总结:

怎么对内核临界区进行保护

在进程内核数据结构的互斥同步访问时,我们最常用的办法是:信号量(睡眠等待),自旋锁(自旋等待),中断禁止和软中断禁止。往往需要几种方法配合使用才能达到我们想要的结果。

1、保护异常(最典型的是系统调用)所访问的数据结构

此时最常选用的是信号量,因为信号量原语允许进程睡眠到资源变为可用,对大部分系统调用而言,这是所期望的行为。信号量的工作方式在单处理器系统和多处理器系统上完全相同。只有在访问每CPU变量的情况下,必须显式地禁用内核抢占,其他情况下内核抢占不会出现问题。

 

  • 2、保护中断所访问的数据结构

1)单处理器情况下:假如数据结构只被这一种中断访问,则完全可以不加同步原语,因为中断不能被同一种中断“中断”;假如数据结构被多个中断处理程序访问,则必须通过禁用本地中断来保护临界区。

02)多处理器情况下:除了必须禁用本地中断,还必须使用自旋锁来避免来自其他CPU的干扰。可以使用如spin_lock_irq()来完成这两件事情。

  • 3、保护可延迟函数(软中断和tasklet)所访问的数据结构

1)单处理器情况下:在单处理器系统上不存在竞争条件,因为可延迟函数的执行在一个CPU上是串行的,一个可延迟函数不会被另一个可延迟函数所中断。因此无需同步原语。

2)多处理器情况下:需要自旋锁来加以保护。由于软中断和tasklet并发程度不同,加锁情况也不同。同一软中断可以在不同CPU上运行,因此无论一个或多个软中断,都必须用如spin_lock加以保护。同一tasklet不能在不同CPU上运行,因此无需加锁;不同tasklet可以在不同CPU上运行,因此也需要如spin_lock的锁加以保护。

  • 4、保护由异常和中断访问的数据结构

单处理器情况下:

1)对中断而言:中断不能被异常“中断”,无需考虑异常的干扰。第1条一样,如果此数据结构只被一种中断访问,则可不加同步原语;否则要禁用本地中断。

2)对异常而言:异常的优先级低,如需访问共享数据结构,必须先禁用本地中断。

多处理器情况下:

1)对中断而言:除了单处理器考虑的情况外,还必须用自旋锁排除其他CPU的干扰。

2)对异常而言:除了单处理器考虑的情况外,还必须用自旋锁排除其他CPU的干扰。

  • 5、保护由异常和可延迟函数访问的数据结构

单处理器情况下:

1)对可延迟函数而言:可延迟函数不能被异常“中断”,无需考虑异常的干扰。在每个CPU上可延迟函数串行执行,不存在竞争条件,因此不用同步原语。

2)对异常而言:异常的优先级低,如需访问共享数据结构,必须先禁用本地软中断。

多处理器情况下:

1)对中断而言:除了单处理器考虑的情况外,还必须用自旋锁排除其他CPU的干扰。

2)对异常而言:除了单处理器考虑的情况外,还必须用自旋锁排除其他CPU的干扰。

  • 6、保护由中断和可延迟函数访问的数据机构

单处理器情况下:

1)对中断而言:中断不能被可延迟函数“中断”,无需考虑可延迟函数的干扰。第1条一样,如果此数据结构只被一种中断访问,则可不加同步原语;否则要禁用本地中断。

2)对可延迟函数而言:可延迟函数的优先级低,如需访问共享数据结构,必须先禁用本地中断。

多处理器情况下:

1)对中断而言:除了单处理器上考虑的外,还必须用自旋锁排除其他CPU的干扰。

2)对可延迟函数而言:除了单处理器上考虑的外,还必须用自旋锁排除其他CPU的干扰。

  • 7、保护由异常、中断和可延迟函数访问的数据结构

单处理器情况下:

1)对中断而言:优先级最高,无需考虑其他两种的影响。第1条一样,如果此数据结构只被一种中断访问,则可不加同步原语;否则要禁用本地中断。

2)对可延迟函数而言:可延迟函数的优先级低,如需访问共享数据结构,必须先禁用本地中断。

3)对异常而言:可延迟函数的优先级低,如需访问共享数据结构,必须先禁用本地中断。禁用了本地中断,也就相当于禁用了本地软中断。

多处理器情况下:

1)对中断而言:除了单处理器上考虑的外,还必须用自旋锁排除其他CPU的干扰。

2)对可延迟函数而言:除了单处理器上考虑的外,还必须用自旋锁排除其他CPU的干扰。

3)对异常而言:除了单处理器上考虑的外,还必须用自旋锁排除其他CPU的干扰。

分析第二轮:

软中断
软中断作为下半部机制的代表,是随着SMP(share memory processor)的出现应运而生的,它也是tasklet实现的基础(tasklet实际上只是在软中断的基础上添加了一定的机制)。软中断一般是“可延迟函数”的总称,有时候也包括了tasklet(请读者在遇到的时候根据上下文推断是否包含tasklet)。它的出现就是因为要满足上面所提出的上半部和下半部的区别,使得对时间不敏感的任务延后执行,而且可以在多个CPU上并行执行,使得总的系统效率可以更高。它的特性包括:

产生后并不是马上可以执行,必须要等待内核的调度才能执行。软中断不能被自己打断(即单个cpu上软中断不能嵌套执行),只能被硬件中断打断(上半部)。
可以并发运行在多个CPU上(即使同一类型的也可以)。所以软中断必须设计为可重入的函数(允许多个CPU同时操作),因此也需要使用自旋锁来保其数据结构。

相关数据结构
软中断描述符
struct softirq_action{ void (*action)(struct softirq_action *);};
描述每一种类型的软中断,其中void(*action)是软中断触发时的执行函数。
软中断全局数据和类型
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;
enum
{
HI_SOFTIRQ=0, /*用于高优先级的tasklet*/
TIMER_SOFTIRQ, /*用于定时器的下半部*/
NET_TX_SOFTIRQ, /*用于网络层发包*/
NET_RX_SOFTIRQ, /*用于网络层收报*/
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ, /*用于低优先级的tasklet*/
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */
NR_SOFTIRQS
};

相关API
注册软中断
void open_softirq(int nr, void (*action)(struct softirq_action *))
1
即注册对应类型的处理函数到全局数组softirq_vec中。例如网络发包对应类型为NET_TX_SOFTIRQ的处理函数net_tx_action.

触发软中断
void raise_softirq(unsigned int nr)
实际上即以软中断类型nr作为偏移量置位每cpu变量irq_stat[cpu_id]的成员变量__softirq_pending,这也是同一类型软中断可以在多个cpu上并行运行的根本原因。

软中断执行函数
do_softirq-->__do_softir

内核抢占

抢占(Preemption)是指内核强行切换正在CPU上运行的进程,在抢占的过程中并不需要得到进程的配合,在随后的某个时刻被抢占的进程还可以恢复运行。

发生抢占的原因主要有:1. 进程的时间片用完了,或者 2. 优先级更高的进程来争夺CPU了。

抢占的过程分两步,第一步触发抢占,第二步执行抢占,这两步中间不一定是连续的,有些特殊情况下甚至会间隔相当长的时间:

  1. 触发抢占:给正在CPU上运行的当前进程设置一个请求重新调度的标志(TIF_NEED_RESCHED),仅此而已,此时进程并没有切换。
  2. 执行抢占:在随后的某个时刻,内核会检查TIF_NEED_RESCHED标志并调用schedule()执行抢占。

抢占只在某些特定的时机发生,这是内核的代码决定的。

触发抢占的时机

每个进程都包含一个TIF_NEED_RESCHED标志,内核根据这个标志判断该进程是否应该被抢占,设置TIF_NEED_RESCHED标志就意味着触发抢占。

直接设置TIF_NEED_RESCHED标志的函数是 set_tsk_need_resched()
触发抢占的函数是resched_task()

TIF_NEED_RESCHED标志什么时候被设置呢?在以下时刻:

1. 周期性的时钟中断

时钟中断处理函数会调用scheduler_tick(),这是调度器核心层(scheduler core)的函数,它通过调度类(scheduling class)的task_tick方法 检查进程的时间片是否耗尽,如果耗尽则触发抢占:

  1.  
    void scheduler_tick(void)
  2.  
    {
  3.  
    ...
  4.  
    curr->sched_class->task_tick(rq, curr, 0);
  5.  
    ...
  6.  
    }

Linux的进程调度是模块化的,不同的调度策略比如CFS、Real-Time被封装成不同的调度类,每个调度类都可以实现自己的task_tick方法,调度器核心层根据进程所属的调度类调用对应的方法,比如CFS对应的是task_tick_fair,Real-Time对应的是task_tick_rt,每个调度类对进程的时间片都有不同的定义。

2. 唤醒进程的时候

当进程被唤醒的时候,如果优先级高于CPU上的当前进程,就会触发抢占。相应的内核代码中,try_to_wake_up()最终通过check_preempt_curr()检查是否触发抢占。

3. 新进程创建的时候

如果新进程的优先级高于CPU上的当前进程,会触发抢占。相应的调度器核心层代码是sched_fork(),它再通过调度类的 task_fork方法触发抢占:

  1.  
    int sched_fork(unsigned long clone_flags, struct task_struct *p)
  2.  
    {
  3.  
    ...
  4.  
    if (p->sched_class->task_fork)
  5.  
    p->sched_class->task_fork(p);
  6.  
    ...
  7.  
    }

4. 进程修改nice值的时候

如果进程修改nice值导致优先级高于CPU上的当前进程,也会触发抢占。内核代码参见 set_user_nice()

5. 进行负载均衡的时候

在多CPU的系统上,进程调度器尽量使各个CPU之间的负载保持均衡,而负载均衡操作可能会需要触发抢占。

不同的调度类有不同的负载均衡算法,涉及的核心代码也不一样,比如CFS类在load_balance()中触发抢占:

  1.  
    load_balance()
  2.  
    {
  3.  
    ...
  4.  
    move_tasks();
  5.  
    ...
  6.  
    resched_cpu();
  7.  
    ...
  8.  
    }

RT类的负载均衡基于overload,如果当前运行队列中的RT进程超过一个,就调用push_rt_task()把进程推给别的CPU,在这里会触发抢占。

 

执行抢占的时机

触发抢占通过设置进程的TIF_NEED_RESCHED标志告诉调度器需要进行抢占操作了,但是真正执行抢占还要等内核代码发现这个标志才行,而内核代码只在设定的几个点上检查TIF_NEED_RESCHED标志,这也就是执行抢占的时机。

抢占如果发生在进程处于用户态的时候,称为User Preemption(用户态抢占);如果发生在进程处于内核态的时候,则称为Kernel Preemption(内核态抢占)。

执行User Preemption(用户态抢占)的时机

1. 从系统调用(syscall)返回用户态时

2. 从中断返回用户态时

执行Kernel Preemption(内核态抢占)的时机

Linux在2.6版本之后就支持内核抢占了,但是请注意,具体取决于内核编译时的选项:

  • CONFIG_PREEMPT_NONE=y
    不允许内核抢占。这是SLES的默认选项。
  • CONFIG_PREEMPT_VOLUNTARY=y
    在一些耗时较长的内核代码中主动调用cond_resched()让出CPU。这是RHEL的默认选项。
  • CONFIG_PREEMPT=y
    允许完全内核抢占。

在 CONFIG_PREEMPT=y 的前提下,内核态抢占的时机是:

    1. 中断处理程序返回内核空间之前会检查TIF_NEED_RESCHED标志,如果置位则调用preempt_schedule_irq()执行抢占。preempt_schedule_irq()是对schedule()的包装。
    2. 当内核从non-preemptible(禁止抢占)状态变成preemptible(允许抢占)的时候;在preempt_enable()中,会最终调用 preempt_schedule 来执行抢占。preempt_schedule()是对schedule()的包装。
posted @ 2019-08-14 14:54  codestacklinuxer  阅读(297)  评论(0编辑  收藏  举报