第四章 进程调度
调度程序负责决定将哪个进程投入运行,何时运行以及运行多长时间。进程调度程序(常常简称调度程序)可看做在可运行态进程之间分配有限的处理器时间资源的内核子系统。在一组处于可运行状态的进程中选择一个来执行,是调度程序所需完成的基本工作。
4.1 多任务
多任务系统可以划分为两类:非抢占式多任务(cooperative multitasking)和抢占式多任务(preemptive multitasking)。Linux提供了抢占式的多任务模式。在此模式下,由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会。这个强制的挂起动作就叫做抢占(preemption)。进程在被抢占之前能够运行的时间是预先设置好的,而且有一个专门的名字,叫进程的时间片(timeslice)。时间片实际上就是分配给每个可运行进程的处理器时间段。
相反,非抢占式多任务模式下,除非进程自己主动停止运行,否则他会一直执行。进程主动挂起自己的操作称为让步(yielding)。理想情况下,进程通常做出让步,以便让每个可运行进程享有足够的处理器时间。
4.2 Linux的进程调度
从2.5版本开始,采用了一种叫做O(1)调度程序的新调度程序——它是因为其算法的行为而得名的。它解决了先前版本Linux调度程序的许多不足,引入了许多强大的新特性和性能特征。这里主要要感谢静态时间片算法和针对每一处理器的运行队列,它们帮助我们摆脱了先前调度程序设计上的限制。
O(1)调度程序虽然对于大服务器的工作负载很理想,但是在有很多交互程序要运行的桌面系统上则表现不佳,因为其缺少交互进程。自2.6版本开始,引入了新的进程调度算法。其中最为著名的是“反转楼梯最后期限调度算法(Rotating Staircase Deadline scheduler)”(RSDL),该算法吸取了队列理论,将公平调度的概念引入了Linux调度程序。并且最终在2.6.23内核版本中替代了O(1)度算法,它此刻被称为“完全公平调度算法”,或者简称CFS。
4.3 策略
4.3.1 I/O消耗型和处理器消耗型进程
进程可以被分为I/O消耗型和处理器消耗型。
- I/O消耗型,指进程的大部分时间用来提交I/O请求或是等待I/O请求。因此,这样的进程经常处于可运行状态,但通常都是运行短短的一会儿,因为它在等待更多的I/O请求时最后总会阻塞(这里所说的I/O是指任何类型的可阻塞资源,比如键盘输入,或者是网络I/O)。举例来说,多数用户图形界面程序(GUI)都属于I/O密集型,即便它们从不读取或者写人磁盘,它们也会在多数时间里都在等待来自鼠标或者键盘的用户交互操作。
- 处理器耗费型进程把时间大多用在执行代码上。除非被抢占,否则它们通常都一直不停地运行,因为它们没有太多的I/O需求。对于这类处理器消耗性的进程,调度策略往往是尽量降低他们的调度频率,而延长其运行时间。
当然这种划分方法并非绝对的。进程可以同时展示这两种行为。
调度策略通常要在两个矛盾的目标中间寻找平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量)。为了满足上述需求,调度程序通常采用一套非常复杂的算法来决定最值得运行的进程投入运行,但是它往往并不保证低优先级进程会被公平对待。Linux为了保证交互式应用和桌面系统的性能,所以对进程的响应做了优化(缩短响应时间),更倾向于优先调度I/O消耗型进程。虽然如此,但在下面你会看到,调度程序也并未忽略处理器消耗型的进程。
4.3.2 进程优先级
调度算法中最基本的一类就是基于优先级的调度。通常做法是(其并未被Linux系统完全采用)优先级高的进程先运行,低的后运行,相同优先级的进程按轮转方式进行调度(一个接一个,重复进行)。调度程序总是选择时间片未用尽而且优先级最高的进程运行。用户和系统都可以通过设置进程的优先级来影响系统的调度。
Linux采用了两种不同的优先级范围。第一种是用nice值,它的范围是从-20到+19,默认值为0:越大的nice值意味着更低的优先级——nice似乎意味着你对系统中的其他进程更“优待”。相比高nice值(低优先级)的进程,低nice值(高优先级)的进程可以获得更多的处理器时间。nice值是所有Unix系统中的标准化的概念——但不同的Unix系统由于调度算法不同,因此nice值的运用方式有所差异。比如一些基于Unix的操作系统,如Mac OSX,进程的nice值代表分配给进程的时间片的绝对值;而Linux系统中,nice值则代表时间片的比例。你可以通过ps -el命令查看系统中的进程列表,结果中标记NI的一列就是进程对应的nice值。
第二种范围是实时优先级,其值是可配置的,默认情况下它的变化范围是从0到99(包括0和99)。与nice值意义相反,越高的实时优先级数值意味着进程优先级越高。任何实时进程的优先级都高于普通的进程,也就是说实时优先级和nice优先级处于互不相交的两个范畴。你可以通过命令:
ps -eo state,uid,pid,ppid,rtprio,time,comm
查看到你系统中的进程列表,以及它们对应的实时优先级(位于RTPRIO列下),其中如果有进程对应列显示“-”,则说明它不是实时进程。
4.3.3 时间片
时间片是一个数值,它表明进程在被抢占前所能持续运行的时间。调度策略必须规定一个默认的时间片,但这并不是件简单的事。时间片过长会导致系统对交互的响应表现欠佳,让人觉得系统无法并发执行应用程序;时间片太短会明显增大进程切换带来的处理器耗时,此外,I/O消耗型和处理器消耗型的进程之间的矛盾在这里也再次显露出来:I/O消耗型不需要长的时间片,而处理器消耗型的进程则希望越长越好。
Linux的CFS调度器并没有直接分配时间片到进程,它是将处理器的使用比划分给了进程。这样一来,进程所获得的处理器时间其实是和系统负载密切相关的。这个比例进一步还会受进程nice值的影响,nice值作为权重将调整进程所使用的处理器时间使用比。具有更高nice值(更低优先权)的进程将被赋予低权重,从而丧失一小部分的处理器使用比:而具有更小nice值(更高优先级)的进程则会被赋予高权重,从而抢得更多的处理器使用比。
Linux中使用新的CFS调度器,其抢占时机取决于新的可运行程序消耗了多少处理器使用比。如果消耗的使用比比当前进程小,则新进程立刻投入运行,抢占当前进程。否则,将推迟其运行。
4.3.4 调度策略的活动
想象下面这样一个系统,它拥有两个可运行的进程:一个文字编辑程序和一个视频编码程序。文字编辑程序显然是I/O消耗型的,因为它大部分时间都在等待用户的键盘输入(无论用户的输入速度有多快,都不可能赶上处理的速度)。用户总是希望按下键系统就能马上响应。相反,视频编码程序是处理器消耗型的。除了最开始从磁盘上读出原始数据流和最后把处理好的视频输出外,程序所有的时间都用来对原始数据进行视频编码。
在这样的场景中,理想情况是调度器应该给予文本编辑程序相比视频编码程序更多的处理器时间,因为它属于交互式应用。对文本编辑器而言,我们有两个目标。第一是我们希望系统给它更多的处理器时间,这并非因为它需要更多的处理器时间(其实它不需要),是因为我们希望在它需要时总是能得到处理器;第二是我们希望文本编辑器能在其被唤醒时(也就是当用户打字时)抢占视频解码程序。这样才能确保文本编辑器具有很好的交互性能,以便能响应用户输入。
Linux操作系统不再通过给文本编辑器分配给定的优先级和时间片,而是分配一个给定的处理器使用比。假如文本编辑器和视频解码程序是仅有的两个运行进程,并且又具有同样的nice值,那么处理器的使用比将都是50%—一它们平分了处理器时间。但因为文本编辑器将更多的时间用于等待用户输入,因此它肯定不会用到处理器的50%。同时,视频解码程序无疑将能有机会用到超过50%的处理器时间,以便它能更快速地完成解码任务。
这里关键的问题是,当文本编辑器程序被唤醒时将发生什么。我们首要目标是确保其能在用户输入发生时立刻运行。在上述场景中,一旦文本编辑器被唤醒,CFS注意到给它的处理器使用比是50%,但是其实它却用得少之又少。特别是,CFS发现文本编辑器比视频解码器运行的时间短得多。这种情况下,为了兑现让所有进程能公平分享处理器的承诺,它会立刻抢占视频解码程序,让文本编辑器投入运行。文本编辑器运行后,立即处理了用户的击键输入后,又一次进入睡眠等待用户下一次输入。因为文本编辑器并没有消费掉承诺给它的50%处理器使用比,因此情况依旧,CFS总是会毫不犹豫地让文本编辑器在需要时被投入运行,而让视频处理程序只能在剩下的时刻运行。
在上述场景中,一旦文本编辑器被唤醒,CFS注意到给它的处理器使用比是50%,但是其实它却用得少之又少。特别是,CFS发现文本编辑器比视频解码器运行的时间短得多。这种情况下,为了兑现让所有进程能公平分享处理器的承诺,它会立刻抢占视频解码程序,让文本编辑器投入运行。文本编辑器运行后,立即处理了用户的击键输入后,又一次进入睡眠等待用户下一次输入。因为文本编辑器并没有消费掉承诺给它的50%处理器使用比,因此情况依旧,CFS总是会毫不犹豫地让文本编辑器在需要时被投入运行,而让视频处理程序只能在剩下的他自己的50%时间内运行。
4.4 Linux调度算法
4.4.1 调度器类
Linux调度器是以模块方式提供的,这样做的目的是允许不同类型的进程可以有针对性地选择调度算法。
这种模块化结构被称为调度器类(scheduler classes),它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器都有一个优先级,基础的调度器代码定义在kemel/sched.c文件中,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出,去选择下面要执行的那一个程序。
完全公平调度(CFS)是一个针对普通进程的调度类,CFS算法实现定义在文件kcmc/sched fair.c中。另外,我们在4.7小节介绍实时进程的调度类。
4.4.2 公平调度
CFS的出发点基于一个简单的理念:进程调度的效果应如同系统具备一个理想中的完美多任务处理器。
CFS的做法是允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法了,CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。nice值在CFS中被作为进程获得的处理器运行比的权重:越高的nice值(越低的优先级)进程获得更低的处理器使用权重,这是相对默认nice值进程的进程而言的;相反,更低的nice值(越高的优先级)的进程获得更高的处理器使用权重。
每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行,为了计算准确的时间片,CFS为完美多任务中的无限小调度周期的近似值设立了一个目标。而这个目标称作“目标延迟”,越小的调度周期将带来越好的交互性,同时也更接近完美的多任务。但是你必须承受更高的切换代价和更差的系统总吞吐能力。
CFS为此引入每个进程获得的时间片底线,这个底线称为最小粒度。默认情况下这个值是 1 ms。如此一来,即便是可运行进程数量趋于无穷,每个最少也能获得1ms的运行时间,确保切换消耗被限制在一定范围内。
现在,让我们再来看看具有不同nice值的两个可运行进程的运行情况——比如一个具有默认nice值(0),另一个具有的nice值是5。这些不同的nice值对应不同的权重,所以上述两个进程将获得不同的处理器使用比。在这个例子中,nice值是5的进程的权重将是默认nice进程的1/3。如果我们的目标延迟是20ms,那么这两个进程将分别获得15m和5m的处理器时间。再比如我们的两个可运行进程的nice值分别是10和15,它们分配的时间片将是多少呢?还是15和5ms!可见,绝对的nice值不再影响调度决策:只有相对值才会影响处理器时间的分配比例。
总结一下,任何进程所获得的处理器时间是由它自己和其他所有可运行进程nce值的相对差值决定的。nice值对时间片的作用不再是算数加权,而是几何加权。任何nce值对应的绝对时间不再是一个绝对值,而是处理器的使用比。CFS称为公平调度器是因为它确保给每个进程公平的处理器使用比。
4.5 Linux调度的实现
CFS是如何得以实现的。其相关代码位于文件 kernel sched fair.c中,我们将特别关注其四个组成部分:
- 时间记账
- 进程选择
- 调度器入口
- 睡眠和唤醒
4.5.1 时间记账
调度器对进程的运行时间做记账
- 调度器实体结构
CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只在公平分配给它的处理器时间内运行。CFS使用调度器实体结构(定义在文件<inux ched.h>的 struct sched_entity中)来追踪进程运行记账:
struct sched_entity{
struct load_weight load;
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime; //用来记录进程已经运行的时间长短(添加了权重的、累计的进程运行时间)
u64 prev_sum_exec_runtime;
u64 last_wakeup;
u64 avr_overlap;
u64 nr_migrations;
u64 start_runtime;
u64 avg_wakeup;
/*这里省略了很多统计变量,只有在设置了 CONFIG SCHEDSTATS时才启用这些变量*/
};
调度器类实体结构作为一个名为se的成员变量,嵌入在进程描述符struct task_struct内。
- 虚拟实时
vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化(或者说是被加权的)。虚拟时间是以ns为单位的,所以vruntime和定时器节拍不再相关。虚拟运行时间可以帮助我们逼近CFS模型所追求的“理想多任务处理器”。
因为优先级相同的所有进程的虚拟运行时间都是相同的——所有任务都将接收到相等的处理器份额。但是因为处理器无法实现完美的多任务,它必须依次运行每个任务。因此CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。
vruntime是如何计算出来的?使用在 kemel/sched_fair.c文件中定义的 update_cur()函数:
static void update_curr(struct cfs_rq *cfs_rq)
{
//把进程当前状态赋值给curr变量
struct ached_entity *curr = cfs_rq->curr;
u64 now = rq_of(cfs_rg)->clock;
unsigned long delta_exec;
if(unlikely (!curr))
return;
//delta_exec是已经运行的时间(当前时间-开始时间)
delta exec = (unsigned long)(now - curr->exec_start);
if (!delta_exec)
return;
__update_curr(cfs_rq,curr,delta_exec);
curr->exec_start = now;
if (entity_is_task(curr)){
struct task_strauct *curtask = task_of(curr);
trace_sched_stat_runtime(curtask, delta_exec, curr->runtime;
cpuacct_charge(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec):
}
}
update curr()计算了当前进程的执行时间,并且将其存放在变量delta_exec中。然后它又将运行时间传递给了__update_curr(),由后者再根据当前进程状态curr和已经运行的时间delta_exec计算添加了权重的已运行时间(第9行),然后修改vruntime值(第12行)。
static inline void
__update_curr(struct cfs_rq *cfs_rq, struct sched_entity *curr, unsigned long delta_exec){
unsigned long delta_exec_weighted;
schedstat_set (curr->exec_max, max((u64)delta_exec, curr->exec_max));
curr->sum_exec_runtime += delta_exec;
schedstat_add (cfs_rq, exec_clock, delta_exec);
delta_exec_weighted = calc_delta_fair(delta_exec, curr);
//因为是多进程并发,意味着单个进程是间断运行的,所以要累加
curr->runtime += delta_exec_weighted;
update_min_runtime(cfs_rq);
}
update_curr()是由系统定时器周期性调用的,无论是在进程处于可运行态,还是被堵塞处于不可运行态。根据这种方式,vruntime可以准确地测量给定进程的运行时间,而且可知道谁应该是下一个被运行的进程。
4.5.2 进程选择
在前面内容中我们的讨论中谈到若存在一个完美的多任务处理器,所有可运行进程的vruntime值将一致。但事实上我们没有找到完美的多任务处理器,因此CFS试图利用一个简单的规则去均衡进程的虚拟运行时间:当CFS需要选择下一个运行进程时,它会挑一个具有最小vruntime的进程。这其实就是CFS调度算法的核心:选择具有最小 vruntime的任务。那么剩下的内容我们就来讨论到底是如何实现选择具有最小vruntime值的进程。
CFS使用红黑树来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。在Linux中,红黑树称为 rbtree,它是一个自平衡二叉搜索树。我们将在第6章讨论自平衡二叉树以及红黑树。现在如果你还不熟悉它们,不要紧,你只需要记住红黑树是一种以树节点形式存储的数据,这些数据都会对应一个键值。我们可以通过这些键值来快速检索节点上的数据(重要的是,通过键值检索到对应节点的速度与整个树的节点规模成指数比关系)。
- 挑选下一个任务
我们先假设,有那么一个红黑树存储了系统中所有的可运行进程,其中节点的键值便是可运行进程的虚拟运行时间。稍后我们可以看到如何生成该树,但现在我们假定已经拥有它了。CFS调度器选取待运行的下一个进程,是所有进程中vruntime最小的那个,它对应的便是在树中最左侧的叶子节点。也就是说,你从树的根节点沿着左边的子节点向下找,一直找到叶子节点,你便找到了其vruntime值最小的那个进程。(再说一次,如果你不熟悉二叉搜索树,不用担心,只要知道它用来加速寻找过程即可)CFS的进程选择算法可简单总结为“运行rbtree树中最左边叶子节点所代表的那个进程”。实现这一过程的函数是__pick_next_entity(),它定义在文件 kemel/sched_fair.c中。内核会维护一个rb_leftmost字段,用来保存最左叶子结点的值。
- 向树中加入进程
CFS如何将进程加入rbtree中,以及如何缓存最左叶子节点。这一切发生在进程变为可运行状态(被唤醒)或者是通过fork()调用第一次创建进程时——在第3章我们讨论过它。enqueue_entity()函数实现了这一目的。该函数更新运行时间和其他一些统计数据,然后调用__enqueue_entity()进行繁重的插入操作,把数据项真正插入到红黑树中。
- 从树中删除进程
CFS是如何从红黑树中删除进程。删除动作发生在进程堵塞(变为不可运行态)或者终止时(结束运行)。
从红黑树中删除进程要容易得多。因为 rbtree实现了 rb_erase()函数,它可完成所有工作。该函数的剩下工作是更新rb_leftmost缓存。如果要删除的进程是最左节点,那么该函数要调用 rb_next()按顺序遍历,找到谁是下一个节点,也就是当前最左节点被删除后,新的最左节点。
4.5.3 调度器入口
进程调度的主要入口点是函数 schedule(),它定义在文件 kernel/sched.c中。它正是内核其他部分用于调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。Schedule()通常都需要和一个具体的调度类相关联,也就是说,它会找到一个最高优先级的调度类——后者需要有自己的可运行队列,然后问后者谁才是下一个该运行的进程。知道了这个背景,就不会吃惊schedule()函数为何实现得如此简单。该函数中唯一重要的事情是,它会调用pick_next_task()(也定义在文件 kernel/sched.c中)。pick_next_task()会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类选择最高优先级的进程。
该函数的核心是for循环,它以优先级为序,从最高的优先级类开始,遍历了每一个调度器类。每一个调度类都实现了pick_next_task()函数,它会返回指向下一个可运行进程的指针,或者没有时返回NULL。我们会从第一个返回非NULL值的类中选择下一个可运行进程。CFS中pick_next_task()实现会调用 pick_next_entity(),而该函教会再来调用我们前面内容中讨论过的__pick_next_entity()函数找最左节点。
4.5.4 睡眠和唤醒
休眠(被阻塞)的进程处于一个特殊的不可执行状态。进程休眠有多种原因,但肯定都是为了等待一些事件。无论哪种情况,内核的操作都相同:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用 schedule()选择和执行一个其他进程。唤醒的过程刚好相反:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。
体眠有两种相关的进程状态:TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。它们的唯一区别是处于TASK_UNINTERRUPTIBLE的进程会忽略信号,而处于TASK_INTERRUPTIBLE状态的进程如果接收到一个信号,会被提前唤醒并响应该信号。两种状态的进程位于同一个等待队列上,等待某些事件,不能够运行。
- 等待队列
休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表等待队列。等待队列可以通过DECLARE_WAITQUEUE()静态创建,也可以由 init waitqueue_head()动态创建。进程把自己放入等待队列中并设置成不可执行状态。当与等待队列相关的事件发生的时候,队列上的进程会被唤醒。为了避免产生竞争条件,休眠和唤醒的实现不能有纰漏。
DEFINE_WAIT(wait);
add_wait_queue(q,&wait);
while (!condition){ //循环判断等待条件是否发生,没发生则执行循环体
prepare_to_wait(&q,&wait,TASK_INTERRUPTIBLE);
if(signal_pending(current))
/*处理信号*/
schedule(); //该函数会调用相应函数将进程从就绪队列中删除,调度其他进程占用CPU
}
set_current_state(TASK_RUNNING); //如果等待事件发生了,退出循环,改状态为task_running
finish_wait(&q,&wait); //将进程移除等待队列
- 唤醒
唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。它调用函数 try_to_wake_up(),该函数负责将进程设置为TASK_RUNNING状态,调用 enqueue_task()将此进程放入红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。通常哪段代码促使等待条件达成,它就要负责随后调用 wake_up()函数。
4.6 抢占和上下文切换
上下文切换,也就是从一个可执行进程切换到另一个可执行进程,由定义在 kernel/sched.c中的 context_switch()函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。它完成了两项基本的工作:
- 调用声明在< asm/mmu_context.h>中的 switch_mm(),该函数负责把虚拟内存从上一个进程切换到新进程中。
- 调用声明在<asm/system.h>中的 switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息,还有其他任何与体系结构相关的状态信息,都必须以每个进程为对象进行管理和保存。
抢占(内核什么时候执行 schedule()切换到下一个程序)
内核必须知道在什么时候调用 schedule()。如果仅靠用户程序代码显式地调用 schedule(),它们可能就会永远地执行下去。相反,内核提供了一个 need_resched标志来表明是否需要重新执行次调度。当某个进程应该被枪占时,scheduler_tick()就会设置这个标志:当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志,内核检查该标志,确认其被设置,调用 schedule()来切换到一个新的进程。该标志对于内核来讲是一个信息,它表示有其他进程应当被运行了,要尽快调用调度程序。(进程设置了need_resched标志,表明它是可以被抢占的。)
每个进程都包含一个need_resched标志,这是因为访问进程描述符中的数值比访问一个全局变量快,在2.6版中,他被移到thread_info结构体中,用一个特别的标志变量中的一位来表示。内核根据当前进程是否设置了need_resched标志,决定是否调用schedule()切换到下一个进程。
4.6.1 用户抢占
内核无论是在中断处理程序还是在系统调用后返回,都会检查 need_resched标志。如果它被设置了,那么,内核会选个其他(更合适的)进程投入运行。
简而言之,用户抢占在以下情况时产生:
- 从系统调返回用户空间时。
- 从中断处理程序返回用户空间时。
4.6.2 内核抢占
内核抢占就是指一个在内核态运行的进程,可能在执行内核函数期间被另一个进程取代。注意,是处在内核态的进程被抢占不是内核被抢占。
只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务。
那么,什么时候重新调度才是安全的呢?只要没有持有锁,内核就可以进行抢占。锁是非抢占区城的标志。由于内核是支持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是否被设置。如果是的话,就会调用调度程序。
如果内核中的进程被阻塞了,或它显示的调用了schedule(),内核抢占也会显示发生。
内核抢占会发生在:
- 中断处理程序正在执行,且返回内核空间之前
前面提到,在不支持内核抢占的内核中,内核态程序会一直执行,直到返回用户空间进程时,才会检查调度。在内核中执行期间,能打断当前执行的,只有中断,在硬件中断来了之后,处理器会根据情况着手处理硬件中断,在硬件中断处理完毕需要恢复现场时,若检查之前的状态是内核态,则不触发调度,只有之前状态是用户态时才会触发调度。而在支持内核抢占的内核中,在从硬件中断返回时,不管是返回用户态和内核态都会检查调度,若是返回内核态,检查当前线程调度标识和抢占标识,若都允许,则可进程调度。这是内核抢占下增加的一个调度点。
- 内核代码再一次具有可抢占性的时候。
- 如果内核中的任务显式地调用 schedule()。
- 如果内核中的任务阻塞(这同样也会导致调用 schedule())。
4.7 实时调度策略
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与 SCHIED_FIFO大体相同,只是 SCHED_RR级的进程在耗尽事先分配给它的时间后就不能再继续执行了。也就是说,SCHED_RR是带有时间片的 SCHED_FIFO——这是一种实时轮流调度算法。当 SCHED_RR任务耗尽它的时间片时,在同一优先级的其他实时进程被轮流调度。**时间片只用来重新调度同一优先级的进程。**对于 SCHED_RR进程,高优先级总是立即抢占低优先级,但低优先级进程决不能抢占 SCHED_RR任务,即使它的时间片耗尽。
这两种实时算法实现的都是静态优先级。内核不为实时进程计算动态优先级。这能保证给定优先级别的实时进程总能抢占优先级比它低的进程。
Linux的实时调度算法提供了一种软实时工作方式。Linux对于实时任务的调度不做任何保证。虽然不能保证硬实时工作方式,但 Linux的实时调度算法的性能还是很不错的。
实时优先级范围从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的实时优先级范围。
4.8 与调度有关的系统调用
4.8.1 与处理器绑定有关的系统调用
Linux调度程序提供强制的处理器绑定(processor affinity)机制。也就是说,虽然它尽力通过一种软的(或者说自然的)亲和性试图使进程尽量在同一个处理器上运行,但它也允许用户强制指定“这个进程无论如何都必须在这些处理器上运行”。这种强制的亲和性保存在进程task_struct的 cpus_allowed这个位掩码标志中。该掩码标志的每一位对应一个系统可用的处理器。默认情况下,所有的位都被设置,进程可以在系统中所有可用的处理器上执行。用户可以通过sched_setafinity()设置不同的一个或几个位组合的位掩码,而调用 sched_getafiinity()则返回当前的 cpus_allowed位掩码。
4.8.2 放弃处理器时间
Linⅸx通过 sched_yield()系统调用,提供了一种让进程显式地将处理器时间让给其他等待执行进程的机制。它是通过将进程从活动队列中(因为进程正在执行,所以它肯定位于此队列当中)移到过期队列中实现的。由此产生的效果不仅抢占了该进程并将其放入优先级队列的最后面,还将其放入过期队列中——这样能确保在一段时间内它都不会再被执行了。由于实时进程不会过期,所以属于例外。它们只被移动到其优先级队列的最后面(不会放到过期队列中)。