BUAA_OO_2021_第二单元总结
BUAA_OO_2021_第二单元总结
第二单元的作业是设计一个电梯调度程序,尽快将所有人送到目的地。主要训练了我们多线程编程能力,包括如何在多线程中安全地通信、互斥和同步,还有通过分析多线程运行逻辑避免死锁的出现。在此基础上,依照SOLID原则进行架构设计,在层次分明的模块结构上进一步设计调度策略,取得更优化的性能。
第一次作业
作业思路
我选择的是生产者消费者模式,实现策略的方式是策略类查看电梯运行状态和候乘表的状态来为电梯运行提出建议。
比较创造性地提出了“策略类—电梯类”分离模式,电梯类作为一个线程类,和乘客表类一起,主要负责电梯的正常运行和安全保证。这点很重要,无论电梯与哪个策略进行对接,都可以100%确保电梯的运行正确,即不会出现关门时上下人、行动过快、把乘客困死在电梯、乘客影分身等等问题。而策略类则是对LOOK算法,SCAN算法、ALS算法等的抽象,通过研究电梯目前的运行状态、载客情况向电梯提出建议,即向哪个方向走,在某楼层接哪些人。
策略类的建议模式,是有很大优点的,它只建议电梯去某楼接某人,而不是安排电梯一定要接到。所以如果两个电梯同时收到策略给出的接同一个人的建议,将只有一个电梯可以请到乘客进来,另一个将会抢空,可以去请求新的建议。
通过这样的模式,我可以放心大胆的进行优化,除了让电梯原地打转完不成任务的安全问题之外,基本上不会出现其他安全问题。这也为我本单元的性能优化奠定了良好的基础。
量子电梯
因为评测机关注是输出的关键节点的信息,如到达某层的一瞬间、门刚打开的一瞬间和完全关闭的一瞬间。并不关注电梯在其他时间的运行状态。而量子力学告诉我们,实物粒子在没有被观测的时候处于量子态,而被观测后发生塌缩。换句话讲,"电梯开门需要200ms,关门需要200ms"和"电梯关门这一事件必须发生在电梯开门400ms"以后是完全等价的。“电梯移动需要400ms”和“电梯移动这一事件必须发生在上一个(关门和移动)时间400ms之后”是完全等价的。
其实实现起来很简单,只需要记录「上次动作时间」然后每次需要移动或关门的时候检查一下是否已经达到时间限制,若是,就直接瞬移。量子电梯的优势包括以下三点:
- 无论电梯开门时以什么样的时间顺序上人,都保证关门时间与开门时间差400ms
- 电梯空等后接第一个人时可以实现一次跃迁节省一次电梯移动时间
- 电梯的关门和移动未完成时,如果有新的客人请求可以直接让电梯受理。(电梯处于
<移动|
和<在原地开门|
的量子叠加态)
类说明
本次作业有四个主要类
RequestTable
作为生产者消费者模型中桌子的职责,负责管理所有没有上电梯的人。InputHandler<<Thread>>
负责从模拟控制台读入请求放进RequestTable
。Elevator<<Thread>>
作为消费者,从RequestTable
上拿走请求,并将其处理掉。Strategy<<Interface>>
是Elevator
的一个建议者(而不是控制者),通过收集信息,来告诉Elevator
应该从RequestTable
中拿取哪些请求。
多线程设计
本次作业共有两个线程,InputHandler
和Elevator
,它们共享一个线程安全对象RequestTable
。RequestTable
对象的锁是本次作业唯一的锁,提供了两种作用:
一个是提供互斥的加人方法、删人方法、关闭方法还有查询全部方法,防止多线程同时访问造成冲突。
另一个是提供了两个同步方案:1. 当没有请求时,电梯线程可以wait在表上,当有新的输入请求就叫醒这些线程;2. 当电梯执行花费时间的操作时(如close和move)不使用Thread.sleep
方法,而是以等待时间为超时时间wait在表上,此时如果出现新的请求,则可以中断当前电梯的动作,并让电梯重新根据当前信息进行策略抉择。这样的目的是为了实现量子电梯的特性,当量子电梯正在为关门或者为移动攒时间时,新来的请求可以将电梯中断,让电梯去接那个新来的人。
调度器设计
本次作业没有设主调度器。让电梯作为线程运行,通过询问Strategy
类来进行决策。交互方式是电梯用自己的运行信息为参数调用Strategy
类的决策方法,然后返回决策结果。然后电梯依据决策结果从RequestTable
类拿取请求。因为策略类仅仅会由电梯这一个线程访问,所以没有线程安全的问题,没有加任何锁。
调度策略
考虑到存在如下请求序列,对此,ALS算法效率简直没有下限,一度让我感觉自己没有理解ALS算法的描述。所以就没有选择用它。
1-FROM-19-TO-20
2-FROM-18-TO-20
...
19-FROM-1-TO-20
我阅读了一些博客,看到学长们用的比较多的是LOOK算法,我觉得既然现实生活中大多数电梯都是LOOK算法,那一定是比较有优势的算法。于是我没有多想,直接实现了一个LOOK算法。并另外实现了两个优化:
- 如果是早上,等到电梯满了或者输入结束再一起出发
- 对于出发点同一楼层的请求,按照结束楼层排序,使得一个来回中客人目的地分布尽量集中,让电梯载客率更大一些。
类图与时序图
第二次作业
类说明
这次作业相较于上次作业仅新加了一个接口,和一个对应实现。
Dispatcher
接口:任何实现了该接口的类应该实现新建电梯,为电梯分配请求的功能
CompeteLookDispatcher
类:用于实现竞争策略的的调度器,仅存有一个RequestTable
,每当新加电梯,就将电梯挂在到这一个RequestTable
上,让它们自由竞争。
调度器设计
线程可以选择两个或者三个,就在于调度器是否需要一个线程。我认真反思了为什么要多线程,认为多线程一定是服务于并行的。比如电梯运行和输入模块,必然需要两个线程。而输入模块和调度模块的运行逻辑似乎就是串行关系,多线程徒增复杂度,故将调度模块设为非线程类,每当InputHandler
想要输入请求,就调用Dispatcher
实例的方法,本质上是利用InputHandler
的线程资源来进行调度运算。
调度策略
本次作业分配策略可以选择自由竞争和分配两种。
去年的最佳算法是基于模拟电梯贪心算法的分配算法,不过即便如此贪心算法也是不能保证求得最优解的。其他的分配方法,也会有固有缺陷,出现本来能接但是不在它的请求队列就接不到的问题。从一开始就要短自由竞争一节。
于是我决定采用自由竞争。自由竞争的优化思路也更简单。分配算法的分配目标是保证每个请求耗费时间尽可能短。而我的优化目标可以是:让每个电梯尽可能在运转而不是空等。
类图与时序图
第三次作业
本次作业没有对结构产生任何更改,而是对A、B、C三种电梯分别实现了新的Strategy
类。
由于放人的逻辑不再是到站就下,所以将决定放哪些人的逻辑也放在Strategy
类的部分去实现。
由于输入结束不再意味着不会有新请求加入到请求表(换乘下人会重新进入请求表),所以对请求列表的结束逻辑进行了重新设计,当输入结束且所有电梯都开始空等时就关闭掉请求表。
调度策略
我进行了一些权衡,最终还是认为自由竞争会占优势。考虑如下序列,最快的运输方式不是全给C电梯,而是让所有电梯一起工作。分配算法无法忍受按时间平均分配的计算量,而自由竞争能很好的解决这个问题。自由竞争是这个测试样例的最优解。
1-FROM-1-TO-19
2-FROM-1-TO-19
...
50-FROM-1-TO-19
A电梯
A电梯主要算法还是LOOK算法。另外,作为什么都能干的电梯,应该在负载比较大的时候选择让渡将B和C能干的给他们干,这更有利于负载均衡。不过让渡算法并不会过于影响性能,能做多少是多少。
最终,让渡算法我实现的部分仅有在接人时优先选择偶数层下的人。
我还尝试做了一个相对复杂的让渡算法,如果电梯处于奇数层,且下一个偶数层时A容量接不到所有人,那么就把一部分人放在这个奇数层,让B来接。不过做出来之后用评测机一测,性能下降,且RTLE了几个点,便将这次更改丢弃了。
B电梯
B电梯就是使用LOOK算法,从1-19层中奇数层接上与运行方向相同的人,并将他们放在距离目的地不超过1层的地方,如果还剩下一层就让A来抽时间去收尾。当然,如果有的选,B电梯会优先选择在奇数层下的人。
C电梯
C电梯其实实现的比较复杂,考虑到C的三个主要用途:完成1-3,18-20的内部请求,利用过剩性能辅助AB电梯,远程运输。我为其安排了三个职责,其中第三个因为有待权衡就没有实现:
-
把1、2、3层的人送至3层, 把18、19、20的人送至19层,方便AB来接。
-
将所有FROM-(1、2、3、18、19、20)-TO-(1、2、3、18、19、20)的请求完成。
-
把FROM-(1、2、3)-TO-(11-17)的人运送至19层方便AB来接。这个优化有可能导致时间反而下降。
类图与时序图
可扩展性
SOLID原则
Single Responsibility Principle:单一职责原则
这方面做的不错,电梯类囊括了所有电梯的共性,和策略有关的特性都让策略类去实现。请求表类围绕着数据展开基本上已经是最小的实现了。
Open Closed Principle:开闭原则
三次作业架构基本没有变更过。第二次作业为InputHandler和RequestTable中间套了一层Dispatcher。第三次作业的终止逻辑需要大改确实是我最初设计时没有考虑到,一定程度上违反了开闭原则。
Liskov Substitution Principle:里氏替换原则
Dispatcher接口自始至终只有一个实现的类就不多说了。我为Strategy接口实现了许多类,每一个都是可以直接插在电梯上使用的。里式替换原则做的是不错的。
Law of Demeter:迪米特法则
第二次作业加上Dispatcher之后,InputHandler想要传输数据都经过Dispatcher,符合迪米特法则。
Interface Segregation Principle:接口隔离原则
电梯和策略的接口设计方面我已经尽我所能将其压缩了,应该算得上是最小接口了。
Dependence Inversion Principle:依赖倒置原则
电梯模块不依赖于策略类的具体实现,只依赖于策略类提供的决策方法,符合依赖倒置原则。
评价
这次作业对六大设计原则都较好的遵守了,第二次作业和第三次作业体验到了迭代开发的好处,节约的时间用来实现更加优化的策略,得到了不错的结果。
BUG分析
第一次作业
刚完成时,就激动地送进了自己的评测机,T了几个点,原来是LOOK算法的转向出了问题。改了之后就没有任何问题了
强测和互测均未出现bug
第二次作业
写完就没有bug,自己的评测机跑了许久也没有任何报错
强测和互测均未出现bug
第三次作业
自测出了一个大问题,因为换乘,RequestTable不再被放进请求的条件从输入结束变成了输入结束且所有电梯内请求为空,我没有意识到这个问题,结果爆了几个RTLE。
强测和互测均未出现bug
发现BUG策略
在本单元学习多线程之后,我发现自己可以写一个带GUI的多线程评测机。评测机采用Producer-Consumer模式和Worker模式,一个线程负责生产测试点,送进一个管道,许多Executor线程从管道中取得测试点,运行,并将结果送入结果管道,由GUI模块从中读取并显示出来。
数据生成部分采用了“具有某些特征的数据“的全随机生成算法。体现在两方面:
- 随机数生成器会有较大可能生成边缘数据。
def random_edge(m, M, r=5):
def logist(x):
return 1 / (1 + (math.exp(-r * (x))))
x = random.random() * 2 - 1
M = M + 1
k = (M - m) / (logist(1) - logist(-1))
return math.floor(k * (logist(x) - logist(-1)) + m)
- 数据会具有密集/分散/量大/量少等特征。
之所以采用全随机数据,因为如果手动构造测试样例,在Java部分没有考虑到的问题依旧不会在评测机部分考虑到,很可能漏掉重要bug。
相对于第一单元的全随机数据,新的生成方法降低了同质数据的量,大幅增加了较极端数据的量。
另外我也手动构造了一点数据,主要考虑了随机难以出现的极端现象。之后和老师的交流中学到了构造数据还应该考虑到顺序问题,这是我之前没有考虑到的。
互测BUG分析
第五次作业(3/5)
刀了3个人Rider、Alterego因为轮询出现CTLE,Assassin因为不知道什么线程原因出现了死锁。
还有另外两人有bug,Caster会在高线程并发时丢失全部输出,Archer会以极低概率生出人来。没有刀掉。
第六次作业(2/3)
Lancer会在最后输入电梯时不能正常退出
Berserker会在等电梯时利用多核CPU轮询,造成CPU超时
Archer本地稳定CPU超时的一个点传上去没有刀中,可能是Linux和Mac操作系统的区别吧,他应该没有轮询。
第七次作业(2/5)
Archer和Alterego由于采用了具有严重问题的ALS算法且没有进行换乘,被我针对性构造的一个点刀了真超时。
Rider存在线程安全问题的,不过不易复现就没去管。
Lancer和Assassin会在最后一个输入时电梯请求时(或某些其他情况)有中等概率出现死锁。于是我写了自动传点脚本强行hack。结果很糟糕,连传了两天也没有刀中。最后Assassin倒是把Lancer给刀了(* ~︿~)。
心得体会
多线程编程主要通过管程来解决同步和互斥的问题。要时刻留意是否代码在任意顺序下执行都能有正常的行为。
学习完多线程,能自己写出一个漂亮的功能完备的支持多线程并发显示的GUI评测机真是令人激动。
本单元中还是没能实现一次刀4个的壮举,非常的可惜,OO的最大乐趣就是互测hack别人的代码了,希望自己下一单元能再接再厉,构造出更加有效的测试用例。
附录:针对ALS构造的超时点
[0.0]Random
[3.0]ADD-114514-B
[3.0]ADD-1919810-B
[15.5]1-FROM-17-TO-20
[15.5]2-FROM-16-TO-20
[15.5]3-FROM-15-TO-20
[15.5]4-FROM-14-TO-20
[15.5]5-FROM-13-TO-20
[15.5]6-FROM-12-TO-20
[15.5]7-FROM-11-TO-20
[15.5]8-FROM-10-TO-20
[15.5]9-FROM-9-TO-20
[15.5]10-FROM-8-TO-20
[15.5]11-FROM-7-TO-20
[15.5]12-FROM-6-TO-20
[15.5]13-FROM-5-TO-20
[15.5]14-FROM-4-TO-20
[15.5]15-FROM-3-TO-16
[15.5]16-FROM-2-TO-17
[15.5]17-FROM-2-TO-17
[15.5]18-FROM-2-TO-17
[15.5]19-FROM-2-TO-17
[15.5]20-FROM-2-TO-17
[15.5]21-FROM-2-TO-17
[15.5]22-FROM-2-TO-17
[15.5]23-FROM-2-TO-17
[15.5]24-FROM-2-TO-17
[15.5]25-FROM-2-TO-17
[15.5]26-FROM-2-TO-17
[15.5]27-FROM-2-TO-17
[15.5]28-FROM-2-TO-17
[15.5]29-FROM-2-TO-17
[15.5]30-FROM-2-TO-17
[15.5]31-FROM-2-TO-17
[15.5]32-FROM-2-TO-17
[15.5]33-FROM-2-TO-17
[15.5]34-FROM-2-TO-17
[15.5]35-FROM-2-TO-17
[15.5]36-FROM-2-TO-17
[15.5]37-FROM-2-TO-17
[15.5]38-FROM-2-TO-17
[15.5]39-FROM-2-TO-17
[15.5]40-FROM-2-TO-17
[15.5]41-FROM-2-TO-17
[15.5]42-FROM-2-TO-17
[15.5]43-FROM-2-TO-17
[15.5]44-FROM-2-TO-17
[15.5]45-FROM-2-TO-17
[15.5]46-FROM-2-TO-17
[15.5]47-FROM-2-TO-17
[15.5]48-FROM-2-TO-17
[15.5]49-FROM-2-TO-17
[15.5]50-FROM-2-TO-17