OO_第二单元总结
oo_第二单元总结
需求:乘客不定时到达,需模拟电梯运行,并支持实时添加电梯。电梯有种类之分,不同种类的电梯运行速度、载客量、可抵达楼层范围均有差别。
设计策略
-
容器类
-
FloorQueue
代表单个各楼层的请求队列
-
Floors
线程安全类,以楼层结构储存了整栋楼的请求,封装了
addPersonReq
、removePersonReq
、size
以及根据楼层号获得该楼层队列等方法。
-
-
线程类
-
输入线程
Input
本线程负责监控请求的到来,并将其存储到
waitingFloors
中。线程结束条件为读取到的请求为null,此时调用waitingFloors
的end()
方法来将isEnd
置为true
,表示该容器对应的input
线程已经结束 -
请求分发线程
Dispatcher
该线程负责将
waitingFloors
中的请求根据一定的调度策略(ElevatorStrategy
)来分发到每个电梯对应的处理队列processingFloors
中- 结束条件:输入线程已经结束(即
waitingFloors
对应的isEnd
为true
,代表着等待队列中不会新增请求),并且等待队列waitingFloors
中没有未分发的请求,此时遍历所有电梯的processingFloors
,调用它们的end
方法,表示它们对应的Disaptcher
线程已经结束;然后将等待processingFloors
锁的线程唤醒,让它们自行判断结束。 - 等待条件:输入线程尚未结束,且
waitingFloors
中没有要分发的请求,则等待。
- 结束条件:输入线程已经结束(即
-
电梯线程
Elevator
该线程负责将它对应的
processingFloors
中的请求,根据调度策略(FloorsStrategy
)来运行并接送乘客。- 结束条件:当处理队列
processingFloors
中没有未处理请求,电梯中没有未到达乘客,并且dispatcher
线程结束(代表着处理队列不会再新增请求),此时电梯线程即可结束 - 等待条件:当处理队列
processingFloors
中没有未处理请求,电梯中没有未到达乘客,但dispatcher
线程未结束(代表着处理队列可能会新增请求),此时需要等待
- 结束条件:当处理队列
-
-
策略类
-
ElevatorStrategy
根据所有电梯的状态与需分发请求,返回合适的电梯的编号。
-
FloorsStrategy
根据当前电梯状态与处理队列中请求在楼层的整体分布情况,来返回下一次电梯的目标楼层。本策略采用
look
算法,该算法实现简单,并且性能表现也不错。即向某一方向运行时,如果当前的层有与电梯运行方向一致的请求,则在电梯未满员的情况下,开门接收该方向的乘客。如果电梯内没有乘客并且当前方向上没有请求,则将电梯转向。
-
-
配置类
-
ArrivePattern
封装了电梯支持的几种
ArrivePattern
模式 -
ElevatorSettings
(第三次作业新增)封装了不同种类电梯的参数,根据传入的电梯种类,可获取相应的电梯参数。
-
同步块与锁
-
Input
线程当读入新的请求后,由于需要将请求添加入
waitingFloors
中,因此需要获取waitingFloors
的锁。添加之后需要notifyAll
,唤醒所有由于waitingFloors
变空从而陷入等待状态的线程。值得注意的一点是,官方读入接口是通过一个while
循环来监控请求,如果将获取请求的这一条语句也添加入同步块,就会导致在没有新请求到达的空闲时间也占用着waitingFloors
锁,导致效率的低下。由于线程不安全是由
input
线程与dispatcher
线程共享waitingFloors
对象造成的,因此我们将waitingFloors
设为锁,保证对waitingFloors
的操作是原子性的(ArrayList
是非线程安全类)。 -
Dispatcher
线程-
第一次 & 第二次作业
由于需要取出
waitingFloors
中的请求并分发,因此首先需要获取waitingFloors
锁。在获取到锁之后:- 如果满足线程结束条件,就遍历获取它负责的所有
processingFloors
的锁,将它们的end
标志位设为true
,表示dispatcher
结束,并将由于processingFloos
为空而陷入等待状态的电梯线程唤醒,使其自行判断结束。 - 满足等待条件,则等待
- 否则分发请求,根据
ElevatorStrategy
获得分发的电梯对象编号,将请求对象从waitingFloors
中移除并装入该电梯对应的processingFloors
中,唤醒由于processingFloors
为空而陷入等待状态的电梯线程。
- 如果满足线程结束条件,就遍历获取它负责的所有
-
第三次作业
与前两次作业有所不同的是,由于本次作业实现了换乘,因此结束条件发生了变化。由于可能有乘客需要换乘,因此
waitingFloors
中的请求的添加来源不止输入线程,还有电梯线程中的换乘请求。所以diapatcher
仍然需要负责这些换乘请求的再分发,要等到所有电梯线程没有换乘请求后才可以结束自己的使命。
-
-
Elevator
线程-
第一次 & 第二次作业
由于需要取出
processingFloors
中的请求,放入电梯中作为乘客运送,因此首先需要获取processingFloors
锁。首先判断是否满足电梯线程结束条件(处理队列为空,且处理队列的end
标志位为true
,代表处理队列中不会被添加新的请求),满足则结束;如果处理队列为空,则等待。 -
第三次作业
由于实现了换乘,所以电梯线程的结束条件有所变化:加一条判断,其他的电梯中没有换乘请求,这样方能保证当前处理队列没有新的请求添加来源。
-
UML图
1. 第一次作业
UML类图
类复杂度较高的如下:
class | OCavg | OCmax | WMC |
---|---|---|---|
Dispatcher | 4.5 | 8.0 | 9.0 |
Strategy | 3.3333333333333335 | 5.0 | 20.0 |
Elevator | 2.466666666666667 | 6.0 | 37.0 |
方法复杂度较高的如下:
method | CogC | ev(G) | iv(G) | v(G) |
---|---|---|---|---|
Strategy.getNextReqFloorInDir() | 9.0 | 5.0 | 4.0 | 8.0 |
Strategy.getTargetFloor() | 9.0 | 5.0 | 3.0 | 5.0 |
Elevator.getInMatchedPassengers() | 10.0 | 4.0 | 5.0 | 8.0 |
Dispatcher.run() | 25.0 | 3.0 | 8.0 | 10.0 |
Strategy
类的两个复杂度较高的方法规模比较大,负责的判断比较多,应当进一步拆分。Elevator
的getInMatchedPassengers
由于考虑到电梯停在指定楼层既可能是顺路接乘客,也可能是电梯内没有乘客而前去接此方向上的反方向请求,因此需要进行两个方向上的判断,所以复杂度较高。
UML顺序图
基本流程为:由Input线程监控并读入请求,将请求加入等待队列中。Dispatcher线程按照策略将等待队列中的指令下发至电梯的处理队列中。Elevator线程根据处理队列中请求的分布,按照策略类的策略进行运输。当Input读入Null结束时,下发结束指令到Dispatcher线程;Dispatcher线程处理完等待队列中的请求并且获取到结束信号,则下发结束信号至电梯线程,自行结束;电梯线程在任务完成并接收到结束信号后,自行结束。
2. 第二次作业
UML类图
UML顺序图
与第一次作业相同。
3. 第三次作业
UML类图
UML顺序图
与第一二次有所不同的是,本次作业实现了换乘,在分发请求时,如果判断分发的该请求在发入相应的电梯后不能直达目的地,就设置相应的换乘层,在换乘层下电梯。下电梯后,对于是在换乘层下电梯的请求,将其起始层设为换乘层,消除换乘层,加入等待队列中等待调度器的再分配。
这样,因为调度器还要负责分发电梯转入的换乘请求,因此结束条件还要加一个判断所有电梯都不会有新的换乘请求。
可扩展性
由于本设计将策略单独分离,线程只需要按照策略类返回的策略执行,因此对性能的提高并不需要对架构进行改动。在完成功能的前提下加入了一些并不算很复杂的策略。但将请求从等待队列中分配到电梯处理队列中这样一个调度器设计存在一个不可避免的问题:无论策略类设计的多么周全,但由于多线程的缘故,等到真正运行时电梯的状态都很有可能与调度时电梯的状态不一。更何况策略类很有可能设计的不好。这也是很多情况下,设计一个总队列存放请求,让所有电梯”抢人”这样一种设计性能反而比分配更优,它不但处理更为实时,并且含有一个天然的局部最优——接走请求的电梯是抵达耗时最少的那个。
而对于换乘,如果在分发请求时对A类电梯的状态考虑的不完善,实际性能表现可能并不比不实现换乘更优,因为换乘后的请求往往需要A类电梯来兜底,如果抵达换乘楼层后A类电梯相距甚远,那么来完成该请求的换乘仍然很耗时——而由于电梯的策略的动态变化,对A类电梯的考虑也很难完善。因此许多没有实现换乘的同学性能反而很优,实现了换乘的同学不但可能没提升性能,还担了因为改了架构而出bug的风险。
调度器设计
总体而言调度器有两个:将所有请求分发到不同的电梯来处理的调度,根据当前电梯状态(所处楼层、运行方向、乘客目的地)与处理队列中的请求在整体楼层中的分布来获取电梯下一个目的地楼层的调度。
两个调度器本人都设计了相应的策略类,这样进一步降低了程序的耦合性。前者通过传入要分发的请求与所有电梯的状态来分析获取合适的策略;后者通过传入当前电梯状态与处理队列状态来分析获取合适的策略。前者需要与两类线程(输入线程、电梯线程)进行交互,它与输入线程共享waitingFloors
对象,与每个电梯线程共享processingFloors
对象。
前者的策略类ElevatorStrategy
主要有以下几点:
-
由于A类电梯运行速度很慢,因此需要长途旅行的请求(
3- -> 15+
or18+ -> 5-
)均交由C类电梯处理(分别送到18层与3层进行换乘)。对于所有C类电梯,优先选择与该请求的起始楼层处于同一半楼层的电梯。 -
对于起始楼层与终点楼层均为奇数的请求,优选选择B类电梯,这毋庸置疑。而对于起始楼层为奇数,终点楼层为偶数的请求,我们可以将(终点楼层-1)设置为换乘楼层,交由A类电梯处理。对于所有B类电梯,优先选择正在驶来的同方向电梯。
-
为控制B类与A类电梯的平衡(可能造成的B类要处理的人很多而A很少的情况),设置了如果处理总数的比大于一个数值,则优先处理请求更少的一方。
后者的策略类FloorsStrategy
主要采用LOOK
算法:
该算法实现简单,并且性能表现也不错。即向某一方向运行时,如果当前的层有与电梯运行方向一致的请求,则在电梯未满员的情况下,开门接收该方向的乘客。如果电梯内没有乘客并且当前方向上没有请求,则将电梯转向。否则前往所有乘客目的地中距当前楼层最近的一层。
自己bug & 找别人bug
本单元作业中出的bug竟然都不是因为多线程,都只要改一行语句就能通过所有测试点(...),第一次是因为函数中理应break
的地方写成了return
,第二次是因为ElevatorStrategy
策略类中应当对所有搭载C类电梯与B类并且终点为偶数层的请求设置换乘层,但由于自己考虑的情况太多,导致其中一种情况忽略了设置换乘。
这种bug都是需要大量的请求数据才有可能触发,但自测时一方面因为懒,懒得构造大规模数据,懒得写一个输出正确性的评测系统,从而没有获得有效的找bug方法,一提交过了中测,随手造了几个小数据没问题,检查下逻辑没有死锁,就觉得万事大吉,但这样的态度造成的后果也是惨痛的,需要引起深刻的反省,日后一定要注意避免;另一方面,还是这一阶段事情太多,太累了。除了本课程,操作系统理论课知识海量又繁琐、需要时间理解复习,操作系统实验课只看指导一头雾水,需要大量时间编写,基物实验也要不少时间,c++课要整理的东西也不少;还有课外,学生工作要花时间,冯如杯进展好像还不错,但随之而来要搞的事太多了,以及蓝桥杯,算法知识基本忘得一干二净,写个普通的剪枝深搜还好,动态规划和图论忘得差不多了,需要时间去复习。如果有时间,我也想整理整理学过的知识,学学新技术,做做算法题,写写博客,探索一些与课程相关但课程中未提及的知识,鼓捣一些小评测机什么的,但是...或许是我效率比较低吧,真的是抽不出时间,我自觉还是很喜欢尝试新技术的,但现在,即使有一点点时间,我也只想喘口气。
对于死锁的分析,我着重于查下面这种结构:
synchronized(A) {
synchronized(B) {
...
}
}
synchronized(B) {
synchronized(A) {
...
}
}
如果怀疑有死锁的情况,可以多跑几次试试看程序会不会无法结束。
对于评测,个人是采用一个python根据输入定时投放数据的小脚本给jar包输入,并实时获得输出。数据多采用手工构造,原计划写一个评测输出正确性的脚本(是否到达指定楼层,是否多次出现等),但时间紧张就鸽掉了,只能人工评测输出正确性,效率太太太低了。
本单元错误可能大部分是由于多线程引起的CTLE或RTLE,着重检查程序是否无法正常结束,第一单元则是着重检查正确性。
心得体会
线程安全
导致线程不安全的原因主要有:
- 原子性:非原子的一个或多个操作在CPU执行的过程中可能被中断,从而导致该操作的结果出现问题。例如
cnt++;
实际上分为从内存中读取count
值,将该值+1,写入内存中这三个原子操作,如果它被打断,在写入内存前有其他线程也写入了内存,那么这样的一次写入就会被覆盖,从而引发错误。 - 可见性:一个线程对共享变量的修改,另外一个线程不能立即看到。这涉及到java的内存模型知识。
- 有序性:程序执行的顺序未按照代码的先后顺序执行。java平台有静态编译(javac)与动态编译(jit)两种编译器,由于动态编译器为了程序的整体性能会对指令进行重排序,因此可能会导致源代码中指定的内存访问顺序与实际执行顺序不一致,导致线程不安全。
对于如上情况,我们可以采用synchronized(lock)
锁来保证该操作的原子性,只有获得锁的线程可以进入同步块中。除此之外,对于第一种情况,我们还可以采用atomic
类比如AtomicInteger
,这些类可以通过CAS保证操作的原子性。对于第二种情况,我们还可以采用volatile
关键字来修饰变量,来保证修改对于其他线程的可见性。
层次化设计
主要是横向的层次化,线程之间的分工协作。
其他
这周时间还算宽裕,我也学习探索并整理了一些常见的设计模式(未完待续...),欢迎看到这里的小伙伴们->戳这里来康康<-、也可交流讨论一哈,谢谢朋友们。