443
社区成员
第一次作业要求比较简单,一栋楼1-11层,通过分配6个电梯实现乘客的要求。
本单元作业是多线程作业,任何涉及线程共享资源的指令都有可能被随时打断,然后被另一个线程获取。用其中最典型的模型,生产者消费者模型来解释,就是生产者和消费者都会不断的访问公用的Table,生产者访问的时候如果Table空了就生产新物品,消费者访问的时候如果Table有物品就取走。两个线程,就像生活中的两个独立的人,同时访问同一个桌Table获取信号并进行操作。在这次电梯调度的作业中,我们就可以将整个流程拆分成两组“生产者消费者模型”,生产者即为发出电梯请求的一方,消费者是处理电梯请求的一方。
️输入线程输进请求放到waitQueue中,由调度器处理waitQueue中的请求,这里的Table就是waitQueue️调度器将waitQueue请求根据调度策略发出请求给processQueue(每个电梯对应一个,一共有6个),由电梯线程处理请求,这里的Table就是processQueue
这样一来,整个过程被分成了一个三级流水线,实现了分而治之的思想。这样做的好处有以下几点:
1.每类线程任务量小,分工明确,代码可读性强。
调度器只需要接受请求,然后根据调度策略分配给特定电梯;电梯线程只需要接受请求 personRequest
,然后根据捎带策略完成任务即可。
2.可扩展性强
将一个大任务拆分后,每个模块内部改动不会影响外部实现。具体来说,就是假如我们要更换电梯捎带策略,并不需要改动调度器,因为调度器分发完请求后就完工了,至于电梯自己是如何捎带最终实现请求的不需要关心。这样的架构使得我的程序可以很方便的更换策略,添加功能。
以下是我第五次作业的UML类图,展现了具体的架构实现方法。
Queue类,实例化7个,一个储存InputHandler
读入的总请求表,另外6个分别是每个电梯线程自己的待处理队列。总表命名为waitQueue
,每个电梯对应的处理队列命名为processQueue
储存在一个Arraylist
中。
InputHandler类,仅负责输入数据,将读入的请求放到waitQueue
中等待Schedule
处理,在输入结束Ctrl^D
信号到来时,给waitQueue
进行标记。
Schedule类,负责分发请求,从waitQueue
中读入数据,通过合适的算法把该Queue
中的Requeset
分发到具体的一个电梯对应的processQueue
中。在waitQueue
收到结束信号时,对每一个processQueue
添加一个结束信号。
Operation类,储存电梯每次做出请求后相关信息,比如下一步的动作是(“OPEN”、“MOVE”、“WAIT”),电梯进出人情况(使用两个HashMap
维护),在ElevatorRun
和Elevator
之间传递,让ElevatorRun
在sleep
之后获得需要print输出的数据。
Strategy类,通过获取电梯当前的楼层、人数,以及处理队列等信息返回电梯的下一个动作:Revese,Move,Wait,Open
。将策略与运行分离开,实现函数可插拔。
ElevatorRun与Elevator类可能与大多数同学不同的是,我的电梯类并不是线程,而是建立了一个ElevatorRun
作为线程,把Elevator
作为属性装入其中。ElevatorRun
是我们真实生活中一个电梯井,由这个井的调度器来控制“电梯Elevator
”这个箱子运行。在每个processQueue
收到结束信号并且电梯内乘客、等待队列都为空时,结束线程。
这样做的好处是所有的线程只需要简单的重写run()
函数内运行逻辑,不需要添加乘客信息、楼层等不属于该“线程”应该管理的属性。此外,如果一个电梯井中能有多架电梯,比如下侧电梯只能运行1-6楼,上侧电梯只能运行6-11楼,就很容易扩展过去。(现实中也会存在这种情况,比如6-11楼是秘密楼层,必须有管理员权限的人才能操作上侧电梯,普通乘客只能调用下侧电梯)
调度器采用影子电梯
策略,即算出局部最优解分配给6个线程。具体做法是对电梯和该电梯的处理队列进行深克隆,调用克隆后的shadowElveator.nextAction
实现模拟推进时间,遍历6个电梯,找出能够使得新请求加入后整体运行时间结束最早的那一种加入请求算法。
为方便说明,举例如下图所示:绿色方块为电梯原有请求完成需要花费时间,每一列为一种分配策略对应各个电梯用时(共六种,即把新请求分配给六个电梯,分别遍历),找出该策略下用时最久的电梯为该策略对应的全局任务完成时间,再将六个策略的时间找出最短的,就获得了局部最优解。下图的情况即为将新请求加入3号电梯是局部最优解。
电梯捎带是LOOK算法,即遵循电梯内请求为主请求,捎带过路者的策略。当路过一层楼时,如果电梯内有乘客到站,就开门让其下电梯;如果该层有同电梯运动方向相同的请求且电梯内人没有满,就开门让其进入;如果不符合以上两点,并且电梯内有人或者没人但同方向有请求,就按原方向移动;如果不符合以上三点,并且电梯内没人,则电梯掉头,并重新判断同方向是否有新请求。除此之外的情况,电梯进入等待状态。
在多线程程序设计中实现中,除了基本的代码逻辑问题,很重要的一点便是线程安全问题。线程安全就是当一个共享对象被多个线程访问时,我们必须确保每个线程访问共享对象时获取到的信息是“及时且正确”的。举例而言,就是两个线程同时执行
if(num == 0) { num++; }
可能出现的情况是线程1执行了if语句,然后切换到线程2执行if语句,然后两线程都给num
增加了1,就使得其变成了2。若要防止这种被打断导致程序执行出非设计者意图的现象发生,就需要对run
函数内的一些语句加锁。
synchronized (waitQueue) {
...// do something
}
这里的内容不能被其他加了同样锁的线程打断,一段任务完成后释放锁,别的线程便可以进入同步块开始执行相应任务。对于每一个共享对象,我们都需要考虑其安全性,并设计合适的同步块保证代码运行逻辑正确。
waitQueue锁
InputHandler
和 Schedule
都会访问该共享对象,因此我们保证在InputHandler
输入时锁住waitQueue
,保证输入请求到其中Arraylist
完毕后,再给Schedule
机会访问并且分发出去。若不加锁,可能会导致Schedule
访问遍历waitQueue
中Arraylist
的时候,添加了新的元素,抛出异常。
涉及到同步块,就一定会涉及到wait
和notifyAll
,否则会导致cpu轮询。在这里,Schedule
需要在当前waitQueue为空且Input信号未终止时释放锁,进入wait状态;而InputHandler
不需要wait
,一旦有输入就将其加入waitQueue
然后调用waitQueue.notifyAll()
使得Schedule
重新获得锁并分配请求出去,具体代码如下所示。
public void run() {
while (true) {
synchronized (waitQueue) {
if (waitQueue.isEmpty()) {
if (waitQueue.alreadyStop()) {
for (/*遍历*/) {
synchronized (processQueues.get(i)) {
processQueues.get(i).stop();
processQueues.get(i).notifyAll();
}
}
return;//线程结束
}
waitQueue.wait();//需要用try
}
for (/*遍历readyQueue*/) {
readyQueue.addRequest();
waitQueue.removeRequest();
}
}
int key = shadowKey();//根据调度策略分配key
synchronized (processQueues.get(key)) {
processQueues.get(key).addRequest();
processQueues.get(key).notifyAll();
}
}
}
processQueue锁
Schedule
和6个ElevatorRun
都会访问对应的processQueue
,因此也需要对其上锁,理由同上。在wait
和notifyAll
的处理中,也是与上一级流水类似的。当Schedule
发配出请求给processQueue
后就会notifyAll
使得ElevatorRun
可以重新抢锁,接着处理请求,最终把processQueue
清空。
线程的结束
线程结束是逐级触发的,首先由InputHandler
收到Null
后给waitQueue
内设置一个终止信号,借用此共享对象告知Schedule
输入请求已经结束,因此Schedule
在分配完毕现有请求后,在每个processQueue
中也添加一个终止信号,使得电梯处理完请求后结束线程即可。
总体而言,整个电梯调度过程的UML协作图如下图所示。由主线程开启InputHandler
和Schedule
,由Schedule
开启每个ElevatorRun
线程。结束的时候以同样的次序添加End标记,并判断当前线程任务是否已经完成,从而判断是否要结束线程。
提高程序的并行度(同时输入请求),增加数据量。在本次作业中,一共只有11层楼,6个电梯,如果中弱测能够通过那么简单的串行数据测试很难测出来bug,一般剩下的都是难以复现的线程安全问题。在并行度高的情况下,更容易复现出线程安全问题的bug。我在本地测试bug时遇到过线程结束问题,在并行度低的情况就没有复现出来,而我把所有输入请求放在同一时刻投入的时候就成功复现了。
复现bug之后,我使用print输出日志的方式找出bug出现的位置,缩小bug出现的范围,wait前后打印可以检查死锁,return前打印可以检查进程是否结束,在 notifyAll 前后打印可以检查轮询问题。
if (processQueue.isEmpty() && elevator.getPassengerNumber() == 0) {
if (processQueue.alreadyStop() || processQueue.alreadyMaintain()) {
//System.out.println(elevator.getId() + "over!");
if (processQueue.alreadyMaintain()) {
processQueue.maintainOver();
TimableOutput.println("MAINTAIN_ABLE-" + elevator.getId());
}
return;
}
try {
//System.out.println(elevator.getId() + "waiting!");
processQueue.wait(); //等待新的输入需求 或者终止需求
//System.out.println(elevator.getId() + "wakeup!");
} catch (InterruptedException e) {
e.printStackTrace();
}
} //等到了输入需求
第五次作业中,强测没有bug,性能分也比较理想,互测出现了一个bug。互测的bug在本地无论如何都无法复现,但是我大概猜到了是哪里出现了问题。
在影子电梯
进行模拟的时候,需要访问现有的6个Elevator
实例进行深克隆,然后模拟。同时,ElevatorRun
在Run函数中会调用Elevator
的属性。这是两个线程同时访问的共享对象Elevator
,而Elevator
中有一个HashMap
存储电梯中的人,如果在进出时调用了深克隆方法,就会导致HashMap
在遍历时修改,从而抛出异常。解决方法比较简单,就是在Elevator
对象上加锁 即可,ElevatorRun
和Schedule
访问时都需要先获取锁再进行“人员进出”、“深克隆”操作,就不会导致异常抛出。
第六次作业新增加的部分主要有以下两点:
1.支持新增电梯,其中电梯总个数、每个电梯速度可以自定义
2.支持维修电梯,使得当前电梯尽快放出乘客,并且不再调度此电梯
对于第一点需求比较容易实现,只需要将Elevator
的参数在new()
的时候可以自定义,然后new()
一个它对应processQueue
,并将其加入Schedule
中的Elevators
和processQueues
队列中即可实现新增电梯的实现。在这之后,调度器就会遍历更多的电梯来模拟,最后找到局部最优解。
对于第二点需求稍微麻烦一点,因为涉及到了电梯运行中Maintain
导致乘客被迫换乘的情况存在。我的做法是Schedule
收到Maintain
请求后给对应的processQueue
发出信号,ElevatorRun
每次进行下一步移动前须先判断是否有维修信号,如果维修则下一步必为OPEN
操作,放出全部乘客且不再接受请求。同时对所有下电梯的乘客进行遍历,如果乘客未到达目的地,就new()
一个新的请求还给waitQueue
使得调度器对其重新分配。而此时新产生的请求出发楼层即当前楼层,id和目的地不变。
PersonRequest personRequest = new PersonRequest(
elevator.getFloor(),
personOut.getToFloor(),
personOut.getPersonId());
Maintain
之后还需要将该电梯原有的未处理的processQueue
还给waitQueue
。上述做法相似,只需将原来的请求再次发送给waitQueue
,等待Schedule
再次分配即可。由此可以看出调度器架构的好处,在扩展此处时比较方便。电梯只负责运行方面的事务,如果有请求处理不了、或者新增换乘请求,就发送给调度器,其余不用再参与进去。相比于hw5,协作关系仅仅添加了ElevatorRun
和waitQueue
之间的请求再分发,UML协作图如下所示。
至此,hw6的架构已经介绍完毕,相比于hw5,我的UML类图几乎没有变化,仅仅是官方包将之前的Request
变为了接口用以输入三种不同请求,hw6UML类图如下图所示。
调度策略中,我依然采用了影子电梯
策略,即在每一个新请求加入waitQueue
的时候,对每个电梯进行深克隆并模拟运行,选出局部最优解。这次作业中涉及了电梯的速度不同,模拟的时候只需要更改运行时间相关参数进行模拟即可。
switch (operation.getOption()) {
case "OPEN": //开关门耗时 2 * 0.2s,乘客进出
time += 0.4;
break;
case "MOVE": //移动一层 speed
time += ((double) (shadowElevator.getSpeed()));
break;
default:
break;
}
第六次作业在我之前的架构上,添加的内容并不多。改动比较多的是Schedule
模块内的函数,需要处理三种请求Maintain\Add\Person
;此外,改动比较多的就是以及电梯线程中的运行逻辑,需要在每次run()
最后判断是否接到维修信号,进而强制将下一步操作设置为OPEN
,且在该种情况的OPEN
之后必须赶出所有乘客。线程安全问题并没有出现,因为整体代码的同步块架构并没有改动,代码写完就一次就都过了。
出现过的问题就是线程结束的问题:Schedule
会在输入结束信号Ctrl^D
并且遍历当前所有Elevator
请求队列以及其内部乘客为空后,发送给每个电梯信号让其线程结束。但是Maintain
后的电梯已经不再Schedule
可视队列中,因此如果只剩被Maintain
的电梯还在运行,其他所有电梯就会提前被结束,导致Maintain
后电梯乘客下电梯以后无法到达目的地。
解决的方法很简单,我将所有被Maintain
的电梯加入一个新的队列中,Schedule
判断是否结束之前,还需要遍历这个队列,确保所有电梯都结束运行后并且waitQueue
为空再发出结束信号。
第六次作业新增加的部分主要有以下两点:
1.对每层楼限制了可同时停靠、接人电梯数目
2.支持新增电梯,且新增电梯的可达性可以自定义
停靠限制处理
使用类似信号量机制处理,比较轻松就处理掉了。只需要在每个电梯开门之前访问该同步锁,如果当前停靠数目合乎要求就输出OPEN
,反之进入wait
。在每个电梯输出CLOSE
之后进行notifyAll
即可。hw7的线程间协作关系增加了Police
与ElevatorRun
的限制关系,以及waitQueue
接受再发请求的范围增大(不仅是Maintain的情况,还有普通换乘),具体UML协作图如下所示。
可达性处理
对于第二点需求实现是较大的考验,因为有可能出现所有“健全”的电梯都被维修,只剩下一些不能到达全部楼层的电梯,这可能会导致一个请求无法直达,必须经过拆分才能到达终点。我的做法是将每一个人的请求抽象成为了一个新的类TransferRequest
,表示每个请求都需要进行换乘才能到达目的地,而能够直达的请求需要换乘的次数是0而已。调度器接受TransferRequest
之后,使用BFS搜索,找到换乘次数最少的路线,将其第一步记录下来分配给合适的processQueue
(所谓第一步,就是当前电梯队列能够直接处理的队列)举例而言,如果乘客需要1->5->9到达目的地,第一步就分配一个1->5的PersonRequest
,第二步再分配一个5->9的PersonRequest
。
进行这一步抽象之后,我们就可以把所有未完成的请求用TransferRequest
包装起来,再次输入给Schedule
进行计算路线并分配请求即可,无论其是由InputHandler
输入的,还是维修电梯吐出来的,还是没能一次性到达目的地的请求。
这里需要注意的一点是,如果规划路线被打断了,也就是1->5之后,能满足5->9的电梯被维修了,那么我的调度器就会重新规划一遍5->9的最短换乘路线。如果路线没被打断,也会重新规划5->9的最短换乘路线,因为并没有被打断,所以这里的最短路线就是从5->9的直达路线。这也就解决了路线一致性的问题,因为我的调度器在规划路线之后只会把该路线的第一步发送给合适的电梯进行处理,并没有发送总路线。但是一旦完成第一步请求后,调度器又会收到一个同样被包装的TransferRequest
,其出发点是刚刚那个请求“第一步”的终点,如果没有路径涉及的电梯被维修,再次规划其路线时,必然会找到刚刚的“第一步”对应的“第二步”,以此类推,最终成功让乘客到达终点。
综上可知,在第七次作业中,我添加了两个新的类,分别是Police
和TransferRequest
。Police
类用以控制每层停靠数量,仅与ElevatorRun
相关;TransferRequest
用于包装PersonRequest
,同样是Request
接口的一个实现,具体UML类图如下图所示。
在这次作业中,调度器的工作量剧增,原因在于其既需要先为请求规划路线,还需要把该路线的第一步分配给合适的电梯,这都算作了我的调度策略
的一部分。严格来讲,这是两个过程。
第一步 通过BFS实现,通过接受TransferRequest
找出最少换乘次数的方案,并返回一个PersonRequest
。
第二步 接受PersonRequest
,通过hw5中的影子电梯策略,为其分配一个局部最优解,将该请求发送给合适的电梯。
由此看来,在之前的架构基础之上,我只需要加一个BFS搜索,然后将第一步返回给之前写好的函数即可。
本次作业中同样没有出现线程安全问题,因为增加的内容大都在调度器这一个模块中。也从此可以看出,本单元我的作业架构较为合理,扩展后不会出现新的线程安全问题,也比较容易实现。
强测出现了一个bug,其实是很简单一个错误。原代码如下:
synchronized (police) {
if (!police.ableToOnlyTake(elevator.getFloor())) {
try {
police.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
police.changeOnlyTake(elevator.getFloor(), 1);
TimableOutput.println("OPEN-" + elevator);
}
更正后的代码如下:
synchronized (police) {
while (!police.ableToOnlyTake(elevator.getFloor())) {
try {
police.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
police.changeOnlyTake(elevator.getFloor(), 1);
TimableOutput.println("OPEN-" + elevator);
}
这里的police
就是为实现信号量机制设置的类,负责管理每层电梯停靠数目。如果使用if判断是否进入等待,就会造成当某层有两个电梯等待时,一旦被唤醒,就都进入了接下来的代码,从而使停靠数目超标,改成while判断即能保证正确性。
互测中没有测出新的bug,只在强测寄了两个点。总体来说性能分也比较理想,基本都是99+,可见在hw5中写的模拟调度策略能够一直沿用到hw7,且有不错的性能,还是比较如意的。
总体而言,在第二单元的三次作业迭代中,我主要修改的模块在于调度器。这样有利有弊,好处在于电梯线程等处几乎不用修改就能沿用,保证程序正确性比较高。坏处在于我的调度器越加越多,最后到达了300行。后来反思,对于hw7的调度器我可以将其拆分成为两部分,一个只负责拆分请求,另一个只负责分发给电梯,这样或许是一个更好的架构。但是hw7之后就不需要再迭代了,所以在写作业的时候就直接无脑加进去了......整体而言,这几次作业的迭代中,我的架构改变并不大,主要改动都集中在了调度器这一个类的改动。
本单元主要学习了多线程编程,我对于同步块、上锁等操作都有了比较深刻的理解。我全程都是用了同步块处理锁,将全部临界区资源被谁访问,顺序是怎样的提前规划好,尽量减少锁的嵌套出现,最后一起用代码实现。先构思,再动笔,是一个非常重要的习惯。
对我个人而言,相比于第一单元,这个单元更加轻松一些。一部分是因为开始第二单元作业时,已经有了java语法的基础,不再需要赶进度...另一方面可能时hw5时架构考虑的比较严谨,这也使得我在后续两次作业迭代中比较轻松就完成了。不仅效率高,而且正确性也很好。出现的bug大都是因为疏忽了、大意了测试太少了导致的,错误还是比较明显的。对于我的bug,一大部分都是可以本地复现的(比较明显就是说的这个),这时debug只需要层层输出print日志,就能够比较容易得找到线程之间的运行错误点。少数不可复现的,在增大了数据并行度后也复现了,所以,还是用同样的方法就找出了bug。总体而言,debug并不占用了太多时间,我的更多精力花在了如何设计架构上面。