一、整体分析
架构分析
最后一次作业的 UML 类图如下所示,整个作业完成过程中没有发生太大的变化,在第二次作业中将上锁改为了第一次直接对数据结构上锁改为调用时自己决定上锁,第三次作业中提取了抽象电梯类。电梯的运行(捎带)策略从第一次作业就采用策略模式,每类电梯都有自己的策略类。
采用两层生产消费者模式,分别是 Input - Scheduler 和 Scheduler - Elevator。
复杂度分析
Method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Elevator.Elevator(int, PersonQueue) | 0 | 1 | 1 | 1 |
Elevator.Elevator(int, int, int, int, int) | 0 | 1 | 1 | 1 |
Elevator.getCapacity() | 0 | 1 | 1 | 1 |
Elevator.getDirection() | 0 | 1 | 1 | 1 |
Elevator.getFloor() | 0 | 1 | 1 | 1 |
Elevator.getId() | 0 | 1 | 1 | 1 |
Elevator.getMoveTime() | 0 | 1 | 1 | 1 |
Elevator.getOutsidePersons() | 0 | 1 | 1 | 1 |
Elevator.isOver() | 0 | 1 | 1 | 1 |
Elevator.nextAction() | 0 | 1 | 1 | 1 |
Elevator.setCapacity(int) | 0 | 1 | 1 | 1 |
Elevator.setDirection(int) | 0 | 1 | 1 | 1 |
Elevator.setFloor(int) | 0 | 1 | 1 | 1 |
Elevator.setMoveTime(int) | 0 | 1 | 1 | 1 |
Elevator.setOver() | 0 | 1 | 1 | 1 |
Elevator.setStrategy(Strategy) | 0 | 1 | 1 | 1 |
Input.Input(ArrayList<Request>) | 0 | 1 | 1 | 1 |
Input.run() | 7 | 3 | 3 | 4 |
Main.main(String[]) | 0 | 1 | 1 | 1 |
NormalElevator.NormalElevator(int, ArrayList<Request>, PersonQueue, Schedule, PersonQueue) | 0 | 1 | 1 | 1 |
NormalElevator.close() | 1 | 1 | 2 | 2 |
NormalElevator.copy() | 0 | 1 | 1 | 1 |
NormalElevator.doubleCarReset(int, int, int) | 0 | 1 | 1 | 1 |
NormalElevator.getPersonCount() | 0 | 1 | 1 | 1 |
NormalElevator.hasInsideRequestsInDirection(int) | 0 | 1 | 1 | 1 |
NormalElevator.hasInsideRequestsToFloor(int) | 0 | 1 | 1 | 1 |
NormalElevator.hasOutsideRequestsFromFloor(int) | 0 | 1 | 1 | 1 |
NormalElevator.hasOutsideRequestsInDirection(int) | 0 | 1 | 1 | 1 |
NormalElevator.in() | 3 | 3 | 2 | 3 |
NormalElevator.isEmpty() | 1 | 1 | 2 | 2 |
NormalElevator.isReset() | 0 | 1 | 1 | 1 |
NormalElevator.normalReset(int, int) | 0 | 1 | 1 | 1 |
NormalElevator.open() | 1 | 1 | 2 | 2 |
NormalElevator.out() | 1 | 1 | 2 | 2 |
NormalElevator.removeAllPersons() | 8 | 2 | 6 | 6 |
NormalElevator.run() | 22 | 9 | 10 | 12 |
NormalElevator.updatePosition() | 1 | 1 | 2 | 2 |
NormalElevator.updateState() | 6 | 2 | 3 | 4 |
NormalStrategy.NormalStrategy(NormalElevator) | 0 | 1 | 1 | 1 |
NormalStrategy.canMove() | 1 | 1 | 2 | 2 |
NormalStrategy.canOpen() | 2 | 3 | 1 | 3 |
NormalStrategy.nextAction() | 8 | 4 | 4 | 6 |
Person.Person(int, int, int) | 0 | 1 | 1 | 1 |
Person.clone() | 0 | 1 | 1 | 1 |
Person.getFromFloor() | 0 | 1 | 1 | 1 |
Person.getId() | 0 | 1 | 1 | 1 |
Person.getToFloor() | 0 | 1 | 1 | 1 |
Person.needTransfer(int, int) | 1 | 1 | 1 | 2 |
Person.toString() | 0 | 1 | 1 | 1 |
PersonQueue.PersonQueue() | 0 | 1 | 1 | 1 |
PersonQueue.addPerson(Person) | 0 | 1 | 1 | 1 |
PersonQueue.clear() | 0 | 1 | 1 | 1 |
PersonQueue.clone() | 1 | 1 | 2 | 2 |
PersonQueue.hasPersonsFromFloor(int) | 0 | 1 | 1 | 1 |
PersonQueue.hasPersonsInDirection(int, int, boolean) | 5 | 1 | 2 | 5 |
PersonQueue.hasPersonsToFloor(int) | 0 | 1 | 1 | 1 |
PersonQueue.hasPersonsToTransfer(int, int) | 0 | 1 | 1 | 1 |
PersonQueue.isEmpty() | 0 | 1 | 1 | 1 |
PersonQueue.popPerson() | 1 | 1 | 2 | 2 |
PersonQueue.popPersonFromFloor(int) | 0 | 1 | 1 | 1 |
PersonQueue.popPersonToFloor(int) | 0 | 1 | 1 | 1 |
PersonQueue.popPersonToTransfer(int, int) | 0 | 1 | 1 | 1 |
PersonQueue.size() | 0 | 1 | 1 | 1 |
Schedule.Schedule(ArrayList<Request>) | 2 | 1 | 3 | 3 |
Schedule.addDoubleCarElevator(SingleCarElevator, SingleCarElevator) | 1 | 1 | 2 | 2 |
Schedule.choose(Person) | 21 | 2 | 6 | 9 |
Schedule.getAnotherElevator(SingleCarElevator) | 1 | 1 | 3 | 3 |
Schedule.getElevator(int) | 0 | 1 | 1 | 1 |
Schedule.getRequest() | 5 | 3 | 3 | 5 |
Schedule.handleDoubleCarResetRequest(DoubleCarResetRequest) | 1 | 1 | 2 | 2 |
Schedule.handleNormalResetRequest(NormalResetRequest) | 0 | 1 | 1 | 1 |
Schedule.handlePersonRequest(PersonRequest) | 5 | 1 | 4 | 4 |
Schedule.isOver() | 5 | 4 | 3 | 5 |
Schedule.run() | 12 | 7 | 7 | 8 |
Schedule.setInputOver() | 0 | 1 | 1 | 1 |
ShadowNormalElevator.ShadowNormalElevator(int, int, int, int, ResetState, PersonQueue, PersonQueue, …) | 1 | 1 | 2 | 2 |
ShadowNormalElevator.canOpen() | 2 | 3 | 1 | 3 |
ShadowNormalElevator.in() | 3 | 3 | 2 | 3 |
ShadowNormalElevator.out() | 1 | 1 | 1 | 2 |
ShadowNormalElevator.stimulate(Person) | 10 | 1 | 7 | 8 |
ShadowSingleCarElevator.ShadowSingleCarElevator(int, int, int, int, int, char, PersonQueue, …) | 0 | 1 | 1 | 1 |
ShadowSingleCarElevator.canMove() | 5 | 3 | 2 | 6 |
ShadowSingleCarElevator.canOpen() | 6 | 3 | 4 | 6 |
ShadowSingleCarElevator.in() | 3 | 3 | 2 | 3 |
ShadowSingleCarElevator.out() | 7 | 2 | 1 | 6 |
ShadowSingleCarElevator.stimulate(Person) | 6 | 1 | 4 | 5 |
Signal.setFree() | 0 | 1 | 1 | 1 |
Signal.setOccupied() | 3 | 2 | 2 | 3 |
SingleCarElevator.SingleCarElevator(int, int, int, int, char, ArrayList<Request>, Signal) | 2 | 1 | 1 | 3 |
SingleCarElevator.canAccept(Person) | 6 | 2 | 6 | 6 |
SingleCarElevator.canFinish(Person) | 3 | 1 | 4 | 4 |
SingleCarElevator.close() | 1 | 1 | 2 | 2 |
SingleCarElevator.copy() | 0 | 1 | 1 | 1 |
SingleCarElevator.getKind() | 0 | 1 | 1 | 1 |
SingleCarElevator.getPersonCount() | 0 | 1 | 1 | 1 |
SingleCarElevator.getTransferFloor() | 0 | 1 | 1 | 1 |
SingleCarElevator.hasInsidePersonsInDirection(int) | 0 | 1 | 1 | 1 |
SingleCarElevator.hasInsidePersonsToFloor(int) | 0 | 1 | 1 | 1 |
相比第一单元,这次作业在方法复杂度上有了很好的改善,出现很大复杂度的方法主要是 Elevator 和 Scheduler 的 run 方法,两类电梯的 stimulate 方法以及 Scheduler 中实现分配策略的 choose 方法。
二、迭代分析
第一次作业
设计架构
在第一次作业中,用 waitRequests
存储输入请求中待分配的请求,outPersons
存储已被分配给某个电梯的请求中还没进入电梯的乘客。那么,对于 Input, Scheduler, Elevator 这三类线程来说,Input 和 Scheduler 之间对 waitRequests
存在数据冲突,Scheduler 和 Elevator 之间存在数据冲突,需要对读写这两个数据结构的操作加锁。
比如,Scheduler 会向某个电梯的 outPersons
中放入请求,而电梯会读取 outPersons
并且取出请求(人进入电梯的动作),这时候需要对 outPersons
的 add
和 pop
等操作加锁,并且进行 wait-notify
操作。
public synchronized void addRequest(Request request) {
queue.add(request);
notifyAll();
}
public synchronized Request popRequest() {
while (queue.isEmpty() && !isOver) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (queue.isEmpty()) { return null; }
notifyAll();
return queue.remove(0);
}
在电梯运行上,需要制定两个策略:捎带策略(电梯本身运行的方式)和分配策略(电梯接收哪些乘客),其中,捎带策略是电梯自身的行为,只需要根据电梯的属性就可以得到,因此适合作为一个策略接口当做电梯的属性,在电梯的 run
方法内每次调用这个策略得到下一步的运行方式,而分配策略需要根据输入请求和所有电梯的状态决定分配给哪辆电梯,因此适合放在 Scheduler
类中完成。在第一次作业中,电梯的捎带策略和分配策略如下:
-
运行(捎带)策略:Look 策略
定义电梯的五个运行时动作:
- 开门 OPEN:电梯中的乘客到达目标楼层,或者有乘客在电梯所处楼层等待并且目标楼层方向与电梯运行方向相同
- 同向移动 MOVE:电梯中有乘客的目标楼层与目标楼层同向,或者有在等电梯的乘客可以被捎带(乘客所处楼层在电梯运行方向上)
- 转向移动 REVERSE:电梯中有乘客或者有乘客在等电梯时,如果不满足同向移动条件,就选择转向移动
- 等待 WAIT:电梯中没有乘客,也没有乘客在等电梯,并且输入还未结束
- 结束 TERMINATE:电梯中没有乘客,也没有乘客在等电梯,并且输入已结束
public Action nextAction() { if (canOpen()) { return Action.OPEN; } else if (elevator.hasInsideRequests() || elevator.hasOutsideRequests()) { return canMove() ? Action.MOVE : Action.REVERSE; } else { // If the elevator is empty and no one is waiting return elevator.isOver() ? Action.TERMINATE : Action.WAIT; } }
-
分配策略:乘客指定电梯
Elevator elevator = elevators.get(request.getElevatorId() - 1); elevator.addRequest(request);
bug 分析
第一次作业中未出现bug。
第二次作业
设计架构
在第二次作业中,新增了 RESET 请求,电梯在收到该请求后需要尽快让乘客离开,也就是说,电梯在收到 RESET 请求后需要尽快将 insidePersons
和 outsidePersons
中的乘客放回 waitRequests
中,新增了 Elevator,Scheduler,Input 三个线程之间关于 waitRequests
的数据冲突。
同时,乘客不再指定电梯,需要我们自己完成请求的分配策略。按照往年博客,我选择了影子电梯的分配策略:模拟将当前请求分配到各个电梯,计算完成目前所有请求的时间和耗电量,选择最优的电梯分配。为了完成模拟,需要对电梯进行克隆,当然由于分配策略只影响性能并不影响正确性,所以并不需要追求完全的克隆,可以构建一个类 ShadowElevator
,里面存储的是计算电梯运行结果所需要的属性,比如:电梯的楼层,移动方向,容量,移动时间,insidePersons 和 outsidePersons。影子电梯的运行采用相同的捎带策略,只是将电梯中 sleep(t)
换为 time += t
。
电梯的运行(捎带)策略变化不大,只需要新增 RESET
行为,为电梯设置一个属性表示是否需要 RESET,RESET 的具体做法是首先将 insidePersons
和 outsidePersons
中的所有请求放入 waitRequests
中,然后设置电梯新的容量和移动时间。
public Action nextAction() {
if (elevator.isReset()) {
return Action.RESET;
} else if (canOpen()) {
return Action.OPEN;
} else if (elevator.hasInsideRequests() || elevator.hasOutsideRequests()) {
return canMove() ? Action.MOVE : Action.REVERSE;
} else {
// If the elevator is empty and no one is waiting for this elevator
return elevator.isOver() ? Action.TERMINATE : Action.WAIT;
}
}
这一过程需要注意对 outsidePersons
和 waitRequests
两个数据加锁:
public void removeAllPersons() {
if (!isReset) {
throw new RuntimeException("Elevator-" + id + " should not be reset!");
}
if (!insidePersons.isEmpty()) {
synchronized (waitRequests) {
open();
while (!insidePersons.isEmpty()) {
Person person = insidePersons.popPerson();
if (person.getToFloor() != floor) {
waitRequests.add(new PersonRequest(floor, person.getToFloor(), person.getId()));
}
TimableOutput.println("OUT-" + person.getId() + "-" + floor + "-" + id);
}
waitRequests.notifyAll();
close();
}
}
synchronized (outsidePersons) {
while (!outsidePersons.isEmpty()) {
Person person = outsidePersons.popPerson();
synchronized (waitRequests) {
waitRequests.add(new PersonRequest(person.getFromFloor(), person.getToFloor(), person.getId()));
waitRequests.notifyAll();
}
}
outsidePersons.notifyAll();
}
}
bug 分析
- RESET 请求优先级:题目要求 RESET 请求需要在 5s 内完成,并且不能有超过两条 ARRIVE,这意味着 RESET 请求 具有相比乘客请求更高的优先级。电梯重置时会把
insidePersons
和outsidePersons
中的请求立刻加入到waitRequests
中,这导致了如果之后有一条新的 RESET 请求,它的位置可能很靠后,需要经过一段时间才会被 Scheduler 取出。 我认为可以设置两个队列,一个存储 RESET 请求,一个存储 Person 请求,每次优先取出 RESET 请求。 - 分配策略:这次作业中因为时间不足,没有将请求考虑正在 RESET 的电梯。在后续作业中,我为电梯设置了缓冲区,如果电梯正在 RESET 状态,不能进行 RECIEVE,就将请求放入缓冲区中,等结束后再取出放入到
outsidePersons
。
第三次作业
设计架构
第三次作业新增了双轿厢电梯,通过 RESET-DCElevator 指令完成。在我的设计中,两个轿厢除了需要考虑不能同时位于换乘楼层外,其他运行规则完全独立,也就是说可以将两个轿厢视作两个线程。这次作业中,我让 Elevator 成为抽象类,实现了 Runnable 接口,第一、二次作业中的电梯作为普通电梯 NormalElevator,双轿厢电梯的一个轿厢作为一类电梯 SingleCarElevator,二者继承 Elevator,并且需要重写 run,setStrategy,open,close 等抽象方法。
具体地,需要处理三个问题:单个轿厢的运行(捎带)策略,如何防止两个轿厢相撞以及如何完成双轿厢请求的模拟过程。
-
单个轿厢的运行(捎带)策略
定义单个轿厢的五个运行时动作:
- 开门 OPEN:电梯中的乘客到达目标楼层,或者有乘客在电梯所处楼层等待并且目标楼层方向与电梯运行方向相同,或者有乘客需要在当前楼层换乘
- 同向移动 MOVE:电梯中有乘客的目标楼层与目标楼层同向,或者有在等电梯的乘客可以被捎带(乘客所处楼层在电梯运行方向上),或者轿厢处于换乘楼层并且没有人需要进出,那么就直接离开为另一个轿厢腾出位置(根据轿厢类型情况决定离开方向)
- 转向移动 REVERSE:电梯中有乘客或者有乘客在等电梯时,如果不满足同向移动条件,就选择转向移动,或者轿厢处于换乘楼层并且没有人需要进出,那么就直接离开为另一个轿厢腾出位置(根据轿厢类型情况决定离开方向)
- 等待 WAIT:电梯中没有乘客,也没有乘客在等电梯,并且未输入或者有请求未完成
- 结束 TERMINATE:电梯中没有乘客,也没有乘客在等电梯,输入已结束并且所有请求都完成
public Action nextAction() { if (canOpen()) { return Action.OPEN; } else if (!elevator.isEmpty() || elevator.getFloor() == elevator.getTransferFloor()) { return canMove() ? Action.MOVE : Action.REVERSE; } else { return elevator.isOver() ? Action.TERMINATE : Action.WAIT; } }
-
如何避免两个轿厢相撞:两个电梯共享一个对象,记录了换乘楼层是否有轿厢占据,通过加锁的方式实现只有一部轿厢可以位于换乘楼层。
public class Signal { private boolean isOccupied = false; public synchronized void setOccupied() { while (isOccupied) { try { wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } } isOccupied = true; notifyAll(); } public synchronized void setFree() { isOccupied = false; notifyAll(); } }
Signal signal = new Signal(); schedule.addDoubleCarElevator( new SingleCarElevator(getId(), newCapacity, newMoveTime, transferFloor, 'A', waitRequests, signal), new SingleCarElevator(getId(), newCapacity, newMoveTime, transferFloor, 'B', waitRequests, signal) );
protected void updatePosition() { try { sleep(getMoveTime()); } catch (InterruptedException e) { e.printStackTrace(); } setFloor(getFloor() + getDirection()); if (getFloor() == transferFloor) { occupiedSignal.setOccupied(); } TimableOutput.println("ARRIVE-" + getFloor() + "-" + getId() + "-" + kind); if (getFloor() - getDirection() == transferFloor) { occupiedSignal.setFree(); } }
-
双轿厢请求的模拟:由于一个请求可能需要换乘无法被一个单轿厢完成,而换乘的请求不一定是被这个双轿厢的另一个轿厢完成,这给影子电梯的模拟带来了挑战(比如通过 1-A 和 2-B的协作完成)。为了简化这个过程,我在实现时规定:一个需要换乘的请求只能由一个双轿厢协作完成(比如只能由 1-A先接受,然后在换乘楼层由 1-B 完成剩下部分)。
int cost = ((SingleCarElevator) elevator).copy().stimulate(person); if (!((SingleCarElevator) elevator).canFinish(person)) { cost += getAnotherElevator((SingleCarElevator) elevator).copy() .stimulate(new Person(person.getId(), tmp.getTransferFloor(), person.getToFloor())); }
bug 分析
本次作业强测中没有出现bug,在互测中由于 RESET-DCElevator 指令删除普通电梯和新增双轿厢电梯处理不当出现问题。
在处理 Elevator 和 Scheduler 线程的结束条件时,我的做法是判断 waitRequests
和 每个电梯的 insidePersons
和 outsidePersons
是否为空并且输入结束:
private boolean isOver() {
synchronized (waitRequests) {
if (!inputOver || !waitRequests.isEmpty()) {
return false;
}
}
for (Elevator elevator : elevators) {
if (!elevator.isEmpty()) {
return false;
}
}
return true;
}
有可能会出现请求都被放入创建的 SingleCarElevator 但是还没有把新增的单轿厢加入 elevators 的情况,或者是 isOver() 判断为真后并且已经完成了所有电梯的 setOver() 后才把新增的单轿厢加入 elevators,这会导致单轿厢无法结束的情况。
三、体会
多线程调试
在三次作业中,最常见的问题是 Elevator 线程一直处于 wait 不被唤醒。在定位过程中,我首先在电梯的 run 方法中,每次得到下一状态时进行输入,定位到出现问题无法被唤醒的电梯。
public void run() {
while (true) {
Action action = strategy.nextAction();
TimableOutput.println(getId() + " " + action);
if (action == Action.TERMINATE) {
} else if (action == Action.MOVE) {
} else if (action == Action.REVERSE) {
} else if (action == Action.OPEN) {
} else if (action == Action.WAIT) {
TimableOutput.println(getId() + " is over: " + isOver);
} else if (action == Action.RESET) {
}
}
}
对于电梯一直处于 wait 的情况是由 Scheduler 造成的,主要原因有两个:
- 没有对
waitRequests
和outsidePersons
进行正确的wait-notify
- 对于部分语句没有加锁,导致多线程情况下语句没有按照期望的方式执行。比如在 Scheduler 中判断可以结束后对电梯 setOver 操作发生在 nextAction() 后,此时电梯的行为是
WAIT
,有可能 setOver() 对waitRequests
进行 notifyAll() 时电梯线程未执行 waitRequests.wait(),那么当电梯真正执行 waitRequests.wait() 就会导致没有线程可以将其唤醒,始终处于 wait 状态。为了发现这个问题,我在电梯 WAIT 对应的动作中输出此时电梯的 over 状态以及在 Scheduler 进行 setOver() 时进行输出。
对于 CPU 空转的情况可以使用 IDEA 自带的 IntelliJ Profiler
工具,定位到发生轮询的 run 方法。
线程安全
在第一次作业中,各个线程之间的数据冲突很容易分析,只有 Input - Scheduler 和 Scheduler - Elevator 之间两组,并且不存在复杂的嵌套调用关系,线程结束条件也很简单。从第二次作业开始产生数据冲突的线程变多,并且随着 RESET 的优先级要求让函数调用过程变得复杂,很有可能发生死锁,需要很清楚各个方法内是否存在上锁,并且谨慎地分析各条语句的执行顺序,很有可能出现某个属性在一个线程里先被使用然后才被另一个线程修改导致错误。在对一些共享的数据进行操作时,一定要多考虑一些,不能理所当然地认为代码的执行顺序。
正如荣老师上课说的,多线程的 bug 必须通过阅读代码才能发现,在写代码前一定要在纸上理清各个线程之间的关系。
同时,为了确保线程安全,需要进行充分的测试,对于一个测试数据应该进行多轮测试,在测试时的数据应该在统一时刻有大量的投入,才能发现比较隐蔽的线程安全问题。
层次化设计
在这次作业中,多线程的引入使得层次化设计非常必要。输入必须单独为一个层次并且作为一个线程,而输入的处理和分配需要由 Scheduler 单独完成,而具体业务的完成由 Elevator 完成。
层次化设计使系统的不同部分相互独立,降低模块之间的耦合度,从而提高了代码的灵活性和可维护性。以这三次作业为例,只要输入方式和内容不变,就不用对输入进行更改,比如在三次作业中都是使用 ElevatorInput 从控制台输入转化为 Request 请求放入 waitRequests
中,因此不需要进行更改,每次迭代新增的是 Request 类型,只要在 Scheduler 和 Elevator 中增加对新请求的处理方式。