相比于Unit1的表达式求导,Unit2的多线程电梯听上去似乎显得更加“高大上”。但在完成了3个task的迭代后再回过头去比较这两个单元,我发现其实它们的侧重点并不相同:Unit1要更偏重于横向的架构设计,如何实现性能与架构之间的平衡;而Unit2由于涉及了多线程,因此更多的难点与重点则放在了线程安全与同步上。故而单纯的从代码量上来看的话,Unit2似乎反而还要少一些,但这并不意味着Unit2的复杂度有所降低,相反,由于允许线程间时间上的无序性,一切反而显得更加不可捉摸。
整个第二单元,我采用的主要模型都是生产者-消费者模型,即构建一个Building
类从控制台实时获取请求,一个Elevator
类获取请求并模拟电梯的运行,还有一个Scheduler
类居中调度,同时维护全部的等待队列。最终版本的时序图如下所示:
Task1
任务目标:实现单部电梯的模拟
任务特点:理想电梯,无容量限制且所有楼层均可达
代码结构度量
在Task1中,整个Unit2的架构设计可以说已经基本成型。除了Building、Elevator、Scheduler三类外,Passenger类就相当于在电梯与大楼间交换的“产品”,而Floor楼层类某种意义上则是笔者对于细粒度的架构的追求的体现,它将到达的乘客按照出发楼层和移动方向做了进一步的细分,从而尽可能地缩小共享对象的范围,实现更高的性能。
代码复杂度分析
NOF | NOM | NOPM | LOC | WMC | DIT | LCOM | |
---|---|---|---|---|---|---|---|
Building | 2 | 2 | 2 | 30 | 4 | 0 | 0 |
Config | 8 | 0 | 0 | 10 | 0 | 0 | -1 |
Elevator | 9 | 11 | 2 | 142 | 30 | 0 | 0 |
Floor | 2 | 4 | 4 | 34 | 8 | 0 | 0 |
Main | 0 | 1 | 1 | 9 | 1 | 0 | -1 |
Passenger | 3 | 7 | 7 | 36 | 8 | 0 | 0.285714 |
Scheduler | 4 | 8 | 7 | 65 | 14 | 0 |
Task1的复杂度不高,不过我觉得这和本单元总体上的代码量都不大有关。可以看到,Elevator类的复杂度显著高于其他类,这也是由于电梯运行本身策略就相对复杂所决定的。
测试
Task1的互测我有一个bug被de出来了,主要是自己忘记及时更新Scheduler的empty标志而导致产生了不必要的CPU轮询最终引发了CTLE。现在想来,除了自己当时写代码的时候太过疏忽大意外,也有自己的本地评测机由于是刚刚搭建因此仅支持功能性测试,并未对运行时间进行检查而导致出错的原因。这也充分地证明了性能测试的重要性,我在Task2的测试中也基于此进一步完善了自己的评测机。
关于本地测试,我仍然是使用python实现。Task1的评测机支持的功能有:
-
随机请求生成
-
获取程序输出(subprocess实现)
-
正确性检查
-
数据可视化(matplotlib实现)
-
批量串行测试(自测+互测)
所谓的数据可视化,是指我把运行的输出绘制成了一张图,这样在一定程度上更容易自己肉眼定位bug(
其实就是好玩),对于一些覆盖性测试会有一定帮助。具体的效果图如下(此为最终task3的可视化版本):
性能与调度策略
对于单部电梯,性能的好坏在很大程度上取决于调度策略。但是,考虑到测试数据点数量不多、产生方式随机、到达时间不可预测等原因,我们必须要承认的是不存在一种针对总运行时间最短的绝对全局最优的调度算法。这一点,可以说是Unit2相比于Unit1在性能优化方向上最大的不同。当然,如果我们对于已经到达的乘客进行模拟运行,或许可以得到一个当前最优的调度算法,但付出的代价是整体复杂度的显著上升以及对向后扩展迭代可能会造成不必要的阻碍。而且如果下一个乘客的到达请求恰好与你之前的选择相悖的话,可能反过来还会蒙受一定的性能损失。基于此,我在Task1确定的调度策略基本上与现实中的电梯运行策略一致,即:
-
若电梯内有乘客,则优先满足梯内乘客的请求
-
捎带只捎带同方向的乘客
-
当梯内没有乘客时,优先选择距离电梯所在层最近的乘客的楼层作为目标层
但是从结果上来看,这个算法的性能并不是非常理想——有2个数据点的性能都是0分。根据我事后的分析,主要原因应该是电梯在没人时的表现尚不够灵活——一旦选定新的目标层,电梯就不会再调整方向,这样的话如果有后来的乘客所在楼层离电梯更近但又恰好位于反方向的话,就会造成性能的损失。这也成为我Task2优化的主要突破点。
不过总体而言,Task1的调度策略已然为整个Unit定下了一个基本的基调与原则,后续的优化则主要是一些局部的完善与调整,整体上无大的变动。要之如下:
-
不拘泥于某种现有的电梯调度算法
-
以现实中的电梯为基本的运行参照,要求自己的电梯性能期望不得低于现实中的电梯
-
采用局部贪心作为整体策略的基本设计原则,努力通过局部最优来逼近全局最优
-
整体策略上保持低复杂度与高灵活度
Task2
任务目标:实现简单多部电梯的模拟
任务特点:有容量限制,所有电梯的参数均相同
代码结构度量
代码复杂度分析
NOF | NOM | NOPM | LOC | WMC | DIT | LCOM | |
---|---|---|---|---|---|---|---|
Building | 3 | 2 | 2 | 30 | 4 | 0 | 0 |
Config | 11 | 0 | 0 | 13 | 0 | 0 | -1 |
Elevator | 7 | 8 | 2 | 114 | 22 | 0 | 0 |
Floor | 2 | 5 | 5 | 58 | 14 | 0 | 0 |
FloorLogOutput | 3 | 7 | 7 | 73 | 14 | 0 | 1 |
Main | 2 | 2 | 1 | 28 | 3 | 0 | 1 |
Passenger | 3 | 11 | 11 | 61 | 16 | 0 | 0.181818 |
PollLog | 4 | 2 | 2 | 28 | 3 | 0 | 0 |
Scheduler | 3 | 9 | 9 | 80 | 18 | 0 |
可以看到,Task2的Elevator类相比于Task1复杂度反而降低了!可见Task2在架构上要比Task1的表现要更好,各类之间的分工更加明确,耦合度与复杂度也顺理成章地降了下来。
测试
公测与互测中都没有被发现bug。在互测中,利用自己的评测机进行批量测试找到了一个其他同学的RTLE型bug,初步估计应该是对方的调度策略有一定的问题。
关于本地测试,自己的评测机在兼容task1的基础上,又增加了一下功能:
-
支持判断RTLE与CTLE型错误,其中RTLE直接通过输出的总时间来看,而CTLE则利用了java自带的
com.sun.management.OperatingSystemMXBean
包中的getProcessCpuTime()
实现。 -
支持并发多进程测试,极大地提高了测试效率,通过Python的
multiprocessing
模块实现。 -
支持并发请求预存储,避免因用户程序无法正常终止而导致的测试数据丢失问题。
性能与调度策略
Task2的性能要显著优于Task1,虽然并不清楚这是否是因为性能分计算方法改变的原因。但有一点是显而易见的,Task2中的性能值不再仅依赖于单部电梯的调度策略。这里面至少还涉及到不同电梯之间的协同。对于这一点,我采取的就是类似现实中的电梯会采用的方法,即如果电梯里都没人,那么当某一层有人按按钮时,所有电梯会一齐向该层移动,谁先抢到就归谁。虽然这在客观上可能会导致电梯资源的浪费,最坏的情况是另一侧同时也有一个请求但相对较远一些结果就被忽视了。
但反过来,有没有什么办法来处理这个最坏的情况呢?
第一层:只要有一个电梯向该方向移动,那么别的电梯就不用管了。
第二层:万一这部电梯在移动过程中遇到了别的请求然后装满了或者反向了怎么办?
第三层:这个时候再让别的电梯去显然比原来慢,所以我们一开始就要判断有没有可能会在移动过程中遇到更多的乘客。
第四层:然而我们无法预知未来……
所以,既然没办法从根本上解决这个问题,我的选择就是按照现实中的情况来,当然这里面有一些偷懒的成分(或者说保守的成分)。但要想严格从数学的角度来论证的话,正如王旭老师所说,我们就必须对数据的分布做一个基本假设,是均匀分布还是指数分布还是正态分布呢?我们并不知道,于是我们连计算每种策略的期望性能都没有办法做到,严格的数学论证也就无从谈起了。
此外,在一般的上下客操作外,电梯端我还引入了换客操作,即如果此时梯内人数已满但梯外仍然有人要上,那么我就会将电梯内目标层距离当前层最远的乘客与梯外目标层距离当前层最近的乘客进行比较,优先目标层更近的那一位。因此如果不巧电梯内的这位要去的目标层更远,那他就可能中途被踢出去(虽然非其所愿)。可以想见,这样的一个优化,在绝大多数情况下,也是可以保证一个正向更优的。
Task3
任务目标:实现多部不同电梯的模拟
任务特点:
-
支持电梯动态加入
-
不同电梯的参数不同(容量、移动时间、可达楼层)
-
性能值包括了所有乘客的等待时间
代码结构度量 & 架构设计与可扩展性分析
Task3由于不同类型的电梯的可达楼层不同,也就是涉及到换乘的操作,因此过去的一些策略就不太好直接拿来用了。但是我们如果换一个角度来思考,即如果我们可以预先知道乘客的预期换乘层,那么我们只要把原来的目标层用换乘层来进行替换,就可以直接把之前的绝大部分策略设计拿来用。正是基于此,我选择了静态换乘而非动态换乘。
此外,静态换乘策略还有诸多优势。首先,静态换乘的出发点是只要我们知道各类电梯的可达楼层,我们就可以提前给出每一组(出发层,目的层)所对应的换乘层,从而将整个换乘策略独立出来单独维护,显著降低了模型的耦合度。不仅如此,静态换乘策略还是一种端到端的策略,换乘层的选择由开发者决定,使用者只需要提供(出发层,目的层,电梯种类,运行方向)这一组参数就可以立即得到相应的换乘层,时间开销与逻辑复杂度都趋于0,并且还极大地降低了出bug的风险与debug的难度。
结合上面的UML类图来看,TransFloorTable类维护的就是这样一张换乘表,而其它类的设计几乎没有什么大的变化。如此一来,整个架构非常契合“高内聚,低耦合”的设计原则。对于日后的扩展而言,如果再增加新的电梯种类,或者已有电梯的可达楼层发生改变的话,只需要对换乘表进行增添或修改即可。
除了TransFloorTable类外,task3还新增加了PollLog类。该类存在的意义主要是记录scheduler每次调度的基本信息,从而实现电梯之间的协同运行。形象地说,就是让整个电梯系统拥有了“记忆“。这样一来,如果某名乘客的请求需要两类以上的电梯,后启动的电梯就可以依据之前的调度信息提前前往换乘层,从而缩短运行时间。
代码复杂度分析
NOF | NOM | NOPM | LOC | WMC | DIT | LCOM | |
---|---|---|---|---|---|---|---|
Building | 2 | 2 | 2 | 36 | 6 | 0 | 0 |
Config | 14 | 0 | 0 | 16 | 0 | 0 | -1 |
Elevator | 9 | 10 | 2 | 164 | 35 | 0 | 0 |
Floor | 3 | 5 | 5 | 52 | 11 | 0 | 0 |
FloorLogOutput | 1 | 7 | 7 | 62 | 13 | 0 | 1 |
Main | 0 | 1 | 1 | 11 | 2 | 0 | -1 |
Pair | 2 | 4 | 4 | 24 | 6 | 0 | 0 |
Passenger | 5 | 16 | 16 | 97 | 25 | 0 | 0.125 |
PollLog | 4 | 5 | 5 | 31 | 7 | 0 | 0 |
Scheduler | 6 | 9 | 9 | 114 | 27 | 0 | 0.333333 |
TransFloorTable | 1 | 10 | 4 | 250 | 64 | 0 |
Task3与Task2相比,各类的复杂度都有所上升,但总体上仍然保持了一个较为均匀的比例。反倒是新加入的TransFloorTable类异军突起,复杂度一跃成为最高。究其原因,我认为是由于自己这里是将换乘表用逻辑语句直接封装在了该类中,并且我制定的换乘表(如下所示)粒度非常细,所以代码量也就相应上升了。
在实际应用中,我认为更加合理的策略显然是用数据库(如Redis)去维护这样的一张换乘表,如果是那样的话,TransFloorTable类的复杂度毫无疑问就可以显著降低了(仅作为接口存在即可)。
测试
Task3的公测也没有bug,互测阶段我利用自己的评测机,成功地发现了一位同学出现的CTLE型bug,但自己的程序也神奇的被别人hack出了一个bug。然而,自己这个被hack的bug在本地根本无法复现,并且经过自己的分析也基本上排除了死锁等线程安全出问题的可能,并且在啥也没改的情况下交上去又过了,实在是让人摸不着头脑。(具体详情见bug提交报告,这里不表)
Task3的评测机一方面对原有的功能进行了一定的修改,使得其能够对多部电梯不同的策略进行检查,同时还增加了覆盖性测试的功能,这主要是为了避免自己的换乘表出现问题。但没有什么技术含量特别高的改进,故在此不表。
但不得不说,虽然Task2本身作业的代码量不大,但评测机的代码量却不小,甚至前两个Task还超过了作业本身的代码量。这大概就是OO给我带来的额外收获吧hhh。
性能与调度策略
Task3在Task2的基础上,除了引入换乘表外,策略上最大的优化就是引入PollLog进行调度日志的记录了。但除此之外,其实Task3还有非常多的细节上的优化,这些优化虽然不起眼,但我认为它们放在一起对于性能的贡献还是不容小觑的,就是不太好起个统一的名字罢了。列举如下:
-
Floor级:在原有的按照目的楼层距离排序的基础上,当电梯运行方向为空时,若电梯位于大楼上部则优先寻找向上请求的乘客,反之亦然。这主要是考虑到A类电梯的可达楼层位于大楼两端的原因。
-
Elevator级:下客在电梯开门瞬间完成,上客在电梯关门瞬间完成,从而减少乘客等待时间
-
Elevator级:若本层没有乘客要下,电梯会权衡是否需要开门进客。具体而言,若开门一次导致所有梯内乘客等待时间的增加量大于开门进客所节约的该名乘客的等待时间的话,电梯会选择不开门。
-
Scheduler级:获取新的运行方向时,优先距离最近的楼层所在方向为运行方向。若不存在正在等待的乘客,再查询换乘日志判断是否有乘客正在换乘途中。
至于多梯协同方面,由于某些请求可以被多类电梯完成,但当请求到来时,我们并不能够确定哪一类电梯会最先到达。因此我的解决方案就是同时将该名乘客放在同层的不同类电梯的等待队列中,就像是现实中这个乘客在等电梯的时候并不死排某个固定的电梯的队,而是东看看西看看,如果有别的电梯先来了,他也会赶紧去坐那个电梯一样。当然,这里为了避免这个乘客同时被多个电梯线程获取,我们有必要在真正让其进入电梯前,进行一次check-then-act
操作,确保此时他没有进入别的电梯。显而易见,这个操作必须是原子的,但是实现起来也不难,只需要利用synchronized
关键字对Passenger
上锁再结合Java的Atomic类型设置进入电梯的标志即可。
SOLID原则的应用
Single Responsibility Principle:单一职责原则
三次作业的各类的分工均十分明确,符合单一职责原则。
Open Closed Principle:开闭原则
本次作业不存在父子类继承的问题,故开闭原则体现的并不明显。
Liskov Substitution Principle:里氏替换原则
本次作业不存在父子类继承的问题,故里氏替换原则无法体现。
Law of Demeter:迪米特法则
这里Building类与Elevator类均通过Scheduler实现对等待队列的存取,但Building与Elevator之间不存在直接通信,符合迪米特法则。
Interface Segregation Principle:接口隔离原则
本次作业中没有行为级接口,Config接口的存在主要是为了维护一些所有类共享的宏观常量参数,属于静态接口,故不适用于接口隔离原则。
Dependence Inversion Principle:依赖倒置原则
所谓的上层不依赖于底层,是指若底层有多种实现的模块,上层不应该依赖于底层的具体实现。但上层显然要依赖于底层提供的具体接口。以本次作业为例,等待队列我先后使用了ArrayBlockingQueue与PriorityBlockingQueue两种实现方式,但它们提供的方法均为Queue所固有的isEmpty()
,poll()
与add()
方法,这就符合依赖倒置原则。