在中国大学MOOC上学习操作系统
希望看视频可以直接点击 哈工大-操作系统课程MOOC
操作系统的那棵“树”(The Tree of OS)
ideas
- CPU运转,需要将一段程序然后设好PC交给CPU去执行。将CPU用于执行程序
- 但是CPU并没有好好运转,因为它遇到I/O的阻塞。
- 解决这个问题,有了多进程,我们让PC跳转,就可以并发地让CPU执行程序。多进程出现了
- 我们使用栈结构来完成跳转的操作
- 结果遇到了一个栈 + Yield 造成混乱的问题,怎么让PC回到204代码段?
- 使用两个栈+两个用户级的TCB,就可以解决跳转混乱的问题。用户级多线程出现了
- 一直在用户态,没有深入到多核心,当内核上出现了I/O阻塞,CPU依然在空等。
- 所以我们需要在内核级别实现内核栈的切换。内核级多线程出现了
- 线程1从A开始,调用B,并压104代码段入线程1用户栈
- 线程1执行B,调用read,内核被阻塞,准备切换内核,进入内核态,并将压204代码段入线程1内核栈,线程1用户栈的两个指针同时也被保存在线程1内核栈中,并最终将信息保存在线程1TCB中。
- 进入线程2,调用D,并压304代码段入线程2的用户栈。
- 执行D,调用read,内核被阻塞,准备切换内核,将404代码段压入线程2内核栈,并保存各种信息,准备切换内核。
- 当线程1或线程2完成读取操作后,CPU立刻拉进来它们的TCB,继续执行直到结束后切换至另一个TCB,最后两个任务都完成。
- 至此,从让CPU执行一直到内核级多线程的idea都有了。
实现
尝试使用代码实现交替在屏幕上打出A和B(这也是Linux 0.01 版本实现的内容)
- 先实现用户级的A、B交替
- 使用代码表达上面的内容
- 将__NR_fork内容放进%eax
- 使用int 0x80指令中断,进入100号地址
- 将%eax内容(也就是int 0x80返回的值)放入res
- 比较res和0
- 不等于时(父进程),就去执行208号地址指令;等于时,就去执行200号地址的指令,打印A,并跳转至200(实现循环打印)。
- INT进入内核
- 进入内核后,形成内核栈和用户栈
- 执行sys_call(系统调用)
- 开始sys_fork
- 开始copy_process
做出一个新的PCB
把父进程的PCB大部分都写进新PCB里,然后eip(PC)设为100(也就是我们要打印A的那部分指令存的位置),并保存当前栈顶指针到TSS,TSS中eax置为0。 - 开始返回
创建好了PCB后,copy_process返回,上层的sys_fork向外返回,直到system_call(意味着sys_call_table语句,也就是上面使用fork以后,system_call会查表调用fork的系统调用)。system_call开始根据当前PCB是否就绪,如果就绪,开始调度,调度后中断返回,然后退回至用户级线程,此时int 0x80也结束。PC跳回100号地址开始打印A
- 又fork了一个打印B的线程,再fork、再int 0x80、再产生一个PCB和内核栈。
- 出现了两个进程/线程以后,就绪队列就有了两个可以执行的任务。
- 父进程(main)调用wait()以后两个任务按照调度规则交替进行。因为wait系统调用中使用了schedule函数
- schedule代码可以根据自己的理解写。比如这里就是FIFO调度。
- 开始实现切换进程/线程的逻辑
实际上就是把现在的CPU状况“照一张照片”(TSS),然后将新的“照片”(TSS)给CPU,完成切换。 - 根据刚刚A的代码,可以看到首先访问100号地址,进行一系列的比较之后进入200号地址,然后就一直开始循环打印A。
- 问题来了,怎么让B打印出来?
我们必须想办法让schedule算法再次执行一下,以便切换到线程B。
schedule是一个系统调用,我们要想办法进入内核态。
看来,中断是一个不错的办法。
那么应该用那种中断?
时钟中断。
do_schedule:
当定时器大于0时,不做处理,直接返回。
当定时器减到0时,就会触发调度
调度 - schedule + switch_to
- 调度完成
A的时间片用完了,所以在100号地址的指令中,res不再等于0(进入了非就绪态),跳到208号地址,一直循环打印B。 - 接着B又执行了一段时间,再一次schedule+switch_to
自此,A和B开始交替打印。
CPU调度策略
当进程阻塞时,CPU该如何选择切换的那个进程,切换的策略实际上就是CPU的调度策略。
直观想法
- FIFO:
- 谁先来就先调度谁:简单有效,比如食堂、银行这样的排队
- 但是如果只是去银行简单地询问业务的人会不会吃亏?
- Priority
- 任务短的d可以适当优先
- 但是这个人询问的时间越来越长怎么办?降低优先级!
- 如果时间很长是因为客户需要填写一个很长的表(长I/O)该怎么办?
如何设计好调度算法?
可以总结出调度算法的评价策略:
- 周转时间(执行时间)
- 响应时间(等待时间)
- 系统吞吐量(完成任务数量)
如何做到合理?
- 吞吐量和响应时间之间有矛盾
响应时间小意味着切换次数多,切换次数多意味着CPU的效率降低了,将造成吞吐量小 - 前台任务和后台任务的关注点不同
前台任务更关注响应时间(用户需要快速反馈),后台任务更关注周转时间(不能死等着) - I/O约束型任务和CPU约束型任务有各自的特点
一般让I/O先执行,因为I/O基本上就占用一小段CPU时间,然后就要去被I/O阻塞,CPU控制权就交给了其他任务。其实一般的前台任务都是I/O约束型任务,后台任务一般都是CPU约束型任务。 - 算法实现要尽量“漂亮”(老师后来提到的)
算法应当用最轻量的数据结构和最快速的逻辑实现,不能调用一下这个算法就占用很大的内存和CPU时间。
FCFS(First Come First Serve)
先来先服务,最简单的调度算法
SJF(Shortest Job First)与RR(Roud-robin)
最短任务优先
吞吐量变大,响应时间变小,平均周转时间维持不变。
RR:轮询调度(时间片轮转),是FCFS和SJF的折中,降低了响应时间。
当时间片太长时:比如给一个无限大的时间片,那RR就是FCFS算法,响应时间又变长了。
当时间片太小时:比如给0.0000001ms的时间片,那每个任务就光切换了,吞吐量变低。
所以在上图的任务中,可以折中地将时间片调到10-100ms,达到一个不错的效果。
PSA(Priority Scheduling Algorithm)
优先级调度
当响应时间和周转时间同时有要求该怎么办?
直观想法:定义两个队列,一个前台任务队列,一个后台任务队列,前台RR后台SJF,并优先执行前台任务。
再次推广一下,我们引入了优先级的概念,也就是前台任务优先级更高,后台任务优先级低一些。
静态优先级的使用(比如一直存在前台任务),会造成后台任务饥饿,当后台任务已经因为饥饿而超出我们可以接受的时间时,这个后台任务就饿死了。
显然我们应该使用动态的优先级来处理优先级调度的问题。但依然有问题没有解决:
- 后台任务优先级动态变高,但后台任务一旦执行,前台的响应时间又拉长了。
- 前后台都是用RR,那我们何必要设计优先级调度呢?后台如何实现SJF?前台任务不还是拉长了响应时间?
- 怎么知道哪些是前台任务,哪些是后台任务?fork时通知吗?
- gcc也需要交互,ctrl+c中断执行怎么实现?word也依然会批处理,比如ctrl+f搜索,这些问题怎么解决?
- SJF算法如何判断作业的长度?
- …
调度算法实例
Linux0.11的调度算法
- 设置一些变量 c为-1,next为0,i指向NR_TASKS的末尾
- p指向PCB数组的末尾
- p借助i指针,获取当前PCB的数据
- 开始遍历PCB数组
- 如果PCB的状态为就绪(TASK_RUNNING)并且PCB的counter>c,c就等于此时PCB的counter,next指向i。就是求最大的counter和其关联的PCB
- 如果这个counter不为0,就跳出循环,即找到了这个进程。
- 否则执行修改所有进程的counter的:所有counter除以2(右移一位)并加上counter的初始值(priority)
- 根据next直接切换
可见counter就起到了优先级和时间片的作用。而修改counter的步骤,会使得阻塞后的进程一定会优先执行,因为时间片和优先级都增加了(初值+当前的一半)。
为什么说counter起了时间片作用
在前面看过的时钟中断代码里,我们知道do_timer会不断减小当前进程的counter值,最终将current->couter置为0,显然这个counter就是个时间片。
为什么说counter起了优先级作用
显然在schedule代码中,程序是通过比对counter来找到那个counter最大的PCB并最终切换,这正符合着优先级调度的定义。
并且在程序中,程序会动态地调整这个counter值使得阻塞后的进程优先级一定大于所有的进程(因为在当前时间片一半的基础上又加了一个完整时间片),这里就运用了动态优先级调度策略。
为什么要对阻塞的进程调整高优先级呢?因为前台任务的特征正是I/O密集型任务,这样做保证了前台任务的优先执行,并且随着阻塞的时间越长,优先级越高。
counter起了哪些作用
- counter保证了响应时间的边界
在最开始的时候counter被设置成了priority
c0 = p
c1 = c0 + p = (1/2)p + p = (3/2)p
c2 = c1 + p = (3/4)p + p = (7/4)p
…
cn收敛于2p,保证了最长周转时间有界
以上公式来自于这段代码:
(*p)->counter = ((*p)->counter>>1) + priority
使用右移代替除以2,运算更快 - 照顾了前台进程
当I/O结束以后,可能这时CPU已经调度了很多次了,根据上面算法CPU每调度一次,每个counter都会加上counter的一半再加一个counter,counter会以这种方式开始增长。换句话说,当I/O阻塞的时间越长,它的counter就越大,极大照顾了I/O阻塞的任务,而前台任务才是I/O密集型的,这样就照顾了前台进程的响应时间。 - 后台进程一直按照counter轮转,这个算法的周转时间近似于SJF的周转时间
- 每个进程只需要维护一个轻量的counter变量,简单且高效。