多线程安全与调度

多线程安全:
不是线程的安全
面试官问:“什么是线程安全”,如果你不能很好的回答,那就请往下看吧。
论语中有句话叫“学而优则仕”,相信很多人都觉得是“学习好了可以做官”。然而,这样理解却是错的。切记望文生义。
同理,“线程安全”也不是指线程的安全,而是指内存的安全。为什么如此说呢?这和操作系统有关。
目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。
在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
假设某个线程把数据处理到一半,觉得很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是自己离开时的样子了。可能被其它线程修改了。
比如把你住的小区看作一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。
因为公共区域人来人往,你放的东西在没有看管措施时,一定是不安全的。内存中的情况亦然如此。
所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。
即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。
那我们该怎么办呢?解决问题的过程其实就是一个取舍的过程,不同的解决方案有不同的侧重点。
私有的东西就不该让别人知道
现实中很多人都会把1万块钱藏着掖着,不让无关的人知道,所以根本不可能扔到大马路上。因为这钱是你的私有物品。
在程序中也是这样的,所以操作系统会为每个线程分配属于它自己的内存空间,通常称为栈内存,其它线程无权访问。这也是由操作系统保障的。
如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是局部变量。
double avgScore(double[] scores) {
double sum = 0;
for (double score : scores) { sum += score; }
int count = scores.length;
double avg = sum / count;
return avg;
}
这里的变量sum,count,avg都是局部变量,它们都会被分配在线程栈内存中。
假如现在A线程来执行这个方法,这些变量会在A的栈内存分配。与此同时,B线程也来执行这个方法,这些变量也会在B的栈内存中分配。
也就是说这些局部变量会在每个线程的栈内存中都分配一份。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其它线程根本就不知道。
就像每个人的家只属于自己,其他人不能进来。所以你把1万块钱放到家里,其他人是不会知道的。且一般还会放到某个房间里,而不是仍在客厅的桌子上。
所以把自己的东西放到自己的私人地盘,是安全的,因为其他人无法知道。而且越隐私的地方越好。
大家不要抢,人人有份
相信聪明的你已经发现,上面的解决方案是基于“位置”的。因为你放东西的“位置”只有你自己知道(或能到达),所以东西是安全的,因此这份安全是由“位置”来保障的。
在程序里就对应于方法的局部变量。局部变量之所以是安全的,就是因为定义它的“位置”是在方法里。这样一来安全是达到了,但是它的使用范围也就被限制在这个方法里了,其它方法想用也不用了啦。
现实中往往会有一个变量需要多个方法都能够使用的情况,此时定义这个变量的“位置”就不能在方法里面了,而应该在方法外面。即从(方法的)局部变量变为(类的)成员变量,其实就是“位置”发生了变化。
那么按照主流编程语言的规定,类的成员变量不能再分配在线程的栈内存中,而应该分配在公共的堆内存中。其实也就是变量在内存中的“位置”发生了变化,由一个私有区域来到了公共区域。因此潜在的安全风险也随之而来。
那怎么保证在公共区域的东西安全呢?答案就是,大家不要抢,人人有份。设想你在街头免费发放矿泉水,来了1万人,你却只有1千瓶水,结果可想而知,一拥而上,场面失守。但如果你有10万瓶水,大家一看,水多着呢,不用着急,一个个排着队来,因为肯定会领到。
东西多了,自然就不值钱了,从另一个角度来说,也就安全了。大街上的共享单车,现在都很安全,因为太多了,到处都是,都长得一样,所以连搞破坏的人都放弃了。因此要让一个东西安全,就疯狂的copy它吧。
回到程序里,要让公共区域堆内存中的数据对于每个线程都是安全的,那就每个线程都拷贝它一份,每个线程只处理自己的这一份拷贝而不去影响别的线程的,这不就安全了嘛。相信你已经猜到了,我要表达的就是ThreadLocal类了。
class StudentAssistant {
ThreadLocal realName = new ThreadLocal<>();
ThreadLocal totalScore = new ThreadLocal<>();
String determineDegree() {
double score = totalScore.get();
if (score >= 90) { return “A”; }
if (score >= 80) { return “B”; }
if (score >= 70) { return “C”; }
if (score >= 60) { return “D”; }
return “E”;
}
double determineOptionalcourseScore() {
double score = totalScore.get();
if (score >= 90) { return 10; }
if (score >= 80) { return 20; }
if (score >= 70) { return 30; }
if (score >= 60) { return 40; }
return 60;
}
}
这个学生助手类有两个成员变量,realName和totalScore,都是ThreadLocal类型的。每个线程在运行时都会拷贝一份存储到自己的本地。
A线程运行的是“张三”和“90”,那么这两个数据“张三”和“90”是存储到A线程对象(Thread类的实例对象)的成员变量里去了。假设此时B线程也在运行,是“李四”和“85”,那么“李四”和“85”这两个数据是存储到了B线程对象(Thread类的实例对象)的成员变量里去了。
线程类(Thread)有一个成员变量,类似于Map类型的,专门用于存储ThreadLocal类型的数据。从逻辑从属关系来讲,这些ThreadLocal数据是属于Thread类的成员变量级别的。从所在“位置”的角度来讲,这些ThreadLocal数据是分配在公共区域的堆内存中的。
说的直白一些,就是把堆内存中的一个数据复制N份,每个线程认领1份,同时规定好,每个线程只能玩自己的那份,不准影响别人的。
需要说明的是这N份数据都还是存储在公共区域堆内存里的,经常听到的“线程本地”,是从逻辑从属关系上来讲的,这些数据和线程一一对应,仿佛成了线程自己“领地”的东西了。其实从数据所在“位置”的角度来讲,它们都位于公共的堆内存中,只不过被线程认领了而已。这一点我要特地强调一下。
其实就像大街上的共享单车。原来只有1辆,大家抢着骑,老出问题。现在从这1辆复制出N辆,每人1辆,各骑各的,问题得解。共享单车就是数据,你就是线程。骑行期间,这辆单车从逻辑上来讲是属于你的,从所在位置上来讲还是在大街上这个公共区域的,因为你发现每个小区大门口都贴着“共享单车,禁止入门”。哈哈哈哈。
共享单车是不是和ThreadLocal很像呀。再重申一遍,ThreadLocal就是,把一个数据复制N份,每个线程认领一份,各玩各的,互不影响。
只能看,不能摸
放在公共区域的东西,只是存在潜在的安全风险,并不是说一定就不安全。有些东西虽然也在公共区域放着,但也是十分安全的。比如你在大街上放一个上百吨的石头雕像,就非常安全,因为大家都弄不动它。
再比如你去旅游时,经常发现一些珍贵的东西,会被用铁栅栏围起来,上面挂一个牌子,写着“只能看,不能摸”。当然可以国际化一点,“only look,don’t touch”。这也是很安全的,因为光看几眼是不可能看坏的。
回到程序里,这种情况就属于,只能读取,不能修改。其实就是常量或只读变量,它们对于多线程是安全的,想改也改不了。
class StudentAssistant {
final double passScore = 60;}
比如把及格分数设定为60分,在前面加上一个final,这样所有线程都动不了它了。这就很安全了。
小节一下:以上三种解决方案,其实都是在“耍花招”。
第一种,找个只有自己知道的地方藏起来,当然安全了。
第二种,每人复制1份,各玩各的,互不影响,当然也安全了。
第三种,更狠了,直接规定,只能读取,禁止修改,当然也安全了。
是不是都在“避重就轻”呀。如果这三种方法都解决不了,该怎么办呢?Don’t worry,just continue reading。
没有规则,那就先入为主
前面给出的三种方案,有点“理想化”了。现实中的情况其实是非常混乱嘈杂的,没有规则的。
比如在中午高峰期你去饭店吃饭,进门后发现只剩一个空桌子了,你心想先去点餐吧,回来就坐这里吧。当你点完餐回来后,发现已经被别人捷足先登了。
因为桌子是属于公共区域的物品,任何人都可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。
解决方法就不用我说了吧,让一个人在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就可以理直气壮的说,“不好意思,这个座位,我,已经占了”。
我再次相信聪明的你已经猜到了我要说的东西了,没错,就是(互斥)锁。
回到程序里,如果公共区域(堆内存)的数据,要被多个线程操作时,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据,先获取锁再说吧。
假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。于是它就拿到了这把锁,然后开始操作数据,干了一会活,累了,就去休息了。
这时,又来了一个线程,发现锁被别人持有着,按照规定,它不能操作数据,因为它无法得到这把锁。当然,它可以选择等待,或放弃,转而去干别的。
第一个线程之所以敢大胆的去睡觉,就是因为它手里拿着锁呢,其它线程是不可能操作数据的。当它回来后继续把数据操作完,就可以把锁给释放了。锁再次回到空闲状态,其它线程就以来抢这把锁了。还是谁先抢到锁谁操作数据。
class ClassAssistant {
double totalScore = 60; final Lock lock = new Lock();
void addScore(double score) { lock.obtain(); totalScore += score; lock.release(); }
void subScore(double score) { lock.obtain(); totalScore -= score; lock.release(); }}
假定一个班级的初始分数是60分,这个班级抽出10名学生来同时参加10个不同的答题节目,每个学生答对一次为班级加上5分,答错一次减去5分。因为10个学生一起进行,所以这一定是一个并发情形。
因此加分和减分这两个方法被并发的调用,它们共同操作总分数。为了保证数据的一致性,需要在每次操作前先获取锁,操作完成后再释放锁。
相信世界充满爱,即使被伤害
再回到一开始的例子,假如你往地上仍1万块钱,是不是一定会丢呢?这要看情况了,如果是在人来人往的都市,可以说肯定会丢的。如果你跑到无人区扔地上,可以说肯定不会丢。
可以看到,都是把东西无保护的放到公共区域里,结果却相差很大。这说明安全问题还和公共区域的环境状况有关系。
比如我把数据放到公共区域的堆内存中,但是始终都只会有1个线程,也就是单线程模型,那这数据肯定是安全的。
再者说,2个线程操作同一个数据和200个线程操作同一个数据,这个数据的安全概率是完全不一样的。肯定线程越多数据不安全的概率越大,线程越少数据不安全的概率越小。取个极限情况,那就是只有1个线程,那不安全概率就是0,也就是安全的。
可能你又猜到了我想表达的内容了,没错,就是CAS。可能大家觉得既然锁可以解决问题,那就用锁得了,为啥又冒出了个CAS呢?
那是因为锁的获取和释放是要花费一定代价的,如果在线程数目特别少的时候,可能根本就不会有别的线程来操作数据,此时你还要获取锁和释放锁,可以说是一种浪费。
针对这种“地广人稀”的情况,专门提出了一种方法,叫CAS(Compare And Swap)。就是在并发很小的情况下,数据被意外修改的概率很低,但是又存在这种可能性,此时就用CAS。
假如一个线程操作数据,干了一半活,累了,想要去休息。(貌似今天的线程体质都不太好)。于是它记录下当前数据的状态(就是数据的值),回家睡觉了。
醒来后打算继续接着干活,但是又担心数据可能被修改了,于是就把睡觉前保存的数据状态拿出来和现在的数据状态比较一下,如果一样,说明自己在睡觉期间,数据没有被人动过(当然也有可能是先被改成了其它,然后又改回来了,这就是ABA问题了),那就接着继续干。如果不一样,说明数据已经被修改了,那之前做的那些操作其实都白瞎了,就干脆放弃,从头再重新开始处理一遍。
所以CAS这种方式适用于并发量不高的情况,也就是数据被意外修改的可能性较小的情况。如果并发量很高的话,你的数据一定会被修改,每次都要放弃,然后从头再来,这样反而花费的代价更大了,还不如直接加锁呢。
这里再解释下ABA问题,假如你睡觉前数据是5,醒来后数据还是5,并不能肯定数据没有被修改过。可能数据先被修改成8然后又改回到5,只是你不知道罢了。对于这个问题,其实也很好解决,再加一个版本号字段就行了,并规定只要修改数据,必须使版本号加1。
这样你睡觉前数据是5版本号是0,醒来后数据是5版本号是0,表明数据没有被修改。如果数据是5版本号是2,表明数据被改动了2次,先改为其它,然后又改回到5。
我再次相信聪明的你已经发现了,这里的CAS其实就是乐观锁,上一种方案里的获取锁和释放锁其实就是悲观锁。乐观锁持乐观态度,就是假设我的数据不会被意外修改,如果修改了,就放弃,从头再来。悲观锁持悲观态度,就是假设我的数据一定会被意外修改,那干脆直接加锁得了。
作者观点:
前两种属于隔离法,一个是位置隔离,一个是数据隔离。
然后两种是标记法,一个是只读标记,一个是加锁标记。
最后一种是大胆法,先来怼一把试试,若不行从头再来。
对于大胆法,还是有必要尝试的。有人曾说过,“梦想还是要有的,万一实现了呢”。

