进程调度
- 进程:程序的运行态表现形式
- 进程调度程序:确保进程能有效工作的一个内核子系统,决定将哪个进程投入运行、何时运行以及运行多长时间,在可运行态进程之间分配有限的处理器时间资源。
最大限度的利用处理器时间的原则是只要有可执行的进程就总会有进程正在执行。
4.1 多任务
多任务操作系统:能同时并发的交互执行多个进程的操作系统。单处理器上是幻觉,多处理器上是多个进程真正在并行。很多进程在内存但是处于堵塞或睡眠状态,只有一个进程在运行。
多任务系统划分为两类:
- 抢占式多任务:有调度程序距诶的那个什么时候停止一个进程的运行,这种强制的挂起称为抢占 。进程在被抢占之前能够运行的时间是预先设置好的,称为进程的时间片,就是分配给每个可运行进程的处理器时间段。
- 非抢占式多任务:除非进程自己主动停止,否则会一直执行。进程主动挂起自己的操作称为让步。缺点是进程可能独占处理器的时间过长。
4.2 Linux的进程调度
- O(1)调度程序:解决了先前版本的不足,引入了强大的新特性和性能,但缺点是对交互进程有先天性不足,即响应时间不敏感。
- 反转楼梯最后期限调度算法RSDL:提高了对交互进程的调度性能,称为完全公平调度算法CFS
4.3 策略
策略决定调度程序在何时让什么进程运行,决定了系统的整体印象,优化使用处理器时间。
I/O消耗型和处理器消耗型的进程
- I/O消耗型:进程的大部分时间用来提交或等待I/O请求,时常堵塞。
- 处理器消耗型:进程把时间大多用在执行代码上,通常在一直运行,除非抢占。
调度策略在两个矛盾的目标中间寻找平衡:
- 进程响应迅速:响应时间短
- 最大系统利用率:高吞吐量
往往并不保证低优先级进程会被公平对待,更倾向于I/O消耗型进程。
进程优先级
基于优先级的调度:根据进程的价值和对处理器时间的需求来对进程分级,优先级高的进程先运行。
两种不同的优先级范围:
nice值:-20~19,默认值为0,nice值越大优先级越低
ps -el 查看系统中的进程列表,标记NI的一列就是nice值
实时优先级:0~99,越高的实时优先级代表进程优先级越高。任何实时进程的优先级都高于普通进程
ps-eo state,uid,pid,ppid,rtprio,time,comm. 查看系统中的进程列表,标记RTPRIO的一列就是实时优先级,"-"表示不是实时进程。
时间片
时间片表明进程在被抢占前还能持续运行的时间:
- 时间片过长:系统对交互响应欠佳
- 时间片太短:增大进程切换带来的处理器耗时
多数操作系统中:默认时间片很短,是否抢占当前进程完全由进程优先级和是否有时间片决定。
Linux中的CFS调度器:不是直接分配的时间片,而是将处理器的使用比划分给进程,nice值作为权重将重新调整进程对处理器的使用比,nice值更高,优先级更低,权重更低,会失去一部分处理器使用比。抢占时机取决于新程序消耗了多少处理器使用比(消耗的比当前少就抢占)
4.4 Linux调度算法
调度器类
Linux调度器以模块方式提供,允许不同类型的进程可以有针对性的选择调度算法,称为调度器类
调度器类:允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器都有优先级,基础的调度器代码定义在kernel/sched.c
中,按优先级遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出,选择下一个执行的进程。
CFS算法定义在kernel/sched_fair.c
中,是一个针对普通进程的调度类。
Unix 系统中的进程调度
现代进程调度器有两个通用的概念:进程优先级和时间片。
- 时间片是指进程运行多少时间,进程一旦启动就会有一个默认时间片。
- 具有更高优先级的进程将运行得更频繁,而且也会被赋予更多的时间片。
优先级以nice值形式输出给用户空间带来的问题:
第一个问题,若要将 nice 值映射到时间片,就必然需要将nice 单位值对应到处理器的绝对时间。但这样做将导致进程切换无法最优化进行。
第二个问题涉及相对nice值,同时和前面的nice值到时间片映射关系也有关系。
解决方式:将nice值呈几何增加而非算数增加
第三个问题,如果执行nice值到时间片的映射,我们需要能分配一个绝对时间片,而且这个绝对时间片必须能在内核的测试范围内。
解决方式:采用一个新的度量机制将从nice 值到时间片的映射与定时器节拍分离开来.
第四个问题是关于基于优先级的调度器为了优化交互任务而唤醒相关进程的问题。
实质问题一一即分配绝对的时闽片引发的固定的切换频率,给公平性造成了很大变数。
CFS 采用的方法是对时间片分配方式进行根本性的重新设计——完全摒弃时间片而是分配给进程一个处理器使用比重。通过这种方式, CFS 确保了进程调度中能有恒定的公平性,而将切换频率置于不断变动中。
公平调度
- CFS 在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice 值来计算时间片。 nice 值在 CFS 中被作为进程获得的处理器运行比的权重。
- CFS为完美多任务中的无限小调度周期的近似值设立了一个目标。“目标延迟”——越小的调度周期将带来越好的交互性,同时也更接近完美的多任务。但必须承受更高的切换代价和更差的系统总吞吐能力。
- 最小粒度:CFS引人每个进程获得的时间片底线,这个底线称为最小粒度。
- 只有相对值才会影响处理器时间的分配比例。
- 任何进程所获得的处理器时间是由它自己和其他所有可运行进程nice值的相对差值决定的。
CFS称为公平调度器是因为它确保给每个进程公平的处理器使用比。它在多进程环境下,降低了调度延迟带来的不公平性。
4.5 Linux调度的实现
CFS的相关代码位于文件 kernel/sched_fair.c
中。其四个组成部分:
- 时间记账
- 进程选择
- 调度器入口
- 睡眠和唤醒
时间记账
CFS 使用调度器实体结构来追踪进程运行记,定义在文件<linux/sched.h>
的 struct_ sched_ entity
中。调度器实体结构作为一个名为se的成员变量,嵌入在进程描述符struct task_struct
中
虚拟实时
vruntime变量存放进程的虚拟运行时间(被加权的)。CFS 使用 vruntime 变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。
定义在kemel/sched_ fair.c
文件中的 update_ curr()
函数实现了该记账功能。
update_ curr()
计算了当前进程的执行时间,并且将其存放在变量delta_ exec
中。delta_ exec
将运行时间传给update_ curr()
,进行加权计算,最后将权重值与vruntime 变量相加。
update_ curr()是由系统定时器周期性调用的,根据这种方式,vruntime可以准确地测量给定进程的运行时间,而且可知道谁应该是下一个被运行的进程。
进程选择
CFS 调度算法的核心:选择具有最小vruntime的任务。
CFS 使用红黑树来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。
1、rbtree自平衡搜索树,最左边叶子节点代表最小vruntime值的进程(要挑选的下一个任务)
实现这一过程的函数是_ pick_ next_ entity() ,它定义在文件kemel/sched_ fair.c中。
2、enqueue_entity()函数实现了将进程加入rbtree 中,以及如何缓存最左叶子节点。(发生在进程变为可运行状态或被唤醒或被fork调用第一次创建进程时)
该函数更新运行时间和其他一些统计数据,然后调用_enqueue_entity()进行繁重的插入操作,把数据项真正插入到红黑树中。
平衡二叉树的基本规则是,如果键值小于当前节点的键值,则需转向树的左分支:相反如果大于当前节点的键值,则转向右分支。
3、删除进程发生在进程堵塞或者终止时,rb_erase()函数完成大部分,剩下是更新rb_leftmost缓存,实际工作是由辅助函数_dequeue_entity()完成的。
调度器入口
- 进程调度的主要入口点是函数
schedule()
,它定义在文件kernel/sched.c
中,也是内核其他部分用子调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。 - Schedule()先找到一个最高优先级的调度类,后者有自己的可运行队列,从中找下一个该运行的进程。
- 该函数中唯一重要的事情是它会调用
pick_ next_ task()
也定义在文件kernel/sched.c
中) pick_ next_task()
会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类中,选择最高优先级的进程。
睡眠和唤醒
休眠进程处于一个特殊的不可执行状态,为等待某些事件发生。
- 休眠:进程把自己标记为休眠状态,从可执行红黑树中移出,放入等待队列,调用schedule选择和执行下一个进程。
- 唤醒:进程被设置为可执行状态,从等待队列中移到可执行红黑树。wake_up()函数执行
- wake_ up()调用函数try_ to_ wake_ up(),将进程设置为TASK_ RUNNING 状态。
- 通常哪段代码促使等待条件达成,它就要负责随后调用wake_up()函数。
- 等待队列:由等待某些事件发生的进程组成的简单链表,wake_ queue_ head_t代表等待队列
- 加入到等待队列:
- 1、调用宏 DEFINE_ WAIT()创建一个等待队列的项。
- 2、调用 add_ wait_ queue()把自己加入到队列中。
- 3、调用 prepare_ to_ wait()方法将进程的状态变更为TASK_ INTERRUPTIBLE 或TASK_ UNINTERRUPTIBLE。
- 4、如果状态被设置为TASK_INTERRUPTIBLE,则信号唤醒进程。
- 5、当进程被唤醒的时候,它会再次检查条件是否为真。
- 6、当条件满足后,进程将自己设置为 TASK_ RUNNING 并调用 finish_ wait()方法把自己移出等待队列。
具体休眠和唤醒之间的转换关系:
4.6 抢占和上下文切换
上下文切换:从一个可执行进程切换到另一个可执行进程,由定义在 kernel/ sched.c 中的 context_switch()
函数负责处理,schedule()会调用该函数,完成了两项基本的工作:
- 用声明在 <asm/mmu_ context.h>中的
switch_ mm()
, 该函数负责把虚拟内存从上一个进程映射切换到新进程中。 - 调用声明在 <asm/system.h> 中的
switch_to()
,该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。
内核必须知道什么时候调用schedule(),通过need_ resched
标志:表明是否需要重新执行一次调度。
- 当某个进程应该被抢占时, scheduler_ tick()就会设置这个标志。
- 当一个优先级高的进程进入可执行状态的时候,try_ to_ wake_ up()也会设置这个标志。
- 每个进程都有need_ resched标志,在进程描述符中,访问快。
用户抢占
用户抢占在以下情况时产生:
- 从系统调返回用户空间时
- 从中断处理程序返回用户空间时
这时内核会检查need_ resched标志,被设置了就会调用schedule,发生用户抢占。
entry_S:包含内核入口部分的程序,内核退出部分的相关代码。
内核抢占
内核抢占会发生在:
- 中断处理程序正在执行,且返回内核空间之前
- preempt_count为0表示没有持有锁,内核可以被抢占。
- 这时检查need_ resched标志和preempt_count,前者被设置,后者为0时,有一个重要的任务需要执行,内核可以安全的被抢占。
- 内核代码再一次具有可抢占性的时候。
- preempt_ count重新变为0,释放锁的代码检查need_ resched标志,如果被设置也会进行调度程序。
- 如果内核中的任务阻塞或任务显式地调用 schedule()。这里内核清楚自己可以被安全的抢占。
4.7 实时调度策略
Linux 提供了两种实时调度策略: SCHED_ FIFO 和 SCHED_ RR(都是静态优先级)普通的、非实时的调度策略是SCHED_ NORMAL。具体的实现定义在文件 kemel/sched_rt.c. 中。
- SCHED_ FIFO 实现了一种简单的、先入先出的调度算法:它不使用时间片。
- 处于可运行状态的SCHED_ FIFO比任何SCHED_ NORMAL都先得到调度。
- 只有更高优先级的 SCHEDFIFO 或者 SCHEDRR任务才能抢占 SCHED_FIFO 任务。
- SCHED_ RR 是带有时间片的 SCHED_FIFO-这是一种实时轮流调度算法。
- 时间片用来重新调度同一优先级的进程。
- 高优先级可以立即抢占低优先级。
- 低优先级不能抢占,即使当前进程时间片耗尽。
Linux 的实时调度算挂提供了一种软实时工作方式.软实时的含义是,内核调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程的要求.相反,硬实时系统保证在一定条件下,可以满足任何调度的要求。
4.8 与调度相关的系统调用
Linux提供了一个系统调用族,用于管理与调度程序相关的参数:
与调度策略和优先级相关的系统调用
- sched_ setscheduler() 和 sched_ getscheduler()分别用于设置和获取进程的调度策略和实时优先级。
- 最重要的工作在于读取或改写进程tast_ struct的policy和rt_priority 的值。
- sched_ setparam()和sched_ getparam()分别用于设置和获取进程的实时优先级。
- 这两个系统调用获取封装在sched_ param特殊结构体的rt_ priority中。
- 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 调度程序提供强制的处理器绑定机制。保存在进程task_ struct的cpus_ allowed这个位掩码标志中,每一位对应一个系统可用的处理器。
- sched_ setaffinity()设置不同的位掩码,sched_ getaffinity()返回当前的cpus_ allowed位掩码。
内核提供的强制处理器绑定方法:
- 当处理进行第一次创建时,它继承了其父进程的相关掩码。由于父进程运行在指定处理器上,子进程也运行在相应处理器上。
- 当处理器绑定关系改变时,内核会采用“移植线程”把任务推到合曲的处理器上。
- 加载平衡器只把任务拉到允许的处理器上,因此,进程只运行在指定处理器上,对处理器的指定是由该进程描述符的 cpus_ allowed 域设置的。
放弃处理器时间
sched_ yield()系统调用:提供了一种让进程显式地将处理器时间让给其他等待执行进程的机制。
它是通过将进程从活动队列中移到过期队列中实现的。
效果是抢占了该进程并把它放入优先级队列的最后面,还将其放入过期队列,这样确保在一段时间内它不会再被执行了。(实时进程不会过期)
内核代码为了方便,可以直接调用 yield(),用户空间的应用程序直接使用 sched_ yield()系统调用就可以了。