[OO 第二单元] 单元总结
概述
OO 第二单元作业围绕着多线程电梯展开,在本单元的三次迭代开发中,我感受到了并发和多线程的奇妙,了解到各种不同的锁的用途,也感受到了多线程如果线程不安全将为程序带来多大的不稳定性,不过目前这一切已经告一段落了,现在到了为这近一个月设计的多线程电梯系统作总结的时候了。
架构设计
第一次作业
本单元第一次作业要求我们设计一个包含六部电梯的多线程实时电梯系统,下图为本次作业的 UML 类图。
在这次作业中,我采用了生产者 – 消费者的架构,不过与普通的生产者–消费者架构不同,我将Scheduler
类作为中间线程,与InputRequest
组成了InputRequest -- RequestList -- Scheduler
的生产者 – 托盘 – 消费者模式,同时也与EleThread
构成Scheduler -- Elevator -- EleThread
的生产者 – 托盘 – 消费者模式。调度器从等待调度的总请求队列 requestList 中将请求分配到各个电梯对应的请求队列中,并且将 requestList 中的该请求删去,对于总请求队列“消费”了一个请求,并且为对应电梯“生产”了一个请求。下面是本次作业设计中的一些细节:
- 将
Elevator
电梯状态类和EleThread
电梯线程类剥离开,这是为了使Scheduler
调度器类可以更好地查看电梯的状态,按照电梯状态进行请求处理,这部分我将在调度器设计中说明。 - 在
Elevator
电梯状态类,我实现了 hasOut 和 inFroms 两个方法,并且在EleThread
电梯线程中以此来判断是否需要开门。 - 在
EleThread
电梯线程类,为了实现尽可能地将乘客捎带上电梯,我采用了检测是否需要开门 – outPassenger – sleep – inPassenger – findDirection – move 的执行顺序,以便在电梯“休眠”期间也可以接受请求。
第二次作业
第二次作业在第一次作业的基础上的改动不多,第一个要求是添加了电梯的个别特征,比如楼层运行速度,电梯最大乘客数等,这个要求很容易实现,只需要添加几个属性并且在往Elevator
中添加请求时改变最大上限和EleThread
线程move
时改变睡眠时长即可。第二个要求是新增了添加电梯和维护电梯的请求。下图为第二次作业的 UML 图:
- 添加电梯方面,只需要在
Scheduler
调度器中创建Elevator
对象和对应的线程,并且将其插入到调度器的elevators
属性中即可。 - 维护电梯方面,我在
Elevator
中新增了两个属性,分别为maintain
和able
,前者表示电梯接收到了维护请求,后者代表电梯准备好接受维护。在第本次作业中,我是通过allOut
方法进行乘客输出,并且在调度器的maintainElevator
方法中取出维护电梯的所有请求。
第三次作业
第三次作业的迭代开发较为复杂,大体分为两个部分,一个是增加了电梯的可达楼层,另一个就是增加了楼层同时可开门的电梯数量。下图为第三次作业的 UML 图:
- 增加电梯可达楼层,这使得本次作业需要考虑电梯换乘的策略,如何存储请求的换乘路线,如何让调度器知道目前请求到了换乘路线的哪个阶段以及如何将请求如何使得
Scheduler
线程在该结束的时候结束等等问题。首先是换乘策略,我先根据目前还可运行的电梯的可达楼层构建一个可达矩阵,再使用floyd最短路径算法计算出最少换乘次数的路径,将其保存在travelPlans
中。在调度器调度某一个请求的时候,会先判断它是否可以在travelPlans
中找到合适的路径,并且下一步是否可达(是否在过程中有电梯被维护),如果可达就直接将请求放入对应的电梯请求队列中,反之则重新规划路径,若是请求的起点和终点重合,则从travelPlans
中删去对应编号的路线。当且仅当travelPlans
为空时,调度器线程结束。 - 控制开门电梯数量,这一点可以通过信号量实现,为了完成只接人和服务中电梯的区分,分别使用了两个信号量,只需要在开门前判断
hasOut
方法的返回值是否为true
。
稳定与异变内容
- 稳定内容:总体架构(生产者 – 消费者模式)、电梯的运行策略和请求的基本分配策略不需要在迭代中改变。
- 易变内容:电梯的特性,并且以此延伸出对锁和线程安全结束的其他要求。
线程设计
锁设计
本单元作业中,我们学习、了解到了很多不同种类的锁,但是在我的作业中,我只使用了 synchronized
同步锁和在第三次作业中使用信号量。
在锁的边界设计上,我将所有的 sleep 方法都放到了锁的外面,以此减少其他线程为了等待获取锁的时间。在我的代码架构中,并没有过多使用 wait – notify。主要是在调度器中,为了不让调度器线程轮询处理请求,我在请求队列为空同时输入并未结束的时候让调度器进入 wait 状态,直到新的请求进入的时候再将其唤醒。
电梯运行策略
电梯总体运行策略选用 LOOK 策略,并且进行一定修改。具体实现上,我在EleThread
电梯线程类中增加了一个属性 goalFloor
,代表该电梯目前的目标楼层。电梯每次到了一个新的楼层,就会判断目前的请求队列的起始楼层和乘客队列的目的楼层是否在电梯目前的方向上,如果是而且相较于 goalFloor
离电梯所在的楼层更远,则更新电梯的目标楼层。这种策略可以使得电梯不需要每次都到达方向上的“顶层”再转移方向,只需要判断目标楼层和当前楼层的相对位置就可以调整电梯方向。
线程协作
本单元作业的线程协作图如下:
调度器设计
电梯主要负责请求的分配。主策略为平均分配,为了尽可能不让过多请求集中在特定的几部电梯,我将电梯内乘客数量加上该电梯请求队列中请求数量之和作为首要分配依据。在数量一致的情况下,对电梯将该请求送到需要位置上所需要的时间作为判断依据。这是在Elevator
电梯状态类 sendTime
方法中实现的。主要逻辑是按照电梯运行方向和当前所处楼层,以十一楼和一楼为底,在该方向上运行到底后再转向,再结合电梯运行一层需要的时间,以此来计算出电梯将该请求送到需要位置上所需要的时间。以此来尽可能减少电梯运行时间,通时均衡的分配策略,也可以避免一个电梯来回过多次增加耗电量的问题。
bug 与 debug
本单元作业中,我在强测中出现了较多错误,甚至有一个错误在第二次和第三次作业中一直存在,下面我将分享这些 bug。
- RTLE:在第一次作业的强测中,我的程序出现了 RTLE 的情况,这并不是因为线程无法结束,而是我错误地将电梯线程中的 sleep 方法都写在了同步块里面,这本质上是同步块设计出了问题。
- 电梯在维护请求后运行过多楼层:这一点在第二次作业和第三次作业中都在强测中出现了问题,出现这个 bug 的主要原因是我对维护电梯的乘客的处理出现错误。我原本是在调度器接收到一个维护请求后,就将该电梯的
maintain
属性设为true
。然后等待电梯运行到run
方法中将电梯的able
属性也设置为true
。问题在于,如果这个维护请求后面跟着其他请求,也必须等待这个维护请求结束才能被处理。如果这是一个乘客请求,其实并不会产生太多影响。但是若这是一个维护请求,就会使得这个维护请求的执行必须等待上一个维护请求执行完毕,而这个等待时间里,第二部需要维护的电梯可能已经运行超过两楼以上。 - 死锁:在第三次作业的迭代开发中,我首次遇到了死锁的问题,问题在于我一开始想直接让
EleThread
电梯运行类获得总等待队列的锁,在实现的时候就觉得这种设计非常奇怪,果不其然最后由于该线程和调度器线程都需要用到总等待队列和对应电梯状态对象的锁,因此会出现死锁。
在 debug 方面,多线程的特性使得在本单元作业中寻找和解决问题极为困难,使用 IDEA 的调试,几乎无法复现 bug。为了能在本地较为稳定地找到问题所在,我一般采用阶段性输出的方式,在线程运行的关键地方进行输出,再在本地观察输出,通过这种方式来找代码问题可以迅速定位到出问题的线程。
心得体会
-
线程安全:本单元作业中我出现了一次死锁的问题,以及数不清次数由于错误的同步锁和 wait - notify 使用导致的 bug。在 debug 的过程中我也逐步学习到如何让线程安全的结束,而不会在奇怪的地方陷入永远醒不来的等待。主要的方法就是添加标志,比如在第六次作业中我加入了 end 属性作为输入结束的标志,以此来实现 Schedule 线程和没 maintain 电梯的安全结束。而在第七次作业中,实现就稍微复杂,我以未到达目的地的请求数量为标志,直到其为 0 时再结束线程。
-
层次化设计:本单元作业中主要使用到的架构为“生产者 – 消费者”模式,如何实现我所构思的两层的“生产者 – 消费者”是完成这三次作业的关键。在本单元作业中,我将输入请求 – 调度请求 – 实现请求的三层任务分配给三个线程完成,较好地运用了层次化的设计思想。