中断与调度:
进程调度总结:
当计算机系统处于就绪状态的用户进程数多于CPU数时,就会产生多个进程或线程同时竞争CPU的结果。假设现在只有一个CPU可用,那么操作系统就必须选择下一个要运行的进程。完成这种选择工作的这一部分称为调度程序,该程序使用的算法称为调度算法。
尽管有一些不同,但许多适用于进程调度的处理方法也同样适用于线程调度。当内核管理线程的时候,调度经常是按线程级别的,与线程所属的进程基本或根本没有关联。
调度时机:
在执行具体的调度程序之前,操作系统必须要解决一个关键的问题:何时进行调度决策?
有以下情形需要调度处理:
1、 在创建一个新进程之后,需要决定是运行父进程还是运行子进程。由于这两种进程都处于就绪状态,所以这是一种正常的调度决策,可以任意决定。
2、在一个进程退出时必须做出调度决策。一个进程不再运行,所以必须从就绪进程集中选择另外某个进程。如果没有就绪的进程,通常会运行一个系统提供的空闲进程。
3、当一个进程在阻塞I/O和信号量上或由于其他原因阻塞时,必须选择另一个进程运行。
4、在一个I/O中断发生时,必须做出调度决策。如果中断来自I/O设备,而该设备现在完成了工作,某些阻塞的等待该I/O进程就成为可运行的就绪进程了。是否让新就绪的进程运行,这取决于调度程序的决定 ,或者让中断发生时运行的进程继续运行,或者应该让某个其他进程运行。
注:区分一个多任务分时系统是抢占式的还是非抢占式的,则要看进程能否在(4)发生中断时,能否产生调度(抢占)。
调度算法的分类
根据如何处理时钟中断,可以把调度算法分为两类:抢占式或非抢占式
抢占式
抢占式调度算法挑选一个进程,并且让该进程运行某个固定时段的最大值。如果在该时段结束时,该进程仍在运行,它就被挂起,而调度程序挑选另一个进程运行(如果存在一个就绪进程)。进行抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把CPU控制返回给调度程序,如果没有可用的时钟,那么非抢占式调度就是惟一的选择了。
这里有一个有趣的问题,就是这个固定时段的最大值设置为多少合适?
假如进程切换需要5毫秒,最大值为20毫秒,那么在做完20毫秒有用的工作之后,CPU将花费5毫秒来进行进程切换,在100毫秒内CPU时间就有20%被浪费在了进程切换上。
为了提高CPU效率,我们将最大值设为500毫秒,这时浪费的时间只有1%。但考虑在一个分时系统中,如果有十个交互用户几乎同时按下回车键,将发生什么情况?假设所有其他进程都用足它们的时间,最后一个不幸的进程不得不等待5秒钟才获取运行机会,这通常是用户无法忍受的。
所以这里得出一个结论:时间设得太短会导致过多的进程切换,降低CPU效率;而设得太长又可能引起对短的交互请求的响应变差。将时间片设为20ms-50ms通常是一个比较合理的折中。
非抢占式
非抢占式调度算法挑选一个进程,然后让该进程运行直至阻塞(阻塞在I/O上或等待另一个进程),或者直到该进程自动释放CPU。即使该进程运行了若干个小时,它也不会被强迫挂起。这样做的结果是,在时钟中断发生时不会进行调度。在处理完时钟中断后,如果没有更高优先级的进程等待到来,则被中断的进程会继续执行。
非抢占式调度最大的问题就是:每个进程需要多少CPU时间就占用多少CPU时间,只有进程主动释放CPU时,其他的进程才可以使用CPU。假如一个进程在执行一个耗时的操作,这就使其它进程一直处于“饿死”状态。
注:Windows3.x是使用的非抢占式,Windows95以后及UNIX,Linux最新内核(参见)都是使用的抢占式。
系统分类
不同的系统环境需要不同的调度算法,我们常常把系统分为以下三类
批处理系统
批处理系统,又名批处理操作系统。
所谓批处理是指用户将作业按照它位的性质分组(或分批),然后再成组(或成批)地提交给计算机系统,由计算机自动完成后再输出结果,从而减少作业建立和结束过程中的时间浪费。根据存放的作业数又可以将批处理系统分为:
单道批处理系统
多道批处理系统
在批处理系统中,不会有用户有不耐烦地在终端旁等待一个短请求的快捷响应。因此,非抢占式算法,或对每个进程都有长时间周期的抢占式算法,通常都是可以接受的。这种处理方式减少了进程的切换从而改善性能。
交互式系统
交互式操作系统是为达到人机交互目的而为机器所编写的操作系统。常见的交互操作系统有Windows,DOS等,在交互式系统当中,最常见的应该是:分时操作系统。
分时操作系统
在操作系统中采用分时技术的系统被称为分时系统,分时操作系统也被称为“事务处理使用的交互式操作系统”。
所谓分时技术就是把处理器的运行时间分成很短的时间片,按时间片轮流把处理器分配给各联机作业使用。若某个作业在分配给它的时间片内不能完成其计算,则该作业暂时停止运行,把处理器让给其他作业使用,等待下一轮再继续运行。由于计算机速度很快,作业运行轮转得很快,给每个用户的感觉好像是自己独占一台计算机。
在交互式用户环境中,为了避免一个进程霸占CPU拒绝为其他进程服务,抢占是必震的。即便没有进程想永远运行,但是,某个进程由于一个程序错误也可能无限期的排斥所有其他进程。为了避免这种现象发生,抢占也是必要的。服务器也归于此类,因为通常它们要服务多个突发的(远程)用户。
实时系统
为了能在某个时间限制内完成某些紧急任务而不需时间片排除,诞生了实时操作系统。这里面的时间限制可以分为两种情况:
1、如果某个动作必须绝对地在规定的时刻(或规定的时间范围)发生,则称为硬实时系统。例如,飞行器的飞行自动控制系统,这类系统必须提供绝对保证,让某个特定的动作在规定的时间内完成。
2、如果能够授受偶尔违反时间规定,并且不会引起任何永久性伤害,则称为软实时系统,如飞机订票系统,银行管理系统。
在实时操作系统的控制下,计算机系统接收到外部信号后及时进行处理,并且要在严格的时限内处理完成接收事件。
在实时操作系统中,抢占有时是不需要的,因为进程了解它们可能会长时间得不到运行,所以通常很快地完成各自的工作并阻塞。实时系统与交互式系统的差别是,实时系统只运行那些用来推进现有应用的程序,而交互式系统是通用的,它可以运行任意的非协作甚至是有恶意的程序。
实时操作系统的主要特点是及时性和可靠性。
调度算法的目标
为了设计调度算法,有必要考虑什么是一个好的调度算法?某些目标取决于环境(批处理、交互式或实时),但是还有一些目标是适用于所有情形的。
1、所有系统
公平:给每个进程公平的CPU份额
相似的进程应该得到相似的服务。对一个进程给予较其他等价的进程更多的CPU时间是不公平的。当然,不同类型的进程可以采用不同的处理方式。
策略强制执行:看到所宣布的策略执行
如果局部策略是,只要需要就必须运行安全控制进程(即便这意味着推迟30秒钟发薪),那么调度程序就必须保证能够强制执行该策略。
平衡:保持系统的所有部分都忙碌
如果CPU和所有I/O设备能够始终的运行,那么相对于让某些部件空转而言,每秒钟就可以完成更多的工作。
例如,在批处理系统中,调度程序控制哪个作业调入内存运行。在内存中既有一些CPU密集型进程又有一些I/O密集型进程是一个较好的想法。如果先调入所有CPU密集型进程,完成之后再调入所有I/O密集型进程,那么当CPU密集型进程运行时,它们就要竞争CPU,而磁盘却在空转。当I/O密集型进程运行时,它们又要竞争磁盘,而CPU又在空转。
2、批处理系统
吞吐量:每小时最大作业数
把所有的因素考虑进去之后,每小完成50个作业好于每小时完成40个作业。
周转时间:从提交到终止间的最小时间
该数据度量了用户要得到输出所需的平均等待时间,时间越小越好。
CPU利用率:保持CPU始终忙碌
3、交互式系统
响应时间:快速响应请求
均衡性:满足用户的期望
用户对做一件事情需要多长时间总是有一种固有的看法(不过通常不正确)。当认为一个请求很复杂需要较多时间时,用户会接受这个看法,但是当认为一个请求很简单,但也需要较多的时间时,用户就会急躁。
4、实时系统
满足截止时间:避免丢失数据
实时系统的特点是或多或少必须满足截止时间。例如,如果计算机正在控制一个以正常速率产生数据的设备,若一个按时运行的数据收集进程出现失败,会导致数据丢失。所以,实时系统最主要的要求是满足所有的(或大多数)截止时间要求。
可预测性: 在多媒体系统中避免品质降低。
在多数实时系统中,特别是那些涉及多媒体的实时系统中,可预测性是很重要的。偶尔不能满足截止时间要求的问题并不严重,但是如果音频进程运行的错误太多,那么音质就会下降得很快。为了避免这些问题,进程调度程序必须是调度可预测和有规律的。
调度策略
1、先来先服务(FCFS、非抢占式)
使用该算法,进程按照它们请求CPU的顺序使用CPU。基本上,有一个就绪进程的单一队列。当第一个作业从外部进入系统,就立即开始并允许运行它所期望的时间。不会中断该作业,因为它需要很长的时间运行。当其也作业进入时,它们就被安排到队列的尾部。当正在运行的进程被阻塞时,队列中的第一个进程就接着运行。在被阻塞的进程变为就绪时,就像一个新来到的作业一样,排到队列未尾。
优点:易于理解并且便于在程序中运用。在这个算法中,一个单链表记录了所有就绪进程。要选取一个进程运行,只要从该队列的头部移走一个进程即可;要添加一个新的作业或阻塞一个进程,只要把该作业或进程附加在相应队列的末尾即可。
缺点:CPU和I/O设备得不到充分利用。
假如,有一个一次运行1秒钟的CPU密集型进程和每个都要进行1000次磁盘读操作的I/O密集型进程存在。CPU密集型进程运行1秒钟,接着I/O密集型进程开始读一个磁盘块,此时它将会被阻塞,加入到队列未尾。CPU密集型进程接着运行,依次循环,这样I/O密集型进程就要要等1000秒钟才能完成操作。如果有一个调度算法每10ms抢占计算密集型进程,那么I/O进程将在10秒钟内完成而不是1000秒钟,而且还不会对计算密集型进程产生多少延迟。
2、最短作业优先(SJF、非抢占式)
进程开始获取CPU一直运行直到完成或者由于某事件被阻塞放弃CPU,运行结束后从当前就绪队列选择“最短”的进程运行。该调度算法适用于运行时间可以预知的任务。
优点:可以有效减少周转时间
假如有A、B、C、D四个进程,分别要执行A(8),B(4),C(4),D(4)分钟,按照原有次序运行作业:
在这里插入图片描述

