一、作业需求分析
本单元的主要目标是模拟多线程实时电梯系统,熟悉线程的创建、运行等基本操作,熟悉多线程程序的设计方法。
第五次作业
在第五次作业中,要求实现一个六部电梯系统,电梯运行参数,荷载人数等在初始化时定义好,乘客请求分配给固定电梯,但电梯可以自行决定实现某种捎带策略。
第六次作业
此次作业相较于第五次作业,新增了自由分配乘客请求的功能,且增加RESET请求,可以在某一时刻改变电梯运行参数。
第七次作业
此次作业新增双轿厢电梯,即可以通过特殊RESET指令将电梯改变为双电梯,设置换乘层,跨楼层请求需要通过电梯协作完成。
对于性能而言,本单元考察指标有:系统运行时间,等待时间与期望时间之差的最大值和系统耗电量。即希望采取恰当的调度策略和电梯捎带策略,使得:
- 运行时间尽量短
- 尽量不让请求等待过长时间
- 尽量减少系统的无效运行
二、同步块设置和锁的选择
第二单元作业的最大特点就是采用多线程编程,而要保证程序的正确性,首先需要确保线程的安全性。我在本单元实现线程交互的方式采用了生产者——消费者模式,此模式下,涉及生产者线程和消费者线程对“托盘”,即临界资源的竞争,因此必须采用锁机制来实现线程间的互斥。
架构介绍
与大部分同学一样,我采用了二级分配的方式。
首先主线程中,创建了以下线程:
- 一个输入线程
- 一个调度器线程
- 六个电梯线程
输入线程InputThread和调度器线程Scheduler共享总队列Waitqueue,调度器线程Scheduler和六个电梯线程Elevator分别共享一个候乘列表opqueue。
请求的流动方向为:
InputThread -> Waitqueue -> Scheduler -> opQueue -> Elevator
同步块设置和锁的选择
本单元的三次作业中,我均采用了synchronized关键字实现了同步块。
synchronized关键字主要有两种实现方法:
- 作为函数的修饰符(也就是常说的同步方法)
- 作为函数内的语句(也就是常说的同步代码块)
事实上,为了尽可能地确保线程安全,我的代码中这两种方法均被实现。
在总队列waitQueue中,对所有的方法均实现了synchronized关键字,以实现当任何一个线程需要读写总队列数据时,其它线程无法获得总队列对象。
public synchronized void setReset(int id, boolean setReset) {
......
}
public synchronized boolean getReset() {
......
}
public synchronized void setInputEnd(boolean inputEnd) {
......
}
public synchronized void addRequest(Request request) {
......
}
public synchronized Request getOneRequestAndRemove() {
......
}
public synchronized boolean isInputEnd() {
......
}
public synchronized boolean isEmpty() {
......
}
在每个电梯和调度器的共享列表中也采用了一样的设置方法,此处不再重复。
另一方面,对于线程中的某些方法中,我们可能会遇到这样的情况:某几句语句执行的时候必须是原子的,即这些语句在执行的时候不能被打断,尤其是在判断线程结束条件时,对于代码语句执行的原子性要求更高。于是,我们可以把这些语句用一个synchronized(Object)关键字包含起来,使得在执行这些语句时,首先获得Object的锁,之后除非执行wait语句自行阻塞,否则语句会一直执行,不会被打断,且对象不会被其他线程抢夺走。
例如:
在调度器Scheduler将判断总队列是否有请求或结束的逻辑中:
synchronized (waitQueue) {
if (waitQueue.isEmpty() && (!waitQueue.isInputEnd()
|| waitQueue.getReset())) {
try {
waitQueue.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else if (waitQueue.isEmpty() && waitQueue.isInputEnd()
&& !waitQueue.getReset()) {
for (int i = 0; i < elevatorNum; ++i) {
opQueues.get(i).setEnd(true);
}
return;
}
}
用**synchronized(waitQueue)**确保同步块代码执行的原子性。
而在电梯中,将候乘列表中的请求转换为自己的目标需求和乘客的过程中,对候乘列表opQueue实现synchronized关键字构造同步块。
避免轮询
轮询是一种极其占用CPU资源的行为,因此在作业中需要采取合适的方法避免轮询的发生。事实上,轮询通常发生于线程在寻找资源的时候,但是实际运行的过程中,相当多的时间内资源是空的,或者说线程得不到自己想要的资源,例如总队列为空时,调度器线程每次都无法得到自己需要分配的请求。
当线程暂时无法获取资源时,可以考虑将线程挂起,即阻塞起来,当其他线程将资源准备好后再通知该线程。
java中提供的wait()和notify()方法即很好地实现了这一点。
同样以调度器Scheduler的实现为例:
if (waitQueue.isEmpty() && (!waitQueue.isInputEnd()
|| waitQueue.getReset())) {
try {
waitQueue.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
当waitQueue的资源为空且未结束时,就将线程挂起。
当InputThread实现
waitQueue.addRequest(request);
方法时:
public synchronized void addRequest(Request request) {
requests.add(request);
notifyAll();
}
waitQueue会notifyAll(),使得Scheduler线程被唤醒。
三、调度器设计
捎带策略设计
在第五次作业中,并不需要实现对请求的分配,因而只需要考虑电梯自身的调度器,即考虑如何实现捎带策略。
我的设计中,有一个Strategy策略,嵌入Elevator线程中,参考LOOK调度算法,并结合Elevator的运行参数、运行状态、候乘列表以及已进入电梯乘客,用状态机实现捎带策略,具体实现如下:
电梯状态,由枚举类Dynastate管理,其拥有OPEN,CLOSE,OPENSTOP,CLOSESTOP,UP,DOWN,RESET几种状态,其中,OPEN,CLOSE,UP,DOWN对应着电梯的开门、关门、上升一层、下降一层的动作,而OPENSTOP,CLOSESTOP对应着保持开门和保持关门的状态,RESET则表示电梯即将进入重置状态。
候乘列表和已进入电梯乘客列表均设置为HashMap<Integer, Requeue>,共有十一个楼层,每层对应一个请求列表,候乘列表的键为乘客所在层,已进入电梯乘客列表的键为乘客的目的楼层。
在Strategy类中,要实现以下几步:
- 获取电梯当前状态和RESET信号
- 根据RESET信号判断是否需要进行RESET操作
- 若不需要RESET,则根据当前状态,候乘列表和已进入电梯列表,决定电梯的下一步状态
- 将决策反馈给电梯
捎带策略的具体实现:
-
若判断RESET信号为真,优先实现RESET,若电梯开门,则让电梯中的人下电梯并关门;若电梯关门或上升或下降,则让电梯停止,乘客下电梯后关门,结束本次决策
-
当前状态为OPEN或OPENSTOP: 若当层有乘客请求,则继续保持开门状态OPENSTOP,否则关门CLOSE
-
当前状态为CLOSE或CLOSESTOP:若所有请求均执行完,则继续保持关门状态CLOSESTOP;否则,若当层有乘客请求,打开门OPEN;否则,若至边界楼层,则向反方向运行UP 或 DOWN;否则,若原本运行方向上仍有可接受请求或有电梯内乘客目的楼层,则继续保持原来运行方向;否则,转变方向。
-
当前状态为UP或DOWN:若当层有请求或有乘客需要下电梯,则打开门OPEN;否则,同前一条的运行判断。
调度策略设计
在第六次、第七次作业中,需要自行实现乘客请求的分配,我的代码中采用了较为简单的分配方法,在调度器Scheduler中实现,通过和电梯的托盘获取电梯运行时参数——电梯内人数,电梯荷载人数,设定阈值为电梯荷载人数乘以500除以移动一层时间,设定静态变量count,每次分配时+1模6,若电梯被RESET或人数超过阈值,则暂时不分配。
对于RESET的电梯,将电梯中的人对应的请求撤销,并根据当前楼层和目标楼层创建新的请求,重新加入到总请求队列中,重新由调度器进行分配。
在第七次作业中,新增了双轿厢电梯,我的做法是,原有的调度分配策略不变,在RESET之后,将电梯置为一个新的调度器,在此调度器中管理两个子电梯——A和B,并通过托盘RequeueA和RequeueB进行交互。此时请求分配的流程变成了:由调度器Scheduler将请求分配给Elevator,根据请求所在楼层、目标楼层和换乘层的关系选择分配给A或B电梯,A或B电梯的运行逻辑和之前的电梯类似。
四、架构设计和迭代
第五次作业
三次作业沿用了一套设计体系,线程类包括InputThread、Scheduler、Elevator,由托盘AllRequeue连接输入线程和调度器线程,由托盘Requeue连接调度器线程和电梯线程,Elevator中由Wrap类存储基本参数——移动一层所需时间、开门所需时间、关门所需时间、荷载人数,由枚举类Dynastate表示电梯状态。
同时设计中采用了策略模式,实现策略接口Strategy,第五次作业中,仅实现了MainStrategy,实现捎带策略;
实现了状态接口States,分别控制不同动作下的输出,由**Announce()**方法实现
线程时序图如下:
第六次作业
结构设计相较于第五次作业并无太大变化,只是增加了对RESET信号的处理。
-
需要在输入线程和调度器中增加额外的RESET信号;
-
其次,在Elevator的状态类Dynastate中,增加RESET状态;
-
策略类MainStrategy中,增加RESET的响应处理,即在状态机设计中,新增转移到RESET和由RESET转移到其他状态的逻辑。
线程协作时序图如下:
第七次作业
第七次作业,增加了双轿厢电梯的功能,相较于前一次作业代码有以下新增部分:
- 新建ElevatorSon类,实现类Elevator功能,设定楼层边界(不再是1-11)
- 新增策略类DoubleStrategy,实现双轿厢电梯的运行策略,考虑跨换乘层和边界楼层情况
- 新增ElevatorLock方法,避免双轿厢电梯相撞
线程协作时序图如下:
五、防撞方法
在第七次作业中,实现了双轿厢电梯,其设定中换乘层是两个轿厢的共享楼层。因此,其实也可以理解其为双轿厢的共享资源,在我的实现中,即为ElevatorSonA和ElevatorSonB的共享资源,因此很自然地想到用锁来进行互斥,从而确保运行的安全性。
在我的设计中,实现了ElevatorLock类,其中int型的lock表示换乘楼层是否有电梯,当子电梯在运行过程中,先判断lock是否为1,如果是1,则等待(wait()),否则,可以直接进入。例如,在子电梯上行的逻辑中:
public void upStair() {
if (kind.equals("-A") && currentFloor == highestFloor - 1) {
if (lock.getLock() == 1) { //换乘层已有电梯
synchronized (lock) {
try {
lock.wait(); //阻塞
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
boolean canMove = false;
boolean setLock = false;
if (currentFloor == highestFloor - 1 && kind.equals("-A")) {
synchronized (lock) {
if (lock.getLock() == 0) {//换乘层无电梯
lock.setLock(1); //直接进入,并置lock为1
canMove = true;
}
}
} else {
if (kind.equals("-B") && currentFloor == lowestFloor) {
setLock = true;
}
canMove = true;
}
if (canMove) {
currentFloor++;
try {
sleep((long) wrap.getMoveTime());
} catch (InterruptedException e) {
e.printStackTrace();
}
setState(new Arrive(this.wrap.getId(), currentFloor, kind));
upOrdown = 1;
if (setLock) {
lock.setLock(0); //离开换乘层,置lock为0
}
}
}
下行逻辑与此对称。
六、DEBUG
CPU超时
在前两次作业中,出现了轮询而不自知的现象,从而导致了CPU超时的现象。为了找出轮询发生的地方,我直接使用了IDEA自带的IntelliJ Profiler工具(这里要感谢rzgg提供的投喂机,为我debug带来了极大的便利)
运行之后得到火焰图:
这里会显示每一个类的CPU运行时间,据此可以分析是哪一个线程,哪一个方法的调用时间过长,进而最有可能导致轮询的地方。
线程安全问题
在第七次作业中,由于对锁的管理较为混乱,因此导致了部分情况下最终线程无法退出的现象出现。
为了找出罪魁祸首,除了在关键时刻设置输出检验线程运行情况之外,可以采用线程快照的方法。
同样用IntelliJ Profiler工具运行结束后:获得线程快照图
据此分析各线程在不同时刻的行为,如等待,执行等。
七、心得体会
这一单元,相比于前一单元的作业,难度有较大的提升,主要在线程安全管理这一块,稍有不慎,就会出现各种各样的错误。例如,因为对锁机制的理解不够到位使得不同线程交互时出现死锁或者无法实现互斥等bug。因此,在编写多线程的程序时,不仅仅要关注代码逻辑的正确性,更重要地是要梳理出线程之间的交互逻辑和可能面临的共享资源竞争的关系,不放过任何一个可能导致线程不安全的细节,除此之外,在线程运行中涉及到wait()或结束标志的判断,需要保持判断条件语句的原子性,否则,当判断逻辑变得复杂,会极大增加造成线程死锁等不安全结果的可能。
从层次化的角度而言,本单元的作业我均采用了生产者-消费者模式,其中就必然面临着不同层次的组合,例如:输入线程、调度器线程;调度器线程、电梯线程都采用生产者-消费者模式,但却在调度或请求分配的不同层次中,如果没有在写代码之前进行对层次的思考和设计,就会使得最终的程序层次混乱。好的设计,必须对层次有尽量完备和全面的思考,使不同层次的模块做到高内聚、低耦合,层次之间进行交互但又不从逻辑上关联,这样无论是对于debug,还是增量开发,都会使工作变得更为简单清晰。