linux内核的调度机制抢占式内核与非抢占式内核

linux抢占式内核与实时系统的关系

一个好的系统的进程调度机制,要兼顾三种不同的应用的需求:

   1交互式应用。这种应用,着重于系统的响应速度,当系统中有大量的进程共存时(多用户),要保证每个用户都有可以接受的响应速度,而不感到明显的延迟。当延迟超过150毫秒时,使用者会明显地感觉到。

   2.批处理应用。批处理的应用往往都是“后台作业”,对响应速度没有要求,但要考虑到“平均速度”

   3.实时应用。这是时间要求最强的,不但要考虑进程执行的平均速度,还要考虑到“即时速度”;不但要考虑响应速度(即从一个事件发生到系统对此作出反应,并开始执行有关程度之间所需的时间),还要考虑有关程序(用记程序)能否在规定时间内执行完。在实时应用中,注重的是对程序执行的“可预测性”。

 

在设计一个进程调度机制时考虑的问题有:

   1.调度的时机:在什么情况下,什么时候调度?【在什么情况下,什么时候,把现在占用CPU的进程换下来。主要在什么位置调用schedule函数】

   2. 调度的“政策”policy:根据什么准则挑选下一个进入运行的进程。【从running的进程队列中找出一个进程,来占用CPU,让它运行】。

   3.调度的方式:是”可剥夺“(preemptive)还是“不可剥夺”(nonpreemptive)。

【一进程主动让出CPU,进程在用户态或是在内核态调用schedule,二是强制剥夺其使用权,在发生中断或是异常或是系统调用之后,从内核态返回到用户态的前夕,由内核调用schedule。】

 

调度的时机:

自愿方式

   1.在内核里面,进程可以通过schedule()或是schedule_timeout启动一次调度。在用户空间可以使用调用pause()或是sleep(s)也可以。

    【这是可见的主动让出的方式。这里指程序员可以看见,程序员编程时主动地让出。】

   2.当用户使用open(),read(),write()等N多个涉及到外设的系统调用时,都可能受阻。这时在内核中自愿放弃运行是不可见的。【程序员认为是阻塞了,其实就是让出CPU,进入等待队列,等一个信号或是软中断,或是硬件中断】

不自愿方式

  即强制地发生在每次从系统调用返回的前夕,以及每次从中断或是异常处理返回到用户空间的前夕。注意:返回到用户空间是关键性的。这意味着只有在用户空间(当CPU在用户空间运行时)发生的中断 或是异常才会引起调度。(在内核空间发生的中断或是异常,不引起调度。linux2.4)

【以上这种方式不自愿地方式我们习惯地称为“非抢占式内核”也有叫“用户抢占”。其实也就只能叫作“半抢占式内核”或是“有条件抢占”。这种方式是linux2.4的实现方式。在linux2.6中对此进行了修改。大家习惯地称linux2.6内核是“抢占式内核”。】

 

抢占式内核

以下是“抢占式内核”的英文解释。

    Kernel preemption is a method used mainly in monolithic and hybrid kernels where all or most device drivers are run in kernel space, whereby the scheduler is permitted to

 forcibly perform a context switch (ie, preemptively schedule; on behalf of a runnable and higher priority process) on a driver or other part of the kernel during its execution,

 rather than co-operatively wait for the driver or kernel function (such as a system call) to complete its execution and return control of the processor to the scheduler.

There are two main benefits to this method in monolithic and hybrid kernels, and answer one of the main criticisms of monolithic kernels from microkernel advocates,

 which is that;

1 A device driver can enter an infinite loop or other unrecoverable state, crashing the whole system

2 Some drivers and system calls on monolithic kernels are slow to execute, and can't return control of the processor to the scheduler or other program until they complete

execution.

源于:http://en.wikipedia.org/wiki/Kernel_preemption

 

对“非抢占式内核”、“抢占式内核”的认识过程。

当一看到这两个名词时,第一感觉就是这是一个不同调度方式的内核。在有一些了解之后,发现这种理解有一些问题。“非抢占式内核”说的含义是:不可抢占内核态的调度方式。当然这种方式与内核实现有关,但它的重点是讲一种什么样的调度方式。针对Kernel preemption我们可以翻译为“内核抢占调度模式”,或是“抢占内核调度模式”。

 

抢占式内核与半抢占式内核的不同

Linux2.4只实现了“有条件抢占式”的调度。它的缺点在于:当进程在内核态时,调度的时机有局限。就是只能在xxx的前夕。例如:当外部来一中断,中断程序过程完后,需要一个用户进程B对此进行进一步的处理(响应IP包数据)。此时进程A正在使用系统调用进入了内核态。那么等到A从系统调用返回之际,内核进行调度,B才有可能运行。假设A的系统调用占用了CPU的时间为T。这个T大于用户要求的响应时间。那这个系统就不够实时。

 

