BUAA OO Unit2总结
前言
第二单元学习的主题是多线程,体会线程安全和层次化设计的思想。三次作业逐步迭代,从最初的指定乘客分配到后来的自己设计分配策略再到电梯的两类reset,尽管在过程中被架构设计,诸多bug和更加全面的数据构造所困扰,甚至废寝忘食,但回过头来,三周的训练仍然让我受益良多,尤其是在层次化架构和多线程的设计上进步显著。
由于三次作业底层逻辑相似,因而并没有进行大规模的重构,作业整体架构类似,只有细节上的修改,具体分析如下:
第一次作业分析
第一次作业的具体要求是:模拟多线程实时电梯系统,模拟的电梯系统是一个类似北京航空航天大学新主楼的电梯系统,楼座内有多部电梯,电梯可以在楼座内1-11层之间运行。系统从标准输入中读入乘客请求信息(起点层,终点楼层),请求调度器会根据此时电梯运行情况(电梯所在楼层,运行方向等)将乘客请求合理分配给某部电梯,然后被分配请求的电梯会经过上下行,开关门,乘客进入/离开电梯等动作将乘客从起点层运送到终点层。
首先通过类图和协作图来分析整体结构
具体分析如下
代码整体可以分为九个线程:
- 主线程主要是start其他的八个线程,并通过一个for循环创建六个电梯并启动。
for (int i = 0; i < 6; i++) {
RequestQueue parallelQueue = new RequestQueue();
processingQueues.add(parallelQueue);
Process process = new Process(parallelQueue, i + 1);
process.start();
}
- InputThread线程的作用是处理分析输入请求,并将这些请求加入waitQueue。输入结束为该线程结束的标志。
- Schedule线程可以读取waitQueue中的请求,并按照其中设定好的策略将请求分配给六个电梯。结束标志是waitQueue为空且结束。
- 六个Process线程是六部电梯的运行线程,作用是模拟电梯的运行,每部电梯都有一个预处理队列和一个正在处理对列,分别表示已被分配给该电梯但还未登上电梯和已登上电梯的请求。结束标志是Schedule线程结束以及两个队列均为空。
电梯运行策略
因为这次作业请求均指定了电梯,因而不需要设计分配策略,只需要设计电梯运行策略,并且我在后两次作业中并没有改变运行策略。
第一次作业我的电梯分为5个状态,通过状态间的改变执行请求处理:
- 状态一(wait),表示电梯目前的处理队列为空,此时电梯静止在某一层,首先会从预处理队列中获取一个请求,如果获取失败则continue。并通过这个请求设定电梯的初始运动方向,之后将电梯设定为状态五。
Request request = processingQueue.getOneRequestAndRemove();
if (request == null) {
continue;
}
int a = request.getFromFloor();
int b = request.getToFloor();
int c;
if (a == processingQueue.getWeiZhi()) {
c = (b > a) ? 1 : -1;
} else if (a < processingQueue.getWeiZhi()) {
c = -1;
} else {
c = 1;
}
processingQueue.setFangXiang(c);
processingQueue.addNewrequest(request);
processingQueue.setZhuangTai(5);
- 状态二(open),表示电梯现在处于开门状态,在输出开门前切换为状态二。
- 状态三(close),表示电梯准备关门,之后输出关门信息。
- 状态四(move),表示电梯已经处理完该层事项,准备移到下一层。在移动前需要判断目前的电梯处理队列是否为空,如果为空,则切换为状态一,不需要移动,否则,在电梯完成移动后切换为状态五。
if (processingQueue.getNowrequests().isEmpty()) {
processingQueue.setZhuangTai(1);
continue;
}
int a = processingQueue.getWeiZhi();
int b = processingQueue.getFangXiang();
try {
Thread.sleep(400);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
processingQueue.setWeiZhi(a + b);
TimableOutput.println("ARRIVE-" + processingQueue.getWeiZhi() + "-" + type);
processingQueue.setZhuangTai(5);
- 状态五(work),表示电梯在当前楼层处理请求。首先判断有无请求到达目的地,有无预处理请求可以进入,来判断是否开门,开门后先处理出门的请求,之后获取起点在该层且与电梯运行方向相同的请求。如果为空则判断此时电梯内部请求是否只剩一个且为预处理请求,若是则判断电梯是否需要转向(这是因为获取的确定电梯方向的第一个请求可能起点在电梯上方但是终点在起点下方)。处理完成后将电梯切换为状态四。
同步块和锁的设置
代码中的RequestQueue类是一个共享类,waitQueue和电梯内部队列均是这个类的实例化,通过之前的分析不难看出,可能被共享的属性均包含在这个类中,因而对这个类中如添加请求和获取请求等可能在两个线程中对同一属性进行访问的方法加锁,来保证线程安全。
遇到的bug及debug方法
- 第一次作业的强测过程较为顺利,在互测中发现了一个bug,主要是对于电梯超载的错误判断,采取了当电梯装载人数达到最大时停止加入请求的策略,但在特殊情况下,可能会出现装载人数直接超过最大人数的情况,不会经历最大人数的阶段,这是代码会出错。
- 因为问题本身较为清晰,通过添加输出日志的形式很快定位到了问题,因为是逻辑上的问题,修复起来也较为轻松。
第二次作业分析
作业要求
主要有三处改动:
- 本次及以后作业不再指定电梯,同学们需设计调度器分配电梯完成乘客请求。
- 将RECEIVE作为调度器的附加输出来说明自己的分配方案。
- 电梯在运行一段时间以后,其性能参数(满载人数、移动时间)可能会发生改变。接收到重置指令的电梯需要尽快停靠后完成重置动作,再投入电梯系统运行。
首先通过类图和协作图来分析整体结构
具体分析如下
可以看到代码的整体结构与第一次作业类似,完全没有新增类,只是依据三处改动对代码细节作出改动:
电梯分配策略
这次作业需要自己设计分配策略,我在考虑实际情况后决定选择以尽量降低电梯总运行时间为目的的平均分配方法,但是也可能会出现电梯耗电量较高的问题。
具体来说,在每分配一个请求前:
- 首先避免分配给正在reset的电梯。
- 在条件一的情况下遍历各个请求,选择处理队列和预处理队列请求总数最少的电梯分派请求。
int a = processingQueues.get(0).getNowrequests().size() + processingQueues.get(0).getRequests1().size(); for (int i = 1; i < 6; i++) { int b = processingQueues.get(i).getNowrequests().size() + processingQueues.get(i).getRequests1().size(); if (b < a) { a = b; x = i; } }
- 另一方面在分派请求前需要输出receive信息。
int n = getNb(request);
processingQueues.get(n).askReset();
TimableOutput.println("RECEIVE-" + request.getPersonId() + "-" + (n + 1));
processingQueues.get(n).addRequest(request);
这里的**askReset()**方法位于共享类中,作用是如果当前电梯在reset则wait();在reset结束后会将其唤醒。
电梯重置
为了保证电梯重置信号传达的及时性,选择在InputThread线程直接将电梯的reset信号置位,并传递必要的reset信息。
else if (request instanceof ResetRequest) {
Request1 request1 = new Request1(0,0,0,
((ResetRequest) request).getElevatorId());
request1.setIfPerson(0);
request1.setCapacity(((ResetRequest) request).getCapacity());
request1.setSpeed(((ResetRequest) request).getSpeed());
processingQueues.get(request1.getElevatorId() - 1).setCa(
request1.getCapacity());
processingQueues.get(request1.getElevatorId() - 1).setSp(
request1.getSpeed());
processingQueues.get(request1.getElevatorId() - 1).setIfReset(1);
}
在reset具体处理的过程中,采取的方法是将reset视为电梯运行的一个状态,设为状态六。可能从状态一(第一个请求就是reset)或状态四跳转到状态六,这里需要注意的一点是还需要将未处理完的请求添加回waitQueue,状态六结束后需要跳转回状态一。
private void reset() {
TimableOutput.println("RESET_BEGIN-" + type);
processingQueue.setCapacity(processingQueue.getCa());
processingQueue.setSpeed(processingQueue.getSp());
Iterator<Request1> iterator = processingQueue.getRequests1().iterator();
while (iterator.hasNext()) {
Request1 it = iterator.next();
Request1 request1 = new Request1(it.getFromFloor(), it.getToFloor(),
it.getPersonId(),0);
waitQueue.addRequest(request1);
iterator.remove();
}
try {
Thread.sleep(1200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
TimableOutput.println("RESET_END-" + type);
}
同步块和锁的设置
与作业一相同,依旧是在RequestQueue这个共享类中为可能在两个线程中对同一属性进行访问的方法加锁,来保证线程安全。
遇到的bug及debug方法
- 第二次作业在强测和互测的过程中共发现了两个问题:
- 是线程不安全的问题,具体来说,可能在电梯线程reset开始后,reset需要的参数,如修改后的电梯速度等信息还没有同步。这个问题是概率发生的,在本地不一定能测出来。
- 是实现逻辑上的问题,因为分配策略会选择不将请求分派给正在reset的电梯,这就会导致可能构造一种数据,在5部电梯正在reset的前提下,在同一时间输入大量请求,这些请求就会被全部分派给一部电梯,进而导致超时问题。
- 多线程中的bug修复难点其实不在于修复本身,而在于如何定位到bug,因为线程不安全的问题有时难以复现,我采取的策略是每次运行都在日志中输出全部我需要的信息,然后多次运行,保证在少量可能出现的bug复现中获取足够的信息。
第三次作业分析
作业要求
新增第二类重置请求,将电梯修改为双轿厢电梯,重置参数包含需要重置的电梯ID,换乘楼层,两个轿厢的相关参数(移动一层的时间和满载人数)相同。当重置完成后,轿厢 A 默认初始在换乘楼层的下面一层,轿厢B默认初始换乘楼层的上面一层。
首先通过类图和协作图来分析整体结构
具体分析如下
可以看到代码的整体结构仍然与第一次作业类似,新增了一个Flag共享类,用来避免两个轿厢碰撞。
第二类电梯重置
第二类电梯实质上是将一个电梯分为两个电梯,只是在一定程度上限定了分裂后电梯的运行范围。在具体实现上有以下几个重点:
- 由于采用arraylist存储电梯,为了保证reset后电梯能够一一对应,采取了在主线程中建立十二个电梯,但是只启动前六个的策略,其余六个电梯在Process线程中由状态六经过特判后启动。同时也在这里为他们设定共享类Flag,在后续避免两个轿厢碰撞。
private void build() {
RequestQueue parallelQueue = processingQueues.get(type + 5);
parallelQueue.setSign(1);
parallelQueue.setCapacity(processingQueue.getCa());
parallelQueue.setSpeed(processingQueue.getSp());
parallelQueue.setTransferFloor(processingQueue.getTransferFloor());
parallelQueue.setWeiZhi(parallelQueue.getTransferFloor() + 1);
parallelQueue.setFind(type);
processingQueue.setWeiZhi(processingQueue.getTransferFloor() - 1);
processingQueue.setSign(1);
Flag flag = new Flag();
processingQueue.setOccupied(flag);
parallelQueue.setOccupied(flag);
Process process = new Process(parallelQueue, type + 6, waitQueue, processingQueues);
process.start();
}
- 分裂后电梯在达到中转层前后需要特判,移出所有请求,并将请求返还到waitQueue,同时掉头,保证电梯不会超过或停留在中转层。
- 电梯线程需要延迟一个周期结束,保证当前位于中转层的电梯能够离开中转层。
private boolean checkIfEnd() {
if (processingQueue.isEnd() && processingQueue.isEmpty() &&
processingQueue.isEmpty1()) {
num = num + 1;
}
return num == 2;
}
避免两个轿厢碰撞
实现方法主要是基于线程安全的Flag共享类。
- 当电梯将要进入中转层时,会调用共享类中的setOccupied方法,在保证另一部电梯不在中转层后进入,同时setOccupied。
public synchronized void setOccupied() {
waitRelease();
state = State.OCCUPIED;
notifyAll();
}
- 这里的waitRelease会在state为OCCUPIED时等待。
private synchronized void waitRelease() {
notifyAll();
while (state == State.OCCUPIED) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
- 当电梯目前处在中转层,将会在离开后调用共享类中的setRelease方法,同时唤醒waitRelease中可能在wait的线程。
public synchronized void setRelease() {
this.state = State.UNOCCUPIED;
notifyAll();
}
这样就可以保证不会出现电梯碰撞的情况。
同步块和锁的设置
这次作业新增了一个Flag共享类,与RequestQueue类相似,对其中可能在两个线程中对同一属性进行访问的方法加锁,来保证线程安全。
遇到的bug及debug方法
- 这次作业出现了一个在强测和互测中的共性问题:在设计上如果一个请求在分配时,这个电梯在reset,会调用电梯里的方法wait(),但是在条件判定时提前获取了电梯的是否是双轿厢的属性(用来确定要wait n号电梯还是n+6号),但是在reset以后,这一属性可能已经发生改变,这回导致请被默认分配给A轿厢,同时导致程序卡在这里。
- 因为问题本身较为清晰,通过添加输出日志的形式很快定位到了问题,因为是逻辑上的问题,修复起来也较为轻松。
心得体会
第二单元关于多线程的学习对于我们的层次化设计提出了更高的要求,因为我们需要同时保证线程的安全性,一方面层次化设计可以避免过多的bug产生,另一方面也有助于我们通过阅读代码逻辑来debug(因为多线程中的bug难以复现,很多时候不得不反复分析代码逻辑)。同时也让我开始重视书写注释(因为之前基本是通过调试来debug,因此在很长一段时间忽视了精确简短的注释),来便于自己回过头来阅读代码逻辑。
总结
第二单元的学习相比于第一单元给我带来了很大的压力,主要是不熟悉线程的创建、运行等基本操作,不熟悉多线程程序的设计方法,也很难确定哪些方法需要加锁。但是在这一过程我也真正开始接触具有极高实用价值的多线程架构,与此同时OS课程也正在讲关于进程和线程的知识,通过对理论的学习和在OO课程中的实践,让我受益匪浅。
最后,还是希望我的头发不要掉得太快。