OO的“电梯月”果然是名不虚传,在这一单元的作业完成过程中,我远没有上一单元的从容,每次作业都是绞尽脑汁,最后的成绩也不如第一单元。终于第二单元结束了,下面我就结合这一单元作业的完成情况谈谈我对作业的设计与理解。
(友情提示:我的整体架构与其他同学相比还是有所不足的,最后的耦合度比较高,类的实现也很臃肿,这也导致我在后续迭代过程中的实现比较困难,如果是后来者想要参考我的架构来完成作业,还请多看看其他大佬的架构,深入思考后再做决定)
三次作业要求介绍
第五次作业中要去利用多线程模拟实现一个多电梯系统,对于输入的需求(指定电梯),程序需要输出时间与电梯的行为,最终将每个请求都成功送到目标楼层。
第六次作业不再指定接受请求的电梯,实现Receive输出来表明哪部电梯接受了请求,并新增Reset指令来改变电梯的一些参数。
第七次作业新增双轿厢Reset指令,将电梯由单轿厢转化为双轿厢,我们必须实现双轿厢电梯的运行与请求分配。
架构设计
整体架构是遵从的老师上课讲的以及实验中练习的“生产者-消费者”模式。一个总请求池与多个分请求池作为共享对象(传送带),Input线程首先从输入中读取请求,将其放到总请求池中,Schedule线程从总请求池取请求,将其合理分配到各个分请求池中,电梯线程从自己的分请求池中获取请求进行处理,如果遇到reset请求,将分请求池中所有请求再放到总请求池中。
UML类图
(空心箭头表示请求的传递)
UML协作图
同步块的涉及与锁的选择
共享对象一定要注意线程安全问题!!!
第五次作业中,由于需求比较简单,我只在Requests类中对每个方法实现了synchronized的隐式锁,这也就意味着,无论对Requests类进行读或写,都会先对共享对象上锁。这样的实现方式简单且易实现,看往年学长的博客,大部分也是选择的这种锁。不过这样实现隐式锁有时候也是会导致线程安全的问题,我会在bug分析中详细说明。
在第六次作业之前,老师又给我们了讲解了除synchronized隐式锁之外的另一种锁——显式锁。显示锁在将更多的工作交给程序员自己,但其应用起来要比隐式锁更灵活。同时,老师进一步介绍了读写锁的使用。读写锁在读的时候不会上锁,只有写的时候对共享对象进行上锁(即OS中讲的Berstein条件)。不难发现,读写锁严格控制上锁的情形,与隐式锁“一刀切”上锁相比,很多情况允许线程继续执行,不会阻塞,有着更好的性能。
在第六次作业中,由于需求的增加使得代码变得更加复杂,再使用synchronized对方法上锁就不再容易去实现需求。体现最明显的就是对于reset请求中有关请求返回主请求池这一步的实现,我们无法为总请求池写一个带锁的方法,只能用同步控制块将需要保证总请求池不会发生其他变化的地方进行控制,如图
这是为了防止对方法上锁导致其它线程长时间无法访问的问题。
第七次作业在同步块与锁的应用方面与第六次作业大致相同,但是由于线程结束条件改变导致一些地方需要添加notify方法来让共享对象结束wait。这里出现了同步控制块的问题(不知道为什么第六次作业没出现),具体情况在后边的bug分析中说明。
调度器设置
我的调度器只有一个Schedule,即总的调度器,每次从总请求池中获取一个请求,如果是reset类方法,加到对应电梯中,如果是正常的个人请求,加到一个合适的电梯中。我的调度器的实现简单,唯一要注意的点就是最佳电梯的选择。
第五次作业的调度器只要将每个请求分给其对应电梯的请求池即可。从第六次作业开始,要求我们自己设计方法对请求进行分配。一开始,我采用了指导书中提到的平均分配方法,即设一个turn,每分配一个请求就turn++,下一个请求就分给turn%6号的电梯。
这个分配方式没有任何问题,能够成功保证请求的分配。但考虑到性能问题,我采用了另外的方法:遍历电梯,看看当前电梯还要运动几层才能够接到当前请求,选择楼层数最少的那部电梯将请求分配。其实这个方法就是脱胎于其他大佬的影子电梯的策略,当时认为影子电梯比较难实现,我就将其简化成了这个策略。最后的结果表明,这个策略性能不错,两次强测每个点性能分基本都在96以上。
策略实现起来要考虑的地方就是对于电梯在reset中的情况的处理,这个会在后边bug分析中详细说明。
有关双轿厢
对于第七次作业中双轿厢的处理,我在原本的六个电梯内部又添加了两个电梯,用作双电梯的两个轿厢,同时添加了相关参数,像最高(最低)可到达楼层等。当reset指令修改电梯为双厢时,会修改一个标志位,此后遍历或其他过程中根据这个标志位判断是否为双厢,采取不同的操作。
双轿厢电梯的请求来自其对应的请求池,第七次作业中将请求池扩展为18个,实现i号电梯映射为第i-1个请求池,i-A号电梯映射为第i+5个,i-B号电梯映射为第i+11个。
双轿厢电梯防止撞车是比较麻烦的一个点,与讨论区一个帖子的思路类似,我自己实现了一个标志类,如图
当电梯要运行到transferfloor时,会先看另外一个电梯的标志,如果另外一个电梯标志为1,说明另一部电梯在transferfloor,应该等待直到其离开transferfloor,然后将O中属于自己的flag置为1。当电梯离开transferfloor时,修改标志位为0。通过这样方式防止两个轿厢相撞。
另外,如果一次运行结束,电梯停在transferfloor,会让其运行一层,离开transferfloor并修改标志位。
bug分析
第五次作业
第五次作业出现的bug是经典的线程安全的bug,老师在第六次课上也提到了,即调用共享对象的方法会返回共享对象内部的属性,返回出的属性会在线程中更改。这时候,返回出来的属性已经不被synchronized锁保护了,假使后边一个线程对这个属性进行读操作的同时,另一个线程对这个属性进行写操作,就会出现“边读边写”,最终体现出来的是有概率出现的ConcurrentModificationException异常。修改我的程序中这个bug的方法比较简单,我的读出的属性只会用来遍历,所以只需要返回属性的深拷贝便可以避免这种“边读边写”的问题。
第六次作业
第六次作业强测暴露出了两个bug,一个是电梯线程的提前结束,这是由于电梯线程内部结束条件设计有误,加上一个电梯的请求池是否为空即可修改。另外一个是调度上的问题,当同一时间接收到五个reset请求与大量请求时,所有请求都会被同一个电梯receive,最终出现RTLE的情况,针对bug我首先对这种情况进行了特判,当有五个及以上电梯在reset中时,就让调度器睡一会。但是结果并不理想,原因是我获取最佳电梯的方法有问题,首先,如果最佳电梯在reset中,后边如果有请求想分配到这个电梯,由于锁的存在,那我会让调度器一直停在这个请求处,导致后边的请求无法及时分发到电梯,假如后边有reset请求,那就会出现电梯非法移动的问题。另外,这个做法无法从根本上解决同一电梯接受大量请求导致超时的问题,所以,我在获取最佳电梯时,会先判断电梯的请求列表中请求数量是否小于一个设定值,如果请求列表中请求数量大于设定值,即使这个电梯最优,也不将请求分配给它。如果遍历一轮找不到最佳电梯,就会自动分给第一个电梯,这时候还需要判断第一个电梯是否在reset中,如果是,将请求放入第一个电梯后第一个不在reset中的电梯的请求列表中。
第七次作业
bug主要是同步块控制问题,之前我在reset过程中会对当前电梯的请求池进行上锁,导致各个电梯的reset无法同步进行(因为调度器循环需要访问每个电梯的请求池,看其是否为空)。同时,我发现自己程序出现轮询的情况,这是由于我随意notify导致的,之前就是对于共享对象类的每一个方法,都写上notify,觉得不会对程序运行与结果造成影响。这样的实现在结果上看没有问题,但是在结果的背后,是CPU一直在轮询。分请求池和总请求池没有真正实现wait,wait后就被notify,导致调度器一直在轮询,所以我删去了查询方法的notify,只有修改的时候才notify共享对象。
找多线程bug的方法:
多线程程序中涉及到的bug具有不好复现的特点,有时候一条数据,可能得在本地跑七八次才能复现(我有一次跑了30多遍才复现)。对于这种bug,先在本地复现bug,之后可以尝试应用println输出来寻找bug出现的具体语句,不过这种方式很有可能会在加了输出之后无法复现bug。如果输出法无法确定位置,那就需要仔细研究输出数据来找出bug出现的原因,如果电梯提前结束可以去看看是不是线程的结束条件有误导致的,如果一个请求在运行过程中下了电梯没再上电梯,就去看看调度器线程以及电梯线程关于返回请求的部分,等等。对于多线程bug的处理没有好的方式,老师也在课上说过,对于多线程的debug问题到现在仍是相关工作者的主要研究方向之一。
还有一种CTLE的bug,这种bug的处理就简单的多。我们可以使用输出大法,在自己认为可能出现轮询的地方进行输出,如果持续输出,便能确定是否轮询。第七次作业中,由于不确定是哪个地方导致了共享对象结束wait,我就依次让notify的地方进行输出,找到了问题出现的原因。
心得体会
线程安全
线程安全的问题是贯穿整个单元的重要问题,也给我debug带来了无尽的困难。在这一单元的训练之后,对于线程安全,我有如下感悟:
1.三思而后行,先把要实现线程安全的具体位置与细节设计好再去实现代码,尽量一次写对,因为想要找出线程安全的bug并不容易,代码一塌糊涂会对找bug提供巨大阻力。
2.注意细节,像是前边bug中提到的返回属性导致其不受锁保护以及过多的notify这种问题,都是实现细节的问题,对于这种细节,往往容易出现线程安全问题,必须提高关注。
3.同步控制块的设计也是要关注的问题,同步控制块不能过大,否则可能会出现意想不到的阻塞问题或者性能问题,同步控制块也不能随意设置,最好是能恰好实现功能并有最好的性能。
层次化设计
第六七次作业的迭代让我意识到,一个良好的架构策略会让作业的完成更加顺畅。其实这次作业,按照生产者-消费者的模式进行架构设计,方向是没有问题的,然而在具体实现过程中,是否将电梯的调度过程封装到电梯中是影响程序层次性与耦合程度的重要因素。我知道的了解的将二级调度封装到电梯中的同学(包括我自己),他们的电梯类最终都很臃肿,有的甚至超过了500行的限制,被迫拆成两个类。如果选择实现一个二级调度类,来对电梯的请求池进行调度,也许会有更好的层次性,更小的耦合,以及更易读的代码。