为了提高Linux的实时性。在linux2.6中引入了“Kernel preemption”(内核抢占调度模式)。并很好的解决了这个问题。一句话就是抢占式内核可以在进程处于内核态时,进行抢占。

当然抢占式内核在以下几种情况下不可抢占:

1.当内核运行中断处理程序和异常处理程序时,在linux内核中进程不能抢占中断,在中断例程中不允许进行调度。进程调度函数schedule会对此作出判断,如果是在中断中调用,会打印出出错信息。

2.当进程在内核态运行临界区的代码时,不可抢占。这些临界区被自旋锁spin_lock保护了起来。【但是当进程使用spin_lock时,自己被锁住并自旋时,这时可以调度。】

3.内核正在进行bottom half(中断的底半部)处理时,不可抢占。【不太懂】

4.内核正在执行调度程序Scheduler时,不可抢占。

5.内核正在对每一个CPU“私有”数据结构操作(per CPU date structures)时,不可抢占。在SMP中,对于Per-cpu数据结构未用spinlocks保护,因为这些数据结构隐含地被保护了。

抢占式内核什么时候,什么位置调用schedule函数?

当中断发生,并完成中断处理时,在返回之前被中断的进程时,可以根据需要进行调度。

抢占式内核为每一个进程的task_struct结构引入了preempt_count变量,称为内核抢占锁。每当进程进入以上五种状态时,preempt_count加1.表示不可抢占。当退出以上五种状态时,preempt_count减1. 每次进行抢占式调度时,先判断preempt_count与0大小,preempt_count<0,表示可抢占。preempt_count>0表示不可抢占。

一系统抢占式的调度器函数:preempt_schedule;preempt_schedule_irq。它们都是调用schedule来完成调度的。

实时操作系统与抢占式内核的关系

实时操作系统要求就是对来自外部的请求,要求有及时的处理。及时到什么程度就是实时操作系统呢?这个没有一个明确的定义,因为用户对响应时间的要求各不相同。

我们可以说当在同样的硬件条件下,Linux2.4的实时性不高,或是不如linux2.6的实时性高。那么提高系统的实时性的方法有很多,提高CPU速度,增加CPU核。优化操作系统等。那么 linux在提高系统实时性的重要贡献就是引入了“内核抢占调度模式”。那么我们也可以说linux很好的支持了实时性。

 

首先,先说一下什么是信号。信号本质上是在软件层次上对中断机制的一种模拟,其主要有以下几种来源:

程序错误:除零,非法内存访问…
外部信号:终端Ctrl-C产生SGINT信号,定时器到期产生SIGALRM…
显式请求:kill函数允许进程发送任何信号给其他进程或进程组。
在Linux下,可以通过以下命令查看系统所有的信号:

kill-l

可以通过类似下面的命令显式的给一个进程发送一个信号:

kill-2 pid

上面的命令将2号信号发送给进程id为pid的进程。不存在编号为0的信号。

目前Linux支持64种信号。信号分为非实时信号(不可靠信号)和实时信号(可靠信号)两种类型,对应于 Linux 的信号值为 1-31 和 34-64。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。本文着重于Linux的信号处理机制。

一般情况下一个进程接受到信号后,会有如下的行为:

进程对信号的响应

忽略信号:大部分信号可被忽略,除SIGSTOP和SIGKILL信号外(这是超级用户杀掉或停掉任意进程的手段)。
捕获信号:注册信号处理函数,它对产生的特定信号做处理。
让信号默认动作起作用:unix内核定义的默认动作,有5种情况:
a) 流产abort:终止进程并产生core文件。
b) 终止stop:终止进程但不生成core文件。
c) 忽略:忽略信号。
d) 挂起suspend:挂起进程。
e) 继续continue:若进程是挂起的,则resume进程,否则忽略此信号。
注册信号处理函数

如果想要进程捕获某个信号,然后作出相应的处理,就需要注册信号处理函数。同中断类似,内核也为每个进程准备了一个信号向量表,信号向量表中记录着每个信号所对应的处理机制,默认情况下是调用默认处理机制。当进程为某个信号注册了信号处理程序后,发生该信号时,内核就会调用注册的函数。

注册信号处理函数是通过系统调用signal()、sigaction()。其中signal()在可靠信号系统调用的基础上实现, 是库函数。它只有两个参数,不支持信号传递信息,主要是用于前32种非实时信号的安装;而sigaction()是较新的函数(由两个系统调用实现:sys_signal以及sys_rt_sigaction),有三个参数,支持信号传递信息,主要用来与 sigqueue() 系统调用配合使用,当然,sigaction()同样支持非实时信号的安装。sigaction()优于signal()主要体现在支持信号带有参数。关于这方面的内容,如果想获取更多,也可参考这里。

