BUAA OO Unit2 —— Multithreading Elevator
作者:互联网
BUAA OO Unit2 —— Multithreading Elevator
by Monument_Valley
0. 写在正文前
本篇博客是对笔者在北航2022年春季《面向对象设计与构造》课程第二单元的三次作业的总结。
本单元的主要任务为通过程序模拟一套多线程运行的电梯系统,并为输入的乘梯请求作出合理的响应,即在不违反电梯可达性的前提下,将乘客在其要求的地点接走,并通过运行&换乘策略将其送达到其指定的地点。
1. Homework 5
1.1 同步块与锁
第一次作业的主任务是对5个楼座中设置的单部电梯进行调度。
如果抛去5个楼座这种只是增加任务量却没有增加难度的设定,本次作业的核心任务很简单:单部电梯直接获取对应输入队列的请求,并根据请求对电梯进行调度。
画个图来看就是这种关系(请忽略字迹):
可以看到,在我们的线程中,我们的输入线程在获取到乘梯请求后,将其分配到不同楼座所对应的总等待队列中,对于这些请求队列,输入线程与电梯要共享它们。输入线程负责向队列投喂请求,电梯负责根据这些请求来安排运行策略,同时进行上下客,将请求从请求队列中移除,投送到电梯轿厢之中。此外,电梯在输出到达信息时,为保证时间戳的递增,需要对输出线程进行保护,即需要输出的时候,获取输出线程的线程锁,待输出结束后释放锁。
所以,上图中的阴影部分就是线程之间的同步块,当访问/修改同步块时,需要对其上锁。
至于同步块内的程序与锁的关系,自然是待同步块内使用这个上锁对象的程序全部执行完毕后,再使用notifyAll()
唤醒所有线程。对于锁外的部分,若上锁后还想访问锁内的程序,就进入到wait()
等待唤醒。
1.2 调度器设计
由于这次作业的本质是5个单电梯,所以也没有什么需要调度的。由于我当时并没有太理解调度器究竟是干什么的,也没有开天眼似的预测未来将要开启哪些功能,再加上单部电梯的运行根本不需要调度器(因为一个楼座内的所有请求最终都会汇集到这个单电梯内)所以本次作业的调度器部分就被我省略了qwq。
关于调度算法,这次作业实现了指导书所提示的ALS算法,并在此基础上增加了捎带乘客方向与电梯乘客方向的判断,减少电梯因识别到某层有请求,但到达请求所在楼层后,由于请求方向与电梯运行方向不一致,导致此时该请求无法被接上电梯,电梯因为多余的开关门而降低运行效率。
1.3 架构设计
这次作业的架构并不复杂,主要就是实现了电梯接受请求-->处理请求-->判断是否终止的过程。
uml类图如下:
时序图如下:
电梯运行时序:
方法复杂度统计(忽略CogC = 0的方法):
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Elevator.updateMainRequest() | 12.0 | 1.0 | 7.0 | 8.0 |
Elevator.dispatchElevator() | 11.0 | 1.0 | 8.0 | 8.0 |
Elevator.run() | 10.0 | 3.0 | 7.0 | 8.0 |
InputThread.run() | 7.0 | 3.0 | 5.0 | 5.0 |
Elevator.moveElevator() | 6.0 | 1.0 | 5.0 | 5.0 |
Elevator.takePassenger() | 4.0 | 1.0 | 5.0 | 5.0 |
Elevator.dropPassenger() | 3.0 | 1.0 | 3.0 | 3.0 |
Elevator.getPassengerDirection(PersonRequest) | 3.0 | 3.0 | 3.0 | 3.0 |
Main.main(String[]) | 2.0 | 1.0 | 3.0 | 3.0 |
Elevator.closeDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.openDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
Total | 60.0 | 30.0 | 63.0 | 65.0 |
Average | 2.5 | 1.25 | 2.625 | 2.7083 |
在解析主请求的时候,依旧使用了面向过程式的编程方法,使得代码复杂度偏高。
1.4 评测成绩
强测96.9,互测被hack到输出线程安全问题(之前心存侥幸,没有对输出做线程保护)。
2. Homework 6
2.1 同步块与锁
第二次作业中增加了一堆横向电梯(至于说为什么电梯会横着跑?问就是Thyssenkrupp友情赞助)。如果说单纯只是电梯会横着跑,那只需要处理好如何让电梯绕圈走的问题就好,可这回的重头戏在这里:一个楼座可以支持多部电梯,输入线程随时都会收到增加电梯的请求。
这时,我们需要一个调度器帮助电梯获取请求。由于笔者在此时仍对调度器没有非常充分的认识,此时对调度器的理解仅限于说它是帮助电梯获取请求的,并没有什么分配的概念。
各线程之间的关系差不多是这样的(请继续忽略我的字迹):
可以看到,输入线程为每个楼座/楼层都安排了一个总请求队列,每个电梯都配备了一个调度器负责从对应的总请求队列中“抢夺”请求并将其放置到电梯内部的处理队列中。电梯之间还共享输出线程。因此,我们有每个电梯与其自己的调度器的同步块(selfWaitQueue),调度器与输入线程的同步块(对应的macroWaitQueue),以及电梯输出信息所用的同步块(output),这些同步块在使用的时候都需要上锁。
2.2 调度器设计
由于在此次设计中,调度器只与对应的一部电梯相连,二调度器之间唯一的联系就是总请求队列(macroWaitQueue)。考虑到此次作业中共享同一个总请求队列的电梯之间没有什么差别,故此次调度器获取请求的策略非常简单:抢总请求队列的线程锁,谁抢到了就给谁分个请求(真是大道至简啊hhhhh)。后来通过跟写了策略的同学对拍,发现自己的调度策略(也就是没有策略)居然比写了策略的同学效率更高,于是我就完全没写策略。
但自由竞争其实是会导致轮询现象的产生。所有的调度器都在抢请求,但只有一个能抢到,剩下的调度器线程只能来回反复地去抢线程,这就产生了轮询。
2.3 架构设计
uml类图如下:
时序图如下:
可以看到,这回多了一步电梯从调度器获取抢到的请求,然后继续处理的工作。
方法复杂度统计(忽略CogC = 0的方法):
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Dispatcher.run() | 14.0 | 5.0 | 6.0 | 8.0 |
Elevator.dispatchElevator() | 7.0 | 1.0 | 8.0 | 8.0 |
Elevator.moveElevator() | 10.0 | 1.0 | 7.0 | 8.0 |
Elevator.run() | 10.0 | 3.0 | 7.0 | 8.0 |
Elevator.takePassenger() | 7.0 | 1.0 | 8.0 | 8.0 |
Elevator.updateCallList() | 13.0 | 1.0 | 8.0 | 8.0 |
Elevator.updateDirection() | 12.0 | 8.0 | 8.0 | 8.0 |
Elevator.dropPassenger() | 6.0 | 1.0 | 6.0 | 6.0 |
Elevator.updateMainRequest() | 10.0 | 1.0 | 5.0 | 6.0 |
InputThread.run() | 8.0 | 3.0 | 6.0 | 6.0 |
Clockwise.getRotateDirection(char, char) | 5.0 | 3.0 | 3.0 | 5.0 |
Elevator.getPassengerDirection(PersonRequest) | 7.0 | 5.0 | 5.0 | 5.0 |
Clockwise.getNextBuilding(Status, char) | 1.0 | 3.0 | 3.0 | 3.0 |
InputThread.processElevatorRequest(Request) | 3.0 | 1.0 | 3.0 | 3.0 |
InputThread.processPersonRequest(Request) | 3.0 | 1.0 | 3.0 | 3.0 |
Main.main(String[]) | 2.0 | 1.0 | 3.0 | 3.0 |
Elevator.closeDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.openDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
InputThread.closeAllWaitQueues() | 1.0 | 1.0 | 2.0 | 2.0 |
Total | 121.0 | 61.0 | 114.0 | 121.0 |
Average | 3.1842 | 1.6052 | 3.0 | 3.1842 |
2.4 评测成绩
强测93.9,互测被hack中CTLE(CPU Time Limit Exceed)。
3. Homework 7
3.1 同步块与锁
这次作业增加了对换乘的支持,同时,横向电梯的可达性也增加了限制,即一部横向电梯不一定能够到达同一层内所有楼座,可达性用一套掩码进行表示。
同步块设计与之前基本一致,但细节方面略有变化。笔者这回先把图放出来,之后再做解释。
3.2 调度器设计
在这次,由于可达性的要求,所有电梯并不能如之前的作业一样平等看待,每次电梯获得请求时,需要保证电梯获取的请求一定能被电梯送达。可按照之前的设计,电梯之间唯一的通用性就在于获取自己所对应的总请求队列。这就使得在这种自由竞争策略下,电梯无法保障自己获取的请求时自己一定能够送达的请求。(自由竞争就跟现在扫货一样,自己费了半天抢到的东西未必是自己需要的。如果说你买房子,地产商说你买了房子就可以抽奖,你好不容易凑够了最便宜的房型的首付,最后抽出的奖是一楼花园的一折代金券,你说你要得了吗?)
因此,这回需要在电梯做自由竞争之前,筛选出可以送达这个请求的电梯,然后在这些电梯内部搞自由竞争。(让全款买房的人去抽这个奖,奖品才能被人要得起)。而这个工作由哪个程序来干呢?这时,笔者终于明白调度器是来干什么的了:调度器负责筛选出符合要求的电梯,让这些电梯进行自由竞争。选择让合适的电梯干合适的事情,这就是调度器的任务。
由上面的图可以看到,每个总请求队列只对应了一个调度器,而调度器连接多个电梯。当电梯可以接送队列的某个请求时,将其放到“可以运送这个任务的电梯”集合之中。筛选完成后,从中随机抽取一个电梯进行安排。
3.3 架构设计
首先先看一看换乘问题。在指导书中,助教组已经告诉我们如何计算换成楼层,在保障人一定能被送达的前提下,选择与出发楼层距离和与到达楼层距离之和最小的楼层作为换乘楼层。为此,我们要在判断时存储好各个横向电梯的可达性,以此作出决策。同时,根据换乘楼层,我们要将我们获得的乘梯请求拆分为多个分请求,分别进行运输。
那我们该怎么区分当前分请求是总请求的前置请求呢?这时候就需要设置一个优先级了。我们让调度器只分配优先级为true的请求(如果为false,说明此时人还没有到达这个楼层,而电梯不能运走没有人的请求,所以调度时暂时忽略它)首先,我们知道一个人对应一个请求,而人的id是固定的,所以请求的id也是固定的。将请求分解后,我们将第一个分请求的优先级设为true,其余的为false。当乘客需要换乘的时候,从对应换乘所需的电梯里将同一个id对应的分请求的优先级设为true,让它可以被电梯运走。
uml类图如下:
时序图如下:
复杂度分析如下:
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Dispatcher.run() | 27.0 | 3.0 | 12.0 | 12.0 |
Elevator.dispatchElevator() | 7.0 | 1.0 | 8.0 | 8.0 |
Elevator.moveElevator() | 10.0 | 1.0 | 7.0 | 8.0 |
Elevator.run() | 10.0 | 3.0 | 7.0 | 8.0 |
Elevator.takePassenger() | 7.0 | 1.0 | 8.0 | 8.0 |
Elevator.updateCallList() | 13.0 | 1.0 | 8.0 | 8.0 |
Elevator.updateDirection() | 12.0 | 8.0 | 8.0 | 8.0 |
Elevator.updateMainRequest() | 12.0 | 3.0 | 5.0 | 7.0 |
Elevator.dropPassenger() | 6.0 | 1.0 | 6.0 | 6.0 |
Elevator.processDropRequest() | 10.0 | 1.0 | 6.0 | 6.0 |
InputThread.processPersonRequest(Request) | 6.0 | 1.0 | 6.0 | 6.0 |
InputThread.run() | 8.0 | 3.0 | 6.0 | 6.0 |
Clockwise.getRotateDirection(char, char) | 5.0 | 3.0 | 3.0 | 5.0 |
Elevator.getPassengerDirection(MyPersonRequest) | 7.0 | 5.0 | 5.0 | 5.0 |
InputThread.findBestTransferFloor(char, char, int, int) | 6.0 | 1.0 | 3.0 | 4.0 |
Clockwise.getNextBuilding(Status, char) | 1.0 | 3.0 | 3.0 | 3.0 |
InputThread.checkReachability(char, char, int) | 3.0 | 3.0 | 1.0 | 3.0 |
InputThread.processElevatorRequest(Request) | 3.0 | 1.0 | 3.0 | 3.0 |
MacroWaitQueue.getInstance() | 3.0 | 1.0 | 1.0 | 3.0 |
WaitQueue.getFirstRequest() | 3.0 | 3.0 | 2.0 | 3.0 |
WaitQueue.hasPerson(int) | 3.0 | 3.0 | 2.0 | 3.0 |
WaitQueue.needWait() | 3.0 | 3.0 | 2.0 | 3.0 |
WaitQueue.setPriority(int) | 3.0 | 3.0 | 3.0 | 3.0 |
Elevator.closeDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.openDoor() | 1.0 | 1.0 | 2.0 | 2.0 |
InputThread.InputThread(ArrayList) | 1.0 | 1.0 | 2.0 | 2.0 |
InputThread.closeAllWaitQueues() | 1.0 | 1.0 | 2.0 | 2.0 |
InputThread.initReachableBuildings() | 1.0 | 1.0 | 2.0 | 2.0 |
InputThread.setNewElevator(int, int, char, int, double, int, int, ...) | 2.0 | 1.0 | 2.0 | 2.0 |
MacroWaitQueue.MacroWaitQueue() | 1.0 | 1.0 | 2.0 | 2.0 |
Main.main(String[]) | 1.0 | 1.0 | 2.0 | 2.0 |
Total | 177.0 | 98.0 | 165.0 | 179.0 |
Average | 2.7230 | 1.5076 | 2.5384 | 2.7538 |
3.4 评测结果
强测78.4,同上CTLE问题。
4. BUG分析
这次作业出现了许多bug,在此跟大家分享一下:
Homework 5
这回只有输出线程安全问题。只需要将官方包中的输出方法用同步块包装一下就好。
Homework 6
本次出现了CTLE的bug(啊, 这就是传说中的轮询)。bug的原因有二:
- 调度器内部出现轮询问题,为此我将整个调度器重新写了一遍,重点强调了跳出while (True)的分支,因此代码修改量必定超过5行限制。
- WaitQueue的多于线程锁问题。由于在修改WaitQueue时,我已经将WaitQueue上锁,因此不需要在WaitQueue内部再上一道锁,否则就会多一个notifyAll(),且这个方法的访问次数极大,因此会消耗许多CPU时间。
对于互测被hack中的测试点,dispatcher修改前后的cpu占用变化如下。其中,上图为修改前,下图为修改后。可以注意到,之前的cpu时间主要被DIspatcher占用,将其优化后,其所占用的时间大幅降低。
Homework 7
这回bug还是CTLE问题。
经检查,由于横向电梯运行策略与程序架构存在严重问题。例如,在之前对横向电梯可达性的判断中,我将一层楼内的横向电梯的可达性做或运算(eg:一个电梯可达A、C座,一个电梯可达B、D、E座,则做完或运算后,我错误地认为该层楼电梯可把人送达到所有楼座,可实际上并没有可以将人从A座运往B座的电梯。)
此外,调度器本身的存在及其内部架构也存在导致轮询的问题。在之前的版本中,一个电梯有一个调度器,调度器在内部不断的做“先取请求,后做判断,如果成功,收给自己;如果失败,退回请求”的操作,导致cpu访问时间过高,资源浪费。为了加强同类型电梯内部的调度分配,我将调度器的层级挪到了电梯之上,输入线程之下。调度器负责将对应电梯组的共享队列中的内容取出,筛选出能运行这个请求的电梯,然后做随机分配,这减少了电梯对主请求的轮询访问,降低cpu时间。
5. 关于其他人的bug
这周事情太多,没空hack别人,遂没做这个“恶人”。
至于自己的bug。。。在随机洗数据的时候发现了一些正确性问题,然后就都改了,也没什么出奇的地方。
6. 心得体会
这个单元的作业为我打开了多线程的大门。第一次作业中对线程锁的理解非常模糊,在摸索中把作业写完了;第二次作业中认识到锁对维护线程安全的意义,但又过于谨慎,增添了许多没有意义的锁,导致程序逻辑较为混乱,出现死锁;第三次作业终于认识到锁与共享对象之间的紧密联系,用锁也就得心应手了。但由于一些正确性的问题,导致成绩很不理想。
在最后一次作业中,我还尝试使用了单例模式,将总请求队列列表采用单例模式保护起来,这个过程也为我对设计模式有了一些扩展性的认识。
上锁的本质其实就是为了保护多线程运行下多个线程对共享对象的访问不会出现偏差的一种措施。为了让各个线程平稳运行,应当尽量减少线程之间共享对象的数量,如果确实有需要访问共享对象,再进行上锁访问,确保运行逻辑合理。
多线程的作业属实搞人心态。这回作业所涉及的正确性判定其实倒不是什么难事(本质上就是分配请求-->接人-->送人-->输出,很直接),但线程之间的交互和死锁问题很难检查。再数个小时的摸索之中,我逐渐学会了多线程程序调试的问题,也从此更加注意自己所写程序占用资源的情况,尽量写出节省资源,节省时间的程序。在获得多线程开发经验的同时,对资源的控制与节省资源的意识,应该也是本单元作业的主要目的之一。
最后,对于本单元互测环节,希望课程组注意一下,别让同学们对着一个地方玩命hack,被一个人因为死锁问题hack16次,这种情况令人感到极度不适,互测体验极差。希望课程组以后能改进一下。课程中大佬与菜鸡并存,在捧上大佬的同时,请至少保护一下菜鸡们的感受。
标签:OO,2.0,1.0,请求,Elevator,电梯,3.0,Unit2,Multithreading 来源: https://www.cnblogs.com/Monument-Valley/p/16221357.html