BUAA_OO_2024 第二单元总结
文章目录
1.1 UML类图+代码架构分析
1.1.1 第一次作业
(一)整体架构概述 && UML类图
需求分析
第一次作业主要任务是实现一个“傻瓜电梯系统”——所有乘客都指定了自己要搭乘的电梯,规定时间内将所有乘客送达即可。
架构设计
由于各个电梯对请求不存在竞争,可以认为是“独立运行”的,所以大致思路就是**Input(线程)处理输入,分发给每个Elevator(线程)**所拥有的waitQueue,每个Elevator根据合适的运行策略处理请求。
这里实现主要按照生产者-消费者模型:由InputHandler类解析并添加请求(生产者),Elevator类处理请求(消费者),中间存放请求的"托盘"就是每一个Elevator各自的waitQueue。
UML类图
(二)任务的分发
本次作业由于各个电梯独立运行,所以仅从功能性看调度器必要性并不大;我直接是直接在InputHandler中把任务直接分发到各个waitQueue:
// InputHandler.java
while (true) {
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
break;
} else {
// We've Got a Valid Request!
WaitQueue queue = QueueMap.getQueue(request.getElevatorId());
queue.addPassenger(new Passenger(request));
}
}
QueueMap是专门用于访问6个等待队列的单例,核心方法就是getQueue(int elevatorId)
,InputHandler与Elevator访问相应队列都是通过QueueMap.getQueue(int elevatorId)
实现的。
(三)电梯运行策略
我按照往届学长的经验,对电梯运行策略进行状态模式建模——具体来说,每一个Elevator都有一个Strategy类的对象,给定当前Elevator的状态时,Strategy类回返回一个动作:WAIT, OPEN, TURN, MOVE
等等;再将这些动作封装到枚举类Advice中。最后效果如下:
// Advice.java
public enum Advice {
MOVE, OPEN, TURN, WAIT, OVER, RESET
}
// Elevator.java
private class Strategy {
private Strategy() {
}
private Advice getAdvice() {
if (queue.isEnd()) {
return Advice.OVER;
}
if (cabin.isEmpty() && queue.isEmpty()) {
return Advice.WAIT;
}
if (queue.havingFoundPassenger(curFloor, isGoingUpward) && notFull() || paxNeedOut()) {
return Advice.OPEN;
}
if (!cabin.isEmpty() || queue.willFindPassenger(curFloor, isGoingUpward)) {
return Advice.MOVE;
}
return Advice.TURN;
}
}
1.1.2 第二次作业
第二次作业新增了RESET请求,并取消了乘客指定电梯的限制。
以下为第二次作业UML类图:
(一)如何解决电梯的调度?
调度/分配器(Scheduler, Dispatcher)在第一次作业中单从功能上看可有可无,但涉及到多电梯竞争同一请求时的调度策略时就显得有一定必要,以下是我的Dispatcher.java
实现:
// Dispatcher.java
public class Dispatcher {
private static final int COUNT = 6;
private static final Random rand = new Random();
private static int getSuggestedElevatorId() {
return rand.nextInt(COUNT) + 1;
}
public static void dispatchPassenger(Passenger passenger) {
int elevatorId = Dispatcher.getSuggestedElevatorId();
WaitQueue queue = QueueMap.getQueue(elevatorId);
synchronized (queue) {
queue.addPassenger(passenger);
queue.notifyAll();
}
}
public static void informReset(ResetRequest request) {
int elevatorId = request.getElevatorId();
WaitQueue queue = QueueMap.getQueue(elevatorId);
synchronized (queue) {
queue.startReset(request);
queue.notifyAll();
}
}
}
在后续两次作业中,我直接采用了最省事的纯Random调度法——这样实现起来十分简洁轻量,也避免了互测中类似id%6分配的策略被精准打击,代价则是性能分着实很随缘(
(二)如何应对Reset指令?
拆解需求:支持Reset指令我们需要解决哪些问题,做哪些扩展?
Reset下的任务分配?
互测中不难发现,有同学的分配策略中有遇到Reset下的电梯直接跳过;此时若使五部电梯重置并且没有设置缓冲队列,剩下的一部电梯就会承担所有请求,导致TLE。
在我的架构下,由于未实现缓冲队列,每当InputHandler解析完一个请求后就会立即分发到一个电梯的任务队列中。为了在原有架构下适应新的需求,我选择沿用random.nextInt(6)+1
的分配策略,任务分配到重置中电梯时,就暂缓输出RECEIVE,直到Reset完成。
Reset动作?
- 清空电梯的轿厢(cabin)和等待队列(queue),重新分配;
- 重置参数;
- 重置完成,退出重置状态.
1.1.3 第三次作业
第三次作业要求支持DoubleCarReset:将某部电梯改造为双轿厢电梯,改造后的两个轿厢分别在上下两半(由给定的换乘层划分开)独立运行,但同一时刻只允许其中一个在换乘层。
UML类图
(一)如何应对DoubleCarReset?
Q1:RESET请求的管理?(分发、响应等)
同上次作业,由于DoubleCarResetRequest与NormalResetRequest都是ResetRequest的子类并且后一条Reset只能再前一条Reset完成后到来,只需在WaitQueue设置一个ResetReq、重置完成置null即可。
Q2:RESET之后,双轿厢电梯的运行?(与原来的电梯有何区别?)
是否OPEN送人? 对于原来的电梯,我们其实本质上不用关心1-11层的限制,因为LOOK策略保证了不涉及到>11层的请求时电梯一定不会试图突破天花板尝试继续上行;但对于双轿厢电梯,单个轿厢不一定能够完成任务,此时到换乘层乘客必须下车。
是否接客?Dispatcher只需要管分配到哪一部电梯(1<=id<=6),而具体分配到哪个轿厢的队列,是可以结合乘客请求确定的。
是否WAIT?&对MOVE影响?其中一个在交换层时另一部必须等待;当电梯需要等待时,必须先离开交换层。下方细说(
Q3:解决换乘层冲突?
A或B需要去交换区,都得向某个控制器发出一个请求,这个控制器行为其实就类似于一个lock,分获取、进入、离开、释放几个步骤。所以我新增了DoubleCarController管理这一过程。
// Elevator.java
private Advice getAdvice() {
if (Counter.allDone()) {
return Advice.OVER;
}
if (queue.isUnderReset()) {
return Advice.RESET;
}
if (cabin.isEmpty() && queue.isEmpty()) { // 没有任务
if (onTransferFloor()) { // 在换乘层,离开后再等待!(TURN or MOVE)
return justEnteredTransferFloor() ? Advice.TURN : Advice.MOVE;
}
return Advice.WAIT;
}
if (queue.havingFoundPassenger(curFloor, isGoingUpward) && notFull() || paxNeedOut()) {
return Advice.OPEN;
}
if (!cabin.isEmpty() || queue.willFindPassenger(curFloor, isGoingUpward)) { // MOVE
if (isEnteringTransferFloor()) { // 目标楼层是换乘层,必须获取许可!
if (DoubleCarController.isTransferFloorOccupied(id)) { // 换乘层被占用
return Advice.WAIT;
} else {
DoubleCarController.occupyTransferFloor(id);
}
}
return Advice.MOVE;
}
return Advice.TURN;
}
DoubleCarController的isTransferFloorOccupied(int id), occupyTransferFloor(int id), leaveTransferFloor(int id)就是完成这一功能的核心方法,其实现如下:
// DoubleCarController.java
private final ArrayList<Boolean> isOccupied = new ArrayList<>();
public static boolean isTransferFloorOccupied(int elevatorId) {
controller.lockBool.lock();
try {
return controller.isOccupied.get(elevatorId - 1);
} finally {
controller.lockBool.unlock();
}
}
public static void occupyTransferFloor(int elevatorId) {
if (isTransferFloorOccupied(elevatorId)) {
throw new AssertionError("Already Occupied!");
}
controller.lockBool.lock();
try {
controller.isOccupied.set(elevatorId - 1, true);
} finally {
controller.lockBool.unlock();
}
}
public static void leaveTransferFloor(int elevatorId) {
if (!isTransferFloorOccupied(elevatorId)) {
throw new AssertionError("Already Left!");
}
controller.lockBool.lock();
try {
controller.isOccupied.set(elevatorId - 1, false);
} finally {
controller.lockBool.unlock();
}
WaitQueue queueL = QueueMap.getQueue(elevatorId, ElevatorType.LOWER);
WaitQueue queueU = QueueMap.getQueue(elevatorId, ElevatorType.UPPER);
synchronized (queueL) {
queueL.notifyAll();
}
synchronized (queueU) {
queueU.notifyAll();
}
}
Q4:最后,Reset动作如何实现?
Reset动作直接新开两个LOWER和UPPER电梯线程即可;这里ElevatorType是一个枚举,有LOWER, UPPER, ORIGINAL三种取值。之所以进行这样的区分是因为初始的电梯与双轿厢电梯在前面提到的OPEN、MOVE、WAIT等运行策略上存在显著差异。
// Send Everyone Out
letAllOut();
// Start Reset & Re-dispatch
TimableOutput.println(String.format("RESET_BEGIN-%d", id));
sendBack(queue.clearAndSave());
// Reset
Thread.sleep(RESET_TIME);
new Thread(new Elevator(id, resetReq.getCapacity(), resetReq.getSpeed(),
1, resetReq.getTransferFloor(), ElevatorType.LOWER)).start();
new Thread(new Elevator(id, resetReq.getCapacity(), resetReq.getSpeed(),
resetReq.getTransferFloor(), 11, ElevatorType.UPPER)).start();
// Finish Reset
TimableOutput.println(String.format("RESET_END-%d", id));
Counter.finishResetReq();
TransferController.setTransferFloor(id, resetReq.getTransferFloor());
queue.endDoubleCarReset();
// ...break
(二)具体实现
总的来说,为支持双轿厢电梯,所有需要扩展的内容总结起来如下:
- ElevatorType:Lower,Upper,ORIGINAL;
- Dispatcher:给定id,要知道给哪个queue——由DoubleCarController提供换乘层信息;
- Elevator&Strategy:针对性扩展;
- DoubleCarController:两大功能——记录换乘层供Dispatchr使用+换乘层互斥实现
(三)最终时序图
1.2 对线程安全的理解
关于线程的终止
我实现了Counter类,计算InputHandler解析的请求与Elevator处理完成的请求,当二者相等且输入结束时会置done为1,Elevator中的Strategy若发现Counter.checkAllDone()为true则会退出;
同步块设置&锁的使用
面对这次WaitQueue内方法数的大幅增加,我针对其中queue和resetReq两个共享对象分别设置了ReentrantReadWriteLock与ReentrantLock:
// WaitQueue.java
private final ArrayList<Passenger> queue = new ArrayList<>(); // --> LockQueue
private ResetRequest resetReq = null; // --> LockResetReq
// *** Locks *** //
private final Lock lockResetReq = new ReentrantLock();
private final ReadWriteLock lockQueue = new ReentrantReadWriteLock();
private final Lock readLockQueue = lockQueue.readLock();
private final Lock writeLockQueue = lockQueue.writeLock();
另外,对于简单的int和bool类型,若需要保证操作原子性,可以直接使用AtomicInteger和AtomicBoolean;若操作本身是元子的,也可直接裸奔,比如在我的Counter.java中:
public class Counter {
private static final AtomicInteger totalPersonReq = new AtomicInteger(0);
private static final AtomicInteger totalResetReq = new AtomicInteger(0);
private static final AtomicInteger finishedPersonReq = new AtomicInteger(0);
private static final AtomicInteger finishedResetReq = new AtomicInteger(0);
private static boolean inputEnd = false; // 只有set与return两种操作,故不需要AtomicBoolean
private static boolean done = false;
}
1.3 Bug分析
关于轮询
printf大法魅力时刻!
轮询的报错是标志性的CTLE,另外加上助教们提供的while(true)循环内插入print调试方法,解决轮询问题并不困难——这种问题主要出现在第一次作业对多线程运作尚不了解的情况。
关于同步互斥
多线程协作的场景中识别共享对象并充分设置同步块与锁是十分必要的。在hw6ddl截止前几个小时,我突发奇想希望在纯随机调度策略基础上加一些条件筛选方法,但是忘记了加synchronized关键字,最终产生了灾难性的后果乃至互测喜提C房…
关于死锁
de关于死锁的bug算是我在本单元遇到最大的困难了:考虑到多线程的玄学运作方式,无论是定位上还是复现上,涉及到死锁的debug都极为困难。目前来看,我遇到的死锁都可以归结为如下模型:
public void method1() {
synchronized(lockA) {
// do sth
synchronized(lockB) {
// do sth
}
}
}
public void method2() {
synchronized(lockB) {
// do sth
synchronized(lockA) {
// do sth
}
}
}
对于发生死锁的定位方式,目前看最朴素、最简洁的方法还是源代码级+printf肉眼调试——尤其对同步块的"嵌套"保持高度敏感(当然还有调用产生的嵌套)。
比如,我写出过这样神奇的函数,本意是把当前等待队列清空并重新分配,但如果同时发生如下调用:
queueA.clearAndSendBack()->queueB.add()
queueB.clearAndSendBack()->queueA.add()
死锁就应运而生力(好耶!(?。解决起来就简单多了,直接把方法拆分就行(大部分情况下线程不安全的写法都不是非写不可的吧;
至于死锁的复现,个人觉得还是得耐着性子多跑几遍,没有什么更好的办法(
public void clearAndSendBack() {
lockQueue.lock(); // queueA.lock
try {
ArrayList<Trip> copy = new ArrayList<>(queue);
queue.clear();
for (Trip trip : copy) {
Controller.dispatchTrip(trip); // 调用queueB.add(),需要queueB.lock
}
} finally {
lockQueue.unlock();
}
}
1.4 心得体会
第二单元算是送大分咯(
本单元压力给的还是挺足,但也收获颇丰:我得以对多线程的设计模式,同步与互斥、锁的使用、原子操作等等知识点有了初步理解,在与debug过程中最艰难的挑战——死锁的鏖战中也积累了一定经验。
最终相比惨烈的hw6,hw7正确性的下限算是保住了(下次不要在ddl前几个小时脑洞大开大改代码了!!!),一方面还是有感于纯Random调度下实现的轻量简洁以及丰富的可扩展空间,一方面也对于最终没有写影子电梯(懒是这样的)导致性能分大量损失有些许遗憾,也算是在复杂度、可读性与性能之前做出了自己的trade-off吧~