第二单元总结

一、问题描述


本单元的问题,是编程经典问题电车难题电梯问题。一个目的选层电梯,在每层的电梯外侧都可以选择想要去的地方。在这个单元,不仅可以见到多个电梯来回鬼畜,还可以看到电梯故障吃人,多线程的bug应有尽有。

第一次作业不同往年,平移了上一届的第二次作业,需要制作一台可捎带电梯。电梯可以采用任意的调度策略,可以SSTF,可以Scan,可以Look,还可以随机游走,甚至造量子电梯电梯楼层从1层到15层,数量是1台,上升和下降的时间是0.4秒,开门0.2秒,关门0.2秒,没有容量限制,在输出开门信息后、输出关门信息前可以任意进出。

第二次作业则近似于原来的第2.5次作业,弱化了往年第三次作业的一部分,改成了5部电梯,电梯楼层为从-3层到16层,且在指导书中隐晦地提到对换乘的支持,除此之外与第一次作业并无差异,但实际上比第一次难度大

  • 第二次作业指导书的部分摘录,可以看出可换乘说得十分隐晦,不仔细看根本看不出来

  • 人员运行逻辑合理,假设请求为ID-FROM-X-TO-Y,则

    • 发出指令时,人员处于电梯外的X层

    • 人员进入电梯后,人员将处于电梯内,且必须是电梯外的当前停靠层的人员才可以进入电梯

    • 人员离开电梯后,人员将处于电梯外的当前停靠层,且必须是电梯内的人员才可以离开电梯

    • 人员不在电梯内的时候,不会有自行移动的行为。即,可以理解为人员会一直待在原地

  • 在电梯运行结束时,所有的乘客都已经到达各自的目标楼层电梯外,且电梯都已经关上了门。

第三次作业,在往年第三次作业的基础上更上一层楼,不仅将电梯分为A、B、C三类,每一类有不同的可达楼层与运行速度,因此明确写到需要支持换乘,有时候可能还得把一个人从电梯里踢出去,而且还需要支持动态加入新的电梯。

电梯类型:分别为A型,B型,C

  • 初始三部电梯A,B,C分别为A型,B型,C

  • 电梯可停靠楼层:

    • A型: -3, -2, -1, 1, 15~20

    • B型: -2, -1, 1, 2, 4~15

    • C型: 1, 3, 5, 7, 9, 11, 13, 15

  • 电梯上升或下降一层的时间:

    • A型: 0.4s

    • B型: 0.5s

    • C型: 0.6s

  • 电梯最大载客量(轿厢容量)

    • A型:6名乘客

    • B型:8名乘客

    • C型:7名乘客

 

二、程序设计


