BUAA_OO_UNIT2_单元总结
第一次作业
第一次作业要求模拟单部多线程电梯的运行,在规定时间内将所有乘客运送至目的楼层。
设计思路
本次作业一共设计了三个线程,分别是输入线程InputThread、调度器线程Scheduler和电梯线程Elevator。
- 输入线程
InputThread将控制台输入的请求添加到等待队列WaitQueue中,当读取到null时,表明输入已结束,此时向WaitQueue发送close信号、唤醒等待WaitQueue的线程并结束自身线程。 - 调度器
Scheduler负责将等待队列WaitQueue中符合可稍带策略的请求添加到处理队列ProcessingQueue中,并设置电梯下一步的状态。在WaitQueue收到输入线程的close信号并且WaitQueue和ProcessingQueue均为空时结束自身线程。本次作业我没有区分Random、Morning、Night三种模式,均采用LOOK算法进行调度。 - 电梯的运行采用了状态转换的思路实现,并专门定义了一个枚举类
ElevatorState用来存放电梯的各种状态。根据电梯的实际运行过程,将电梯状态分为四种:WAIT(当前处理队列中没有请求,电梯停留在某一层)、UP(上行)、DOWN(下行)、OPENCLOSE(在某一楼层进行如下操作:1. 开门 2. 如果电梯内有乘客到达了目的楼层,将其放出电梯,并从ProcessingQueue中删去该请求 3. 如果该楼层有乘客等待进入电梯,在不超载的情况下将其接入电梯 4. 关门)。每种状态对应的具体实现过程由Elevator类中的dealUp()、dealDown()、dealOpenclose()方法完成。
同步块的设置和锁的选择
- 输入线程
InputThread与调度器线程Scheduler共享WaitQueue这一变量。- 输入线程
InputThread读入请求时对WaitQueue加锁,在其后的同步块内向WaitQueue中增添请求。 - 调度器
Scheduler遍历WaitQueue时对其加锁,在其后的同步块内把WaitQueue中符合调度策略的请求移除并添加到ProcessingQueue中。
- 输入线程
- 调度器线程
Scheduler和电梯线程Elevator共享ProcessingQueue这一变量,而电梯处于WAIT、UP、DOWN状态时不需要访问ProcessingQueue,只有在OPENCLOSE状态下需要对ProcessingQueue进行互斥保护。由于电梯在一个时刻只能有一种运行状态,因此我直接在getIn()、getOff()方法前添加synchronized关键字来保护线程安全。
调度器设计
本次作业中的Scheduler实际上是一个“身兼二职”的存在,一方面负责将WaitQueue中符合可稍带策略的请求添加到处理队列ProcessingQueue中,另一方面还要控制电梯进行状态转换 ,这实际上违背了单一职责原则。
UML类图
UML时序图
bug分析
本次作业出现bug的主要原因是状态转换情况考虑不全面,在电梯为空时如果在其下方楼层(距离>1层)出现一个新的请求,电梯会不停在当前楼层与下一楼层之间上下往返,无法接到最新请求,最终RTLE,添加相应的状态转换语句后即可解决。
第二次作业
第二次作业要求模拟多部同型号电梯的运行,并且有了增加电梯的请求。
设计思路
- 本次作业中,由于存在多部电梯,一个调度器无法同时控制多部电梯的状态转换,因此我实现了调度与电梯状态控制的分离,为每部电梯配置一个属于自己的控制器
ElevatorController,调度器Scheduler负责将WaitQueue中的请求分配给不同的电梯。 - 每部电梯中有
outQueue和inQueue两个队列,分别存放已分配给该部电梯但不在电梯内部的请求和该电梯内部的请求。 - 输入线程
InputThread读取控制台输入的请求,如果读取到乘客请求,将其添加到等待队列WaitQueue中;如果读取到增加电梯的请求,向电梯容器ArrayList<Elevator> elevators中添加一部电梯并向控制器容器ArrayList<ElevatorController> controllers中添加该部电梯对应的控制器。当读取到null时,表明输入已结束,此时向WaitQueue发送close信号、唤醒等待WaitQueue的线程并结束自身线程。 - 调度器
Scheduler负责将等待队列WaitQueue中按照调度策略添加到相应电梯的OutQueue中。在WaitQueue收到输入线程的close信号并且WaitQueue为空时结束自身线程。 ElevatorController负责电梯的状态转换。电梯状态转换策略与第一次作业相同。
同步块的设置与锁的选择
- 输入线程
InputThread与调度器线程Scheduler共享WaitQueue、elevators、controllers这三个变量。- 在输入线程
InputThread读入请求时对WaitQueue加锁,如果当前请求为乘客请求,在其后的同步块内向WaitQueue中增添请求;如果当前请求是增加一部电梯,则对先后对elevators和controllers加锁,在其后的同步块内向elevators和controllers添加电梯和控制器。 - 在调度器
Scheduler遍历WaitQueue时对其加锁,在其后的同步块内判断应该把该条请求分配给哪部电梯。在向第\(i\)部电梯的outQueue中添加请求时,对elevators.get(i).getOutQueue()加锁。
- 在输入线程
- 每部电梯与自己的控制器共享
outQueue和inQueue这两个变量。ElevatorController对elevator.getOutQueue()加锁,在其后的同步块内对elevator.getOInQueue()加锁,根据outQueue和inQueue中的请求控制电梯状态转换。Elevator在OPENCLOSE状态下先对inQueue加锁,在其后的同步块内将到达目的楼层的乘客放出电梯。之后再对outQueue和inQueue加锁,在不超载的情况下将该层乘客请求由outQueue转移至inQueue中。
调度器设计
对于Morning模式而言,调度器基本将所有请求平均分配给每部电梯。在Random和Night模式下,调度策略是遍历电梯容器elevators,如果发现某部电梯未满载且符合可稍带原则,就将请求分配给该部电梯;如果当前所有电梯均满载,就将请求分配给距离最近的电梯;如果目前没有符合可稍带原则的电梯,就将请求分配给当前请求数目最少的电梯。
UML类图
UML时序图

