BUAA-OO-第二单元作业总结(电梯调度模拟)
前言
在完成了第一单元的表达式求导后,我们进入了第二单元多线程的“狼窝”之中,三次作业下来,收获自然是有很多,自我感觉还是良好的(滑稽),与第一单元的不知所措相比,第二单元明显就长记性了很多,在处理电梯运行的过程中也比较好地理解了指导书的意图。下面我来对这一单元作业进行一下总结。
一、从多线程的协同和同步控制看设计策略
与之前拿过题来就开始写不同,这一次我在自己的脑海里构思了非常久,反反复复看指导书以及提供的一些关于多线程的资料,也上网找了一些前一届学长学姐的博客参考设计,起码我是有一整天都在看资料没有动手开始写,这一次算是基本先把该想的都先想好了。
1.多线程协同
在模拟电梯运行的过程中,分为两类线程:Input和Elevator,看名字就知道,Input负责投放请求,到最后自己收到null后就可以退出了,Elevator就是模拟电梯了,内部包含存储队列以及模拟运行所必要的方法,开关门就sleep相应时间即可,唯一需要注意的就是退出的条件,不仅仅是电梯没人并且暂时不需要它,更重要的是要看之后有没有人会来,这一点我还是没有犯什么错的,具体的运行过程其实也不用怎么管,让它们自己在那里跑就可以了,只要各种行为都对,就不必太过担心。
三次作业主体的模式基本没有大变化,就是Producer-Consumer模式以及后来的Worker Thread模式,二者本质上是相通的,都是避免生产者和消费者之间发生直接的信息交互,通过一个中介,在这里就是调度器了,来进行协同工作,增大了吞吐率,把这个想明白,保证线程安全,三次作业下来想重构也是很难的一件事呢(当然为了追求更高的效率肯定要大改了)。
2.同步控制
多线程与单线程最大的不同就是线程之间的协同与控制,对于共享资源要注意进行保护,否则的话,由于多线程执行的不确定性,难免导致出现线程安全问题。这三次作业中,主要的共享资源就是调度器,这一点没有变,调度器里面包含了一些必要的返回状态的方法,以及改变状态的方法,最基本的当然就是有put和get方法,最初由于知识的不足,只知道了个synchronized关键字,而且也不知道什么样的操作组合算是原子性的,所以也就不管三七二十一了,是个方法就加了锁,加了锁的方法最后都用notifyAll方法唤醒wait的线程,这样的缺点自然就是效率低下,线程频繁被唤醒,但是相比于轮询而言还是可以接受的,而且安全性肯定还是有的。
但是第七次作业这个思路就出现了一些问题,由于最开始wait的条件过于严苛,导致好多线程该wait的时候还在不断地在循环体里运行着,导致ctle,最后我仔细分析了调度器中每个方法的原子性,去掉了一些不必要的锁,同时放宽了wait条件,这样终于算是“涉险过关”了(cpu时间还是很长)。
二、从功能设计和性能设计的平衡看架构设计的可扩展性
对于这一次作业,我自己认为还是做到了功能设计与性能设计的平衡,采用了LOOK算法模拟电梯运行,在测试点完全随机的情况下也可以胜任绝大多数的电梯运行模拟,效率也算稳定,在功能方面三次作业一直延续了主要的设计,没有发生重构的现象(普通的增删改查是不能算重构的哦),这一次算是真真切切体会到了什么叫做迭代开发,代码量是真的少,感觉爽的飞起,现在想一想自己第一单元写的代码,感觉已经无法理解自己当初是怎么能完成当时的作业的,当然,第二单元的迭代开发也就到第七次作业为止了,问题是如果再来一次迭代开发,第七次作业打下的基础还能不能“可堪大任”呢?下面我就从SOLID原则来简要分析一下第七次作业的可扩展性。
1.SRP原则
该原则要求每个类或方法都只有一个明确的职责,在这里我认为我还完成的不错,每个类具体职责很明显,方法也拆分了很多,拆出来的方法职责也很明确单一,但是缺点就是我的电梯线程的run方法还是拆分的不够,细看下来还是很长,依旧有好多代码可以分出去作为单独一个方法,导致run方法并没有作为一个提纲性质的方法,这是需要进一步修改的,但是对于扩展性来讲影响应该不大?毕竟如果LOOK算法还可以吃老本吃到底的话,是没有大的影响的。
2.OCP原则
无需修改已有实现(close),而是通过扩展来增加新功能(open),这就是OCP原则,这条显然做的不是很好了,第五次作业和第六次作业均对已有的方法进行了修改,来适应电梯数量与换乘需求的增加,如果现在来看的话,继续增加需求,比如检修停止啊,起码Input类就要做出修改,这主要是run方法还是太集中了,SRP原则做的不好导致的,如果再分成货梯客梯什么的,可能调度方法也要修改,我觉得这条原则想要完全做到太难了,因为谁也不知道未来会有啥需求,就算能够预测个一次两次,也不能够保证自己无需修改已有实现。但还是争取做到吧。
3.LSP原则
任何父类出现的地方都可以使用子类来代替,并不会导致使用相应类的程序出现错误,这是LSP原则,但是在这里我并没有使用子类与父类,而且我觉得今后应该也可以不需要,但是如果出现要加父类电梯的话,我认为我的程序就需要大改了,因为我这里只考虑了统一建模,没有考虑继承关系,这个原则可能就出现了违背了。
4.ISP原则
一个接口只封装一组高度内聚的操作,避免封装多种可能/可选的方案,这是ISP原则,说白了就是一个接口一种方法,这里我也没实现任何接口,因此也不太好说究竟会不会触犯这条原则,看看第一单元的作业,接口里好几个方法,这条原则倒是肯定违背了。
5.DIP原则
上层模块不应该依赖底层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象,这就是DIP原则,这一条我认为我还是没有触犯的,毕竟还是没有啥上层底层之分,比较平面,大家地位都是并列的,但是如果涉及到新加入上层模块的话,我觉得我可能就要触犯这个原则了,因为由于现有的细节已经完成了,要想全面放弃重构我觉得我可能就受不了了,可能就会削足适履上层依赖底层,抽象依赖细节了。
一直到第七次作业才开始讲设计原则,这就不可避免地前两次作业要把错误犯个遍了,但是第七次作业完成的似乎还凑活,因此如果再来一次迭代开发的话应该还能凑活着用,可扩展性相比第一单元大大提高,个人认为主要是本单元没有复杂的继承关系与对象划分,实现起来比较容易,因此重构概率较低,还算是兼顾了功能与性能,不过,如果要是想抢性能分的话,还是要大改一些的,比如最短路径什么的我这里就完全没有,虽然这一单元结束了,但还是可以考虑考虑的。
三、基于度量分析程序结构
1.第五次作业
下面是本次作业的有关图表:
这一次作业是单部ALS可捎带电梯(说好的傻瓜电梯呢QAQ),典型的消费者-生产者模式,由类图可知,Input担任生产者,向Scheduler中送入请求数据(put方法),Elevator自然就是电梯了,利用get方法取出当前楼层可以上电梯的乘客,其内部包含相关的模拟电梯运行的数据,比如方向,开关门时间,运行时间,Scheduler中有一HashMap存储所有请求,Elevator中也有一HashMap存储电梯中的乘客,同时二者中均包含有判断当前楼层是否有请求,当前楼层之上,之下是否有请求的方法,具体的调度与运行方法为LOOK算法,性能上比较稳定,整体设计不算复杂,但是由于本次作业只有一部电梯,没有考虑到多部电梯的运行,所以在开启线程方面要做出改动,同时,虽然调度算法可以沿用,但是会出现多部电梯同时移动争抢乘客的现象,需要调整,但是影响不大。
在复杂度上可以看出最复杂的方法就是run方法了,可以看出这个方法还是写的比较冗长复杂,还可以细分出许多私有方法,Elevator贡献了绝大部分的复杂度,但是平均下来还是可以接受的,没有出现第一单元作业那样的超高平均复杂度,因此设计上还算合格,依旧可以继续细化使之降低。
具体的线程之间的协作图如下,它表明了整个程序的线程之间的协作关系:
2.第六次作业
下面是本次作业的有关图表(有关方法的复杂度仅展示部分):
这一次作业由于加入了多部电梯,并且还引入了轿厢容量,因此需要进行相应的修改,为电梯增加了相应的属性,当然,调度算法也要有一定的改动,主体依旧还是LOOK算法,不同的是我依据每个人的id对电梯数目取模,按照得到的余数来决定派出哪部电梯去接乘客,但是,在这里并不是这个人非要坐这个电梯不可,只要最先到达他所在楼层的电梯能装入乘客,就可以把他带走,这样可以在一定程度上节省时间,但是,在这个方法中,我处理的并不是非常好,对于每一个电梯都在调度器里加入了一个方法来看此时需不需要它,其实都是实质一样的代码,可以合并成一个方法,没必要用五个方法来处理,其他的方法基本没有改变,经过提交发现,中测弱测部分测试点CPU时间比较长,有4-5秒,这其实就有点危险了,但是当时并没有在意,以为是取模造成的运算量增加,其实这个时候就有频繁唤醒的问题了,直到下一次作业才做出改变,但是在强测中,CPU时间反倒没有那么高,也许是环境优化了吧。
在复杂度方面,由于大部分继承了上一次作业,导致run方法还是太复杂,elevator线程还是复杂度高,此外,这一次调度器的复杂度也比较高,主要是判断是否需要当前电梯的方法if语句太多,这肯定复杂度会高了,可以考虑另外的设计来进行判断的工作,但平均下来还算可以,虽然与上一次作业相比有所复杂,但是毕竟加入了新的需求。
具体的线程之间的协作图如下,基本上没有太大的变化:
3.第七次作业
下面是本次作业的有关图表:
这一次作业主要的问题就是电梯分成了若干种,并且停靠的楼层不一样,引入了换乘的需求,所以为了便于设置乘客的乘坐方案,这一次没有直接使用提供的PersonRequest类,而是自己扩展了一个单独的类(MyPersonRequest),保存了电梯的可到达楼层,然后提供了若干获得和改变状态的方法,在设计乘坐方案时,使用的逻辑就是能直达就直达,不能的话再进行换乘,由三类电梯信息可知不论从哪里到哪里,换乘一次均可以完成任务,所以为了简单性就没有进行优化工作,好处当然是容易写,坏处当然就是性能差了一点。继续为电梯添加必要的属性,修改并添加了电梯和调度器中的一些方法,以适应换乘的需求,但大体还是变化不算大,由于这次作业说输出不保证线程安全,所以为了保险起见开了个Output类进行有线程安全的输出。同时延续上一次作业取模的思路,给每一类的电梯分别从小到大编号,来判断究竟需要调度哪部电梯,当然,调度器和乘客类内部均有电梯的可到达楼层信息,还是重复存储了,需要进一步改正,同时没有进行二次调度,导致调度分配还是有点不合理,CPU时间还是比较长,这都是可以改进的地方。
在复杂度方面,这一次复杂度又一次提升了,除了延续下来的run方法问题,还有就是换乘相关的方法,以及一些判断请求的方法,复杂度都较高,这都是因为内部if语句逻辑比较复杂导致的,平均复杂度也比较高,也就是说还可以将方法继续细化,来减少复杂度。
具体的线程之间的协作图如下,可以看出变化较大:
四、对自己程序的bug分析
这一单元的作业还是非常稳健的,强测均顺利过关(虽然第七次作业有点耍诈了),只有在第六次作业的时候被hack了两次,其他两次作业没有被hack到,姑且认为另外两次没啥问题吧,在这里先对第六次作业bug做出一些分析。
老实说,我也不太清楚究竟是不是这个原因,在这里仅对此做个猜测吧。在创建新的线程的时候(Main主线程里),我的Input线程开始的太靠后了,并且创建电梯线程的方法也不是很好,是先准备了五个电梯,完了看到时候需要几个就开启几个,导致线程开启之间可能出现了冲突,线程搅在了一起导致了RE,这个问题在我修改了这两点之后(提前Input线程,利用循环体而不是if语句判断数量开启电梯线程)就好了,不改的时候就肯定过关不了,这让我也很费解,因为如果要是线程安全问题的话,有可能再次提交就过了,但是我的问题竟然可以稳定RE,并且我觉得我也没有什么改动就莫名其妙地过了,也许可能问题不在这里而确实是个线程安全问题,但我确实不知道这是什么原理,其实也是糊里糊涂。
此外,在第七次作业中测的时候,我出现了大范围的RE和CTLE的现象,起初也以为是像第六次作业一样的原因,但是仔细分析下来发现不对,是我的线程该wait的时候没有wait,而且被频繁唤醒,具体检查的措施就是在电梯线程run方法循环体最开始加一句输出,如果出现了刷屏现象,那肯定是典型没wait好的原因,处理方法无外乎就是放宽wait条件(把判断当前和以后是否需要该电梯改成只判断当前是否需要),并且适当调整调度方法,如果不想重构的话,也只能这么做了。
在本地判断死锁的时候可以在输入数据后马上给一个null,这样有比较大的概率测出来死锁的,主要还是看大量数据冲击之下会不会有什么问题,毕竟如果数据太少的话,发现问题也容易,也比较好改。
五、分析他人bug时的策略
这一单元要是想hack到别人的话,如果还是靠自己手动构造样例,显然不切实际了,我的大脑显然承受不住这样的电梯模拟,因此需要自动化测评手段,但是,依旧是延续了第一单元,我还是没有评测机,这也导致我基本不可能hack到别人了,只能够把指导书上的样例提交上去权当开心了,感觉其他人hack的热情也消退了许多,房间里经常安静的很,所以在这里我只能够来构想一下如果我有测评机我会用什么样的手段了分析他人bug。
1.预计采取的测试策略
我可以分为全面打击和重点打击来构想一下,全面打击当然就是随机生成数据胡乱轰炸了,当然这样有效性显然不是很好,所以还要考虑重点打击,比如看大规模数据在同一时刻输入,大量数据都从同一层出发,指令之间的间隔缩小,或者是扩大很多,诸如此类,主要是考虑到边界数据, 极端数据,主要是针对死锁,感觉WA的可能性不太大,这样hack的可能性更大一点。
2.发现线程安全问题
线程的安全问题主要就是死锁与活锁了,一般来说,活锁是因为电梯运行过程中某些判断条件不适当导致的,这比较好发现,基本测一测都能找到,主要是死锁,本地复现的时候可以在大量数据之后送入null,来判断是否可能存在死锁,如果有时间的话,最好是阅读代码,细致地分析一下代码的逻辑是否有漏洞,从而采取相应的措施来进行hack。
3.与第一单元的差异
这一单元,要想hack他人,如果按照第一单元的方法的话,肯定是不太行的,第一单元可以按照正则表达式随机生成数据,然后轰炸就行,也不用特地去针对什么特殊情况,随机生成情况也比较少,时间够总还是能够全覆盖的。但是第二单元就不行了,综合多线程执行的不确定性,本地和评测机环境不同等多方面原因,要想hack不容易,情况太多太复杂了,说不定什么情况就出现了线程安全问题,并且很难复现,所以应当有针对性地采取一些措施,比如我上面提到的,否则单纯靠随机数据,是不太可能发现他人bug的。
六、心得体会
这一单元说实在话,感觉比第一单元难度有所降低了,起码没有次次重构,也体会到了刚一开放提交就AK了的愉悦感,真心感受到了啥叫迭代开发,总体感觉还是不错的,当然了,在线程安全方面,这一单元作业也没有使用什么concurrent包里的方法,全靠方法加锁莽,虽然可以保证绝大部分的线程安全性,但也是个很有性能缺陷的方法了,现在想想,遗憾也许就是没有挑战自我,冲击一下性能分了,但是也觉得助教说的对,良好的架构,保证正确性才是关键,不能够在设计上开倒车,否则一旦正确性保证不了,出现了重大的线程安全问题,真就是得不偿失了,这三次作业其实也就是在最后一次遇到了些困难,总体实现得比较稳定(LOOK算法是真的香啊)。从设计原则的角度来看,SOLID原则以及其他的一些设计原则实现的还是有一些瑕疵,主要原因是最开始就没太在意这方面的设计,但是毕竟是第一次接触到多线程,这些都是难免的,在以后的学习中,乃至以后的工作当中,都要注意这些问题的,总而言之,还有好多可以改进进步的地方,希望在接下来的学习中能够总结经验,把好的经验运用到日后的学习中去。
不管如何,保持着还算愉快的心情,马上就要进入第三单元的学习了,OO赛程也已经过半了,希望在接下来的两个单元不要出现什么大的意外吧,能够平稳落地,这也就是我最大的心愿了,但愿它能够实现吧。