OO第二单元作业总结
在第二单元作业中,我们通过多线程的手段实现了电梯调度,前两次作业是单电梯调度,第三次作业是多电梯调度。这个单元中的性能分要求是完成所有请求的时间最短,因此在简单实现电梯调度的基础上,我还使用了一些调度算法来追求性能分,但是效果上不是很理想,只能勉强获得90分,在这里我想把我自己的做法写出了,供大家参考。
本次作业分为以下部分,三次作业实现介绍(包括调度方法), 总结作业。请读者各取所需。
(注:本次电梯的全部调度算法仅针对作业题目,对实际情况并不相符)
三次作业实现
第一次作业
第一次作业的要求是实现一个FAFS的单电梯调度,而且没有性能分要求。
设计上采用生产者-消费者模式。输入进程作为生产者,将请求放到请求队列中。电梯进程作为消费者,从请求队列中获取请求并执行。
整体思路非常简单,需要注意的地方是请求队列的线程安全和在处理所有请求后,程序应当结束。
对于线程安全我的做法是,请求队列放到一个对象里,这个对象public的方法前都用synchronized修饰。这样的做法一是可以保证线程安全,而是可以方便的管理线程安全的方法。但是如果线程安全的方法过长就会导致一个线程长时间占用对象而阻塞其他线程,因此我们要充分区分哪些步骤必须是线程安全的,哪些不是。
对于结束,我在请求队列对象里加了一个endFlag来记录输入是否结束,电梯每次请求需求的时候,我会检查一下endFlag来判断电梯应该wait还是结束。
第一次作业虽然非常简单,但是具有很大意义。我第二次、第三次作业的架构都是在此基础上建立的。
第二次作业
第二次作业的要求是实现一个性能比ALS(A Little Stupid)电梯接近或更优的电梯。本次作业比较麻烦的地方在于这次作业有时间限制,如果时间慢与ALS调度则算超时,这就限制了很多调度算法的使用。
ALS(可捎带电梯)规则介绍(copy from 指导书):
-
可捎带电梯调度器将会新增主请求和被捎带请求两个概念
-
主请求选择规则:
-
如果电梯中没有乘客,将请求队列中到达时间最早的请求作为主请求
-
如果电梯中有乘客,将其中到达时间最早的乘客请求作为主请求
-
-
被捎带请求选择规则:
-
电梯的主请求存在,即主请求到该请求进入电梯时尚未完成
-
该请求到达请求队列的时间小于等于电梯到达该请求出发楼层关门的截止时间
-
电梯的运行方向和该请求的目标方向一致。即电梯主请求的目标楼层和被捎带请求的目标楼层,两者在当前楼层的同一侧。
-
-
其他:
-
标准ALS电梯不会连续开关门。即开门关门一次之后,如果请求队列中还有请求,不能立即再执行开关门操作,会先执行请求。
-
可见ALS电梯的性能不是很优,主需求的选取会对整个调度产生很大影响。但是随意改变主需求的选取会导致有概率超时,因此优化ALS调度可能性不大,只能另辟蹊径,使用其他调度策略。
由于电梯承载人数没有限制,所以Scan算法/Look算法(电梯来回扫描),理论上会比ALS更优(电梯来回移动次数减少)。实现方法是初始设置一个电梯方向,然后电梯朝着这个方向走,如果该方向上没有请求(上人或下人)则掉头。
但是这样做会有一些问题。考虑如下请求:1-FROM-1-TO-15,2-FROM- -1 -TO- -2.显然先送2再送1更优,但是如果电梯初始方向为向上,那么电梯就会先执行1再执行2,导致效率下降。
对于这个问题,我发现:当电梯在0楼时(假设),向上a层有一个请求,向下b层有一个请求。如果先处理a再处理b,花费时间为a+a+b;如果先处理b再处理a,花费时间为b+b+a。因此我发现在选择方向的时候,选择最近的一个端点(所有请求的最大楼层和最小楼层)会更好。此时我们得到了ALS(A Little Smart)电梯。
但是还有一个问题。考虑如下情况,1-FROM-1-TO-15,当电梯运行到3楼的时候,突然来了请求2-FROM- -1 -TO- -2,那么我们发现电梯转身去处理2更优。为了实现这个操作,我让电梯并不存目标方向,而是每走一层楼,然后寻找目标楼层。目标楼层的选取是选择最近的端点。每到一层楼的时候,出人,进人(该层所有人)。此时我们得到了ALS(A Little Smarter)电梯。
具体实现:
在实现指导书上的ALS电梯时,我的做法和之前第一次作业一样,调度器里只存了请求队列,电梯从调度器里去请求主请求和捎带请求,然后电梯自己存内部请求队列,电梯自己判断向哪里走(调度器80行,电梯180行)。一个电梯活成了调度器的模样 :) 。同时我在实现时,发现把请求分为主请求和捎带请求,俩者处理起来非常麻烦,开关门总是出问题,可能在同一层开关门两次。
有了ALS电梯的教训,我在写第二版电梯的时候,考虑了更多的细节问题。电梯应当只做电梯的事情,调度应当交给调度器来管,电梯只负责向目标楼层运行就行(简化电梯操作)。调度器负责给电梯目标楼层,负责控制电梯开关门上下人。由于时间效率在于调度,而不是CPU时间,因此我并没有开一个调度器进程,而是电梯进程会调用调度器对象来处理请求。
实现上,在上一次的基础上,我加了一个请求队列buffer,输入将请求放到buffer里,调度器在使用请求的时候,将buffer里的请求都取出来,存到自身的请求队列当中,然后在执行计算。这样做的目的在于,如果直接把请求队列放到调度器中,给调度器上锁,会导致,电梯进程长时间占用调度器,阻塞输入进程的情况。在获取目标楼层的时候,调度器扫描所有电梯内请求,和电梯外请求,计算出端点,然后将电梯目标楼层最近的端点返回(如果端点在电梯两侧,否则返回最远端点)。
在实现过程中,可能会出现由于同时输入请求过多,导致输入进程不能将全部请求存到请求队列中,调度器就已经将电梯送走了。为了处理这种情况,我让调度器在从buffer里获取请求的时候,采用多次请求的方法:当这次请求到的时候,就去请求下一次直至请求不到。这样在调度器处理(存储到内部)得到的请求的时候,输入进程还可以将请求放到buffer当中。
第三次作业
第三次作业的电梯有三部,电梯有人数限制,而且每部电梯停靠的楼层都不一样(见下图),不过庆幸的是没有超时限制。
由于每部电梯的运行时间相差不多(A 0.4s,B 0.5s, C 0.6s),因此电梯调度时间我并没有考虑,而是更关注于电梯的调度。
通过观察楼层我们发现,1层和15层是三部电梯都能到达的地方,因此可以作为一个中转站。3层只有C能到达,因此1楼和5楼可以作为去3楼的中转站。有了中转站,我想到了一种大胆的构想:楼层分段,电梯先通过来回移动把段内的请求送到中转站(如果不在段内直达),然后再换段,处理下一段的请求。通过多次移动,把电梯从有限容量变成了无限容量,楼层是没有容量限制的 :)。
我先根据这样的想法做了一版,因为采用第二次作业的ALS调度,主请求和捎带请求非常难处理。而且还有问题是对于A电梯,由于1层到15层,A电梯不可直达,分段不连续,也比较难处理。而且对于中转站同时输入2个段,也不好处理。因此这一版的效果不是很好,和别人比较后发现性能很差,于是打算重新写一版。但是这一次的尝试也有收获,比如我实现了楼层处理,请求分段处理,程序终止(因为有4个进程,同时终止也有些难度),这些具体我在实现部分介绍。而且尝试时发现,电梯分段确实困难重重,因此后来决定只给请求分段,电梯不考虑分段,只考虑能不能处理请求。
具体实现:
输入进程,将请求扔到buffer中。
电梯采用第二次作业的Look算法,每个电梯的作用只有向目标楼层移动。
单例模式的Segment类来辅助请求分段,作用是判断俩个楼层是否在同一个段,以及获得中转站楼层。
单例模式的Floor类来控制电梯停靠楼层,用来判断该电梯能否处理这个请求。
RequestBuffer类,用来作为未处理请求的队列,能够根据电梯返回相应的未处理请求的目标楼层(这部分理论上应当调度器去做,当时实现的时候没有考虑到,导致buffer功能有点多)。
Request类,作为请求,存储了currentFloor(请求者所在楼层),finalFloor(请求者最终要去的楼层),toFloor(下一次乘坐电梯要去的楼层)。请求每次进入请求队列的时候都要计算一次toFloor(根据Segment,如果可以直达toFloor = finalFloor,否则则进入同方向上的中转站)。这样做的目的在于,每个请求自己处理自己的目标楼层,不需要调度器去考虑请求应当如何移动,这里参考了老师提到了command模式(我没有仔细研究过,抱歉)。
调度器,通过电梯传递的id和currentFloor来计算出该电梯的目标楼层,和上下人。调度器里存了电梯内部请求队列。对于不同的电梯,我是通过传递电梯id来告诉调度器哪个电梯在请求,而且对于电梯的信息,我都用了对象数组来存储,这样每个线程通过不同的id来访问不同的对象,调度器处理起来更方便更好些,能减少很多if语句(伪Thread-Sepcific Storage?)。
对于终止的处理,我是每个线程设置了一个endFlag,每个线程是否结束会判断其他3个线程是否结束,如果其他线程都结束了,这个线程也结束。当一个电梯的内部没有请求时,这个电梯线程设置为结束,当他得到请求后再让这个线程复活。这样做的目的在于,如果只考虑输入线程,可能某个电梯内部还会产生新的请求,如果直接结束了,这个请求就无法处理了。
总结
这三次作业难度层层递进,设计也越来越复杂,不过还好最后实现出来的代码既兼顾了性能和代码风格。
设计模式是个好东西 :)。
总结作业
1.分析和总结
参考第一部分的内容。
2.设计检查
第一次作业
分析:第一次作业比较简单,整体复杂度较低,代码量较少。采用生产者-消费者模式,结构清晰。
第二次作业
分析:第二次作业沿袭了第一次作业的设计,同样采用生产者消费者模式。不过在第二次作业中把调度的工作全部交给调度器一个类来做,圈复杂度有点高。其中Dispatcher.getToFloor()方法复杂度最高,可能是因为我在取toFloor的时候采用了很多判断语句,而且这个方法也与其他类的交互比较多。
第三次作业
分析:第三次作业较前一次作业而言复杂度有所减少,在设计的时候我考虑了之前作业实现时出问题的地方。复杂度比较高的方法有:RequestBuffer.getToFloor(),RequestBuffer.getInRequest()。可能的原因在于,我把楼层判断也放到了RequestBuffer里,导致复杂度较高。
SOLID设计原则检查
- SRP:三次作业基本都做到了单一功能原则,输入进程负责处理输入,请求队列存储请求,调度器调度上下人和电梯,电梯负责向目标楼层运行。可能调度器的功能有点多,但是如果拆分开来可能需要多存储一些数据。在第三次作业里,RequestBuffer类的功能出了作为请求队列还包含了判断请求能否被电梯处理的功能,这里做的不是很好。
- OCP:在第三次作业里,确实可以做到只添加相应的数据,就可以多创建一个电梯。我大部分方法都用了对象数组来存储,电梯的访问也是用id来进行,只需要多一个new,就可以多一个电梯。
- LSP:本次作业由于继承关系不明显,因此该原则可以不考虑。
- ISP:由于对象不多,而且每个对象功能相对简单,因此并不需要添加接口。
- DIP:这个原则我做的不是很好,比如我在第三次作业中调度器类过分依赖RequestBuffer来处理能否抽取请求,依赖过于具体,这里的设计有待考虑。
3.互测攻防战
本次作业互测难度很大,第一次作业非常简单,第二次作业没有了时间限制,第三次作业非常复杂。因此我三次作业互测都没有刀到人,也没有被刀(儒雅随和)。
第一次作业互测时,我仔细阅读了所有人的代码,发现一个同学代码里没有使用任何对共享对象的保护,比如ArrayList这样并不保证原子操作的对象,他也没有进行保护。但是由于出现线程不安全现象是随机的,因此并没有刀出来。
第二次作业,我并没有仔细阅读所有人代码,而是用测评姬的随机测试,测试了同组代码,发现有些同学会有超时现象,但是互测不考虑超时,也没有刀出来。
第三次作业由于难度太大,互测困难,所以我只是提交了几组随机数据。
4.BUG查找策略
本次作业由于第一次使用多线程编程,线程安全是一个重要的bug来源。因此在检查bug时,一定要注意对共享对象的访问。为了方便检查,可以把所有对共享对象的访问放到同一个类里,只检查这一个类就好。
关于电梯调度上的bug,可以通过制造特定的数据来检验自己的电梯能否按照自己的预期执行。比如第二次作业里,我构造了很多测试,来卡自己的电梯,以保证不会超时。
对于其他bug,比如终止出错,没有停下来,这些可以通过随机数据来检查。
显然随机测试寻找bug是非常玄学的,因此还需要一些手段,比如单元测试等,来辅助检查。这些我做的不是很好,也是我今后应当更多考虑的地方。
5.心得体会
本次作业,我个人进步最大的地方是学习了几个设计模式来辅助自己设计,这让我代码整体结构比较好,思路清晰,逻辑严谨。这次作业我也学到了多线程编程的基本方法。
但是,我还有许多地方有待进步,比如测试环节。设计模式也需要抓紧时间学习。
6.建议
关于互测我有点小建议。本次作业的互测确实很困难,很多bug是复现起来很难(可能是我测试方法有问题),所以导致大家互测兴致不高,提交空刀保活跃度。因此我建议,在bug难以复现的情况下,可以通过提交书面文本来指出bug所在,让助教检查。这样做可以增加大家阅读代码的积极性,同时可以训练bug定位能力,也可以让互测更有趣一些。