本单元实际上已经不可能使用单线程的程序完成作业,至少需要两个线程,一个用于控制电梯,一个用于接收输入。实际上,除了主线程外,我使用了三种线程完成本次作业。

  1. 算法设计

    在讲述本单元三次作业中电梯的做法前,我想要先谈一下我对于调度算法的理解。

    电梯的调度,实际上和磁道扫描十分接近,因此可以沿用磁道扫描中的一些思想,但仍不完全等同,这主要是因为电梯除了上人还有下人的任务。

    磁道扫描算法中,有以下一些经典的算法:

    Scan算法:使磁头总是在磁道的两端来回移动,并接受在途中遇到的请求。这一算法是最基础、最公平的算法,但在请求稀疏或一些特殊情况时,速度上可能存在一定的问题。

    SSTF算法(最短寻道算法):总是选择距离当前磁头最近的请求。这一算法在磁道扫描的过程中性能较好,但若用于电梯则需要注意很多问题,因为电梯除了上人还要下人,但也有已经成型的相关算法。

    Look算法:改进的Scan算法。当发现磁头运行的方向没有请求时,将磁头调转,接收反方向的请求。这样一来,就省去了空转的时间。在一些十分特殊的数据下,Look算法的性能将会变得非常差,但是这并不影响Look作为一种公平的、实用性较高的算法。

    在第一次作业中,我选用的算法是Look算法,例如,若电梯现在正在上行,如果上方没有请求进来,且电梯内部没有人想要在上方的楼层下来,就检查是否还有请求,若仍有未完成的请求则下行,否则停在原地。我使用一个分派器Dispatcher,兼做调度工作,以及一个包含了三种状态(上行、下行、停止)的电梯类Elevator。这也导致了我的分派器和电梯耦合程度较高。

    在第二次作业中,由于引入了多个电梯,分派和调度必须分开。考虑到调度算法对于每个电梯是独立的,我将调度器和电梯整合,形成了带有调度机制的电梯,分派器只需要管理请求的输入和分配即可。因此,我的电梯类Elevator变得十分臃肿,但这也是因为我做了一些处理:为了使分派器能够预测电梯在某个楼层会不会满员,我利用Look算法的性质,在电梯内部管理了一张表。通过电梯内接受的上行、下行请求的信息,以及电梯内乘客的信息,再加上电梯当前的运行方向,可以得到电梯在第二次换向之前哪些楼层将会满员。维护这样一张表的复杂度并不低,且在每次电梯中接收请求的时候都要更新,但这样至少比每次判断是否能够接收的时候现行判断要好得多。分派器中做的工作,是将请求较为均匀地放在各个电梯中。为了达到均匀,我设置了一个循环队列,当某一个电梯接收了请求时,我将指针指向其后继,下一次优先从其后继开始分配。但最后我仍然会选择一个按时间加权距离最近的电梯进行分配。

    第三次作业中,我沿用了第二次的调度算法,三类电梯都是Look算法。实际上,我也尝试着引入其他的调度算法。我将Elevator抽象成了一个接口,将A电梯改造成了SSTF算法的电梯,但后来发现效果并不理想,因此我的工程中虽有相应的基类SstfElevator,但并没有对应的子类。派生子类,完全是为了在构造函数中传入参数。对于换乘的实现,我将请求PersonRequest包装成了类Person,用Person类来支持换乘。而换乘的策略我没有做过多的研究,也没有使用弗洛伊德算法等计算最短路径,我仅仅规定,例如16楼以上去2-14楼在15楼换乘等,也就是将1层、15层、5层和-2层作为了中转站。虽然算法简单,但结果的性能也还算说得过去。最终的强测结果约99.5分,虽然很大程度上是性能分算法放水的功劳,但也可以说明这样的方法并不差。

  2. 数据结构设计

    由于第一次作业的电梯请求队列和主请求队列实际上可以合二为一,且我将第一次作业的电梯请求队列结构沿用,我不再详述第一次作业的数据结构。

    在后两次的作业中,对于主请求队列,我直接采用了单链表LinkedList来进行存储。由于我的分配算法需要对队列进行遍历,实际上没有必要选用线程安全容器,直接对请求队列上锁可能是不错的选择。由于需要支持换乘操作,我将请求PersonRequest包装成了另一个类Person。而对于电梯内部维护的队列,我则采用了双重ArrayList<LinkedList<Person>>的结构,在每一层维护两个链表,分别存储当前楼层的上行请求和下行请求。在电梯开门的时候,我就可以方便地分开队列中的上行请求和下行请求。而电梯内的人,也采用ArrayList<LinkedList<Person>>进行存储,只是每个链表变成了该楼层需要离开电梯的请求。

    对于第三次的电梯的存储方式,我采用了ArrayList。虽然在计算请求时使用循环队列的模式,我并没有在加入电梯时考虑这一点,而是直接加在数组的后方。我并没有使用工厂模式创建电梯,但我使用了类似的“工厂函数”,专门用于创建电梯并将电梯加入电梯队列。

  3. 线程协作设计

    在我的各次作业中,皆只有四类线程:主线程、接收器RequestReceiver线程、分派器Dispatcher线程和电梯Elevator线程。其中,主线程仅用来创建其他线程,创建成功后就直接结束。

RequestReceiver仅用来接收请求输入。每当一个请求被输入,RequestReceiver将请求放入队列并通知分派器。直到发现某一次接收到的请求是null,即结束输入,此时将RequestReceiver对象的开关stopped置位,以便于Dispatcher判断是否已经没有将会输入的请求。

Dispatcher用来创建电梯线程、向每个电梯分派请求,并将电梯内需要换乘的请求重新接收并加入请求队列。当发现每个电梯都不在运转,且请求队列为空,并且输入已经结束的时候,Dispatcher停止运行,并通知每个Elevator结束运行。

Elevator线程则各自不相干地根据内部的分请求队列自行运转。由于电梯内部设置了一个静态的表,我的电梯结构并不适合动态判断换乘,因此换乘逻辑设计得较为简单,只设了一个换乘队列,供Dispatcher取出并整合。Elevator中设计了一个terminate方法,用来供Dispatcher调用以停止运行。

三、程序结构分析


对于代码,我使用了DesigniteJava进行分析。以下是第三次作业的分析结果。

