OO第二单元总结 —— 多线程电梯调度
OO第二单元总结 —— 多线程电梯调度
一、设计分析
1. 整体设计
在本单元的三次作业中,使用了两级调度器进行调度,整体架构基本相同,大致如下图所示:
其中Scheduler
为一级调度器,设置为一个独立的线程,负责将读入的请求发送给各个电梯线程。而Dispatcher
为第二级调度器,没有设置为独立线程,而是包含在电梯线程内部,负责控制电梯的行为,处理请求。
2. 调度器设计与同步块设计
-
同步块设计
本单元作业中,同步块主要用于对共享对象的查询与修改,如:
synchronized (requestQueue) { requestQueue.addNewRequest((PersonRequest) request); requestQueue.notifyAll(); }
或者仅仅是唤醒在等待的线程:
synchronized (requestQueue) { requestQueue.close(); requestQueue.notifyAll(); break; }
也即同步块的设置实际上是为了防止对共享对象的查询与修改使信息在不同线程中不同步而设计的。
-
同步锁选择
同步锁被设置为了共享对象,一般选择在同步块中被修改(或查询)的共享对象作为同步锁。有时需要仅仅为了唤醒等待的线程而使用同步块,此时将锁设置为目标线程等待的共享对象。若要在同一段程序中对两个共享对象进行操作,可以进行嵌套,但会增加出现死锁问题的可能,可以的话还是应该尽量避免。
-
调度器设计
第一级调度器:
-
功能:
设计为独立线程,接收到来自输入模块的请求后,识别每一条请求,并进行相应的处理。
增加电梯请求:新建电梯后初始化,并启动线程。
乘客请求:经过判断后加入对应电梯的等待序列。
-
实际操作设计:
第五次作业:逻辑较为简单,不需要进行判断,直接将获得的请求通过发送给电梯线程。
第六次作业:存在 3 ~ 5 个相同电梯,对每一个电梯使用
virtualRun()
方法估算将新请求加入该电梯后大致的运行时间,选择最少的一个作为实际的操作对象。同时也需要对新出现的新增电梯请求进行处理。第七次作业:电梯之间产生了区别,因而在调用
virtualRun()
方法之前先调用transferStrategy()
方法判断可以由哪一类电梯来处理该请求。
第二级调度器:
-
功能:
包含在电梯线程内部,接收到来自第一级调度器的请求,并通过对电梯当前时刻属性和所属等待队列的判断控制电梯的行为。
-
实际操作设计:
三次作业没有发生较大改动。
将电梯的行为进行拆解,并定义为一些基本的指令:
enum Order { MoveUp, MoveDown, Wait, Open, Close, LoadSame, LoadOpposite, Stop }
电梯启动后,由二级调度器读取状态信息,经过逻辑判断后向电梯发送一条合适的指令,电梯执行指令后更新状态信息,并等待下一条指令,循环执行这一流程直至电梯接收到
Stop
指令。
调度器与线程的交互:
第二级调度器包含在电梯线程内部,依靠电梯线程与其他线程交互。
第一级调度器为独立的线程,依靠共享对象与其他线程交互:
-
与输入模块:
// Scheduler.java synchronized (requests) { if (requests.isEmpty()) { if (requests.noMoreRequest()) { // 请求全部处理完成,进行结束处理 ... } else { try { requests.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } } // RequestProducer.java Request request = elevatorInput.nextRequest(); if (request == null) { synchronized (requestQueue) { requestQueue.close(); requestQueue.notifyAll(); break; } } if (request instanceof PersonRequest) { synchronized (requestQueue) { requestQueue.addNewRequest((PersonRequest) request); requestQueue.notifyAll(); } }
当输入模块接收到请求时,判断请求类型后将其加入共享对象
requestQueue
,随后唤醒正在等待的Scheduler
;当输入模块没有接收到请求但输入尚未终止时,调度器等待;
当输入结束时,输入模块修改共享对象的标志域,调度器识别后进行相应操作。
-
与电梯线程:
// Scheduler.java public void run() { if (requests.noMoreRequest() && requests.isEmpty()) { for (int index = 0; index < elevators.size(); index++) { synchronized (waitingCrowds.get(index)) { waitingCrowds.get(index).end(); waitingCrowds.get(index).notifyAll(); } } } // ... } public void addElevator(int id, String type) { int lowestFloor = 1; int highestFloor = 20; WaitingCrowd waitingCrowd = new WaitingCrowd(lowestFloor, highestFloor); waitingCrowds.add(waitingCrowd); Dispatcher dispatcher = new RandomMode(type, waitingCrowd); Elevator elevator = new Elevator(id, dispatcher, type); elevator.start(); elevators.add(elevator); } private void insertPerson(int aim, Person person) { synchronized (waitingCrowds.get(aim)) { waitingCrowds.get(aim).addPerson(person); waitingCrowds.get(aim).notifyAll(); } }
当调度器获得了一条增加电梯的请求时,创建需要的电梯后对其初始化,加入电梯列表并启动。
当调度器获得了一条乘客请求时,在经过一系列逻辑判断后将其加入对应电梯的等待队列,也即调度器和电梯之间的共享对象
waitingCrowd
。当输入结束时,调度器设置共享对象的相应域并唤醒电梯线程。
-
3. 架构设计
- UML类图
- UML协作图
-
功能与性能设计&可扩展性分析
本次作业中,主要的功能点有:
-
三种不同的到达模式
通过元组记录后作为各模块的类型标志,通过多态性来解决不同模式下的问题。
// Dispatcher.java public abstract class Dispatcher { // ... } // RandomMode.java public class RandomMode extends Dispatcher { // ... } // ...
-
三种不同的电梯
不同种电梯之间的区别主要有:
可到达的层数
移动速度
载客量其中可到达层数上的区别由第一级调度器
Scheduler
通过给不同种类的电梯分配不同的乘客请求来实现,而移动速度和载客量上的差异则由创建电梯时传入的表示电梯类型的字符串控制,在构造函数内实现。public Elevator(int id, Dispatcher dispatcher, String type) { // ... this.type = type; switch (type) { case "A" : limNumOfPassengers = 8; movingSpeed = 600; break; case "B" : limNumOfPassengers = 6; movingSpeed = 400; break; case "C" : limNumOfPassengers = 4; movingSpeed = 200; break; } }
-
乘客的调度(第一级调度)
获取到乘客请求后,按照如下流程处理:
graph LR A[接收乘客请求] --> B[获取输送策略集] B --> C{从列表获取一台电梯} C -->|成功| D{获取电梯类型} D -->|在策略集中| E[模拟运行] E --> F[更新记录数据] F --> C D -->|不在策略集中| C C -->|失败| G[将请求加入记录的电梯中]在获取输送策略集时,为该请求针对每一种策略分配对应的换乘方案。为防止单一类型请求高度集中,对每一种可能的策略都进行尝试。本次作业中该过程使用了静态分配过程。
(wtcl写不出来动态分配)private ArrayList<String> transferStrategy(PersonRequest personRequest) { ArrayList<String> res = new ArrayList<>(); int from = personRequest.getFromFloor(); int to = personRequest.getToFloor(); if ((from < 6 || from > 16) && (to < 6 || to > 16)) { res.add("C"); } if ((from % 2 == 1 && Math.abs(from - to) > 1) || (from % 2 == 0 && Math.abs(from - to) > 2)) { res.add("B"); } res.add("A"); return res; }
对于需要换乘的乘客,其行程被分为 2 ~ 3 段,为便于说明,规定电梯的优先级 C > B > A,并将乘客的多段行程中所搭载的电梯优先级最高的一段行程称为主程。
在模拟运行时
因为懒因为担心对电梯状态信息的高度要求引发莫名其妙的死锁或线程安全问题或逻辑过于复杂导致CPU运行时间过高(貌似没有担心的必要)而使用了极其粗略的估算,具体如下:忽略了换乘等待时间
忽略了因非主程电梯繁忙而导致的等待时间
假设非主程电梯随叫随到
假设所有换乘乘客对所模拟的电梯来说均已到达(即假设需要其他电梯进行的形成均已完成)
此时,相当于假设其他电梯均已完成全部请求,且在未载客时可以瞬移到任一楼层。
private double virtualRun(Elevator elevator, PersonRequest newPerson) { int step = 0, from = newPerson.getFromFloor(), to = newPerson.getToFloor(); double extraTime = 0; String type = elevator.getType(); // 计算非主程耗时 switch (type) { case "B" : // ... break; case "C" : // ... break; // A电梯主程直达 } // 防止逻辑错误导致死循环或虚假调度策略 if (from == to) { return 210; } Person person = new Person(from, to, 0, true, null); // 拷贝电梯状态信息 ArrayList<Person> tempContaining = new ArrayList<>(elevator.getContaining()); WaitingCrowd tempWaitingCrowd = new WaitingCrowd(elevator.getWaitingCrowd()); tempWaitingCrowd.addPerson(person); int location = elevator.getLocation(); Heading towards = elevator.getHeading(); // 模拟运行,计算时间 while (true) { for (; location <= 20 && location > 0; location = (towards == Heading.Up) ? (location + 1) : (location - 1)) { // 记录移动的层数 step++; // 判断是否开门并进行相应处理(略) ... if (door) { extraTime += 0.4; } // 判断是否继续前进并进行相应处理(略) ... // 掉头 towards = (towards == Heading.Up) ? Heading.Down : Heading.Up; // 判断是否开门并进行相应处理(略) ... // 完成后结束方法,返回计算结果 return ((double)step) * elevator.getMovingSpeed() + extraTime; } } }
将乘客请求加入对应的电梯时,如果该乘客为需要换乘的乘客,则以链表形式组织各行程,将各段分别作为独立的请求加入不同电梯,并在请求类内部设置表示前段请求是否完成的标记。
synchronized
的两种用法混用确实看起来很不舒服,但是懒得改了emmmmimport com.oocourse.elevator3.PersonRequest; public class Person extends PersonRequest { private boolean arrived; private final Person nextTerm; // 省略了部分方法 public Person(int fromFloor, int toFloor, int personId, boolean arrived, Person nextTerm) { super(fromFloor, toFloor, personId); this.arrived = arrived; this.nextTerm = nextTerm; } public synchronized void arouse() { this.arrived = true; } public synchronized void personArrive() { if (nextTerm != null) { nextTerm.arouse(); // 唤醒下一程所搭载的电梯 Scheduler.arouseElevators(); } } public synchronized boolean hasArrived() { return arrived; } }
-
电梯的调度(第二级调度)
采用通过指令驱动的调度方式。
enum Order { MoveUp, MoveDown, Wait, Open, Close, LoadSame, LoadOpposite, Stop } public void run() { while (true) { switch (dispatcher.getOrder(location, containing, heading, doorOpen)) { case Stop: return; case Wait: synchronized (dispatcher.getWaitingCrowd()) { try { dispatcher.getWaitingCrowd().wait(); } catch (InterruptedException e) { e.printStackTrace(); } } break; // ... } } }
单部电梯内部的调度策略使用了LOOK算法,不再详细展示。
-
可扩展性分析
因为第一单元重构到PTSD,第二单元从第五次作业开始就始终注意程序的可扩展性,使得在面对后两次作业时基本架构没有变化,新功能大多是靠增补来实现。第六次作业主要的工作内容是对
Scheduler
的扩展,新增virtualRun()
方法,由直接转发请求变为了选择性地转发请求到合适的电梯。第七次作业主要的工作仍然是对
Scheduler
的扩展,新增transferStrategy()
方法,决定换乘策略,并在virtualRun()
方法中加入了计算换乘相关的部分。对于第七次作业,仍可支持相当程度的扩展。
More Arrive Patten :在
Scheduler
内通过不同方法来实现;在电梯内通过构建更多Dispatcher
的子类来实现。More Elevator :本单元电梯之间的区别并不明显。在上述的架构中,电梯运行模式上的差异(如可达楼层)只影响
Scheduler
对请求的分配,而与电梯本身的运行无关,因而只需编写新的virtualRun()
方法(或在原有方法内添加新的条件分支)即可实现;而电梯属性上的差异只需根据类型标志在构造方法内创建新的分支统一初始化即可。……
总的来说,因为在设计时尽可能地将程序模块化,每个类与方法尽量做到分工明确,所以可以只通过新增子类,新增方法,或在原有方法内针对新的输入新增条件分支来满足一定的扩展需求。
-
二、BUG分析
1. 逻辑错误
因为从设计到编写的全过程完全是从模拟的角度出发,而非算法设计,因而很少出现逻辑错误,但在第七次作业时还是因为粗心而出错。
在处理换乘时,选择使用类似链表的形式处理,本应在前一程完成后逐个唤醒,但在最初时却错误地将后续结点全部唤醒。
public synchronized void personArrive() {
arrived = true;
if (nextTerm != null) {
nextTerm.personArrive();
}
Scheduler.arouseElevators();
}
修改之后的代码见上。
2. 多线程问题
由于在此单元之前未曾真正意义上接触过多线程编程,因而在这一过程中走了许多弯路。
-
循环加锁问题
出现在第五次作业中,发生错误的代码如下:
// Dispatcher.java public Order normalOrder(int floorNum, ArrayList<PersonRequest> containing, Heading heading, boolean doorOpen) { synchronized (getWaitingCrowd()) { // ... synchronized (getRequestQueue()) { // ... } } } // Scheduler.java public void run() { while (true) { synchronized (requests) { if (random()) { return; } } } } private boolean random() { // ... synchronized (waitingCrowds.get(index)) { waitingCrowds.get(index).end(); waitingCrowds.get(index).notifyAll(); } }
最基本的死锁问题,但当时的我并不理解。
修复之后不再有任何一组共享对象同时被多个线程共享。
-
持续等待问题
按照预先设计的流程,当调度器转发了全部请求但输入仍未结束时会进入等待:
if (requests.isEmpty()) { if (requests.noMoreRequest()) { for (int index = 0; index < elevators.size(); index++) { synchronized (waitingCrowds.get(index)) { waitingCrowds.get(index).end(); waitingCrowds.get(index).notifyAll(); } } return true; } else { try { requests.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } else { // ... }
当输入结束时,由输入模块唤醒调度器线程进行结束处理。
if (request == null) { synchronized (requestQueue) { requestQueue.close(); requestQueue.notifyAll(); break; } }
但在第六次作业中加入了新增电梯的请求,使得将这类请求作为最后一条输入时会使调度器一直等待下去,导致程序无法正常结束。
修改后选择在处理新增电梯请求后唤醒调度器。
if (request instanceof ElevatorRequest) { // 新增电梯(略) synchronized (requestQueue) { requestQueue.notifyAll(); } }
3. 发现BUG的方法
本单元主要依靠直接阅读源代码来寻找BUG,在发现BUG后通过打印运行过程信息进行定位。
这种方法效率较低,但也有一定实效,在互测时凭借自己的经验(踩过的坑)发现了一些线程安全问题,但始终也没能复现。
与上一单元最大的不同或许就是占据了主要地位的线程安全问题并不是一定能通过测试找到,即使找到也有很大概率无法复现。
三、心得体会
- 好的架构是十分重要的,这会给迭代开发和BUG修复带来极大的便利。
- 在设计时应该尽量将功能拆解,避免出现逻辑过于复杂的方法或类。
- 进行多线程的交互时,尽可能减少共享对象的数量,或许可以减少死锁问题发生的可能。
更多的也已经包含在上面的内容里了,也就不再重复了~
感谢大家的帮助,也祝大家接下来的两个单元都能一帆风顺~
2021年4月23日