BUAA_OO_2020_第二单元总结
第一次
本次作业采用生产者、消费者模式设计,大致框架如图所示:
-
生产者:输入线程
-
消费者:电梯线程
-
托盘:Dispatcher调度器
线程安全方面,调度器中的指令队列为输入、电梯线程共享对象,需要保证其线程安全。
调度器中包含synchronized关键字修饰的put()
, get()
方法。InputHandler线程作为生产者,调用put()
方法向队列中添加请求;电梯线程作为消费者,调用get()
方法获取请求并执行。
结束线程策略为,当输入结束后,输入线程向调度器传递stop信号后自行结束。再由调度器向电梯传递stop信号,电梯在指令运行结束后自行结束。
调度算法
调度策略采用look算法,即在某一方向运行时,若当前层有请求,且请求的运行方向与当前相同,则开门接收乘客。若电梯内没有乘客,且前方没有新请求,则转向。
基于度量的代码结构分析
-
代码结构
第一次作业要求实现单部多线程可捎带电梯,初次接触多线程,代码结构比较简单,UML类图如下:
第一次作业结构相对简单,总共分为四个类:主类、输入类、电梯类、调度器类。
时序图如下:
程序基本运行流程:主线程启动InputHandler和Elevator线程,Elevator线程在获取到指令之前处于wait
状态,直到获取指令并执行。在输入结束后,由InputHandler发出结束信号,即置Dispatcher中的stop信号位true,并自行结束。当电梯指令全部执行完毕且获取到stop信号为true时,自行结束。至此,程序自行结束。
-
代码复杂度
-
方法复杂度
- 类复杂度
-
依赖矩阵
本次作业代码,所有方法都控制在35行以下,因为我对于代码行数的控制有了明确的意识,尽量对于一部分功能进行拆分,使得代码结构更加清晰,且方便模块检查。
根据设计原则,
run()
方法瞄准Main函数,简洁为主,仅描述重要步骤,但其复杂度相对较高,经检查,是由于其涉及结束线程的判断部分,if-else结构相对其他方法较多。可以考虑将判断是否结束线程单独抽离为一个方法来减少其复杂度。类复杂度都在合理范围内。
本次没有出现循环依赖情况,调度器和电梯之间关联较大,这是由于本次作业我暂时没有采用分调度器的方式,而是将指令队列直接放在了总调度器里,从而造成电梯和调度器之间的频繁信息交换。
-
关于测评
本次作业没有在公测中出现bug。
在互测中被黑出一个bug。由于初次接触多线程,对于轮询的含义理解不清,没有规避导致电梯在运行到中途等待下一指令到来时出现轮询,不断while循环判断是否还有指令,导致CTLE。后经过修改,使得电梯在运行中途暂时没有指令时进行等待,有新指令到达后再唤醒,修正了bug。
第二次
设计策略
依然采用生产者-消费者模式
-
生产者:输入线程
-
消费者:电梯线程
-
托盘:Controller总控制器
本次作业开始涉及多部电梯,因此对代码的结构层次进行了调整。首先,新增Controller类,作为总控制器,而每个电梯都对应一个属于自己的Dispatcher,只负责单个电梯的调度。另外,为了方便对于每个乘客的信息进行记录,新增Person类。
另外,本次作业首次出现电梯承载人数限制,为防止超载,我采用的方法是在电梯类判断,即电梯到达某一层查询是否接人时,考虑当前电梯剩余空位,若还有余位,则上人,否则忽略。
线程安全方面,指令序列作为共享对象,需要保证线程安全。Person类中有关人员状态的属性可能同时被多个电梯线程访问,也需要保证线程安全。另,为了保证正确输出,新增Output类实现线程安全输出。
值得一提的是,经过理论课的学习,我了解了读写锁这一特殊加锁方法,并在本次作业的Person类上予以尝试。即读操作间互不影响,但写-写,写-读则被锁住。经过对拍比较读写锁与synchronized直接修饰方法两中模式的运行时间,发现性能并无提升(其实有不少点还更慢了)。究其原因,本次作业情景的读操作频率并没有明显大于写,导致读写锁的优势没能发挥出来,反而由于实现读写锁的时间成本较高,导致性能不升反降(仅为个人猜测,若有错误请指教)。因此最终还是采用synchronized关键字加锁。
调度算法
“无为而治” 出自《道德经》,是道家的治国理念。它并不是什么也不做,而是不过多地干预、充分发挥民众的创造力,做到自我实现。
自本次作业涉及多部电梯调度开始,我选用的调度算法融合了无为而治的精神内涵,即控制器不对指令的分配有过多约束,而是发挥各电梯的自我能动性。具体分配细节如下:
-
将读取的指令加入每一部电梯的队列中
-
依旧基于look调度算法,当某一部电梯要执行该乘客的指令时,将Person类中的Taken属性设为true,表示该乘客已经进入某个电梯中。
-
所有电梯在遍历查找需要执行的指令时,需要判断该乘客还未进入其他电梯,即Taken=false,才考虑加入该乘客。
综上,可以生动地总结为抢人分配方法。通过后期和同学们的讨论来看,这种方法在随机生成的数据时比较吃香,因为基本保证乘客尽快进入相应电梯。但缺点是比较浪费电梯资源,会出现多个电梯哄抢1人情况。
该分配方法对于代码复杂度也有影响。由于电梯没到一层,遍历指令序列时,都需要对Person类的Taken进行一些处理,这导致Person类对电梯类、调度器类的依赖都比较深。
除了该分配方法,我还尝试以下两种分配方法,由于性能相对落后,最终没有采用。
-
随机分配:按照指令进入顺序S型分配进个电梯
-
最近分配:查找与当前指令起点逻辑距离最近的电梯进行分配。逻辑距离:综合电梯当前位置、请求起点的位置、电梯运行的方向、请求方向计算得出的电梯接到该乘客之前移动的距离。
基于度量的代码结构分析
- 与第一次作业相似,由InputHandler读入请求,并加入Controller中的指令队列。Controller将指令按照一定规律下发至各个电梯,并加入至对应电梯的调度器中的待执行请求队列。电梯从Dispatcher中获取指令并执行。当输入结束,InputHandler向Controller发出结束信号,自行结束。Controller再将结束信号下发到各电梯。由每个电梯自行判断,当自己任务已经完成,自行结束。至此,程序自行结束。
-
代码复杂度
-
方法复杂度
-
类复杂度
-
依赖矩阵
本次作业在第一次作业的基础上进行扩展,方法复杂度的情况与第一次作业基本相同,依然时电梯run方法复杂度高于其他方法。
getID()
涉及到通过i=0,1... 分别返回 "A", "B"...,存在五个if-else分支,因此 ev(G) 较高。电梯基于代码合理拆分,本次作业依然保证代码行数都在40以下。由依赖矩阵可见,和Person类的关联度整体较高,这是调度算法的涉及造成的,将在以下调度算法部分阐述。
-
关于测评
本次作业没有在互测和公测中出现bug。
在互测中,用一个边界数据(同一时刻30人同时从1楼出发)黑到两位同学,都是在执行完指令后线程没有自行结束,导致RTLE。但本来是想黑A,B同学的,最后黑到的却是A,C同学,由此可见多线程的不稳定性,bug关键时刻复现与否,还是看脸。
本次作业,我屋内一共成功五刀,全是RTLE。由此可见,如何让线程正常地自行结束是多线程编程的难点之一。
第三次
设计策略
本次作业的基本策略与第二次作业完全相同。几点不同如下
针对换乘策略,我将需要换乘的指令拆分为两部分,首先向Controller中的请求队列中装入第一部分,下发至能够到达该请求起点和终点的所有电梯。
不同电梯的属性,根据创建电梯的构造函数参数改变来创建不同种类电梯。
结束线程策略,需要更新原先的方法,因为存在输入结束且当前电梯没有未结束的请求,但是可能在其他电梯运行过程中新增换乘请求的情况。更新为:控制器不仅要下发输入结束的信号,还要向某一电梯下发其他电梯的状态。若输入结束,且其他电梯都没有未结束指令,且当前电梯没有未结束指令,则自行结束线程。
线程安全方面基本要求延续上次作业即可。
另,由于性能判定增加了人员等待时间,所有应当保证上电梯者在最后一刻上,下电梯者一开门就下。
调度算法
对于单个无需换乘的指令,调度算法与第二次相同,即所有别分配到该指令的电梯进行抢人。
对于换乘指令,我统一以1、5、15三层为换乘点,防止换乘点过多导致混乱或开关门过于频繁等问题。在Controller类中预处理所有换乘情况,构成换乘矩阵,在读入请求后,根据矩阵的对应元素直接获取换乘方法。
SOLID原则
-
单一责任原则
-
输入类仅负责读取输入并加入控制器和向控制器发送结束信号;
-
总控制器仅负责将指令按调度需求分配给电梯;
-
电梯负责将指令装入调度器、从调度器取指令、给控制器返回电梯状态。其责任略多。
-
调度器仅负责存储电梯指令、指导电梯进行掉头、前进等操作。
-
-
开放封闭原则
-
使用的电梯类延续自第一次作业,几乎无改动。多个电梯即对电梯类进行多次实例化。
-
输入线程延续自第一次作业,根据输入要求略加修改对于 “托盘” 的方法调用。
-
调度器延续自第一次作业,几乎无改动。
-
控制器改动较多,多次作业迭代的改动基本都归于控制器方法的增减。
-
-
里氏替换原则:未涉及继承。
-
接口分离原则:仅有线程实现Runnable接口。
-
依赖倒置原则:出现电梯类和控制器类相互依赖情况,是设计策略需要所致。
在功能与性能平衡上,首先满足功能正常,并采用较为简单的分派策略,并没有在控制器内大篇幅进行判断分配指令,从而保证了控制器逻辑相对简单,预防bug的出现。
基于度量的代码结构分析
-
代码结构
本次作业要求实现动态添加电梯且电梯到达层数有限制的多部多线程可捎带调度电梯。UML类图如下:
- 本次作业在类的层次和第二次作业没有任何差别,改动了Controller中的部分方法,并增加Person类中有关换乘的属性。
-
时序图如下:
-
与第二次作业类似,新增在电梯在执行完指令后,判断是否还有换乘的下一部分,若有,则将后半部分指令加入请求队列,重复上述过程。
结束过程已在设计策略中提到,不再详述。
-
代码复杂度
-
方法复杂度
- 类复杂度
-
依赖矩阵
-
-
Controller类的
getCategory()
方法ev(G)较高,因为其涉及根据请求的起点、终点返回换乘情况矩阵的下标,因此if-else分支较多。延续前两次作业,本次作业依然控制单个方法规模在30行以下。
电梯类的WMC超标,且电梯类的代码规模偏大,可见电梯类承担的责任较多。经分析,除了电梯运行的相关函数,进行Controller和Dispatcher之间的信息沟通也全部经由电梯实现,这导致电梯中方法数量较大。
本次作业出现循环依赖情况,这是由于电梯类和控制器类之间存在双向的信息交换,即控制器向电梯下发指令和结束信号,电梯向控制器返回当前运行状态,用于判断是否能结束线程。
Person类相关的关联深度依然较高,原因与上次作业相同,在控制器、调度器中都需要经常访问乘客的相关信息。
关于测评
本次作业没有在共测中出现bug。
在互测中,被我本地测过n次的几乎一模一样的数据黑出ctle,无法复现,再次提交即通过。经分析,由于我考虑测评机会在指令放完后立即^D,因此没有处理当所有电梯都等待新指令时直接^D的情况。修改后提交也可通过。但该测试点其实也没有涉及到这个情况,所以我依然很迷惑。
在互测中,黑到两位同学。一位同学在最后一个需要执行的指令需要换乘时并没有成功换乘,而是在换乘点直接结束了。这便是电梯的结束条件判断有误,需要执行该换乘请求后半部分任务的电梯线程误以为不会再有新的指令而错误地结束了,导致换乘后请求没有执行。另一位同学只能处理新增电梯编号为X1,X2,X3的情况,是指导书要求误解造成的。
心得体会
线程安全
线程安全,万恶之源。有了他以后,出现WA都很开心。
在本单元的学习过程中,我在第三次作业书写过程中出现线程安全问题,原因为Controller类中的方法加锁不充分,即并没有对所有访问请求队列的方法进行加锁导致。另外,若所有电梯都在等待新指令到来时直接^D,所有电梯都会处于wait状态。
结合同学们的交流和我个人测评感受,本单元的一大难点就是第三次作业电梯线程的自行结束。由于结束条件变多,若缺失考虑,则会造成互测中被黑到的同学一样的线程提前结束错误;若没有处理好结束条件之间的关系和电梯等待条件,很容易造成所有电梯线程都陷入等待的死锁情况。
设计架构
本单元重点在多线程,我的代码实现中除了对Runnable接口的实现,没有其他继承实现关系,因此代码层次比较简单。
对于生产者-消费者模式的应用,三次作业迭代经历了单一消费者到多消费者的过渡,实验课上也体验了多生产者&多消费者的过渡,让我深刻体会到了设计模式的扩展过程和方法。