bug分析
本次作业中新增加了ElevatorController这一线程,它与Elevator线程共享outQueue和inQueue这两个变量。如何在输入结束并且已经处理完所有请求的情况下按照正确的逻辑结束所有线程是我思考的重点。一开始为了避免死锁,我在ElevatorController每完成一次状态转换时就notifyAll()一次,让出outQueue的控制权。这样滥用notifyAll()的行为导致了轮询。解决方法为分析代码逻辑,只在outQueue和inQueue均为空时才对outQueue进行notifyAll()操作,确保ElevatorController每次拿到outQueue的锁时都能完成尽量多的状态转换。
第三次作业
第三次作业中电梯分为三种类型,每类电梯有不同的运行速度、可达楼层和容量限制。
设计思路
- 考虑到A类电梯与B、C类电梯之间的速度差异,为了尽快完成所有请求,同时避免由于多次换乘导致开关门次数过多浪费时间,决定采用最多可换乘一次的运行策略。此时,对于需要换乘的请求而言,
Elevator也变成了生产者,在完成前半段运送任务后,Elevator需要将后半段的请求包装成一个新的Instruction,添加到WaitQueue中,由调度器分配给其他类型的电梯继续执行。如果采用和前两次作业中一样的线程结束判断条件,在输入已经结束、WaitQueue为空且换乘请求的前半部分尚未执行完毕的情况下,调度器就会停止工作,导致后半段请求无法分配给其他电梯,最终无法将乘客送达正确楼层。因此,需要额外设置一个共享信号tranInstrs用于判断是否存在由于换乘导致的潜在新请求。只有在WaitQueue收到输入线程的close信号且WaitQueue和tranInstrs均为空时调度器才会结束自身线程。 Instruction类中增添了换乘楼层tranFloor这一属性,默认为0。输入线程InputThread读取控制台输入的请求,如果读取到乘客请求,根据该请求的出发楼层和目的楼层判断其是否需要换乘并设置换乘楼层,将包装后的Instruction添加到等待队列WaitQueue中;如果读取到增加电梯的请求,向电梯容器ArrayList<Elevator> elevators中添加一部电梯并向控制器容器ArrayList<ElevatorController> controllers中添加该部电梯对应的控制器。当读取到null时,表明输入已结束,此时向WaitQueue发送close信号、唤醒等待WaitQueue的调度器线程并结束自身线程。- 根据出发楼层和目的楼层的不同将请求分类,为每类请求指定某一种换乘策略,由调度器分配给对应类型的电梯。
同步块的设置与锁的选择
- 输入线程
InputThread与调度器线程Scheduler共享WaitQueue、elevators、controllers这三个变量。- 在输入线程
InputThread读入请求时对WaitQueue加锁,如果当前请求为乘客请求,在其后的同步块内向WaitQueue中增添请求;如果当前请求是增加一部电梯,则对先后对elevators和controllers加锁,在其后的同步块内向elevators和controllers添加电梯和控制器。 - 在调度器
Scheduler遍历WaitQueue时对其加锁,在其后的同步块内判断应该把该条请求分配给哪部电梯。在向第\(i\)部电梯的outQueue中添加请求时,对elevators.get(i).getOutQueue()加锁。
- 在输入线程
- 每部电梯与自己的控制器共享
outQueue和inQueue这两个变量。ElevatorController对elevator.getOutQueue()加锁,在其后的同步块内对elevator.getOInQueue()加锁,根据outQueue和inQueue中的请求控制电梯状态转换。Elevator在OPENCLOSE状态下先对inQueue加锁,在其后的同步块内将到达目的楼层的乘客放出电梯。之后再对outQueue和inQueue加锁,在不超载的情况下将该层乘客请求由outQueue转移至inQueue中。
- 输入线程
InputThread、调度器线程Scheduler与电梯线程Elevator共享tranInstrs这一变量。- 每当输入线程接收到一个需要换乘的指令,就对
tranInstrs加锁,在其后的同步块中执行tranInstrs.add(1)操作。 - 对于需要换乘的请求,当执行完前半段运送任务后,
Elevator对tranInstrs加锁,在其后的同步块中执行tranInstrs.remove(0)操作。 - 在调度器
Scheduler判断是否满足线程结束条件时对tranInstrs加锁,如果满足直接结束自身线程,否则结束tranInstrs同步块,让出tranInstrs的控制权。
- 每当输入线程接收到一个需要换乘的指令,就对
调度器设计
根据出发楼层、目的楼层的不同将请求分为以下四类:
-
出发楼层、目的楼层均位于1-3/18-20:分配给C类电梯,不换乘
-
出发楼层、目的楼层均为奇数:分配给B类电梯,不换乘
-
出发楼层为奇数、目的楼层为偶数,且二者距离 > 1:先分配给B类电梯,在距离目的楼层还有一层时换乘A类电梯
-
其他情况:分配给A类电梯,不换乘
同种电梯间请求的分配策略沿用第二次作业中的设计。
UML类图
UML时序图

