/* 还是那样,纯粹是学习记录,无意侵权~^ _ ^ */
/* 大牛们的书已经很精简了,完全不知道该删简哪些部分... */
/* copy了全部!只能说,处于“书越读越厚”的阶段....菜鸟一枚~^ _ ^ */
--------------------------------------------------------------------------------------------------------------------------------------------------
第3章讨论了进程,它在操作系统看来是程序的运行态表现形式。本章将讨论进程调度程序,它是确保进程能有效工作的一个内核子系统。
调度程序负责决定将哪个进程投入运行,何时运行以及运行多长时间。进程调度程序(常常简称调度程序)可看做在可运行态进程之间分配有限的处理器时间资源的内核子系统。调度程序是像Linux这样的多任务操作系统的基础。只有通过调度程序的合理调度,系统资源才能最大限度地发挥作用,多进程才会有并发执行的效果。
调度程序没有太复杂的原理。最大限度地利用处理器时间的原则是,只要有可以执行的进程,那么就总会有进程正在执行。但是只要系统中可运行的进程的数目比处理器的个数多,就注定某一给定时刻会有一些进程不能执行。这些进程在等待运行。在一组处于可运行状态的进程中选择一个来执行,是调度程序所需完成的基本工作。
在此模式下,由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会。这个强制的挂起动作就叫做抢占( preemption).进程在被抢占之前能够运行的时间是预先设置好的,而且有一个专门的名字,叫进程的时间片(timeslice)。时间片实际上就是分配给每个可运行进程的处理器时间段。有效管理时间片能使调度程序从系统全局的角度做出调度决定,这样做还可以避免个别进程独占系统资源。当今众多现代操作系统对程序运行都采用了动态时间片计算的方式,并且引入了可配置的计算策略.不过我们将看到,Linux独一无二的“公平”调度程度本身并没有采取时间片来达到公平调度。
相反,在非抢占式多任务模式下,除非进程自己主动停止运行,否则它会一直执行。进程主动挂起自己的操作称为让步( yielding)。理想情况下,进程通常做出让步,以便让每个可运行进程享有足够的处理器时间。但这种机制有很多缺点:调度程序无法对每个进程该执行多长时间做出统一规定,所以进程独占的处理器时间可能超出用户的预料;更糟的是,一个决不做出让步的悬挂进程就能使系统崩溃。幸运的是,近20年以来,绝大部分的操作系统的设计都采用了抢占式多任务——除了Mac OS 9(以及其前身)、还有Windows 3.1(以及其前身)这些出名且麻烦的异端以外。毫无疑问,Unix从一开始就采用的是抢先式的多任务。
注:O(1)用的是大O表示法。简而言之,它是指不管输入有多大,调度程序都可以在恒定时间内完成工作。第6章是一份完整的大O表示法说明。
第一种是用nicc值,它的范围是从-20到+19,默认值为0;越大的nice值意味着更低的优先级----nice似乎意味着你对系统中的其他进程更“优待”。相比高nice值(低优先级)的进程,低nice值(高优先级)的进程可以获得更多的处理器时间。nIce值是所有Unix系统中的标准化的概念——但不同的Unix系统由于调度算法不同,因此nice值的运用方式有所差异。比如一些基于Unix的操作系统,如Mac OS X,进程的nice值代表分配给进程的时间片的绝对值;而Linux系统中,nice值则代表时间片的比例。你可以通过
ps -el命令查看系统中的进程列表,结果中标记NI的一列就是进程对应的nice值。如:
[root@zx ~]# ps -el
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 0 1 0 0 80 0 - 502 select ? 00:00:02 init
5 S 0 2 0 0 75 -5 - 0 kthrea ? 00:00:00 kthreadd
1 S 0 3 2 0 -40 - - 0 migrat ? 00:00:00 migration/0
1 S 0 4 2 0 75 -5 - 0 ksofti ? 00:00:03 ksoftirqd/0
5 S 0 5 2 0 -40 - - 0 watchd ? 00:00:00 watchdog/0
1 S 0 6 2 0 75 -5 - 0 worker ? 00:00:00 events/0
1 S 0 7 2 0 75 -5 - 0 worker ? 00:00:00 khelper
1 S 0 80 2 0 75 -5 - 0 worker ? 00:00:00 kintegrityd/0
1 S 0 82 2 0 75 -5 - 0 worker ? 00:00:00 kblockd/0
1 S 0 84 2 0 75 -5 - 0 worker ? 00:00:00 kacpid
1 S 0 85 2 0 75 -5 - 0 worker ? 00:00:00 kacpi_notify
1 S 0 215 2 0 75 -5 - 0 worker ? 00:00:00 cqueue
第二种范围是实时优先级,其值是可配置的,默认情况下它的变化范围是从0到99(包括0和99)。与nice值意义相反,越高的实时优先级数值意味着进程优先级越高。
任何实时进程的优先级都高于普通的进程,也就是说实时优先级和nice优先级处于互不相交的两个范畴。Linux实时优先级的实现参考了Unix相关标准——特别是POSIX.lb。大部分现代的Unix操作系统也都提供类似的机制。你可以通过命令:
[root@zx ~]# ps -eo state,uid,pid,ppid,rtprio,time,comm
S UID PID PPID RTPRIO TIME COMMAND
S 0 1 0 - 00:00:02 init
S 0 2 0 - 00:00:00 kthreadd
S 0 3 2 99 00:00:00 migration/0
S 0 4 2 - 00:00:03 ksoftirqd/0
S 0 5 2 99 00:00:00 watchdog/0
S 0 6 2 - 00:00:00 events/0
S 0 7 2 - 00:00:00 khelper
S 0 80 2 - 00:00:00 kintegrityd/0
S 0 82 2 - 00:00:00 kblockd/0
S 0 84 2 - 00:00:00 kacpid
查看到你系统中的进程列表,以及它们对应的实时优先级(位于RTPRIO列下),其中如果有进程对应列显示“-”,则说明它不是实时进程。
注:在其他系统中,时间片有时也称为量子(quantum)或处理器片(proccssor slice)。但Linux把它叫做时间片,因此你也最好这样叫。
在这样的场景中,理想情况是调度器应该给予文本编辑程序相比视频编码程序更多的处理器时间,因为它属于交互式应用。对文本编辑器而言,我们有两个目标。第一是我们希望系统给它更多的处理器时间,这并非因为它需要更多的处理器时间(其实它不需要),是因为我们希望在它需要时总是能得到处理器;第二是我们希望文本编辑器能在其被唤醒时(也就是当用户打字时)抢占视频解码程序。这样才能确保文本编辑器具有很好的交互性能,以便能响应用户输入。在多数操作系统中,上述目标的达成是要依靠系统分配给文本编辑器比视频解码程序更高的优先级和更多的时间片。先进的操作系统可以自动发现文本编辑器是交互性程序,从而自动地完成上述分配动作。
Linux操作系统同样需要追求上述目标,但是它采用不同方法。它不再通过给文本编辑器分配给定的优先级和时间片,而是分配一个给定的处理器使用比。假如文本编辑器和视频解码程序是仅有的两个运行进程,并且又具有同样的nice值,那么处理器的使用比将都是50%----它们平分了处理器时间。但因为文本编辑器将更多的时间用于等待用户输入,因此它肯定不会用到处理器的50%。同时,视频解码程序无疑将能有机会用到超过50%的处理器时间,以便它能更快速地完成解码任务。
这里关键的问题是,当文本编辑器程序被唤醒时将发生什么。我们首要目标是确保其能在用户输入发生时立刻运行。在上述场景中,一旦文本编辑器被唤醒.CFS注意到给它的处理器使用比是50%,但是其实它却用得少之又少。特别是,CFS发现文本编辑器比视频解码器运行的时间短得多。这种情况下,为了兑现让所有进程能公平分享处理器的承诺,它会立刻抢占视频解码程序,让文本编辑器投入运行。文本编辑器运行后,立即处理了用户的击键输入后,又一次进入睡眠等待用户下一次输入。因为文本编辑器并没有消费掉承诺给它的50%处理器使用比,因此情况依旧,CFS总是会毫不犹豫地让文本编辑器在需要时被投入运行,而让视频处理程序只能在剩下的时刻运行。
时间片是指进程运行多少时间,进程一旦启动就会有一个默认时间片。具有更高优先级的进程将运行得更频繁,而且(在多数系统上)也会被赋予更多的时间片。在Unix系统上,优先级以nice值形式输出给用户空间。这点听起来简单,但是在现实中,却会导致许多反常的问题,我们下面具体讨论。
第一个问题,若要将nice值映射到时间片,就必然需要将nice单位值对应到处理器的绝对时间。但这样做将导致进程切换无法最优化进行。举例说明,假定我们将默认nice值(0)分配给一个进程——对应的是一个100ms的时间片;同时再分配一个最高nice值(+20,最低的优先级)给另一个进程——对应的时间片是5ms。我们接着假定上述两个进程都处于可运行状态。那么默认优先级的进程将获得20/21(105ms中的100ms)的处理器时间,而低优先级的进程会获得1/21(105ms中的5ms)的处理器时间。我们本可以选择任意数值用于本例子中,但这个分配值正好是鼓具说服力的,所以我们选择它。现在,我们看看如果运行两个同等低优先级的进程情况将如何。我们是希望它们能各自获得一半的处理器时间,事实上也确实如此。但是任何一个进程每次仅仅只能获得5ms的处理器时间(10ms中各占一半)。也就是说,相比刚才例子中105ms内进行一次上下文切换,现在则需要在10ms内继续进行两次上下文切换。类推,如果是两个具有普通优先级的进程,它们同样会每个获得50%处理器时间,但是是在100ms内各获得一半。
显然,我们看到这些时间片的分配方式并不很理想:它们是给定nice值到时间片映射与进程运行优先级混合的共同作用结果。事实上,给定高nice值(低优先级)的进程往往是后台进程,且多是计算密集型;而普通优先级的进程则更多是前台用户任务。所以这种时间片分配方式显然和初衷背道而驰的。
第二个问题涉及相对nice值,同时和前面的nlce值到时间片映射关系也脱不了干系。假设我们有两个进程,分别具有不同的优先级。第一个假设nice值只是0,第二个假设是10它们将被分别映射到时间片100ms和95ms (O(1)调度算法确实这么干了)。它们的时间片几乎一样,其差别微乎其微。但是如果我们的迸程分别赋予18和19的nice值,那么它们则分别被映射为10ms和5ms的时间片。如果这样,前者相比后者获得了两倍的处理器时间!不过nice值通常都使用相对值(nice系统调用是在原值上增加或减少,而不是在绝对值上操作),也就是说:“把进程的nice值减小1”所带来的效果极大地取决于其nIce的初始值。
第三个问题,如果执行nice值到时间片的映射,我们需要能分配一个绝对时间片,而且这个绝对时间片必须能在内核的测试范围内。在多数操作系统中,上述要求意味着时间片必须是定时器节拍的整数倍(请先参看第11章“定时器和时间测量”关于时间的讨论)。但这么做必然会引发了几个问题。首先,最小时间片必然是定时器节拍的整数倍,也就是10ms或者1ms的倍数。其次,系统定时器限制了两个时间片的差异:连续的nicc值映射到时间片,其差别范围多至10ms或者少则1ms。最后,时间片还会随着定时器节拍改变(如果这里所讨论的定时器节拍对你来说很陌生,快去先看看第11章再说。因为这点正是引入CFS的唯一原因)。
第四个问题也是最后一个是关于基于优先级的调度器为了优化交互任务而唤醒相关进程的问题。这种系统中,你可能为了进程能更快地投入运行,而去对新要唤醒的进程提升优先级,即便它们的时间片已经用尽了。虽然上述方法确实能提升不少交互性能,但是一些例外情况也有可能发生,因为它同时也给某些特殊的睡眠/唤醒用例 一个玩弄调度器的后门,使得给定进程打破公平原则,获得更多处理器时间,损害系统中其他进程的利益。
上述问题中的绝大多数都可以通过对传统Unix调度器进行改造解决,虽然这种改造修改不小,但也并非是结构性调整。比如,将nice值呈几何增加而非算数增加的方式解决第二个问题;采用一个新的度量机制将从nice值到时间片的映射与定时器节拍分离开来,以此解决第三个问题。但是这些解决方案都回避了实质问题——即分配绝对的时间片引发的固定的切换频率,给公平性造成了很大变数。 CFS采用的方法是对时间片分配方式进行根本性的重新设计(就进程调度器而言):完全摒弃时间片而是分配给进程一个处理器使用比重。通过这种方式,CFS确保了进程调度中能有恒定的公平性,而将切换频率置于不断变动中。
1.调度器实体结构
CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只在公平分配给它的处理器时间内运行。CFS使用调度器实体结构(定义在文件
调度器实体结构作为一个名为se的成员变量,嵌入在进程描述符struct task_struct内。我们已经在第3章讨论过进程描述符。
2.虚拟实时
vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化(或者说是被加权的)。虚拟时间是以ns为单位的,所以vruntime和定时器节拍不再相关。虚拟运行时间可以帮助我们逼近CFS模型所追求的“理想多任务处理器”。如果我们真有这样一个理想的处理器,那么我们就不再需要vruntime了。因为优先级相同的所有进程的虚拟运行时都是相同的——所有任务都将接收到相等的处理器份额。但是因为处理器无法实现完美的多任务,它必须依次运行每个任务.因此CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。
定义在kernel/sched_fair.c文件中的update_curr函数实现了该记账功能:
update_curr()计算了当前进程的执行时间,并且将其存放在变量delta_exec中。然后它又将运行时间传递给了_update_curr(),由后者再根据当前可运行进程总数对运行时间进行加权计算。最终将上述的权重值与当前运行进程的vruntime相加。
update_curr()是由系统定时器周期性调用的,无论是在进程处于可运行态,还是被堵塞处于不可运行态。根据这种方式,vruntime可以准确地测量给定进程的运行时间,而且可知道谁应该是下一个被运行的进程。
1.挑选下一个任务
我们先假设,有那么一个红黑树存储了系统中所有的可运行进程,其中节点的键值便是可运行进程的虚拟运行时间。稍后我们可以看到如何生成该树,但现在我们假定已经拥有它了。CFS调度器选取待运行的下一个进程,是所有进程中vruntime最小的那个,它对应的便是在树中最左侧的叶子节点。也就是说,你从树的根节点沿着左边的子节点向下找,一直找到叶子节点,你便找到了其vruntme值最小的那个进程。(再说一次,如果你不熟悉二叉搜索树,不用担心,只要知道它用来加速寻找过程即可)CFS的进程选择算法可简单总结为“运行rbtree树中最左边叶子节点所代表的那个进程”。实现这一过程的函数是_pick_next_entity(),它定义在文件kernel/sched_fair.c中:
注意_pick_next_entity()函数本身并不会遍历树找到最左叶子节点,因为该值已经缓存在rb- leftmost字段中。虽然红黑树让我们可以很有效地找到最左叶子节点(O(树的高度)等于树节点总数的O(log n),这是平衡树的优势),但是更容易的做法是把最左叶子节点缓存起来。这个函数的返回值便是CFS调度选择的下一个运行进程。如果该函数返回值是NULL,那么表示没有最左叶子节点,也就是说树中没有任何节点了。这种情况下,表示没有可运行进程,CFS调度器便选择idle任务运行。
2.向树中加入进程
现在,我们来看CFS如何将进程加入rbtree中,以及如何缓存最左叶子节点。这一切发生在进程变为可运行状态(被唤醒)或者是通过fork()调用第一次创建进程时——在第3章我们讨论过它。enqueue_entity()函数实现了这一目的:
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int wakeup)
{
/*
* Update run-time statistics of the 'current'.
*/
update_curr(cfs_rq);
account_entity_enqueue(cfs_rq, se);
if (wakeup) {
place_entity(cfs_rq, se, 0);
enqueue_sleeper(cfs_rq, se);
}
update_stats_enqueue(cfs_rq, se);
check_spread(cfs_rq, se);
if (se != cfs_rq->curr)
__enqueue_entity(cfs_rq, se);
}
该函数更新运行时间和其他一些统计数据,然后调用_enqueue_entity()进行繁重的插入操作,把数据项真正插入到红黑树中:
3.从树中删除进程
最后我们看看CFS是如何从红黑树中删除进程的。删除动作发生在进程堵塞(变为不可运行态)或者终止时(结束运行):
和给红黑树添加进程一样,实际工作是由辅助函数_dequeue_entity()完成的。
从红黑树中删除进程要容易得多。因为rbtree实现了rb_erase()函数,它可完成所有工作。该函数的剩下工作是更新rb_Ieftmost缓存。如果要删除的进程是最左节点,那么该函数要调用rb_next()按顺序遍历,找到谁是下一个节点,也就是当前最左节点被删除后,新的最左节点。
注意该函数开始部分的优化。因为CFS是普通进程的调度类,而系统运行的绝大多数进程都是普通进程,因此这里有一个小技巧用来加速选择下一个CFS提供的进程,前提是所有可运行进程数量等于CFS类对应的可运行进程数(这样就说明所有的可运行进程都是CFS类的)。该函数的核心是for循环,它以优先级为序,从最高的优先级类开始,遍历了每一个调度类。每一个调度类都实现了pick_next_task函数,它会返回指向下一个可运行进程的指针,或者没有时返回NULL.我们会从第一个返回非NULL值的类中选择下一个可运行进程。 CFS中pick_next_task()实现会调用pick_next_entity(),而该函数会再来调用我们前面内容中讨论过的_pick_next_cntity函数。
在第3章里曾经讨论过,休眠有两种相关的进程状态:TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。它们的唯一区别是处于TASK- UNINTERRUPTIBLE的进程会忽略信号,而处于TASK_ INTERRUPTIBLE状态的进程如果接收到一个信号,会被提前唤醒并响应该信号。两种状态的进程位于同一个等待队列上,等待某些事件,不能够运行。
1.等待队列
休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head来代表等待队列。等待队列可以通过DECLARE_WAITQUEUE()静态创建,也可以由init_ waitqueue_head()动态创建口进程把自己放入等待队列中并设置成不可执行状态。当与等待队列相关的事件发生的时候,队列上的进程会被唤醒。为了避免产生竞争条件,休眠和唤醒的实现不能有纰漏。
针对休眠,以前曾经使用过一些简单的接口。但那些接口会带来竞争条件:有可能导致在判定条件变力真后,进程却开始了休眠,那样就会使进程无限期地休眠下去.所以,在内核中进行休眠的推荐操作就相对复杂了一些:
进程通过执行下面几个步骤将自己加入到一个等待队列中:
1)调用宏DEFINE_WAIT()创建一个等待队列的项。
2)调用add wait_queue()把自己加入到队列中。该队列会在进程等待的条件满足时唤醒它。当然我们必须在其他地方撰写相关代码,在事件发生时,对等待队列执行wake_up()操作。
3)调用prepare_to_wait()方法将进程的状态变更为TASK INTERRUPTIBLE或TASK UNINTERRUPTIBLE。而且该函数如果有必要的话会将进程加回到等待队列,这是在接下来的循环遍历中所需要的。
4)如果状态被设置为TASK_ INTERRUPTIBLE,则信号唤醒进程-这就是所谓的伪唤醒(唤醒不是因为事件的发生),因此检查并处理信号。
5)当进程被唤醒的时候,它会再次检查条件是否为真。如果是,它就退出循环;如果不是,它再次调用schedule()并一直重复这步操作。
6)当条件满足后,进程将自己设置为TASK- RUNNING并调用finish_wait()方法把自己移出等待队列。
如果在进程开始休眠之前条件就已经达成了,那么循环会退出,进程不会存在错误地进入休眠的倾向。需要注意的是,内核代码在循环体内常常需要完成一些其他的任务,比知,它可能在调用Schedule之前需要释放掉锁,而在这以后再重新获取它们,或者响应其他事件。
函数inotify_read(),位于文件fs/notify/inotify/inotify_user.c中,负责从通知文件描述符中读取信息,它的实现无疑是等待队列的一个典型用法:
static ssize_t inotify_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
struct inotify_device *dev;
char __user *start;
int ret;
DEFINE_WAIT(wait);
start = buf;
dev = file->private_data;
while (1) {
struct inotify_kernel_event *kevent;
prepare_to_wait(&dev->wq, &wait, TASK_INTERRUPTIBLE);
mutex_lock(&dev->ev_mutex);
kevent = get_one_event(dev, count);
mutex_unlock(&dev->ev_mutex);
if (kevent) {
ret = PTR_ERR(kevent);
if (IS_ERR(kevent))
break;
ret = copy_event_to_user(kevent, buf);
free_kevent(kevent);
if (ret < 0)
break;
buf += ret;
count -= ret;
continue;
}
ret = -EAGAIN;
if (file->f_flags & O_NONBLOCK)
break;
ret = -EINTR;
if (signal_pending(current))
break;
if (start != buf)
break;
schedule();
}
finish_wait(&dev->wq, &wait);
if (start != buf && ret != -EFAULT)
ret = buf - start;
return ret;
}
这个函数遵循了我们例子中的使用模式,主要的区别是它在while循环中检查了状态,而不是在while循环条件语句中。原因是该条件的检测更复杂些,而且需要获得锁。也正因为如此,循环退出是通过break完成的。
2.唤醒
唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。它调用函数try_to_wake_up(),该函数负责将进程设置为TASK—RUNNING状态,调用enqueue_task()将此进程放入红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,逐要设置need_resched标志。通常哪段代码促使等待条件达成,它就要负责随后调用wake_up()函数.举例来说,当磁盘数据到来时,VFS就要负责对等待队列调用wake_up(),以便唤醒队列中等待这些数据的进程。
关于休眠有一点需要注意,存在虚假的唤醒。有时候进程被唤醒并不是因为它所等待的条件达成了才需要用一个循环处理来保证它等待的条件真正达成.图4-1描述了每个调度程序状态之间的关系。
上下文切换,也就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/ sched.c中的context_switch()函数负责处理.每当一个新的进程被选出来准备投入运行的时候,schcdule()就会调用该函数。它完成了两项基本的工作:
a) 调用声明在
b) 调用声明在
内核必须知道在什么时候调用schedule()。如果仅靠用户程序代码显式地调用schedule(),它们可能就会永远地执行下去。相反,内核提供了一个need_resched标志来表明是否需要重新执行一次调度(见表4-1)。当某个进程应该被抢占时,schedule_tick()就会设置这个标志:当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志,内核检查该标志,确认其被设置,调用schedule()来切换到一个新的进程。该标志对于内核来讲是一个信息,它表示有其他进程应当被运行了,要尽快调用调度程序。
再返回用户空间以及从中断返回的时候,内核也会检查need_resched标志。如果已被设置,内核会在继续执行之前调用调度程序。
每个进程都包含一个need_resched标志,这是因为访问进程描述符内的数值要比访问一个全局变量快(因为current宏速度很快并且描述符通常都在高速缓存中)。在2.2以前的内核版本中,该标志曾经是一个全局变置。2.2到2.4版内核中它在task- struct中。而在2.6版中,它被移到thread_info结构体里,用一个特别的标志变量中的一位来表示。
a) 从系统调返回用户空间时。
b) 从中断处理程序返回用户空间时。
与其他大部分的Unix变体和其他大部分的操作系统不同,Linux完整地支持内核抢占。在不支持内核抢占的内核中,内核代码可以一直执行,到它完成为止。也就是说,调度程序没有办法在一个内核级的任务正在执行的时候重新调度——内核中的各任务是以协作方式调度的,不具备抢占性。内核代码一直要执行到完成(返回用户空间)或明显的阻塞为止。在2.6版的内核中,内核引入了抢占内力;现在,只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务。
那么,什么时候重新调度才是安全的呢?只要没有持有锁,内核就可以进行抢占。锁是非抢占区域的标志。由于内核是支持SMP的,所以,如果没有持有锁,正在执行的代码就是可重新导入的,也就是可以抢占的。
为了支持内核抢占所做的第一处变动,就是为每个进程的thread_info引入preempt_count计数器。该计数器初始值为0,每当使用锁的时候数值加1,释放锁的时候数值减1。当数值为0的时候,内核就可执行抢占。从中断返回内核空间的时候,内核会检查need resched和preempt_count的值。如果need resched被设置,并且preempt_count为0的话,这说明有一个更为重要的任务需要执行并且可以安全地抢占,此时,调度程序就会被调用。如果preempt_ count不为0,说明当前任务持有锁,所以抢占是不安全的。这时,内核就会像通常那样直接从中断返回当前执行进程。如果当前进程持有的所有的锁都被释放了,preempt_count就会重新为0。此时,释放锁的代码会检查need resched是否被设置。如果是的话,就会调用调度程序。有些内核代码需要允许或禁止内核抢占,相关内容会在第9章讨论。
如果内核中的进程被阻塞了,或它显式地调用了schedule(),内核抢占也会显式地发生。这种形式的内核抢占从来都是受支持的,因为根本无须额外的逻辑来保证内核可以安全地被抢占。如果代码显式地调用了schcdule(),那么它应该清楚自己是可以安全地被抢占的。
内核抢占会发生在:
a) 中断处理程序正在执行,且返回内核空间之前。
b) 内核代码再一次具有可抢占性的时候。
c) 如果内核中的任务显式地调用schedule()。
d) 如果内核中的任务阻塞(这同样也会导致调用schedule())。
Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR。而普通的、非实时的调度策略是SCHED_NORMAL.借助调度类的框架,这些实时策略并不被完全公平调度器来管理,而是被一个特殊的实时调度器管理。具体的实现定义在文件kernel/sched_ rt.c.中,在接下来的内容中我们将讨论实时调度策略和算法。
SCHED_FIFO实现了一种简单的、先入先出的调度算法:它不使用时间片。处于可运行状态的SCHED_FIFO级的进程会比任何SCHED_NORMAL级的进程都先得到调度。一旦一个SCHED_FIFO级进程处于可执行状态,就会一直执行,直到它自己受阻塞或显式地释放处理器为止:它不基于时间片,可以一直执行下去。只有更高优先级的SCHED_FIFO或者SCHED_RR任务才能抢占SCHED_FIFO任务。如果有两个或者更多的同优先级的SCHED_FIFO级进程,它们会轮流执行,但是依然只有在它们愿意让出处理器时才会退出.只要有SCHED_FIFO级进程在执行,其他级别较低的进程就只能等待它变为不可运行态后才有机会执行。
SCHED_RR与SCHED_FIFO大体相同,只是SCHED_RR级的进程在耗尽事先分配给它的时间后就不能再继续执行了.也就是说,SCHED_RR是带有时间片的SCHED_ FIFO, 这是一种实时轮流调度算法。当SCHED_RR任务耗尽它的时间片时,在同一优先级的其他实时进程被轮流调度。时间片只用来重新调度同一优先级的进程。对于SCHED_FIFO进程,高优先级总是立即抢占低优先级,但低优先级进程决不能抢占SCHED_RR任务,即使它的时间片耗尽。
这两种实时算法实现的都是静态优先级。内核不为实时进程计算动态优先级.这能保证给定优先级别的实时进程总能抢占优先级比它低的进程。
Linux的实时调度算法提供了一种软实时工作方式.软实时的含义是,内核调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程的要求.相反,硬实时系统保证在一定条件下,可以满足任何调度的要求。Linux对于实时任务的调度不做任何保证。虽然不能保证硬实时工作方式,但Linux的实时调度算法的性能还是很不错的。2.6版的内核可以满足严格的时间要求。
实时优先级范围从0到MAX_ RT_PRIO减1。默认情况下,MAX_RT_PRIO为100, 所以默认的实时优先级范围是从0到99. SCHED_NORMAL级进程的nice值共享了这个取值空间;它的取值范围是从MAX_RT_PRIO到(MAX_ RT_PRIO+40).也就是说,在默认情况下,nice值从-20到+19直接对应的是从100到139的实时优先级范围。
Linux提供了一个系统调用族,用于管理与调度程序相关的参数。这些系统调用可以用来操作和处理进程优先级、调度策略及处理器绑定,同时还提供了显式地将处理器交给其他进程的机制。
许多书籍(还有友善的man帮助文件)都提供了这些系统调用(它们都包含在C库中,没用什么太多的封装,基本上只调用了系统调用而已)的说明。表4-2列举了这些系统调用并给出了简短的说明。第5章会讨论它们是如何实现的。
sched_get_priority_max()和sched_get_priority_min()分别用于返回给定调度策略的最大和最小优先级。实时调度策略的最大优先级是MAX_USER_RT_PRIO减1,最小优先级等于1。
对于一个普通的进程,nice()函数可以将给定进程的静态优先级增加一个给定的量。只有超级用户才能在调用它时使用负值,从而提高进程的优先级。nice()函数会调用内核的set-user_nice()函数,这个函数会设置进程的task_struct的static_prio和prio值。
Linux调度程序提供强制的处理器绑定(proccssor affinity)机制。也就是说,虽然它尽力通过一种软的(或者说自然的)亲和性试图使进程尽量在同一个处理器上运行,但它也允许用户强制指定“这个进程无论如何都必须在这些处理器上运行”。这种强制的亲和性保存在进程task_struct的cpus_allowed这个位掩码标志中。该掩码标志的每一位对应一个系统可用的处理器。默认情况下,所有的位都被设置,进程可以在系统中所有可用的处理器上执行。用户可以通过sched_setaffinity()设置不同的一个或几个位组合的位掩码,而调用sched_ getaffinity()则返回当前的cpus_allowed位掩码.
内核提供的强制处理器绑定的方法很简单.首先,当处理进行第一次创建时,它继承了其父进程的相关掩码。由于父进程运行在指定处理器上,子进程也运行在相应处理器上。其次,当处理器绑定关系改变时,内核会采用“移植线程"任务推到合法的处理器上。最后,加载平衡器只把任务拉到允许的处理器上,因此,进程只运行在指定处理器上,对处理器的指定是由该进程描述符的cpus_allowed域设置的。
Linux通过sched_yield()系统调用,提供了一种让进程显式地将处理器时间让给其他等待执行进程的机制。它是通过将进程从活动队列中(因为进程正在执行,所以它肯定位于此队列当中)移到过期队列中实现的。由此产生的效果不仅抢占了该进程并将其放入优先级队列的最后面,还将其放入过期队列中——这样能确保在一段时间内它都不会再被执行了。由于实时进程不会过期,所以属于例外。它们只被移动到其优先级队列的最后面(不会放到过期队列中)。在Linux的早期版本中,sched_yield()的语义有所不同,进程只会被放置到优先级队列的末尾,放弃的时间往往不会太长。现在,应用程序甚至内核代码在调用sched_yield()前,应该仔细考虑是否真的希望放弃处理器时间。
内核代码为了方便,可以直接调用yield(),先要确定给定进程确实处于可执行状态,然后再调用sched_yield()。用户空间的应用程序直接使用sched_yield()系统调用就可以了。
进程调度程序是内核重要的组成部分,因为运行着的进程首先在使用计算机(至少在我们大多数人看来)。然而,满足进程调度的各种需要绝不是轻而易举的:很难找到“一刀切"的算法,既适合众多的可运行进程,又具有可伸缩性,还能在调度周期和吞吐量之间求得平衡,同时还满足各种负载的需求。不过.Linux内核的新CFS调度程序尽量满足了各个方面的需求,并以较完善的可伸缩性和新颖的方法提供了最佳的解决方案。
前面的章节覆盖了进程管理的相关内容,本章则考察了进程调度所遵循的基本原理、具体实现、调度算法以及目前Linux内核所使用的接口。第5章将涵盖内核提供给运行进程的主要接口——系统调用。