在我开始写这次博客作业的时候,窗外响起了希望之花,由此联想到乘坐自己写的电梯FROM-3-TO--1下楼洗澡,然后······
开个玩笑,这么辣鸡的电梯肯定不会投入实际使用的,何况只是一次作业。还是从中认真分析一下经验和不足吧。
作业一:FIFO单电梯
现在看起来,作业一的难度在整个单元真的仅仅相当于热身。使用线程安全的集合类ConcurrentLinkedQueue存储线程,再用while(true)轮询的方法实现电梯的获取请求,获得请求后运行电梯,运行结束后等待下一个请求即可。整个程序甚至不需要使用wait(),notifyAll()和synchronized修饰符。
唯一的一个坑点在于终止条件的设定:除了输入终止外,还需要当前存储请求的队列为空。有一个条件不满足,电梯就不能停止运行,对应在程序中也就是Elevator的线程不能停止(不要停下来啊?:)
第一次作业的类图和度量分析如下。
从数据表现上看,由于本次作业比较简单,设计复杂度并不大,但个别地方仍有提升空间。
另外,在Elevator对象中又跑了一个具有run方法的Queue对象(换句话说,输入和队列是一个合而为一的类),在第二次作业前的OO理论课上提到了这种情况应该避免,于是后两次作业Input和Queue(Monitor)都是分开的。
作业二:可捎带单电梯
在实际写代码之前,我也想过了一些或大或小的调度策略上的优化,但在简单程度和效率上均无明显优势。我也想过诸如是否某层开门即上全部人(题目要求是上前进方向相同的人)之类的问题,但仔细考虑之后,也确实未必比题目要求的做法快,于是还是忠实按参考算法实现了第二次作业的电梯策略。
按照之前说过的拆开Input和Monitor之后,整个程序更加接近于典型的生产者——消费者设计模式,生产者:Input线程;消费者:Elevator线程;缓冲区:Monitor。为优化效率和减小设计难度,我在Monitor的设计上下了工夫,用HashMap代替Queue,以fromFloor、direction(也就是signum(toFloor - fromFloor))作为key,每个key对应一个Queue作为value,存储特定楼层出发、前往特定方向的请求集合,然后根据题目要求的算法,改变电梯运行的流程,实现可捎带调度。
第二次作业的类图和度量分析如下。
这一次Elevator类和Monitor类的设计明显复杂许多,主要体现在run()方法和mainRequest的设置上。
在设计时,由于电梯的运行状态受mainRequest的运行状态直接影响,因此要考虑mainRequest的各种情况,尤其是direction这一参数,在上行、下行与暂停时都要仔细处理。
即便如此,在强测后仍存在一个bug,未能想到办法解决。
作业三:多电梯调度
这一次作业,据老师所说可能是本学期难度的顶峰,在艰难的coding过程中,我可谓深有体会。
从电梯的初始化开始,这次作业就充满陷阱。
(1)电梯与Monitor的初始化
每个电梯有不同的速度、载客量、可达楼层,在初始化时必须将它们作为参数导入进去。别忘了,还有电梯的ID。
而由于电梯在同一栋楼里,最低楼层和最高楼层是相同且有限的。很自然地,我想到用boolean数组表示电梯在各楼层允许停靠的状态。
电梯的初始化就算解决了。
而对于Monitor,由于存在一些靠单个电梯不能直达的请求,为了实现这些请求的拆分处理,Monitor必须知道每个电梯可停靠楼层,因此用于初始化各个电梯的可达楼层数组信息,必须全部导入Monitor。
至于Input,这时候拆分Input和Monitor的好处体现出来了——可以直接沿用上一次作业的Input类。
(2)Monitor的大坑点——请求拆分
如果不是因为请求拆分,这次作业的难度其实也不会特别大。
但就是因为有了这个拆分请求的特殊要求,这次作业就有了一个超级大的坑点。对于需要换乘的请求,由于存在A->B->C的时序关系,不能简单的拆分为A->B和B->C。怎么办呢?只能自建一种结构来存储这些需要换乘的请求。先存储这个请求原本的样子,再用一个Queue(当然用List也是可以的)来按时序存储这些请求拆分后的分请求。至于拆分点的选择,就根据之前初始化Monitor时的数组,选择一个从fromFloor和toFloor都可以直达的楼层(这里有一个限制,因为本次作业每个请求最多换乘一次,所以每个请求最多也就两开花,不用再做更复杂的细分。这肯定是限制了这个程序的可扩展性,但是生活实践中也很少出现需要换两次以上电梯才能到达目标楼层的情况吧)。至于在哪个楼层进行换乘,我采用了比较简单的做法:理论最近距离。也就是在所有能够换乘的floor中,选择abs(fromFloor-floor)+abs(toFloor-floor)最小的floor。这个做法的灵感是去年DSP课上的地铁最短距离,应该说更贴近于用户的思维:怎么短怎么来,距离短的走法肯定快。其实论实现难度,还有更加简单的拆分方式,是我室友采用的:1层和15层每个电梯都能到,选这两个楼层就完事了。
(3)Elevator的大坑点——又是终止条件
对于每个Elevator的调度,可以采用第二次作业的捎带算法,不过是加了些限制条件:只能拉取自己可到达楼层的请求、容量已满时不能上人等。
之前提到Queue里按时序存储拆分后请求,这样对于每个Elevator,拉取请求时都是从Queue中拉取第一个请求,就避免了出现时序错误(先B->C后A->B)的问题。
同样是为了简单起见,各个线程各自从Monitor中获取(更准确的说叫做“抢”)满足条件的请求,获取之后就不存在由别的电梯来执行这一请求的可能性了(怎么有种下一单元出租车的感觉?),避免执行时可能出现的混乱。
本来以为这样就结束了,结果······3个中测点没过?
多次debug后终于发现问题所在:存在一种情况,Monitor的Queue空,输入已经停止,但电梯中运行着需要换乘的请求,这个请求跑完后,又要放回requestList中,而按照之前的处理方式,程序已经视为结束了,能换乘的电梯肯定都停了。距离结束还剩两个小时,怎么办?
办法总比困难多!在这生死存亡的危急时刻,我想到了一个简单的方法:再建一个Queue专门存储需要换乘的请求,当这些请求的拆分出来的第一个分请求执行完以后就从Queue中删除,这样终止条件加上一条:这个新建的Queue也空。问题就解决了。
可是一提交,前9组全过,最后一组CPU时间超时,不知道因为什么,有电梯进程开始了魔鬼般的疯狂轮询。怎么办?只能采用没办法的办法,遇到轮询先sleep一定的秒数,虽然治标不治本,但是CPU时间确实下降了足足90%,过掉了最后一个点。
意外又发生了,跑了同学的强测数据,炸了几乎所有的点。仔细一看,出现问题的指令都是通往特定楼层的。稍微想想都知道,是那个面向过程时代遗留下的bug类型:数组处理错了。毕竟楼层有正有负,中间却少了个0层,映射到从0开始的连续的数组下标,情况还是有点复杂的。改掉之后,测试点全过,强测和互测都没出现bug。
第三次作业的类图和度量分析如下。
毫无疑问,程序又复杂了一大截,不过相比上一次作业,类图结构没有大的变化,仍然是典型的生产者——消费者设计模式。
最后强测中的效率并不理想,几乎都是保底的分数。除了电梯的调度算法外,我想到了一些可以在细节上优化的地方:
a.在换乘楼层的选择上结合电梯运行状态,而不是机械地选择最短路程。
b.换乘请求执行时,执行第二级请求的电梯预先到达换乘楼层,提高效率。
c.电梯满员且不下人时不停靠。
······
如果实现这些细节,也许性能分能提升一大块。但写的时候时间已经严重不足(因为搞OS,周一才开始写这次OO的作业,截止前2小时才de完bug),也只能说是遗憾吧。
什么,互测?这个应该说在自己写电梯的时候投入了太多精力,无心再hack其他人的bug了吧。另一方面,即便想hack其他人,也没有找到一个能够定时投放需求的方法(也就是不会写测试程序)。
本单元作业的心得体会
多线程一时爽,线程调度火葬场(确信)
在第一次作业中,可以说是很好地体会到了多线程的好处,而后两次作业,则是完完全全地体会到了写多线程程序的辛苦,无论是wait()和notifyAll()的正确运用、synchronized修饰符和锁的正确使用,都让我花费了好多时间才搞清楚。更别说复杂的线程调度了。
也正是在这种复杂中,我更加深刻地体会到了依照固定的设计模式进行程序设计的重要性。它使我从贼费脑细胞的程序框架设计中解脱出来,直接进入具体问题的解决环节,用上一单元指导书的话讲,真的就是避免了“重复造轮子”,可以说是受益匪浅了。在接下来的OO学习中,熟悉几个经典而用处广泛的设计模式,用模板化的思维来节省设计时间,把更多的时间投入在性能优化上。