操作系统是一个复杂系统,将来还会面对很多复杂系统,希望通过对操作系统的学习,形成对复杂系统的研究和开发能力。
本部分还介绍了一个实际的调度算法,理解操作系统调度的考虑因素和实现方法。
参考资料:
- 课程:哈工大操作系统(本部分对应 L13 && L14 && L15)
- 实验:操作系统原理与实践_Linux - 蓝桥云课 (lanqiao.cn)
- 笔记:操作系统学习导引 · 语雀 (yuque.com)
1. 操作系统树
操作系统是一个复杂系统,Linux 的作者 Linus 在论坛上画了一棵树(Linux Kernel Source Tree)来描述整个 Linux 系统。
虽然名字是操作系统树,但我没有找到跟老师课件里一样的树,我觉得下面这个图还不错。不过这些结构都是在变化的,理解其结构含义即可。
要开发操作系统的某个模块,心中就必须要有整个源码树的图案。设计操作系统就更需要对整体有一个把握。
2. Linux 0.01 对于进程切换的实验
在 Linux 0.11 之前,Linus 最早写的是 Linux 0.01,效果就是在屏幕上交替地打出AB,这就是操作系统树最开始的那棵小树。
如何交替地打出AB呢?
创建两个进程,分别循环打出A和B
main(){ if(!fork()){while(1)printf("A");} if(!fork()){while(1)printf("B");} wait(); }
fork 的实现:
main(){ mov __NR_fork, %eax int 0x80 100:mov %eax, res cmpl res,0 jne 208 200:printf("A") jmp 200 208: ... 304:wait() }
简单解释一下:
操作系统学习笔记5 中已经讲过,eax中存放的是返回值,如果值为1则是父进程(208位置的程序),值为0则为子进程(200位置的程序),子进程 输出 A
int 0x80 -> sys_fork -> copy_process
int 指令进入内核,形成内核栈和用户栈的图像;system_call 处理中断指令,在 sys_call_table中找到 sys_fork;
copy_process 就是在内核中根据父进程创建子进程的过程,能够实现如下的结构(右边),那么当子进程执行时,屏幕上就出现A 。
子进程创建完毕,父进程返回
system_call中,从call sys_call_table返回后,会判断当前进程/线程是否阻塞,如果阻塞,就要调用reschedule,选择下一个进程并进行进程切换。
之前还提到过,除了判断当前进程的阻塞情况,还需要判断时间片是否用完,下面的代码中没有写。
当我们的调度算法选择到 A 这个子进程,执行后屏幕开始输出A。
类似地,用同样的过程创建 B 子进程的PCB和栈,并返回。与A进程不同的地方在于 eip 指向打印 B 的语句地址。
现在整个内核栈和用户栈的情况如下,子进程A和子进程B的 PCB 就形成了一个就绪队列,等待主进程AB从执行态切出:
父进程执行 wait(),进行等待,而等待的底层功能代码就是将自己的状态变为阻塞,操作系统就会调用 schedule ,将其从CPU中切出,换上 两个子进程其中之一。
schedule 负责调度下一个进程,在这个实验中就是选择两个子进程之一,比如我们选择第一个fork 出的 A进程,接下来 switch_to。
switch_to 的操作就是将当前CPU的现场信息保存到AB的TSS中,将A的TSS中的信息恢复到CPU现场
A 进程开始执行,开始不断地打印 A
现在屏幕上只能打印A,怎么才能交替打印AB呢?
A进程本身不会引发阻塞,不会主动释放CPU控制权,如何切换为B进程?
思考:现在是在用户态打印A,如何在用户态打印B呢?应当进入内核把进程切换过来,如何找到B进程?需要调度schedule,schedule是内核代码,所以需要中断进入内核。(时钟中断)
时钟中断 实现 :
current是当前进程的PCB
current->counter是当前进程的剩余时间片
每次时钟中断(设定),都让当前进程的
current->counter--
如果减到了0(如下图所示那次的时钟中断),那么就调用
schedule()
,从而switch_to 到B进程打印BB 的 TSS 信息 载入 CPU,发生切换五段论(切换如下图)。发现其中的eip=300,就会跳转到 地址为 300 的用户态程序去执行,也就是打印出B;如上图。
接下来不断的时钟中断,当进程B的剩余时间片为0时,就再切换为进程A,eip 指向200 打印 A
以此类推,不断循环,那么屏幕上就交替的输出A和B
到这里,Linux 0.01 —— 简单的操作系统模型就跃然纸上了,也把前面各讲串到了一起。后续的复杂操作系统,也就是在这个模型的基础上继续增添模块和丰富完善。
3. CPU 调度策略
前文以及前面几讲反复提到了 schedule 调度算法,下面挑选其中的一种来了解一下操作系统在这方面需要考虑哪些问题,以及如何实现算法的。
3.1 调度的直观理解
举个例子来解释一下调度这件事:
如下图,PID:1的进程阻塞了,接下来要从PID:2和PID:3的进程中挑一个,挑哪个?
PID:2的进程是刚刚read调用的,PID:3的进程时刚刚时间片用完了的,挑哪个比较好?
这就 涉及 选择哪个进程的问题。CPU调度/进程调度的直观想法就是两种:
- FIFO,先来先得,比如银行、食堂排队
- Priority,提高某些任务的优先级,但需要考虑如何划定优先级。
3.2 调度算法的考虑
我们的调度算法衡量的标准就是 进程的满意程度,量化一点的标准就是 时间:
- 尽快结束任务(对于每个进程来说):从任务进入到任务结束时间(周转时间)短;比如编译一个巨型程序。
- 用户操作尽快响应:响应时间短,即从用户操作到操作系统响应时间短;比如图形化界面的显示。
- 系统内耗时间短:吞吐量,即单位时间完成的任务量;比如执行多项任务的服务器。
调度算法如何在这些标准中间做到合理分配呢?折中、综合。
吞吐量和响应时间之间是有矛盾的:
每个进程的响应时间少-> 进程间的切换次数多-> 系统内耗大 -> 吞吐量小;
前台任务和后台任务的关注点不同:
前台任务关注响应时间,后台任务关注周转时间;
IO约束型任务和CPU约束型任务特点不同:
- IO约束型任务:仅需要较短CPU执行时间,主要等待I/O反应;
- CPU约束型任务:需要较长CPU执行时间;
- 应当IO 约束型任务优先,因为IO任务执行较短时间就会释放CPU控制权,这时放上CPU约束型任务,就相当于前后两者同时进行工作。
- 因此,前台任务(很像IO型)或许应当优先于后台任务(很像CPU型);这也考虑了用户体验。
3.3 基本的调度算法
3.3.1 FCFS && SJF
FCFS,先来先服务,First Come First Served:
- 第一种:平均周转时间 40.2
- 第二种:平均周转时间 35
- 数学原理 排序不等式。
基于排序不等式,引入第二个算法 SJF,短作业优先,这样周转时间理论上是最小的,证明如下,p1被用到的次数最多,而其时间最短:
但是现实中基于有物理意义的排序,这样并不绝对最优。来看看响应时间:
- 如果P2是前文 3.2 所说的 前台任务,需要响应时间极短,但是在上述情境下,P2要等待很长时间。
- 基于缩小响应时间的考虑,提出下一种算法:
3.3.2 RR
RR,轮转时间片,Round Robin。
为了保证每个程序的响应时间,设置时间片(比如下图为10),每个进程时间片用完就切出。这样限制了最长响应时间不会太长(在限制进程个数的情况下)。
但是还存在问题,即 响应时间和周转时间同时存在,上述调度算法没办法让多种类型的任务都满意。
3.3.3 综合的直观想法
- 不同类型的任务放在不同的队列中;
- 例如分为前台任务队列和后台任务队列,前台RR,后台SJF,只有前台任务没有时才调度后台任务
但这种想法还是太简单,因为前台任务在不断产生,可能会引起后台任务的饥饿。所以:
3.3.4 优先级调度
以轮转调度为核心,前后台都用时间片管理,在此基础上增加优先级调度
3.4 一些其他问题
上述分析其实基于很多隐含的假设,而这些假设都是问题:
如何知道哪些任务是前台任务、哪些任务是后台任务?
- 调度算法应当有一点学习机制,能够辨认任务并进行调整;
有些任务横跨前后台,不纯粹是前、后台任务,比如gcc也需要交互、word也会执行批处理程序等等...
算法要辨认前后台任务,还需要随着任务的实际情况变化而变化。
SJF 中,还需要判断任务的时间长短...
4. 一个实际的调度函数
实际的 schedule 是多种基本算法的融合,综合考虑各种情况,也会针对当前设备和操作系统的应用场景,同时也要求算法本身尽可能的简单。
这里探讨的调度函数面向普通的PC机情境,即既有前台任务、也有后台任务;既要考虑响应时间、也要考虑周转时间......
4.1 kernel/sched.c
下面看一个实际的调度函数——Linux0.11的调度函数,既考虑多种因素,也尽量简洁:
首先遍历所有的任务
i=NR_TASKS; while(i--)
如果
state = TASK_RUNNING
,说明是就绪态,并且通过循环赋值比较,找到就绪队列中counter 最大的任务如果就绪任务中最大的 counter==0,说明所有任务的时间片都用完了,就需要重置所有任务的counter,然后再重新遍历
重置的方法:
(*p)->counter=((*p)->counter>>1)+(*p)->priority;
这样重置后会让 阻塞态的counter 较大,如果阻塞态转为就绪态,其counter较大,就会被优先执行,并且其时间片也较大。
特殊情况:如果没有就绪任务,则遍历结束时c==-1,也会break,那这个时候next应该就是初始值task[NR_TASKS],这个任务是进程0.
4.2 时间片设置
作为时间片的衡量,在时钟中断里需要设置 counter 自减,每中断一次就 -1,如果 =0 则进行 reschedule()
. 这种设计就是基于RR的考虑。
4.3 优先级设置
作为优先级的衡量,通过上面的调度算法,实现了进程优先级的动态调整。
所有就绪任务的 counter=0,启动所有任务的重置:
就绪任务的 counter=priority;
阻塞任务的 counter=counter/2 + priority;
每个任务默认的priority继承父进程,linux0.11默认是15
counter 作为优先级调度,照顾了对响应时间要求较高,且占用CPU时间较少的IO进程,那么就蕴含了 3.3 中 SJF 调度的思想。
4.4 响应时间的界 && 总结
- counter 不断上升,不断的迭代,是一个公比为1/2的等比数列,收敛于2p;也即每个进程最长时间片为2p,还能保证轮转时间片的响应时间。
- 总结如下图,整个算法十分简洁有效:
4.5 附:源码以及注释
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm, wake up any interruptible tasks that have got a signal */
for(p= &LAST_TASK ; p> &FIRST_TASK ; --p) {
if (*p) {
if ((*p)->alarm &&(*p)->alarm< jiffies) {
(*p)->signal|= (1<<(SIGALRM-1));
(*p)->alarm =0;
}
if (((*p)->signal& ~(_BLOCKABLE & (*p)->blocked))&&
(*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}
}
/* this is the scheduler proper: */
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS]; // 是从0~NR_TASKS-1吗?那task[NR_TASKS]是什么?
while (--i) { // 遍历所有的任务,从里面找出就绪且counter最大的那一个
if (!*--p)
continue;
if ((*p)->state ==TASK_RUNNING && (*p)->counter> c)
c = (*p)->counter, next =i;
}
// 如果c==-1,则break,所有的任务都是非就绪的?可能会出现这种情况吗?
// 如果c>0,则break,只要至少有一个counter>0的就绪任务,就会break,且next一定是就绪任务中counter最大的那个
if (c) break;
// 如果c=0,表示所有就绪任务的时间片都没有了,需要重置
for(p = &LAST_TASK; p > &FIRST_TASK; --p) {
if (*p)
(*p)->counter= ((*p)->counter>> 1) + (*p)->priority;
}
}
switch_to(next);
}