时间管理能力是作为测试的重要能力之一。我们经常会面临比如这样的情况:多个需求同时提测、一个需求正在测试中另一个紧急需求来插队、一个任务需要另一个任务完成后才能开启、一个需求有明确deadline需要倒排期等等,要在这样复杂的场景中从容不迫完成任务,就必须具备强大的时间管理能力。
当然我们不用闭门造车,CPU调度算法就是一个很好的老师,它是无数优秀工程师心血积累的结晶,可以教会我们如何在错综复杂的源源不断的任务涌来时合理调度资源执行任务。
1."不使用算法"的算法——FCFS(FIRST COME FIRST SCHEDULE)
算法描述:按任务到达的时间顺序依次执行
不作为也是一种作为,不使用算法本身其实也是一个算法,简单说就是对任务一视同仁,排队执行。当工作效率固定时,完成所有任务的总时间为常数与处理的顺序无关,因此这个算法的优点在于“省心”,只要无脑执行即可。然而总处理时间并不是衡量CPU调度的唯一指标,我们通常还需要考虑等待时间、响应时间等维度。
来看下面这个情况,三个任务P1,P2,P3,处理时间分别是8,1,1,如果任务到达的顺序是P1-P2-P3,那执行情况会是下面这样:
这时P2等待时间为8,P3为9,平均等待时间17/3=6.33。
但若任务到达顺序改为P2-P3-P1那情况就大不一样:
此时的平均等待时间=(1+2)/3=1,明显优于上面的情况。
处理时间短的任务跟在时间长的任务后的现象叫做“护航效应”,会导致等待时间增长,CPU效率低下。对应到测试工作中,更长的等待时间就意味着更高的代码、环境空间维护成本和更高的沟通协调成本。因此我们不应只想着总完成时间一样就忽略时间管理的重要性。
2.先做简单的——SJF(SHORTEST JOB FIRST)
算法描述:
1- 开始前,将已经在等待队列中的任务按处理时间从少到多排序;
2- 当前没有正在进行的任务时,执行等待队列中的第一个(处理时间最少的);
3- 当新任务到达时,根据处理时间插入队列中合适的位置
这个算法是针对FCFS容易因护航效应导致等待时间过高的问题进行的优化,每次执行的时候选择最少处理时间的任务从而避免长时间任务被安排在短时间任务之前。来看下面这个场景:
开始时队列中只有P1,而在P1执行过程中,剩下三个任务也依次到达,当P1完成后,CPU依次选择等待队列中处理时间最少的P4-P2-P3执行。平均等待时间:[(11-1)+(15-2)+(8-3)]/4=7。执行过程如下图所示。
如果使用FCFS按P1-P2-P3-P4进行调度,则平均等待时间:[(8-1)+(12-2)+(17-3)]/4=7.75
可以看到,SJF确实缩短了等待时间,提升了效率,不过似乎仍有改进的空间。可以看到第一个任务仍然太长了导致后面三个任务早早到了却无法执行。如果不那么“一根筋”非要把一个任务做完再考虑下一个,而是允许把任务做一半暂停,那效率还可以进一步提高。
3.做最快能完成的活——SRTF(SHORTEST REMAINING TIME FIRST)
算法描述:
1- 开始前,将已经在等待队列中的任务按剩余处理时间从少到多排序;
2- 当前没有正在进行的任务时,执行等待队列中的第一个(剩余处理时间最少的);
3- 执行一个基本单位后,更新当前任务的剩余处理时间;
4- 当新任务到达时,比较其处理时间和当前任务的剩余处理时间,若新任务更少,则打断当前任务,将其放回等待队列,反正新任务进入等待队列。
5- 对等待队列再次排序。
相比于SJF,SRTF算法唯一的不同之处就在于新的任务可以打断当前正在执行的任务进行插队,而不用等一个任务完全结束。
还是看刚才的场景,开始时只有P1在队列中,所以执行P1。当进行完一个时间单位后,P2进入队列,此时P1剩余时间=7,P2剩余时间=4,因此打断P1执行P2。
P2执行一个单位后,P3进入队列,此时剩余时间P2=3,P3=5,不足以打断P2。
再过一个时间单位,P4进入队列,此时剩余时间P2=2,P4=3,继续执行P2,如下图。
当P2执行完成后,所有任务都已进入队列,剩余时间分别P1=7,P3=5,P4=3,按着P4-P3-P1的顺序执行。平均等待时间(包括P1中断等待)=[(13-1)+(8-2)+(5-3)]/4=5,在SJF的基础上又得到了大大的优化。完整过程如下图:
对应到测试工作中,允许给任务打断点,不纠结于一次要把一个任务啃完也是一种重要的能力,尤其是当遇到沟通协调受阻预计时间被延长的情况时,先临时脱身把新来的简单需求完成再回头处理可以显著提升工作效率。
对于优化等待时间这个维度,SRTF算法做到了理论上的极致。但不知你注意到没有,这个算法存在一个巨大的漏洞。
CPU实际运行时,大大小小的任务会不断涌来,这会导致一个需要较长处理时间的大任务可能永远没有办法被执行,或每次执行一点就被新来的小任务打断,造成“饥饿效应”。而对应到工作中,处理时间长的任务往往意味着大需求、重要项目,如果只使用SRTF,那我们就会一直处理迭代小需求而严重拖慢了这些重要需求的进度。
4.重要的优先——PS(PRIORITY SCHEDULE)
算法描述:
1- 开始前,将已经在等待队列中的任务优先级从高到低排序;
2- 当前没有正在进行的任务时,执行等待队列中的第一个;
3- 执行一个基本时间单位后,微调高等待队列中任务的优先级权重;
4- 当新任务到达时,比较其和当前执行任务的优先级。若新任务更高,则打断当前任务,反之新任务进入等待队列;
5- 对等待队列按优先级再次排序;
PS算法主要根据任务的重要性权重来进行调度,确保重要的任务被优先执行。其中第三步提高等待队列中的任务的权重则是防止低优先级任务永远无法执行的饥饿效应。
还是这个场景,现在加上优先级的属性,由于一共20个时间单位就能完成,所以简单起见不设置等待队列的权重升高。
开始时队列中只有P1,一个时间单位后P2到达,且优先级高,因此优先执行P2。在次过程中,P3、P4到达,因其优先级不够无法打断P2。P2完成后,根据队列中优先级顺序依次执行P1、P3和P4。完整流程如下图:
平均等待时间=[(5-1)+(12-2)+(17-3)]/4=7,可以看到相比于SRTF,在效率上有所下降,但保证了最重要的任务最早完成。
PS算法给我们的启示是:优先做重要的事。一方面重要的任务最能体现工作的价值,另一方面,可以避免把过多精力投入到看似紧急但并不重要的小事上而导致重要的任务被拖成既重要又紧急的炸弹。
此外PS算法和SRTF实际上并不冲突,可以组合使用。例如在同优先级或优先级接近的情况下使用SRTF,具体的权重设置和搭配方式需要根据实际情况来定。
5. AMAZING!不可思议的调度异常
到目前为止,可以发现,CPU调度是通过合理的安排处理任务的顺序,实现让重要的先完成、让任务等待的开销最小化等好处,并不直接提高处理任务的效率本身。
而提升CPU运算速度、增加CPU线程数、减少任务之间的相互依赖关系等方式则是可以直接提升处理任务的效率。
那么请思考一下,在一个给定的调度算法下执行给定的任务,哪些效率改进方法可以最大程度缩短总处理时间,又有哪些“改进”可能反而引起总处理时间变长?
答案是,所有的手段都无法保障总处理时间缩短! 看似优化效率的手段都有可能导致总处理时间增长!
来看这一个场景:等待队列中总共9个给定任务,运行过程无新增,优先级、处理时间和依赖关系如下图,CPU共三个线程。调度算法为:按优先级执行,同一个任务只能由一个线程完成,不能打断。
可以看到总运行时长为12个时间单位。
现在我们来增加一个线程,这时新的线程4用了2个时间单位完成了任务P4,解锁了P5-P8,导致线程1在完成P1后,无法执行P9而需要执行优先级更高的P8,导致P9被排在线程2执行,最终完成时间延长到15。
减少依赖关系的情况也是类似,例如我们解除P7和P8对P4的依赖,则在线程2处理P4的过程中,P5和P6暂时锁定,线程3会执行P7而线程1执行P8,导致P9被延迟执行,最终完成时间延长到16。
再来看提示CPU运算速度的情况,假的CPU处理每个任务的时间减少1,那么在线程1执行P1的过程中,线程2可以完成P2和P4,这同样会导致在P1结束时,P5-P8被解锁,从而耽误P9的执行,最终完成时间延长至13。
通过这几个场景可以看出,在给定调度算法的情况下,看似提升效率的手段有可能反而导致处理效率下降,这种现象叫做“调度异常”。调度异常现象的存在提醒我们合理的调度算法对于CPU工作效率有重要的影响。
那么细思恐极的事情就来了。
回到测试工作中来反思一下,如果努力加班相当于提升CPU处理速度,使用mock类工具相当于减少任务间的依赖,使用自动化脚本相当于增加了线程的话,那我们一直埋头追求的这些“效率提升”又是否会让我们陷入调度异常呢?
至少留一点思考在任务安排上,重视时间的管理总是没错的。
6. 普通调度与实时调度
继续来看CPU调度算法。之前讨论的所有算法都假定,所有的任务没有截止时间,早些完成和晚些完成效果是一致的。
但真实场景中每一个任务的价值或多或少存在“时效性”,当到达deadline但任务没完成时,其价值下降或归零甚至造成负面影响。一个任务根据其超过deadline后价值下降方式的不同,可以分为软实时任务(soft-realtime)、硬实时任务(hard-realtime)。
对应到测试工作中,大多需求属于软实时任务,但有些涉及上下游对接、政策性改动的则属于硬实时任务,通常必须倒排期以确保按时完成。
对于软实时任务,可以近似当作普通任务进行CPU调度。而对于硬实时任务,CPU调度时就必须考虑能否在deadline前完成,资源有限无法保证完成的情况下则需要最小化“迟到”时间以最小化损失。
一句话概括CPU实时调度算法就是,尽可能先完成最紧急的任务。下面来具体看。
7. 紧急的先完成——EDD(EARLIEST DUE DATE)& EDF(EARLIEST DEADLINE FIRST)
算法描述EDD:
针对等待队列中的硬实时任务,计算任务预留时间(截止时间-到达时间)
优先执行预留时间最少的任务
还是先来看一个简单的应用,现在假设等待队列中现有P1-P5硬实时任务(到达时间均为0),处理时间和预留时间如下表所示,根据规则容易画出调度图:
这个算法非常简单,就是优先执行时间预算最少的任务。但里面涉及到的一些数学原理还是值得稍稍深入探究一下的。下面首先引入一个实时操作系统中的概念:迟到时间。
迟到时间是衡量实时系统的重要指标,其值为完成时间-截止时间。如果一个任务在deadline前成功完成,那迟到时间为负值,反正若任务逾期,则其值为正。在一个调度算法下,每个任务都会有自己的迟到时间,值最大的那个被称为最大迟到时间,意味着“最危险”的任务的迟到时间。
因此一个实时系统从迟到时间维度考虑,主要目标就是:尽可能保证所有任务的迟到时间为负值(让最大迟到时间<0)。而EDD算法是在静态(所以待处理任务已知)情况下最优化该目标。
来看下面这张图:假设有P1和P2两个任务,预留时间P1小于P2,截止时间分别为d1,d2,根据EDD算法优先执行P1,两个任务完成时间分别为f1,f2;若我们交换任务执行顺序,先执行P2,再执行P1,其完成时间分别为f1’和f2’。
由于两个任务执行总时间不变,因此f2=f1’。计算迟到时间L1=f1-d1,L2=f2-d2,L1’=f2-d1,L2’=f2’-d2。因为有f2>f1所以L1’>L1,又因d1<d2,所以L1’>L2。
所以第二种算法的最大迟到时间L1’必然大于EDD算法(无论L1和L2哪个更大)。
EDD算法实现了“将所有任务中迟到时间最大的那个最小化”的目标,为系统带来最大的健壮性和突发情况抵御能力。可以推知若EDD算法下发现有任务无法按时完成,那意味着无论用什么调度策略,都必然会有任务超时。
另外值得注意的一点是,EDD算法只适用于所有任务已知的情况,若中途插入预留时间更短的需求打断原有任务,则可能导致任务超时:
对于可能动态插入硬实时任务(这种情况风险很高,需要尽可能提前避免)的极端情况下,CPU调度会使用EDF算法:执行所有任务中,deadline最近的任务。
EDF算法可以看做是EDD算法的动态版本,当一个新硬实时任务到达时,其预留时间即为截止时间-当前时间,而当前进行任务在此刻的剩余预留时间同样是其截止时间-当前时间,故可直接简化为执行deadline最近的任务(deadline是第一生产力)。
简单来看一下应用场景:
和EDD算法一样,EDF同样是最小化最大迟到时间的最优算法,该场景下Lmax=L2=L3=0,没有任务逾期。在动态调度中若EDF无法保证所有任务按期完成,其他任何算法也均无法做到。
8. 实时系统的杀手——延迟(DELAY)
通过EDD和EDF算法,我们在实时任务的调度中找到最小化最大迟到时间的算法,让最危险的任务获得最多的安全余地。这时容易让人产生一种错觉,最容易逾期的任务都有t时间的余量,那么中间过程中稍稍“偷懒”一点,产生一点点小于t的延迟肯定问题也不大吧?
然而事情的真相是,对于实时系统,任何一个微小的延迟引入都可能造成一个明显的任务延迟,甚至是逾期的灾难性后果。因此即便最大迟到时间有余量,也绝不可掉以轻心。下面来看具体的例子:
P1是一个周期性任务,每个周期4个时间单位,需要执行2单位,P2需要执行6时间单位,截止时间在15。根据EDF算法可以列出调度顺序,并容易计算L1=-2,L2=-3,如下如所示。
P2任务看似有3个时间单位的余量,但若在过程中引入累计3个单位的延迟,尽管P2仍可以按时完成,但却造成了P1在第四个周期错过截止时间:
再来看这个场景,P1和P2是两个周期任务,周期分别为4和5单位,处理时间2单位,调度使用优先级算法且P1的优先级高于P2。
通过计算时间占用率可以看出两个任务可以合理安排开并确保完成,二者开始时间相同的第一周期相对最为“紧张”,L2=-1,如下图所示。
当“最危险”的第一周期过去之后,P2出现一个单位的延迟(小于后面周期的迟到余量),结果导致因优先级问题,第二周期P2没能按时完成。尽管该场景当延迟出现时改为EDF算法可以避免逾期,但确定算法下延迟的引入导致的“后果放大”效果仍然值得警惕。
如果任务和任务之前存在资源互斥、依赖关系,那延迟造成的连锁反应则可能更为严重。
看下面这个场景,P1和P2两个任务,使用EDD算法,其中黄色部分为两个任务的资源互斥部分(mutex),正常情况下,可以顺利完成,且时间很宽裕。
在P1执行完一个时间单位后,出现了一个2个单位的延迟,于是灾难性的后果就出现了,P2在P1等待过程中开始执行,并且进入了critical section,这导致P1在延迟恢复后,只能继续执行一个单位,随即因资源互斥被卡住,不得不等待P2先执行完成。最终造成P1在拥有5个时间单位余量的情况下错过deadline。
下面来总结一下实时任务调度算法给我们测试工作的启发。
对于实时任务,调度算法反而是简单的,把最早deadline的工作最先完成掉就是最安全的方法,这个大家第一反应都能想得到,学习实时任务调度的知识绝不是让我们简单的根据业务需求的deadline顺序来安排手头的工作,这对我们毫无好处。
恰恰相反,我们首先应避免手头出现、堆积硬实时任务,尤其避免执行硬实时任务同时还会被另一个“更硬”的紧急任务插队的情况。实时任务调度是很危险的,即便是追求安全最大化的EDD和EDF算法也无法保证所有任务一定能够按时完成。正如之前在PS算法中提到的,重要的事情要优先做,可以最大限度避免重要的事情沦为既重要又紧急的硬实时任务。
其次,如果我们不得不面临处理实时任务,一定要做好时间计划表,关键节点一定要确保及时的跟进和处理,最大限度避免延迟的出现。时刻牢记一个微小的延迟,放在复杂的协作网络中可能会给实时任务造成严重的进度拖延。
附录1: 主动把任务拆解——RR(ROUND ROBIN)
大名鼎鼎的轮询策略被我放到了附录里,尽管这是CPU调度中一个非常重要的策略,但和其他具体的算法相比,轮询更像一种思维方式,不局限于实现形式,可以和各类算法搭配使用。
轮询的基本方法就是设定一个小单位的时间片,每个任务执行一个时间片后就移回等待队列的末尾,由下一个任务执行一个时间片,以此往复。由于任务被切的很碎,在执行时需要花费一定成本进行上下文转换(context switch),用于保存当前任务进度、读取下一任务进度等。因此轮询会增长处理任务的总时间,其好处是缩短了所有任务的响应时间,每个新任务到来后都会很快得到执行。时间片长短的选择是算法的关键,选的过长会失去轮询的意义,而过短则会有更大比例的资源用在上下文转换的S/L中,影响运行效率。
对于我们来说,学习轮询算法可以提升“多线程”能力,在同时面对多个任务的时候也能从容淡定,响应及时又靠谱。关键同样是两点,一是把大的任务拆解成小而独立的模块,每次执行一个任务的模块;而是记录好断点数据,做好上下文转换,避免重复或者遗漏。
附录2:毕竟不是机器
尽管我们一直在借鉴CPU调度算法学习时间管理的心法,但也不能忘了人和机器毕竟还是不一样的。
这里简单提两点。
一是机器的效率是固定的,而人有“能量潮汐”,通常早上精神状态最佳,下午两三点最容易犯困,所以安排任务时,尽量将需要专注、发挥想象力的和核心任务安排在上午,并合理安排工作和休息以保证充沛的精力;
二是机器只会被动执行进入等待队列的任务,而人具有主观能动性,可以主动选择任务、剔除不合理任务以及创造有价值的任务。CPU调度算法中凝结了很多值得学习的洞见和心法,我们需要的是借鉴融汇而非简单照搬使用。
最后祝大家都能成为时间管理的高手。
END