概述
经过了第一单元的多项式面向对象练习,本次作业主要通过实现多电梯调度,练习java多线程。重点在处理线程安全,线程间交互,代码架构(生产消费者模式)
同步块设置和锁的选择
这三次作业都采取的生产消费者的设计模式,因此锁的选择主要考虑对其中各个托盘的互斥访问与修改,以及notifyAll,wait线程交互方式下的唤醒与等待。因此,锁自然而然地选择为各线程之间的共享对象。同步块的设置也是基于上述用途的考虑。
锁的选择直接服务于同步块的功能和其*享对象的使用。
例如:
在InputThread类中,向RequestQueue中添加Request:
synchronized (requestQueue) {
requestQueue.offer(request);
requestQueue.notifyAll();
}
同步块中要对共享对象requestQueue添加元素,出于线程互斥访问修改的考虑,要对这一共享对象进行加锁。另一方面,在添加完元素后,需要唤醒正在等待的线程,也需要对这一对象加锁。
在类Scheduler中,为了防止轮询requestQueue而导致轮询引起的CPU超时,在队列为空的时候,需要让调度器等待:
if (requestQueue.noRequest() && !requestQueue.isEnd()) { //
try {
requestQueue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
调度器设计
第一次作业由于只有单部电梯,分布式和集中式效果相同。第二次作业中采取了分布调度的方式,而在第三次作业中分别尝试了集中式和分布式调度的方式。
在程序中,分布式和集中式共有的线程有输入线程InputThread,调度器线程Scheduler,电梯运行线程ElevatorProcessor。
对于InputThread和Scheduler之间的交互,两者的差别不大。首先由InputThread进行命令的读取,通知处于等待中的Scheduler线程,线程对指令进行处理以后,直到共享队列为空以后,进入等待。此时重复InputThread读取等操作。
集中式和分布式的区别主要在于Scheduler和ElevatorProcessor的交互上。
集中式
集中式调度由Scheduler将指令加入到多部电梯共享的队列当中,再由ElevatorProcessor进行电梯调度,多部电梯之间进行竞争。方式比较简单,线程数目比较少,不容易出现线程死锁等问题。
但在多部ALS电梯的情况下,由于每个电梯具有自己的主任务,而主任务在选择以后可能并未进入该电梯之中,在这段空挡时间中,有可能主任务被其他电梯截胡。所以在电梯调度的时候需要小心地设置标志位。我认为对于这个问题更好地解决方式是消除电梯调度算法对主任务的依赖,采用其他,例如look算法,将极大地改善程序的简洁性。
分布式
分布式调度方式下,各个电梯具有自己的乘客队列,各个乘客队列之间相互独立。总体上,调度器具有自己的算法,来将指令选择地加入到一个队列当中,再由ElevetorProcessor进行调度。相比集中式(*竞争),各个电梯处理队列之间相互不可见。不会出现截胡的情形(虽然ALS不如LOOK等快)。线程数目的增加会使得锁的数目增加,容易引起死锁,调试比较困难。
在第三次作业中,增加了指令的拆分,拆分是由HashMap维护的前一指令到后一指令的映射,在Scheduler将指令拆分以后,将指令对加入HashMap中,只有在key执行完成以后,value才可以被加入ElevatorProcessor之中。
在最初设计的时候,由Scheduler一个调度器来维护HashMap和InputThread读取到的指令。由于两个队列都需要随时进行加入和等待,因此要么会出现,一个队列等待,另一个队列阻塞而无法加入的情况,要么会引起轮询,导致CPU超时。
最后通过增加一个专门服务于HashMap的守护线程来解决上述的矛盾。在ElevatorProcessor将前一个指令运行完以后,会将前一个指令加入到finished队列当中。SplitedController查询finished队列,将后一部分加入到电梯当中。对于拆分成三部分的指令来说,将三段指令按照<first,second>,<second,third>的方式加入到HashMap中即可满足要求
两种调度方式的比较
集中式调度由于只有一个队列,调度器也基本上是把InputThread中的指令加入到新的队列当中,对电梯行为的控制能力有限,想要对电梯进行控制,主要还是要通过电梯算法来实现。但是本地测试的时候,集中式(*竞争)相比静态拆分指令的分布式调度,大致会快10-20s,这主要是电梯在*竞争的时候,相当于是一种动态的调度,更能适应不同的情况。但是由于在加入指令以后,你能做的几乎没有,所以,属于一种下限高,上限低的调度策略
如果分布式调度器能够考虑到当前各个电梯的状态,以及实时决策,预计会比集中式的快(
第三次作业分析
UML类图
UML协作图
可扩展性分析
本单元的作业相比上个单元,没有出现上次极其痛苦的重构,主要是通过更改一两个类,以及封装现有的类(PersonRequest)来实现的。
在本地分别尝试了集中式和分布式,两种架构。提交采用了分布式的方式(如上)
对于分布式而言,共有两层的调度来维持电梯的运行,分别是Scheduler和ElevatorProcessor,两者之间是相互独立的。
因此如果增加更多类型或者数目的电梯,只需要对调度器的算法进行修改即可,而其他部分不受影响。同样地,给电梯增加新的模式,也可以通过给ElevatorProcessor增加一个新的算法来实现。所以总体上说,各个类之间的依赖关系不强,实现了模块化,这也是上个单元作业所不具有的特性。
BUG分析
1. 死锁问题
死锁问题是本次作业中出现最多的问题,而且难以复现,也受到运行环境的影响。本地不会出现的bug,到了评测机上,就有可能出现,给调试造成了不小的压力。死锁在作业中出现过三次。都是由于粗心大意,锁获取的顺序不同,导致互相占用下一个语句的锁,引起的死锁问题。
这一问题可以通过在类中维护一个锁顺序数组来确保锁获取的顺序一致,降低了调试(瞪眼)的压力
2. 轮询问题
轮询问题是在第三次作业中,因为Scheduler中维护了两个请求队列引起的,在上文调度器设计->分布式中也有叙述。该bug出现于中测中,在本地通过JProfiler调试出来的。增加了一个专门的类来维护HashMap和requestQueue,确保每个类只维护一个等待队列。不仅避免了轮询的问题,也避免了当一个队列阻塞,另一个队列无法加入到电梯中而导致的性能下降。
针对线程安全问题的一种解决策略
线程安全的问题,尤其是死锁,加锁错误或者未加锁问题,都可以通过使用ArrayBlockingQueue这一线程安全的队列来进行。
添加元素
put(E e):把 e 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续
取出元素
take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 Blocking 有新的对象被加入为止
不难看出,这个类的行为符合wait-notify模式下的电梯行为,所以,可以直接采用这一线程安全类来实现。(但是还是觉得采用原始一点的方法能够搞清原理,所以没用)
Hack策略
主要通过瞪眼法,有针对性地制造数据来进行Hack。在瞪眼的过程中,主要针对锁获取顺序不一致(死锁),未加锁(逻辑错误),电梯调度算法(超时),极端数据构造(超时)来进行hack。
第一单元中采取了自动生成数据地方式来进行测试的,这种方式对于极端情况难以覆盖,同时也需要避免bug同质的问题。
心得体会
本次作业,相比上一个单元的作业,在架构设计方面,有了一个很明显的改观,通过一两个类的修改就可以实现新的功能,没有进行重构,也算是尝到了架构带来的甜头。
在封装类方面,本次将电梯的属性以及运行方式分开的方式,应该是比较成功的,显著降低了方法的复杂度以及长度,代码也能清晰一点。
此外,由于分布式采用的静态调度的方式,没有考虑电梯实时的运行状态,所以性能上在本地不如集中式。