Linux下信号处理机制

进程如何发现和接受信号?

我们知道,信号是异步的,一个进程不可能等待信号的到来,也不知道信号会到来,那么,进程是如何发现和接受信号呢?实际上,信号的接收不是由用户进程来完成的,而是由内核代理。当一个进程P2向另一个进程P1发送信号后,内核接受到信号,并将其放在P1的信号队列当中。当P1再次陷入内核态时,会检查信号队列,并根据相应的信号调取相应的信号处理函数。如下图所示:

其中,动作c:发现和捕捉信号

信号检测和响应时机

刚才我们说,当P1再次陷入内核时,会检查信号队列。那么,P1什么时候会再次陷入内核呢?陷入内核后在什么时机会检测信号队列呢?

当前进程由于系统调用、中断或异常而进入系统空间以后,从系统空间返回到用户空间的前夕。
当前进程在内核中进入睡眠以后刚被唤醒的时候(必定是在系统调用中),或者由于不可忽略信号的存在而提前返回到用户空间。
进入信号处理函数

发现信号后,根据信号向量,知道了处理函数,那么该如何进入信号处理程序,又该如何返回呢?

我们知道,用户进程提供的信号处理函数是在用户态里的,而我们发现信号,找到信号处理函数的时刻处于内核态中,所以我们需要从内核态跑到用户态去执行信号处理程序,执行完毕后还要返回内核态。这个过程如下图所示:

如图中所见,处理信号的整个过程是这样的:进程由于  系统调用或者中断  进入内核,完成相应任务返回用户空间的前夕,检查信号队列,如果有信号,则根据信号向量表找到信号处理函数,设置好“frame”后,跳到用户态执行信号处理函数。信号处理函数执行完毕后,返回内核态,设置“frame”,再返回到用户态继续执行程序。

在上面这段话中,我提到“frame”,frame是什么?那么为什么要设置frame?为什么在执行完信号处理函数后还要返回内核态呢?

什么叫Frame?

在调用一个子程序时,堆栈要往下(逻辑意义上是往上)伸展,这是因为需要在堆栈中保存子程序的返回地址,还因为子程序往往有局部变量,也要占用堆栈中的空间。此外,调用子程序时的参数也是在堆栈中。子程序调用嵌套越深,则堆栈伸展的层次也越多。在堆栈中的每一个这样的层次,就称为一个”框架”,即frame。

一般来说,当子程序和调用它的程序在同一空间中时,堆栈的伸展,也就是堆栈中框架的建立,过程主要如下:

call指令将返回地址压入堆栈(自动)
用push指令压入调用参数
调整堆栈指针来分配局部变量
为什么以及怎么设置frame?

我们知道,当进程陷入内核态的时候,会在堆栈中保存中断现场。因为用户态和内核态是两个运行级别,所以要使用两个不同的栈。当用户进程通过系统调用刚进入内核的时候,CPU会自动在该进程的内核栈上压入下图所示的内容:(图来自《Linux内核完全注释》)

在处理完系统调用以后,就要调用do_signal()函数进行设置frame等工作。这时内核堆栈的状态应该跟下图左半部分类似(系统调用将一些信息压入栈了):

在找到了信号处理函数之后,do_signal函数首先把内核堆栈中存放返回执行点的eip保存为old_eip,然后将eip替换为信号处理函数的地址,然后将内核中保存的“原ESP”(即用户态栈地址)减去一定的值,目的是扩大用户态的栈,然后将内核栈上的内容保存到用户栈上,这个过程就是设置frame.值得注意的是下面两点:

之所以把EIP的值设置成信号处理函数的地址,是因为一旦进程返回用户态,就要去执行信号处理程序,所以EIP要指向信号处理程序而不是原来应该执行的地址。
之所以要把frame从内核栈拷贝到用户栈,是因为进程从内核态返回用户态会清理这次调用所用到的内核栈(类似函数调用),内核栈又太小,不能单纯的在栈上保存另一个frame(想象一下嵌套信号处理),而我们需要EAX(系统调用返回值)、EIP这些信息以便执行完信号处理函数后能继续执行程序,所以把它们拷贝到用户态栈以保存起来。
以上这些搞清楚之后,下面的事情就顺利多了。这时进程返回用户空间,就会根据内核栈中的EIP值执行信号处理函数。那么,信号处理程序执行完后,怎么返回程序继续执行呢?

信号处理函数执行完后怎么办?

信号处理程序执行完毕之后,进程会主动调用sigreturn()系统调用再次回到内核,查看有没有其他信号需要处理,如果没有,这时内核就会做一些善后工作,将之前保存的frame恢复到内核栈,恢复eip的值为old_eip,然后返回用户空间,程序就能够继续执行。至此,内核遍完成了一次(或几次)信号处理工作。