Total LOC analyzed: 2035        Number of packages: 4
Number of classes: 23   Number of methods: 212
-Total architecture smell instances detected-
Cyclic dependency: 0   God component: 0
Ambiguous interface: 0 Feature concentration: 1
Unstable dependency: 1 Scattered functionality: 0
Dense structure: 0
-Total design smell instances detected-
Imperative abstraction: 0       Multifaceted abstraction: 1
Unnecessary abstraction: 0     Unutilized abstraction: 12
Feature envy: 0 Deficient encapsulation: 1
Unexploited encapsulation: 0   Broken modularization: 0
Cyclically-dependent modularization: 1 Hub-like modularization: 0
Insufficient modularization: 2 Broken hierarchy: 2
Cyclic hierarchy: 0     Deep hierarchy: 0
Missing hierarchy: 0   Multipath hierarchy: 0
Rebellious hierarchy: 0 Wide hierarchy: 0
-Total implementation smell instances detected-
Abstract function call from constructor: 0     Complex conditional: 6
Complex method: 7       Empty catch clause: 0
Long identifier: 0     Long method: 0
Long parameter list: 2 Long statement: 4
Magic number: 53       Missing default: 10

从分析中可以看出,第三次作业包含了较多的复杂方法和复杂条件语句,在架构上也有类结构复杂等问题,且我构造的SstfElevator抽象类并没有被使用,在分析中也得到了相应的体现。

如图,可以看出电梯类的成员十分复杂,这也是因为电梯内部整合了太多逻辑的原因。实际上,可以选择将电梯类重新分成电梯和调度器两个类,这样逻辑关系将会显得更加清晰。

根据MetricsReloaded对第三次作业的分析,可以看出Dispatcher类和两个电梯抽象类ScanElevator和SstfElevator的方法复杂度看起来很病态,但这大概和我对每一个楼层都单独用一个链表存储有关。我的算法的实际复杂度并没有这么高。一些方法的结构化程度不好,和其他方法耦合程度高,这些在下次作业中需要尽量避免。

就SOLID设计原则而言,本次作业并没有满足单一职责原则,因为我将电梯和调度器整合在了一起,使得可维护性下降了。实际上,我也费了一些功夫使得ScanElevator类的代码没有超过500行。

在第三次作业的基础上,也可以进行更进一步的扩展。例如,如果想要加入电梯故障的模拟,具体行为是电梯内部人员被强行弹出电梯,并将电梯删除,可将内部人员全部放入换乘队列,并在电梯中设置一个开关,用于表示电梯被删除。Dispatcher将待删除电梯的请求取出之后,即可在电梯队列中删除该电梯。但是这样必定会对电梯的内部逻辑造成修改,不满足开闭原则。

 

四、Bug分析与发现


本单元作业中,我三次作业都没有被发现bug。然而在编写的时候,我因为线程安全的问题出了很多次死锁,基本上都是因为分派器和电梯互相等待唤醒。

在本次作业,我使用了评测机进行测试,效果良好。用评测机自动生成的数据中,我设置两个请求之间的时间差满足指数分布,且随机生成1到3条相同目的地或相同起点的请求。在评测方法上,我采用了延迟输入的方法,在输入前面加上时间戳,通过课程组下发的jar的改造版进行测试,对输出使用类似课程网站的评测机的方法进行解析,并给出正确性与性能的评定。具体而言,一种思路是设计一个模拟电梯类,让这个电梯在照搬执行输出的步骤的同时检查输出的合法性。在此我要感谢hjw、yjz等大佬对评测机做出的贡献。我也用这个评测机测出了一些别人的bug,包括死锁、电梯吃人等。

在评测机的制作上,我并没有在数据生成上下很多的工夫,因此对于bug的针对性不高。我测出别人的bug也一般是用评测机跑一晚上才能得到一两个bug。

 

五、总结与心得


本单元的作业使我初次接触到多线程编程,也了解到什么叫做“原子操作”、以及多线程存在的诸多问题。操作系统的课程也正讲到多进程/线程的问题,二者可以结合理解。我也发现了制作评测机带来的巨大便利,因为自动评测不仅并不复杂,而且使我再也不用对着空气debug。

线程安全是一个非常不好理解的问题。一方面,我们需要深入了解多线程同步的实现机制,另一方面又要会对代码进行分析,也就是“懂不一定会”,甚至会也不一定懂。在设计的过程中,一方面要注意缩小监管范围以提高并发程度,另一方面又要注意防止竞争,一些看似会引发竞争的操作实际上没有问题,而一些看起来人畜无害的语句却必须上锁。若是嵌套上锁,又有可能形成死锁,导致无限等待。对此我并没有形成一个完善的解决方案,还需要在不断实践中摸索。

在设计原则上,我体会到设计原则对编写带来的好处。遵循设计原则可以给代码带来高可读性,使得编写更加快速,也可以降低bug出现概率,即便出现了bug也较容易修复。

posted @ 2020-04-14 19:43  ?SyntaxError  阅读(166)  评论(0编辑  收藏  举报