OO第二单元作业总结
第二单元我们在第一单元单一进程面向对象的设计基础之上,进一步学习和练习使用了多进程。
目标是模拟多线程实时电梯系统,熟悉线程的创建、运行等基本操作。
第五次作业
题目描述
大楼有 A,B,C,D,EA,B,C,D,E 五个座,每个楼座有对应的一台电梯,可以在楼座内 1-101−10 层之间运行。电梯系统具有的功能为:上下行,开关门,以及模拟乘客的进出。在电梯的每个入口,都有一个输入装置,让每个乘客输入自己的目的位置。电梯基于这样的一个目的地选择系统进行调度,将乘客运送到指定的目标位置。
主要架构思想
第一次的作业由于电梯功能较为简单,所以我的总体设计也教为简单,使用了生产者消费者模式,设计了三个类:它们之间的关系见下图
TestMain为生产者,为每个楼建一个请求队列(RequestQueue),每栋楼建一个电梯(Elevator),
for (int i = 1;i <= 5;i++) {
RequestQueue parrallelQueue = new RequestQueue();
processingQueue.add(parrallelQueue);
Elevator elevator = new Elevator(parrallelQueue,i,(char)('A' - 1 + i));
elevator.start();
}
并通过官方包获取请求,再把请求根据请求楼座,分发给响应的请求队列;
processingQueue.get(request.getFromBuilding() - 'A').addRequest(request);
RequestQueue相当于生产者消费者模式中的托盘,负责存储响应楼座的请求队列,并且含有一些对当前楼座请求队列的访问、增减操作。
private ArrayList<PersonRequest> requests;
private boolean isEnd; //用于判断是否结束的标志位
Elevator为消费者,内含电梯的基本属性,主要用于处理请求,实现电梯的开关门,电梯在不同楼层之间的移动以及乘客的进出电梯。
public void run() {
while (true) {
if (processingQueue.isEnd() && processingQueue.isEmpty() && inQueue.isEmpty()) {
elevatorClose();//结束检查
return; //结束线程
}
mainTask = processingQueue.getMainTask(inQueue); //获取主请求
while (mainTask != null &&
(inQueue.contains(mainTask) || processingQueue.contains(mainTask))) {
changeState(); //获取运行状态
ArrayList<PersonRequest> outQueue = getOutQueue(); //获取出电梯的人员队列
ArrayList<PersonRequest> intoQueue = getIntoQueue(outQueue); //获取进电梯的人员队列
if (!intoQueue.isEmpty() || !outQueue.isEmpty()) {
openDoor(); //开门
outPerson(outQueue); //乘客出
inPerson(); //乘客进
closeDoor(); //关门
}
travel(); //电梯运行
}
}
}
第5次作业电梯调度时序图(Sequence Diagram)如下:
调度策略
在这次作业中,我使用了ALS调度策略,即新增主请求和被捎带请求两个概念
-
1、主请求选择规则:
-
(1)、如果电梯中没有乘客,将请求队列中到达时间最早的请求作为主请求
-
(2)、如果电梯中有乘客,将其中到达时间最早的乘客请求作为主请求
-
-
2、被捎带请求选择规则:
-
(1)、电梯的主请求存在
-
(2)、该请求投喂的时刻小于等于电梯到达该请求出发楼层关门的截止时间
-
(3)、电梯的运行方向和该请求的目标方向一致
-
获得主请求的代码实现逻辑如下
if (!inQueue.isEmpty()) {
return inQueue.get(0);
} else if (!requests.isEmpty()) {
return requests.get(0);
} else { //isEnd
return null;
}
获得捎带请求:
for (PersonRequest re : requests) {
if (in.size() < num) { //num为可容纳乘客数
if (state == 1 && re.getFromFloor() == floor && re.getToFloor() > floor) { //电梯上移
in.add(re);
} else if (state == -1 && re.getFromFloor() == floor && re.getToFloor() < floor) { //电梯下移
in.add(re);
}
} else {
break;
}
}
同步块与锁
对于在不同进程中传递的共享对象,我们为了防止出现读写冲突等问题,需要对其加锁。
例如,Elevator与RequestQueue之间的共享对象是waitQueue,所以在对waitQueue进行访问和修改的时候都需要对其加锁,如下
synchronized (waitQueue) {
//some code
}
等待与唤醒
为了防止轮询的现象发生,我们需要使得线程在不必须运行的时候及时让出cpu,我们可以使用wait...notifyAll的方法(notify可能导致线程死锁)。
在本次实验中,我选择将等待与唤醒集中在RequestQueue中,Elevator需要从RequestQueue中获得主请求mainTask(使用getMainTask方法),故可以在电梯内人为空并且请求队列为空时,将进程挂起,而当加入请求或请求输入全部结束时唤醒进程。(此处需要防止由于不必要的唤醒导致的轮询现象)
while (!isEnd && requests.isEmpty() && inQueue.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
对于这次作业,我最开始的架构是设计了一个Schedule进行请求分配,经过Profiler分析,发现Schedule占用cpu时间过长,出现了轮询现象,最后就改成了,直接在TestMain中将请求分配给对应的请求队列。
需要注意的是无论是wait还是notifyAll都需要先获得锁,如果未持有锁的进程调用wait、notify、notifyAll会抛出java.lang.IllegalMonitorStateException异常。
死锁
常见的死锁情况可以分为以下几种:(参考《java并发编程基础》第10章)
抱死锁(Deadly Embrace)多个线程由于存在环路的锁依赖关系而永远地等待下去。
锁顺序死锁:产生原因:两个线程试图以不同的顺序来获得相同的锁。
协作对象之间发生的死锁:如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过程,导致其他线程无法及时获得当前被持有的锁。
资源死锁:当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
在本次作业的完成过程中,我也遇到了死锁现象,后来进行逻辑分析,发现是由于相互引用锁而使得进程永远等待下去。
bug与分析
这次作业我被hack的bug是由于输出线程没加锁,导致输出线程不安全,修改方法为:
synchronized (TimableOutput.class) {
TimableOutput.println(
String.format("some string");
}
即,在所有的输出中对TimableOutput.class加锁。
在这次互测过程中我构造了一个在同一地点进入大量乘客的请求,hack到了一位同学。
第六次作业
这次作业在第五次作业的基础上难度有所提升,将新增楼座与楼座之间移动的环形电梯,每个楼层可以对应多台环形电梯,可以在A→B→C→D→E→A 或者 A→E→D→C→B→A 所组成的回路之间行,例如,位于A 座 2 楼的横向电梯可以直接运行到B 座 2 楼 E 座 2 楼。同时允许动态新增电梯。
这就涉及到不光要对乘客的请求能够进行分配,而且对于增加不同电梯类的请求能够进行分配与处理,考虑增设一个专门用于分配请求的类TestInput2。同时对于横向和纵向的电梯,尽管它们的运行方式不同,但它们所执行的开关门进出乘客等的逻辑是一样的,所以我们考虑对于不同类型的电梯(横向或纵向)给它们每个电梯配备一个相应的控制器,通过电梯通过其自己的控制器获取到运行方案。
主要架构
这次作业在第五次作业的基础上有较大的改动,主要表现为,另设TestInput2进程用于输入请求分配,设置两种Controller(Building,Floor)用于控制电梯运行,另设输出线程保证输出线程安全。
输入:单例模式(只有一个实例)
输入+控制器(controller)+电梯(Elevator):生产者-消费者模式
设计架构如下:
TestInput2
作用:负责读取输入的请求信息(官方包),根据请求类型(加人/加电梯)分配请求。
实现:由主线程进入。
while (true) {
Request request = elevatorInput.nextRequest();
// when request == null
// it means there are no more lines in stdin
if (request == null) {
for (Building building : buildings) {
building.setEnd(true);
}
for (Floor floor : floors) {
floor.setEnd(true);
}
break;
} else {
// a new valid request
if (request instanceof PersonRequest) {
// a PersonRequest
// your code here
PersonRequest person = (PersonRequest) request;
if (person.getFromBuilding() == person.getToBuilding()) {
buildings.get(person.getFromBuilding() - 'A').addRequest(person);
} else {
floors.get(person.getFromFloor() - 1).addRequest(person);
}
} else if (request instanceof ElevatorRequest) {
// an ElevatorRequest
// your code here
ElevatorRequest eleRe = (ElevatorRequest) request;
if (eleRe.getType().equals("building")) {
Elevator elevator = new Elevator(
eleRe.getElevatorId(),eleRe.getBuilding(),
1,buildings.get(eleRe.getBuilding() - 'A'));
elevator.start();
} else {
Elevator elevator = new Elevator(
eleRe.getElevatorId(), 'A',
eleRe.getFloor(),floors.get(eleRe.getFloor() - 1));
elevator.start();
}
}
}
Elevator
作用:负责完成人员请求(PersonRequest)的运输。
属性:电梯的运行参数(开关门时间,运行速度,容量),电梯内部人员队列,电梯的控制器,电梯id
每个电梯接受其所在楼/座的控制
while (!controller.checkEnd() || !people.isEmpty()) {
if (direction == Direction.STOP) {
while (people.isEmpty() && controller.checkEmpty()) {
synchronized (controller.getWaitQueue()) {
if (controller.checkEnd() && people.isEmpty()) {
return; //线程结束
}
try {
controller.getWaitQueue().wait(); //等待,避免轮询
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
direction = controller.getDirection(building,floor);
} else {
if (checkOpen()) {
open();
if (people.isEmpty()) {
direction = controller.getDirection(building,floor);
}
close();
}
if (!people.isEmpty()) {
move();
} else if (controller.hasPerson(building,floor,direction)) {
move();
} else {
direction = Direction.STOP;
}
}
}
电梯运行:
switch (direction) {
case UP: {
floor++;
sleep(FLOOR_TRAVEL_TIME);
break;
}
case DOWN: {
floor--;
sleep(FLOOR_TRAVEL_TIME);
break;
}
case RIGHT: {
building = (char)((building + 1 - 'A') % 5 + 'A');
sleep(BUILDING_TRAVEL_TIME);
break;
}
case LEFT: {
building = (char)((building + 4 - 'A') % 5 + 'A');
sleep(BUILDING_TRAVEL_TIME);
break;
}
default:
}
Controller
作用:存储等待队列(waitQueue),控制该楼座/楼层的电梯运行方向。
类型:
-
Building(楼座)A,B,C,D,E 控制电梯纵向运动
-
Floor(楼层)1-10控制电梯横向运动
Printer
作用:输出Printer类,负责将字符串输出,加锁,实现时间戳不减。
public class Printer {
public static void println(String s) { //封装输出
synchronized (TimableOutput.class) {
TimableOutput.println(s);
}
}
}
第6次作业的时序图如下:
调度策略
电梯运行策略
考虑到如果使用ALS策略,因为同楼座/同楼层的电梯可能不只一部,所以需要对它们各自的主请求进行标记,所以我最终选择了相对容易操作的look调度策略。
look策略:scan的变型,当运行方向上无请求时,转换运行方向。
synchronized (this.getWaitQueue()) {
for (PersonRequest request : this.getWaitQueue()) {
if (request.getFromFloor() > floor) {
return Direction.UP;
} else if (request.getFromFloor() < floor) {
return Direction.DOWN;
} else {
return (request.getToFloor() > floor) ? Direction.UP : Direction.DOWN;
}
}
return Direction.STOP;
}
这个需要特别注意横向电梯运行方向的选择问题,由于电梯是环状运行的,所以对于任意楼层一旦该楼层存在请求则电梯运行方向上就存在请求,那么该如何给出电梯运行方向的指令呢?我的方法在电梯刚开始运行的时候通过请求所在座与当前楼座之间距离给电梯一个初始方向,然后该电梯一直按照这个方向运行即可。
请求分配策略
电梯类请求:根据要求(building/floor)增加电梯类(对应的控制器不同)
人员请求:首先根据横向还是纵向移动将初始请求加入到对应Controller的等待队列中,再采用自由竞争策略将请求分配给电梯(即哪个电梯优先到(进入获得进入人员方法)就进哪个电梯)
相关逻辑与代码可见于之前的TestInput2部分内容。
共享对象与锁
同楼座(层)不同电梯共享等待队列,在改变请求队列以及查看请求队列的方法中给等待队列加锁。(与第五次作业部分相同)
ConcurrentModificationException产生原因(modCount与expectModCount不一致)、解决办法(Iterator遍历,加锁)
Iterator<PersonRequest> it = waitQueue.iterator();
while (it.hasNext()) {
PersonRequest request = it.next();
//是否进入的判断
if (in) {
intoQueue.add(request);
it.remove();
}
}
保证输出线程安全:输出线程TimeOutput.class加锁,本次作业中另设Printer类来处理输出。
bug与分析
本次实验,没有被hack,也没有hack到别人。
对于多线程的设计,由于无法通过加断点的方式进行运行于判断,这就为debug带来了不小的挑战,一个很好用的解决办法是通过加print输出来判断程序运行情况和具体变量的变化情况。
第七次作业
题目描述
本次作业在第六次作业的基础上难度进一步提升,要求的一些变化
-
每个横向电梯所处理的乘客的请求符合如下条件,请求的起始楼座为 P,目的楼座为 Q,电梯的可开关门信息为M,有如下公式:
((M >> (P -'A')) & 1) + ((M >> (Q -'A')) & 1) == 2
,这意味着一部电梯只有部分楼座能够开门 -
对于乘客请求,输入保证 [起点座 == 终点座] + [起点层 == 终点层]\ \ne 2 \\,即楼座和楼层不会同时相同,这意味着乘客需要在中转楼层换乘。
-
可定制电梯参数容纳人数、运行速度、可开关门信息M(若满足
((M >> (X-'A')) & 1) == 1
,则代表该电梯在楼座 \text{X} 可开关门。(\text{X} \in [\text{A},\text{B},\text{C},\text{D},\text{E}])(X∈[A,B,C,D,E]))
主要架构
这次作业涉及到在第六次作业基础上引入Worker Thread设计模式(流水线)。输入将请求送到传送带上,对于工人,工作没来就一直等,工作来了就干活。我们需要对一个请求进行拆分,哪一部分完成,即将进行那部分进行标记。(Worker Thread设计模式与Producer-Customer模式的区别可以参考
对于本次作业的具体设计,一个乘客的请求可以分成三个部分,纵向运动+中转楼层横向运动+纵向运动,具体实现可以分为动态拆分和静态拆分两种。笔者的最终实现是采用了静态拆分的方法,即根据路径可达信息在请求读入时拆成多个阶段任务。
我们从这个uml图可以看出右侧的架构与第六次作业的架构大体相同,不再赘述,下面主要对左侧部分进行说明。
MyParser用于找到中转楼层将一个乘客请求拆分成纵(起始楼层到中转楼层)—— 横(起始楼座到中转楼座)——纵(中转楼层到目的楼层)三个请求的一个workingStages队列。获得中转楼层的代码如下:
private int getSwitchFloor() { //获得中转楼层
int sum = 20;
int tmp = 1; //1层有一个横向电梯,故tmp初始化为1
for (int i = 10;i >= 1;i--) {
for (Elevator ele : Schedule.getInstance().getFloors().get(i - 1).getEleList()) {
if (((ele.getOpenable() >> (person.getFromBuilding() - 'A')) & 1)
+ ((ele.getOpenable() >> (person.getToBuilding() - 'A')) & 1) == 2) {
if (Math.abs(person.getFromFloor() - i)
+ Math.abs(person.getToFloor() - i) < sum) {
tmp = i;
sum = Math.abs(person.getFromFloor() - i)
+ Math.abs(person.getToFloor() - i);
}
}
}
}
return tmp;
}
MyRequest从MyParser中获得拆分后该乘客对应的请求队列并进行保存,含有完成阶段的标记point,完成一部分point++
,可以从中获得待完成的阶段(getUnfinishedStage),判断该乘客请求是否全部完成(allStageFinished)。
RequestList是一个单例,含有一个map,用于将乘客的id与MyRequest对应上,便于快速查找下一阶段请求,并将其加入到流水线调度(Schedule)中(这部由Elevator完成)
//乘客出电梯后进行
MyRequest myRe = RequestList.getInstance().getMyRequest(request.getPersonId());
myRe.finishStage();
if (myRe.allStagesFinished()) {
RequestCounter.getInstance().release();
} else {
Schedule.getInstance().addPer(myRe.getUnfinishedStages());
}
RequestCount也是一个单例,用于验收每一个乘客的请求是否都达到了目的地,验收完毕即可结束所有进程。(代码逻辑与实验课exp4-2内容完全相同)
public class RequestCounter {
private int count;
private static final RequestCounter COUNTER = new RequestCounter();
RequestCounter() {
count = 0;
}
public static RequestCounter getInstance() {
return COUNTER;
}
public synchronized void release() { //代表完成一个任务
count += 1;
notifyAll();
}
public synchronized void acquire() { //检验一个任务的完成,如果没有已完成的任务,等待
while (true) {
if (count > 0) {
count -= 1;
break;
} else {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
这次作业的时序图大致如下(时序图绘制可参考
注:实际有多部电梯进行任务处理,在此时序图中未画出。
调度策略
请求静态拆分(见架构部分中的说明)。
纵向电梯调度与第六次作业相同。
横向作业电梯调度涉及到部分楼座部分电梯无法到达的情况,需要在获取进入电梯队列时补充根据该电梯的可开关门信息该乘客的请求能否被该电梯送达的判断。
if (openable != 0 &
((openable >> (request.getFromBuilding() - 'A')) & 1)
+ ((openable >> (request.getToBuilding() - 'A')) & 1) != 2) {
continue; //不可能上电梯
}
共享对象与锁
这次涉及的共享对象还是是电梯与控制器之间的waitQueue,不再赘述。
bug与分析
可能的扩展方向
-
部分纵向电梯仅允许在部分楼层中停靠 -> 处理方法与第七次类似
-
允许删除电梯,即有使某部电梯停止运行的请求 ->可以给该电梯打上标记不再接收请求
-
打印某一乘客的运动历史记录 -> 将乘客运动信息进行存储
心得体会
线程安全即是保证对象的状态不会出现和设计者的意愿不一致的情况。在处理输入输出和查看改变共享对象的时候尤其要注意是否会产生线程不安全的问题。
本单元的架构设计层次大体上可以分成三个部分即输入、处理、输出。在根据具体的任务将任务拆解,根据小任务设计小模块,比如从第五次作业到第六次作业,我们增加了横向电梯的调度,所以构造了一个Controller用于控制电梯的运行;从第六次到第七次,乘客出现了换成请求,我们增加了流水线的处理部分,包括请求的拆分,获取下一阶段请求等。
通过第二单元的学习,我对于多线程的设计模式有了更深入的理解,对于线程安全同步块与锁有了更深刻的理解。并通过三次作业,更深入地理解了迭代开发的意义,提升了代码能力及发现错误的能力。如何更好地维护线程安全,满足用户需求始终是我们不懈探索的方向。