OO2022第二单元作业总结
OO2022第二单元作业总结
前言
本文为OO2022第二单元总结。本次作业主要以电梯系统的设计为内容熟悉和熟练掌握线程方面的知识。主体思路上我才用了生产者消费者模式,在文章中分别阐述了三次作业中的主要思路,调度器设计与交互,同步代码块的设置与锁的选择。之后利用UML类图,与方法复杂度分析了本次作业架构的不完美之处,最后分享了自己在本次作业中关于层次化设计与线程安全的心得体会。如有错误,谢谢指正!
三次作业分析
1.1 第5次作业分析 单部多线程电梯
1.1.1 需求分析
本次作业以新主楼ABCDE五个楼座的五个电梯为现实需求,需要我们设计电梯系统,实现电梯的实时模拟。具体而言,本次作业电梯系统具有的功能为:上下行,开关门,以及模拟乘客的进出。实际上,这五座电梯完全相同,而且在此次作业中每座只有一个电梯,也不涉及换乘,所以五座电梯没有任何关系,完全独立,因此只需要设计好一个电梯类线程,将该线程实例化五次,start()五次即可。
1.1.2 主要思路
主要思路方面,整体设计模式采取“消费者-生产者模式”,电梯是消费者接取请求,输入线程是生产者,放入请求,它们二者共享一个临界区托盘WaitBuffer,相当于一个存放请求的共享缓冲区域,请求的放入和取出都在这个区域实现。
1.1.3 主要类与功能
🔶INput
:读入线程,从标准输入中读取数据,使用调度器将请求分配给五个楼座的WaitBuffer中的一个。当输入结束时,将调度器内的isend置位,电梯如果检测到isend已经置位,并且WaitBuffer为空,电梯内为空,则结束电梯线程。
🔶WaitBuffer
:二维数组,放置请求,是临界区,每个楼座都有一个。
🔶Scheduler
:调度器,本次作业比较简单,简单的根据请求的出发楼座,将请求put进对应楼座的WaitBuffer。
🔶Strategy
:策略类,利用有限状态机控制电梯状态,利用look算法控制电梯方向。
🔶EleStatus
:电梯状态,包括当前楼层,当前楼座,电梯状态,内部乘客数组等。我将电梯这些重要属性抽象为一个类的原因,会在1.1.4最后一部分阐述。
1.1.4 调度器设计及交互
在本次作业中,我的调度器十分简单,也不是线程,只是简单的根据请求的出发楼座,将请求put进对应楼座的WaitBuffer。
所以,这里我想重点将一下Strategy内的有限状态机这一部分,它决定电梯线程的运行状态,类似一个“状态模式”,通过利用上学期计组学过的有限状态机,根据这一时刻的电梯运行状态(EleStatus)和WaitBuffer的情况来决定下一时刻电梯运行状态和电梯方向。
对于电梯的方向改变我采用的调度方法是:LOOK算法,Look算法与扫描算法基本相同,只接与电梯运行方向相同的人,不同之处在于扫描算法每次需要到一个楼层的顶部或底部才改变方向,而Look算法改进之处在于,在电梯所移动的方向上不再有请求时立即改变运行方向,比扫描算法提前更改方向,效率更高。
下面是总结出的电梯状态转换图。候乘表指的就是电梯对于楼座的WaitBuffer。
1.1.5同步块的设置和锁的选择
这一次作业中,我对两个变量的读写取进行了加锁。
- 第一个,候乘表WaitBuffer,这一点也不难理解,因为它是“消费者-生产者”模式的缓冲区,所有对WaitBuffer进行读写取的操作都需要加锁。
- 执行写操作(put操作)是调度器
- 执行读操作(get操作)是电梯
- 第二个,调度器的isend,因为输入线程读入结束要将isend置位,同时电梯线程的有限状态机转换也需要读取调度器isend位以实现线程结束,所以这也是一个共享对象,需要加锁。
- 执行写操作(set),是Input线程
- 执行读操作(get),是电梯线程
此外,关于Elestatus,在本次作业中我将电梯的一部分重要状态和属性抽象为了一个单独的类Elestatus,是因为我一开始想的是,之后如果每个楼座多个电梯,调度器如果要实时分配请求的话,可能需要读取电梯内部的一些重要属性,比如这个电梯的负载量比另一个电梯少,就分配给他,这个电梯离请求比较近,就优先分配给他。但是此次作业中由于每个楼座只有一个电梯,也不涉及上述这个问题,所以Elestatus在作业5中不是共享对象,而在作业6,作业7中我们之后会说,它是共享对象。
1.2 第6次作业分析 横向电梯
1.2.1 需求分析
添加横向电梯,电梯横向运转,处理横向请求,在本次作业中与与纵向电梯完全独立,没有交互关系。此外,每个楼座可以有多个电梯运送乘客,需要合适的调度策略进行分配。
1.2.2 新增类及功能
🔶ElevatorCro
:横向电梯,内部一些逻辑与纵向电梯有区别。
🔶StrategyCro
:横向策略,内部的方向改变算法不再是look算法,一些逻辑更改。
🔶WaitBufferCro
:横向等待队列,内部一些方法与纵向候乘表不同,且数组初始化大小与纵向候乘表不同。
1.2.3主要思路
关于本次作业中的多部电梯,一开始有两种方案,自由竞争或是实现调度算法。之前我在我的架构中已经实现了一个调度器,因此本着不想重构的原则更倾向于选择调度器。此外,我当时想的是:
- 如果自由竞争的话,来一个人所有电梯都会朝这个电梯飞奔去,但最终只有一个电梯会接到这个人,这样是否太慢呢?
- 此外电梯相互争抢请求,处理不好的话会不会又出现线程安全的Bug呢?
基于这两个原因我在这次作业中使用了调度算法。(PS.但后来事实证明,好的调度算法太难写了,如果调度算法一般的话,可能比自由竞争也没好到哪去,并且自由竞争实现起来很简单也没什么思考量,太香了😍,所以后来因为作业7电梯调度算法太难写了,又改成了自由竞争,但是不管怎样,此次作业我使用的还是调度算法)
关于横向电梯,因为已经把策略类封装好了,只需要修改策略类,和电梯内部的一些逻辑即可,整体框架与纵向电梯相同。
- 关于横向电梯的方向,look算法不再适用,考虑到横向电梯只涉及到五个楼座,不同策略快慢也差不了多少。我使用了改进的类似ALS策略,首先WaitBuffer内最早来的为主请求(也就是数组的第一个请求),以最短的路径接到这个主请求,将该主请求的方向设为电梯下一刻方向,以该方向转动,转动过程中不考虑方向,只要有可捎带请求就捎带。当电梯内部为空时,去WaitBuffer内寻找下一个主请求。
- 最短的路径指,从A到E,A->E肯定比A->B->C->D->E短
- 转动过程中不考虑可捎带请求方向,只要有可捎带请求就捎带(不考虑请求的方向为逆时针或顺时针),是因为只有五个楼座比较少,且横向电梯是转动运行,让电梯尽量满载转动会更高效,而且比较好写代码。
1.2.4调度器设计及交互
此次作业中,也未涉及换乘,调度器的作用是利用调度算法把请求分配给某一电梯。
首先判断是横向请求?还是纵向请求?横向请求去对应floor找一个电梯,纵向请求去对应building找一个电梯。
如何找“一个电梯”用到调度算法?考虑到调度算法如果太复杂,正确性无法保证,所以我设计了一个比较简单的调度算法?
- 如果所有电梯都正在运转,找一个等待人数最少的电梯(电梯对应WaitBuffer中人数最少),分配给他对应的WaitBuffer。
- 如果有电梯在REST休息状态,优先分配给REST状态的电梯,且分配给离这个请求最近的处于REST状态的电梯。
1.2.5 同步块的设置和锁的选择
与上一次作业相同,isend和WaitBuffer仍然是共享对象,仍需加锁。
与上一次作业不同的是,这次EleStatus也加了锁(如1.1.5中最后所述),因为调度器需要读取电梯当前状态(是否为REST,当前所处楼层等),而电梯要实时更改这些参数,所以EleStatus也是共享对象。
- 执行读操作(getEleStatus),调度器
- 执行写操作(setEleStatus),电梯线程
1.3 第七次作业分析 单部多线程电梯
1.3.1 需求分析
增加换乘需求,实现电梯定制化,电梯速度,容量不同,横向电梯可停靠楼座有要求。
1.3.2 主要思路
电梯定制化速度,容量,只要在构造器内增加几个形参,使得实例化对象时可以设置电梯的容量,速度属性即可,实现比较简单。
难点在于换乘,如我在1.2.3中所述,如果要实现换乘的话,调度算法是比较难实现的,较为复杂,因为不同电梯速度不同,容量不同,可停靠楼层不同,如何给电梯分配考虑的因素太多了,很难实现一个完美的调度算法。而如果使用的是自由竞争,让电梯自己去抢请求,那么速度快的,离得近的,而且容量没满的电梯会更优先抢到这个请求,无序考虑任何其他因素,只要保证把共享对象锁好,不要出现电梯争抢时的线程安全问题即可。
综上,在本次作业中,我把电梯调度这一块,舍弃了电梯调度算法,而采用了更为简单高效的自由竞争算法(自由竞争实现起来很简单,工作量也比较小,因此重构为自由竞争也是一个不错的选择)。
1.3.3 新增类与功能
🔶WholeBuffer
:总的共享区域,Input往里面放请求,调度器在这次作业也是一个线程,从中取请求然后分配。
🔶Secheduler
:修改调度器为线程,并且新增属性count,记录换乘人数,这是是为了让程序顺利结束,因为此次涉及到换乘,当读入结束时,电梯线程不一定结束,因为换乘还未完毕,详细内容在下一节阐述。
1.3.3 调度器设计及交互
此次作业中,为了实现换乘,更改了整体的协作结构。
-
第一,如何实现换乘?
- 首先我将PersonRequest重新封装,使得该类可以记录中转楼层,中转楼座并且可以修改起始楼层楼座,目的地楼层楼座。
- 其次,请求一开始都被存放在WholeBuffer等待调度器取出,当该请求进入被调度器
Scheduler
get到,首先判断该请求是否已经到达目的地,如果到达目的地则跳过他,继续get。如果当前请求未到达目的地,继续判断请求是否已经不需换乘,如果需要换乘,则更改其中转楼层楼座,放入对应WaitBuffer等待电梯运送,不需换乘的话直接放入对应WaitBuffer即可。 - 此外,乘客出电梯时仍要重新进入WholeBuffer,因为如果其还未到达目的地,仍需换乘,仍需调度器将其重新分配。
-
第二,线程如何终止?之前当读入到null后立即传个各个电梯,当各个电梯运完自己候乘表中的人后就会结束。但这样有一个很大的问题是,如果还有换乘的人,那么他从一个电梯A出来准备换到下一个电梯B的时候,B电梯可能已经结束线程了,这样就导致没有把人送到目的地。解决方法是将分配器改为线程,同时用内部的count记录换乘人数,当
count == 0 (无人换乘)&& isend(读入结束) && wholeBuffer.isempty()
,告知电梯可以结束线程。
1.3.4 同步块的设置和锁的选择
此次对三个变量进行加锁,WaitBuffer和Elestatus与之前一样需要加锁。
新增WholeBuffer也是共享对象,同样需要加锁。
- 写操作(put),Input线程
- 读操作(get),Scheduler调度器线程
2.1 架构分析及代码分析
- 最终架构的UML类图
- 最终架构方法复杂度
-
代码复杂度分析
-
从方法复杂度来看,集中在一些if判断分支和for循环较多的函数上,比如findmindir寻找最短路径,findmidfloor寻找中转楼层,hengcannotmove判断一个楼层横向电梯可否接送该请求等,dirHasRQ判断该方向有无请求,这些函数无一例外都是if分支比较多,且有的还拥有循环结构,导致了方法复杂度过高。
-
此外这些函数与其他模块的调用关系也比较复杂,耦合程度比较高。这些地方容易出现Bug,并且维护难度较大。
-
-
代码架构分析
- 本次作业可以从UML类图中看到,我的架构中有许多可以“合并”的东西,比如横向电梯ElevatorCro和纵向电梯ElevatorPor,横向策略StrategyCro,纵向策略StrategyPor,横向候乘表WaitBufferCro,纵向候乘表WaitBufferPor这些。他们内部的部分逻辑相同,部分逻辑相同,更好的架构是通过继承将这些类抽象为更高层次的类,将他们相同的地方抽出到更高层次父类中,这样的话架构将更加清晰,层次性结构性更强。因此我认为我本次的加架构并不是很好,以后应注意这样的问题。
时序图(sequence diagram)
3.1 Bug分析
3.1.1 我的Bug
本次课下完成作业时,我特别注意线程安全方面的问题,因此在作业中没有出现线程安全方面的大Bug。唯一的一个Bug是我考虑不周的问题,就是第7次作业中,如果本层没有可以使用的横向电梯,那么横向请求也需要换乘,在进行迭代时我没有考虑到这种情况,不过这个Bug在我使用自己的评测机测试时被检测了出来,也成功进行了修改,也算有惊无险。
3.1.2 Hack的Bug
吃了上次作业没有写评测机的亏,本次Hack我使用了自己写的一个简单自动评测机,自动生成数据进行测试。Hack到其他同学的Bug有以下一些:
- 数组越界,这是一个很容易犯的问题。
- RTLE:出现了死锁或死循环,是线程之间对于共享区域的读写访问没有设计好。
- 状态输出错误:电梯的状态转换出现了问题,有限状态机的逻辑没有设计好,也没有进行充分测试。
3.1.3 Hack策略
大体上采用自动评测机进行测试。一个策略是修改数据生成器生成一些更有针对性的数据。比如对同一楼层,同一楼座大量测试,增加数据的聚集性;在互测限制时间的最后大量投入请求,检查其是否超时等。
4.1 心得体会
4.1.1 关于层次化设计:
写这一单元最大的体会我们需要提前构思好自己的架构,想清楚自己要设计哪些类,每个类的功能都各自是什么,减少类与类之间的耦合,每个类实现的功能要单一。
例如本次作业我认为层次化的地方有
-
主线程和读入线程分开,读入线程只负责读入和把请求放到缓冲区,主线程负责对类实例化和启动各个线程
-
抽象出Strategy策略类,将策略类作为电梯的一个属性,策略类里面实现电梯的状态转换,方向转换,我们通过更换策略类,就可以灵活地切换执行策略,也可以应用工厂模式组装不同电梯。
4.2.2 关于线程安全
- 关于wait()和notifyAll()
这一部分有关线程安全,自己一定要想清楚想明白,什么时候该wait()、什么时候该notifyAll(),当出现进程没有正常结束的时候,使用最朴素的print方法比调试更直观、也更容易找到是哪块出现了问题。比如在测试是否死锁的时候我们可以在wait前后都加上print,如果有一对输出,那么证明未出现死锁,否则只有一个输出的话证明线程wait后并未被唤醒,线程无法结束。此外关于轮询问题,我们要注意与线程有关的while循环,轮询一般都发生在这里,常见的错误是没有wait
,导致一直在轮询。
- 关于Synchronize
锁只加在需要加的地方,只对共享数据进行加锁,保证数据的一致性,但我们也不能乱加锁,不该加锁的地方加锁会导致程序性能下降。