架构设计的可扩展性
相比于第一单元,本单元作业基本上实现了迭代开发,扩展性较强。但由于为每部电梯都配置了一个controller,后续如果电梯数量大幅增加,会导致线程数量过多,可以考虑使用一个控制器控制多部同类型的电梯,从而减少线程数量。
bug分析
由于调度电梯时只根据请求的类型就决定分配给哪类电梯,没有考虑该类电梯的运行状态以及电梯所在楼层与请求出发楼层之间的距离,在大量同类型请求同时到达的高并发情况下可能出现换乘比不换乘还要慢的情况,最终导致RTLE。
互测策略
本单元采用的hack策略主要是纯手动构造极端测试样例,主要考察在不同请求出发楼层、方向差异较大和大量请求同时到达的高并发情况下电梯能否在规定时间内将全部乘客送达相应楼层,以及是否会出现死锁、线程无法结束等情况。但由于多线程调度的随机性较高,hack效果不是很好。
与第一单元多项式求导相比,本单元多线程电梯的测试具有不稳定性和不可复现性。因此,我没有采用第一单元中随机构造数据的方法,而是手动构造极端样例,针对某一特殊情况进行大量测试。
心得体会
-
线程安全
线程安全在多线程编程中是一个极为重要且令人头疼的问题,如何保证共享对象的互斥访问以及处理好不同线程之间等待、唤醒关系是设计时的一大难点。起初由于我对多线程实现原理和
synchronized、notifyAll()机制了解得不透彻,完成起来很艰难。经过三次迭代设计和不断踩坑,我逐渐加深了对多线程的理解。我认为进行多线程设计时要着重注意以下几点:-
考虑清楚什么是共享变量、哪些线程需要访问共享变量、这些线程对共享变量进行读操作还是写操作、对于共享变量的读取是否有必要获取最准确的数据(是否有必要加锁)等。
-
为了避免两个或多个线程因争夺资源而陷入互相等待的死锁境地,尽量减少
synchronized的嵌套使用。 -
根据代码逻辑确定何时使用
notifyAll(),不要滥用,否则会造成轮询。 -
可以尝试使用线程安全类简化互斥访问过程。
-
-
层次化设计
本单元作业我基本上实现了迭代开发,第二、三次作业在第一次作业的基础上新增了
ElevatorController这一控制线程,整体思路和结构比较清晰。通过本单元的学习与实践,我认识到层次化设计有利于降低不同线程之间的耦合度,一定程度上也增强了架构的可扩展性。在我们的设计中,每个线程应当只专注于自己的任务,不去过多考虑其他部分的实现过程。在新的需求到来时,也应该能够做到避免大幅修改之前已经实现的部分,而是通过增加新的层次或细节来实现新的功能。

浙公网安备 33010602011771号