BUAA_OO 第二单元总结——电梯
前言
电梯月终于连滚带爬地结束了,有句话说得好,你在第一单元的全部嘚瑟都会在接下来的单元里报复回来(我说的)。第七次作业互测结束的时候长舒了一口气,其实主要还是感觉有些力不从心,上一个博客周没有意识到预习多线程的重要性,结果等到第五次作业还剩三天的时候开始突击,导致知识体系并不扎实,理论的茅草房摇摇欲坠,写出来的东西自己都不知道为什么......再加上这几周事情确实有点多,似乎到第三周才真正理解线程和锁的奥秘,悔不当初啊...
第五次作业
架构
生产者-消费者模式贯穿了本单元三次作业。
本次作业只包含两个线程,输入线程获取输入,放入等待队列。等待队列是一个包含20个元素的列表,代表20个楼层,每个楼层又是一个新列表,需要根据乘客的出发地放入到相应楼层的列表中。为方便电梯运行调度,我还将每层的等待队列分成了上行和下行两个列表存储,这样电梯在决定运行方向和是否搭载乘客时就不需要遍历列表了。(套娃)
针对三种到达模式,我采用了三个策略类,共同继承Strategy
父类,其中包括了选择乘客的不同策略、指导电梯运行方向的不同策略等。在程序最开始获取到达模式并由工厂制造电梯,选取对应的模式传入电梯中(策略类作为电梯的一个成员变量)。由于策略类的使用,电梯类中的方法主要负责电梯的运行行为,如上行下行、开关门、进出乘客等,而至于什么样的乘客进出,电梯向哪一方向运行,电梯是否调头等都由策略类中的方法来承担,这样可以尽量减小电梯类的复杂度。
面对“纸片人”的不合理设定,我也决定不讲武德地将开门时间设为0.4s,关门时间设为0s,在开门结束后获取队列中上电梯的人,毕竟这0.4s还是很可能有新的人进来的。
三次作业中电梯的运行我均采用了LOOK算法,但由于第一次run方法较为混乱,决定放到第二次作业再总结。
UML类图
去掉策略类等之后UML类图如下:
线程协作
时序图如下:
锁和同步块
本次作业还没有认识到多线程的锁和同步块的作用,甚至是先写完了全部逻辑之后加的锁......甚至竟然在所有synchronized
块之后都加了notifyAll
,不忍直视。在加入请求的时候锁住的是出发层的Floor
类,电梯运行过程中涉及到一层的到达时只锁住该层,而涉及到电梯运行方向检查与改变时锁住整个WaitQueue
。本来以为只锁相应楼层会更加合理,至少不影响输入线程向其它层加入新请求,但最后发现其实单锁一个楼层几乎没有产生任何时间的节约,反而导致我的逻辑非常混乱,两个锁锁来锁去很难检查死锁的问题。
我也并没有把WaitQueue
设计成线程安全类,锁主要只存在于了两个线程的run
方法中(不知道咋想的)。最初写的时候为了判断该层是否有人,我锁住了整个这一层,当有人且电梯决定搭载时直接执行了开门行为,但此时并没有释放锁,这导致整整零点几秒的时间都被锁住浪费了,这段时间很有可能再加入新的请求,但输入线程被阻塞住了。于是后来我设置了一些tag
变量,只在判断状态是锁住等待队列,改变tag
的值后就释放锁,之后电梯根据tag
在没有上锁的情况下执行相应的行为。
复杂度分析
仅截取了飘红部分:
果不其然,涉及到电梯运行策略的方法几乎全部爆红,这些方法也确实有较高的复杂度,在判断电梯运行方向、获取新乘客时需要考虑很多问题。Elevator.run()
方法居然也红了,这确实应该解决,理想中的电梯运行应该只是几个状态的变化,状态转换的判断应该整合到方法里,而不应该在run方法里有太多逻辑。
和其他同学相比这次作业代码量似乎有点多,难以置信在拆分出策略类的情况下电梯类依旧如此臃肿?!其实主要占地方的还是那个复杂度较高的run方法还有一个为了优化强加的调头方法,在后来的作业中这个方法也被放入到了策略类中。
自己的bug
本次作业出了致命bug!一切的一切都归咎于手痒的优化...在最初的版本中,我的电梯需要关门后判断是否调头,这意味着电梯不能在原方向上放下最后一个乘客后直接搭载上本层另一个方向的乘客,而是需要关门,转换方向后再开门,这不实际,也浪费了0.4s的时间,于是我在系统关闭前半小时紧急优化,违背着高内聚低耦合的祖训,把方向转换加进了上电梯的方法里...于是报应来了,电梯应该在内部没有乘客时再判断是否调头,但我忘记了这一行,导致在电梯内还有一个人时,电梯判断同方向没有等待的乘客了,于是直接调头上行,这个坐在电梯里的乘客到不了低层,在上面的所有层中这个人也满足不了出电梯的条件,同时电梯在运行只知道自己载着这一方向人要继续走,于是电梯直奔21层(天堂号电梯专线)。
低情商:我被刀了;高情商:我为同屋的人提供了快乐。以后在提交前一小时绝对不会再动我的代码了。
思考
入门多线程的第一次作业实现得还是有些蹩脚,尤其是锁的分配,当时还存在着很大的误区,以为锁了一个列表,其内部的元素都不能获取了,于是一开始锁得非常混乱,只想着锁涉及到的最小成员,然而这并不能满足线程交互的要求。后来再改的时候更加混乱,为了求稳直接把run方法拆分出了三个同步块(判断开门与开门、上人关门与调头、上下移动一层或检查队列后wait),这三个同步块划分得毫无逻辑,连我自己都觉得十分迷惑。
第六次作业
架构
本次作业增加了多部电梯的要求,于是我为每部电梯分配了自己的等待队列,等待队列的结构和第五次作业相同,同时每个等待队列作为成员变量传入到电梯中。
为了各电梯间的协作,我加入了调度器,和电梯一样,根据乘客到达模式分为三个策略类。调度器起操纵全局分配的作用,因此在我的程序中,它是一个静态类,只在程序开始时由工厂根据到达模式初始化策略对象,之后全程根据设置好的策略进行调度。
调度器调度需要了解到全局的状态,也就是需要随时获得所有电梯及其等待队列的信息,于是我用一个HashMap管理所有电梯,一个HashMap管理所有队列,电梯和等待队列是分开存在的,可以用相同的Key
查到,只是在电梯里包括了等待队列的引用,我认为这样可以降低耦合度。
电梯运行逻辑
本次逻辑用一种相对优雅的方式解决了上一次作业电梯重复开关门的问题,采用调头前后两次查找上人的方式满足了调头后立刻搭载乘客的需求。
UML类图
去掉一些重复的策略类后UML类图如下:
可以发现,调度器和等待队列与其他类交互最为频繁,对于调度器来说,它需要获取全局的信息之后进行调度,对于等待队列来说,电梯、调度器等主要部分各个方法都需要获取其内部的信息。
线程协作
时序图如下:
锁和同步块
本次作业设置了4个加锁的共享对象,下面总结了需要上锁的情况。
WaitQueue
:分配新请求,输入线程结束,电梯获取输入结束信息;
WaitQueue.get(floorIndex)
:(指等待队列中的相应楼层)加入新请求,电梯上人;
WaitQueueMap
:添加新电梯,调度器选择电梯;
elevatorQueue
:添加新电梯,调度器选择电梯。
新请求的分配过程中需要计算分配到各个电梯时的权重,这是一个读取状态的过程,为实现全局上的性能优化,我并没有对这一过程上锁,而只是模糊地获取状态进行分配,不过分关注那些“恰巧不巧”的情况。
在我的架构中,等待队列和电梯是分开管理的,因此在加入电梯时需要依次在等待队列们的队列和电梯们的队列中加入新的成员,而调度器在选择分入的电梯时需要获取所有电梯和等待队列的信息,因此需要避免等待队列个数和电梯个数不对应的情况。我采用了这样的方法:添加电梯时先锁住WaitQueueMap
,添加新的WaitQueue
后再锁住elevatorQueue
添加新电梯(两个锁是并列而非嵌套),而在调度时先获取电梯队列中的电梯序号,因此即便输入线程还没有添加结束,仍可以保证获取到的电梯必有等待队列与其对应。
复杂度分析
仅截取飘红部分:
果然,又是清一色的各种strategy
方法复杂度爆炸,尤其是changeDirection
方法,它需要判断电梯状态,本层是否有乘客,在电梯目前运行方向上是否还有乘客,电梯运行反方向上是否有乘客等。电梯的run方法依旧有些复杂,本次重构后的run方法已经足够精简了,可能因为判断时调用了很多方法,所以复杂度才高上去了,不过我觉得这种偏向状态机的逻辑还是比较清晰的,所以也就不准备再改了。
电梯类依旧有点大,主要是因为后来优化的时候加入了很多获取电梯状态的方法,比如获取电梯内部乘客想去的最高/最低层数,电梯即将在更高/更低位置停下的层数集合等。等待队列也有点大,跟电梯类一样,也是为了优化加入了各种获取状态信息的方法。
自己的bug
本次作业又爆炸了,T掉了两个点(没有死锁),被强测卡了策略。原因是在优化调度分配电梯的时候没有考虑电梯当前等待队列中的人数,导致一瞬间来了很多有相同需求的人的时候通过计算权重全部分配给了同一个电梯,于是最后它自己来来回回地跑,实际运行时间爆出了210s。然而我在优化开始前还闪过了这个考虑人数的念头,结果写代码的时候给忘了,以后设计阶段还是要把所有想到的问题写下来。
修复时我计算了当前状态下电梯需要服务的数量,为人数赋予了较大的权重:
time = time + (elevator.hasPersonNumber() + waitQueue.getWaitingNum()) * 4.0;
理论上这样就能解决问题了,但还是顺便加了一个随机分配的部分:当乘客与任何一个电梯都不顺路时,以前我只会分配给列表中第一部电梯,这也可能导致很多人分给同一部电梯,于是我加了一个tag变量,每次出现无顺路难以计算权重的情况时分配给tag指向的电梯,再将tag++,这样就可以把相对费时的需求分配得更加均衡。
修复后性能分极差的点运行速度一度快过了我性能几乎拉满的同学,太遗憾了。
思考
由于还不太理解多个线程的协作,本次作业保守地没有为调度器开辟一个新的线程,这导致想优化时很难实现“先存一部分然后再分配”或者“night模式下加入新电梯后将所有等待队列的人倒出来重新分配”。多数情况下,存一部分再分配是会比一个一个分配更优的,因为可以把有完全相同需求的人尽量分配在同一趟电梯中,我的night模式原本是先将所有需求存入一个列表,等输入结束后再统一分配,但最后突然发现输入所有人到达电梯并不意味着程序的结束,甚至可能在很久以后突然冒出个加电梯的指令后再结束,这样中间的大量时间都浪费掉了,性能差也很可能T。没有线程的调度器很难及时获取乘客全部到达的信息,除非内置一个计时器,但这种方法我难以驾驭,于是在多次尝试未果后推倒了两百行优化代码,直接三种到达模式一视同仁来一个分配一个了。
本次作业依旧没有意识到的一个问题是把等待队列作为了电梯的一个成员变量,虽然在外部获取信息时是分别调用的,但这种传入电梯内部的方式并不符合降低复杂度的思想,课上老师讲到这个问题后,我在第七次作业中进行了改进。
第七次作业
架构
有了第六次作业优化不了的窘境后,本次作业果断给调度器分配线程。输入线程-调度器、调度器-电梯构成了两个生产者-消费者模块。
在输入线程和调度器之间设置等待分配队列作为托盘,调度器和电梯之间以电梯自身对应的等待队列作为托盘。
我将最初的PersonRequest对象进行了一次外包装,用一个Person类进行封装,这样可以在调度后给给人赋予更多的信息,而电梯可以参照这些信息决定在哪一层将这个乘客放下。
UML类图(去除策略类)
线程协作
时序图如下:
锁和同步块
新增请求和电梯运行部分的逻辑与上次作业基本一致,这里仅总结程序结束的逻辑:
- InputHandler读到null——WaitDispatchQueue设置输入结束标志
- Dispatcher检查所有等待队列空,所有电梯Wait,所有电梯空,输入结束——调度器线程结束,所有WaitQueue设置调度停止标志
- Elevator检查调度停止标志——电梯线程结束
为保证安全,线程结束部分的判断均进行了加锁。
复杂度分析
仅截取飘红部分:
策略类方法依旧英雄不减当年...本次涉及到了多个线程互相唤醒结束,因此需要在很多地方互相判断结束或等待状态,判断方法可能被调用了很多次,于是也红了。
本次电梯类几乎没动,也没有用到很特殊的调度策略(其实调度器策略类没用了),所以只有涉及调度的Strategy代码较多,其实也可以将优化的调度部分拆分成较小的类,但其实一百多行也不是很多啦,拆分太多类与类之间互相调用反而可能会更复杂一些。
自己的bug
本次作业终于顺利活过了强测与互测,但其实在本地测试时遇到过可能结束不了的情况,尽管没有轮询和死锁。当然,指导书要求至多加入两部电梯,可能因为时间较短,出现一次不能停下后就再也不能复现的。本地测试当然是要对程序疯狂点炒饭,于是我直接0s加入20部电梯,结果有高达10%的概率程序停不下来。
我认为这是在程序结束时调度器和电梯互相唤醒部分出现了问题,具体来讲是电梯检查了输入线程结束标志,调度器未结束标志,并发现自己内部没有需求,外部没有等待需求时决定wait,设置自己的status为Waiting并通知调度器自己wait了,而在刚好wait并通知了调度器之后但还没有执行wait之前,CPU分配给了调度器线程(好巧不巧),此时调度器线程发现所有电梯状态都是Waiting,于是以为所有电梯真的在睡着,就唤醒了所有电梯并结束了自己的线程。而当CPU再次转到刚才那个电梯时,它才真正开始wait,也就是说刚才唤醒早了,而这次wait之后调度器已经结束了,就再也唤醒不了电梯了。我认为每多一部电梯就多一个这样不巧的位置,于是当电梯足够多的时候,bug也就复现了。
思考后我认为可以借鉴单例模式的双检锁方法,在通知调度器之后再判断一次调度器状态,如果此时调度器已经结束了,那么电梯将不执行wait,而是直接结束线程。伪代码:
if(Dispatcher.inputEnd() && !Dispatcher.isEnd() && this.hasNoRequest()) {
this.status = Waiting;
notify Dispatcher;
if(Dispatcher.isEnd()) {
return;
}
else {
wait();
}
}
思考
可能这周不太忙了,有时间自己写一些短小的程序验证多线程的各种行为了,本次作业突然对线程和锁有了更深的理解,原来线程之间就靠着同一个锁进行着协作,因此尽管多了一个线程,但代码写起来更加顺手了。
本次作业虽然没有采用标准的单例模式(每次获取对象),但将公共部分全部放进了不同的静态类,调用时只需要调用相应的静态类,上锁时也只需要锁住相应的类,避免了各个类之间互相作为成员变量乱牵线的情况。
互测体验
第五次作业时有些异常忙碌了,因此也没太关注互测,倒是在课下白盒测试出了一个同学的bug,但交了两次没有hack中也就不了了之了(反正太忙了啊啊)后来屋内有人用一条Morning语句成功hack,不得不说,不论作为写代码的还是测代码的,过于关注高级问题的往往忽略了近在眼前的小麻烦。
第六次作业结合了黑盒白盒测试,黑盒测试查出了一个同学没能把所有人运送到指定位置,白盒测试手造极端数据测出了一个死锁一个CTLE,如果我没记错的话手造数据还测出了一个报异常的同学,但交了几次没刀中反而刀偏了[捂脸],反而社死地刀中了另一位同学三下。本次互测体验极为微妙,最后一分钟测出了CTLE交了数据(激动的心颤抖的手),然而这一刀不光刀了我最好的朋友,甚至在接下来的一周和她一起吐槽这个最后一分钟刀她的人,结果公布身份时发现狼人竟是我自己[悲]。
第七次互测沿用第六次的模式,周二跑黑盒测试,周三早起做白盒测试。A房限定,黑盒测试大家都很安全,于是白盒手造极端数据卡策略,通过改自己的电梯调度使其适配极端数据后跑进70s,虽然但是,有点为了刀而刀了。本次我也发现了一个问题,有些同学通过打表手造固定的分配策略,有些同学分配时策略过于简单(比如像我之前一样不考虑电梯人数等),这种问题可以直接通过读代码被发现,尤其在配上ALS算法的电梯运行时更为致命,于是可以有针对性地构造测试样例,0.6s的A电梯真的很容易炸出210s限制。ALS算法会在电梯空了的时候接上最近的请求并把它作为运行方向,这时候只需要先占用BC两类较快电梯(一般人在没得分配不会调度之后就只发分给第一个电梯,而第一个电梯恰好是A),然后再安排请求与电梯的运行顺序反向,电梯就需要每次只载一个人跑全程[无奈]。
当然,我的手造数据还很弱只够hack两个人,后来借鉴了大佬的数据后竟然一刀四人(光速离开这座城市),其中一位还以211s的时间刚好越线,而且可能某位同学运气不太好不巧遇上了个有点慢的分配,出乎意料地多刀了一个人。
总结
当OO最难月遇上冯如杯ddl高发周,卑微的孩子差点被肝到吐血。然而每次还是忍不住地熬夜花费大部分时间加优化(人类的本质,如此贪婪)。过程中很多略显中二的感慨到了结束时也就剩下简单的一句话:很累,但也很快乐,我变秃了,但或许也变强了一点点。不论怎么样,新的爆肝旅途又要开始了,奥利给!