则A的周转时间为8分钟,B为12分钟,C为16分钟,D为20分钟,平均(8+12+16+20)/4=14分钟
如果按照最短作业优先次序运行:
在这里插入图片描述

则B的周转时间为4分钟,C为8分钟,D为12分钟,A为20分钟,平均(4+8+12+20)/4=11分钟
缺点:利用短进程,长进程可能由于得不到CPU,而“饿死”。
有必要指出,只有在所有作业都可同时运行的情形下,最短作业优先算法才是最优化的。作为一个反例,考虑5个作业,从A到E,运行时间分别是2、4、1、1和1。它们的到达时间是0、0、3、3和3。 开始只能选择A或B,因为其他三个作业还没有到达。使用最短作业优先,将按照A、B、C、D、E的顺序运行作业,其平均等待时间是4.6。但是,按照B、C、D、E、A的顺序运行作业,其平均等待时间则是4.4。
3、最高响应比优先(HRP、非抢占式)
进程开始获取CPU一直运行直到完成或由于某事件被阻塞放弃CPU,运行结束从当前就绪队列选择最高响应比的进程投入运行。
响应比=(响应时间+运行时间)/运行时间
在响应时间固定的情况下,利于短进程。长进程随着等待时间变长,响应比会提高,因此长进程也能在足够长的时间被调度。
优缺点:利用短进程、长进程不会被饿死。
4、最短剩余时间(SRT、抢占)
使用这个算法,调度程序总是选择剩余运行时间最短的那个进程运行。有关的运行时间必须提前掌握。当一个新的作业到达时,其整个时间同当前进程剩余时间做比较。如果新的进程比当前运行进程需要更少的时间,当前进程就被挂起,而运行新的进程。
优缺点:利于短进程,开销大,不利于长进程。
5、轮转调度
每个进程被分配一个时间段,称为时间片,即允许该进程在该时间段中运行。如果在时间片结束时该进程还在运行,则将剥夺CPU并分配给另一个进程。如果该进程在时间片结束前阻塞或结束,则CPU立即进行切换。
时间片轮转调度很容易实现,调度程序所要做的就是维护一张可运行进程列表,如下图
在这里插入图片描述
当一个进程用完它的时间片后,就被移到队列的末尾,如下图
在这里插入图片描述
此算法是最古老、最简单、最公平且使用最广的算法
6、优先级调度
轮转调度做了一个隐含的假设,即所有的进程同等重要,而拥有和操作多用户计算机系统的人对此有不同的看法。例如,在一所大学里,等级顺序可能是教务长首先,然后是教授、秘书、后勤人员,最后是学生。这种将外部因素考虑在内的需要就导致了优先级调度。其基本思想很清楚:每个进程被赋予一个优先级,允许优先级最高的可运行进程先运行。
为了防止高优先级进程无休止地运行下去,调度程序可以在每个时钟滴答(即每个时钟中断)降低当前进程的优先级。如果这个动作导致该进程的优先级低于次高优先级的进程,则进行进程切换。一个可采用的方法是,每个进程可以被赋予一个允许运行的最大时间片,当这个时间片用完时,下一个次高优先级的进程获得机会运行。
优先级可以静态赋予或动态赋予。例如可以将一个进程的优先级设为100,也可以将一个进程的优先级设为1/f(f为该进程在上一时间片中所占的部分)。
可以很方便地将一组进程按优先级分成若干类,并且在各类之间采用优先级调度,而在各类进程的内部采用轮转调度。如图:
在这里插入图片描述
其调度算法如下:
只要存在优先级为第4类的可运行进程,就按照轮转法为每个进程运行一个时间片,此时不理会较低先级的进程。若第4类进程为空,则按照轮转法运行第3类进程。若第4类和第3类均为空,则按轮转法运行第2类进程。如果不偶尔对优先级进行调整,则低优先级进程很可能会产生饥饿现象。
除了上面介绍的几种算法以外,还有多级队列,多级反馈队列,保证调度,彩票调度等算法,此处不再一一的进行介绍,详细可见参考资料。
策略和机制
到目前为止,我们隐含地假设系统中所有进程分属不同的用户,并且,进程间相互竞争CPU。通常情况下确实如此,但有时也有这样的情况:一个进程有许多子进程并在共控制下运行。例如,一个数据库管理系统可能有许多子进程,每一个子进程可能处理不同的请求,或每一个子进程实现不同的功能(如请求分析,磁盘访问等)。主进程完全可能掌握哪一个子进程最重要(或最紧迫)而哪一个最不重要。但是,以上讨论的调度算法中没有一个算法从用户进程接收有关的调度决策信息,这就导致了调度程序很少能够做出最优的选择。
解决问题的方法是将调度机制与调度策略分离,也就是将调度算法以某种形式参数化,而参数可以由用户进程填写。我们再来看一下数据库的例子。假设内核使用优先级调度算法,但提供一条可供进程设置(并改变)优先级的系统调用。这样,尽管父进程本身并不参与调度,但它可以控制如何调度子进程的细节。在这里调度机制位于内核,而调度策略则由用户进程决定。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值