第五次作业
与第一单元相比,第二单元的作业完成得要更顺利一点,虽然初次接触多线程感觉很懵逼,但理解之后便能发现其实不是很难。
程序结构分析
结构基本上就是InputThread
获得输入后交给Manager
,Manager
负责ElevatorThread
的调度控制。同时,等待队列和Manager
是一体的,因此Manager
不仅负责调度,也负责将乘客请求传递给电梯。
分析和评价
因为一开始对多线程不是非常了解,所以在编写代码之初走了一些弯路,重写了一次才有了现在这个架构。这次作业的做法是一个比较经典的单生产者对单消费者模式,以Manager
作为中间媒介(也就是托盘或传送带)来协调输入与电梯之间的交互。
在实现细节方面,首先,在InputThread
从官方包中获得PersonRequest
时,先将其转化为Person
类的对象,再传给Manager
。Manager
我并没有设计为一个线程,而是一个静态的类。同时由于Manager
在全局具有唯一性,因此我将其全部的属性和方法都设置为了静态属性和静态方法,外部对其的访问全部通过类名来进行,相当于一个比较简略的单例模式。为了保证操作时的线程安全,我在所有访问people
的地方全部用synchronized
+notifyAll
上锁,由于线程间共享对象只有people
,所以线程安全是比较容易保证的。除此之外,将Person
类设置为只读也是出于线程安全方面的考虑。
在调度策略方面,我是采取了贪心策略,具体来说就是:
当电梯向某个方向移动时:
- 如果在移动方向上出现了新的请求,就按ALS的规则进行捎带,这一点没有问题。
- 如果在移动方向的相反方向上出现了新的请求:
- 如果请求的方向与移动方向相同,则回去接发出请求的乘客,再继续运行。
- 如果请求的方向与移动方向相反,则比较当前目的楼层和请求的目的楼层与当前楼层的差值,若当前目的楼层差值较小,则继续运行到目的楼层再返回;否则先返回再继续运行。
从理论上来说,贪心策略在大多数情况下都是局部最优的,然而因为我在实现的时候处理不当所以并没有在性能上获得太大的优势,最终的性能分反而比直接使用look调度略低一点。在这里,贪心的特殊之处主要体现在“回溯”上,如何处理回溯与正常运行的关系是需要仔细考虑的。
除了以上两个方面,线程结束条件也需要考虑。我的线程结束方式是在输入线程收到结束信号后,向电梯的队列中放入一个“空请求”,当队列中仅剩空请求时,队列将空请求传递给电梯。当电梯中仅剩空请求时线程结束。
本次作业的代码量统计如下:
NUMOF LINES FILENAME 153 lines Homework5\src\ElevatorThread.java 29 lines Homework5\src\InputThread.java 13 lines Homework5\src\MainClass.java 109 lines Homework5\src\Manager.java 43 lines Homework5\src\Person.java -------------------------------------------- total : 347 lines.
代码量不是很大,因为更新了IDEA 2020.1之后MetricsReloaded不能用了所以暂且自己分析一下代码复杂度(QAQ)。首先,本程序逻辑最为复杂的地方出现在Manager
与ElevatorThread
的计算目的地部分。我计算目的地的方式是Manager
计算需要前去接人的最远处,Elevator
需要前去送人的最远处,两者取较远的一者作为目的地。因此在两个类中都出现了比较复杂的分支结构。其他的部分相对来说比较简单。因为电梯运行逻辑与调度逻辑是分离的,所以代码的耦合性尚可。
电梯运行的时序逻辑如下。
Bug分析
本次作业在强测与互测中均没有被发现的bug,对于别人的bug来说,也只是构造了几个超时的数据点——先构造一个运行时间比较长的数据点,再用时间限制减去运行时间,以得到的结果作为输入的起始时间,便可以构造出TLE的数据点。(其实这样hack的意义不大,在之后的作业中由于互测规则更加完善也无法再继续构造这样的数据了)
在互测过程中也未发现线程安全相关的bug。
第六次作业
第六次作业相比第五次作业从一部电梯变成了预先指定电梯数量的多部电梯,其他方面没有发生太多变化。
程序结构分析
在结构上,本次作业基本上沿用了上一次作业的结构,只是增加了一个Assistant
类用来管理电梯的状态(不过后来没有用到),并将等待队列与Manager
分离。
分析与评价
实际上本次作业我的方法相对于上一次几乎没有改变,仍然是输入线程获得请求后交给Manager
,由Manager
放入等待队列,由电梯接收。由于从单部电梯变成了多部电梯,我的设计是让每个电梯都使用单独的等待队列,等待队列起到第五次作业中Manager
的作用,而这里的Manager
仅仅起到将请求分配到队列的作用。
由于上次的贪心策略没有实现好,在性能上吃了一点小亏,我从这次作业开始每部电梯都改用look调度算法,并增加waitFloor
,即电梯空闲时即到中间楼层等待,以在随机数据中获得较好的表现。正如前所述 ,我还增加了Assistant
类,用来管理电梯的状态,以供Manager
分配时读取。在分配时,我试图采取以下策略来改善整体性能:
- 在有空闲电梯时,优先选取空闲电梯,将请求分配给空闲电梯
- 若第一条不满足,选取运行中的电梯可以捎带的,将请求分配给可捎带电梯
- 若前两条不满足,选取运行方向与请求相同的电梯,分配给方向相同的电梯
- 若前三条不满足,选取队列中人数最少的一个电梯,分配给等待队列最短的电梯
经过这四条,一定可以选出一个队列,将请求分配给相应队列。然而,当我以平均分配策略作为对照组对性能进行评估时,我发现以上调度策略并没有收到良好的效果,甚至在某些情况下表现不如平均分配,因此我最后废除了这个分配策略,改用平均分配,由此Assistant
类也失去了它的作用。最终,我的“无为而治”策略的性能分数较上一次有所提升,总体上来说比较让人满意。
代码量统计如下:
NUMOF LINES FILENAME 66 lines Homework6\src\Assistant.java 178 lines Homework6\src\ElevatorThread.java 30 lines Homework6\src\InputThread.java 21 lines Homework6\src\MainClass.java 38 lines Homework6\src\Manager.java 54 lines Homework6\src\Person.java 75 lines Homework6\src\WaitQueue.java -------------------------------------------- total : 462 lines.
相比于上次增加的基本上只有Assistant
和WaitQueue
相关的代码。而且本次比较令人偷税的地方是,这次作业的大量逻辑都可以复用上一次的作业,因此完成作业的难度可以说是很低。代码的复杂度和耦合性基本上与上次持平,只是Assistant
类最终并没有在程序中起到作用,比较多余(并且因为不想再改变现有程序结构,并没有从最终版本中删掉)。
电梯运行的时序逻辑如下,除了等待队列从总调度器中分离之外其余都与第五次作业相同。
Bug分析
本次作业依然没有被发现的bug。在hack他人时,也仅仅是发现了一个同学的程序会在某些情况下,有乘客不能送到目的地(这个错误由自动测试程序发现)。我认为,这是他的线程结束条件设置不当造成的。例如,在乘客进出电梯的过程中,可能会出现某些瞬间乘客被从队列中取出,而尚未放入电梯的乘客列表中,此时若结束标志已经置位,外部发现电梯内外均无乘客,便将电梯线程结束,此时便会出现问题。我认为,在线程交互过程中,不仅需要关注共享对象,也要仔细考虑这样的中间状态,是否有可能引发问题。
第七次作业
第七次作业相比于第六次作业,增加了每种电梯可停靠楼层的限制并要求支持动态增加电梯数量。
程序结构分析
程序结构依然和第六次几乎相同,变化在于删去了冗余的Assistant
类,并将TimableOutput
封装为了线程安全类。
分析与评价
在电梯运行与调度方面,基本上与第六次作业相同——电梯采用look运行策略,对于人员请求采取均匀分配策略。相较于前两次作业,第三次作业比较特殊的一点是由于电梯不是所有楼层都可以停靠,所以人员在某些情况下需要换乘。也正是因为换乘的需求的存在,电梯线程的结束方式也需要相应调整。
首先是换乘策略,我采取的换乘策略是“静态的”换乘策略,即只要出发楼层和目标楼层确定,换乘方式即确定,以下便是我用目测法得到的换乘方案:
而对于线程结束策略,由于原有的线程结束策略可能出现电梯内的人需要换乘,而换乘的电梯线程已经结束的情况,因此需要增加结束标志位。每个电梯的结束标志位置位,当且仅当电梯对应等待序列为空且电梯内仅剩空请求。而电梯线程结束当且仅当所有电梯的结束标志位全部置位。这样便解决了问题。
本次作业的代码量统计如下:
NUMOF LINES FILENAME 100 lines Homework7\src\ConcurrentOutput.java 194 lines Homework7\src\ElevatorA.java 190 lines Homework7\src\ElevatorB.java 190 lines Homework7\src\ElevatorC.java 4 lines Homework7\src\ElevatorType.java 52 lines Homework7\src\InputThread.java 28 lines Homework7\src\MainClass.java 147 lines Homework7\src\Manager.java 95 lines Homework7\src\Person.java 84 lines Homework7\src\WaitQueue.java ---------------------------------------------- total : 1084 lines.
虽然代码量达到了1000+行,但是由于三个电梯类的代码几乎相同(其实回顾来看,这样的设计是比较失败的,冗余代码会给程序的修改带来很多的不方便之处),并且从前几次作业复用了大量的代码,所以实际上这次作业的工作量亦不是很大。本次作业的时序逻辑如下:
Bug分析
本次作业在强测和互测中均没有出现被发现的bug,在互测中发现了其他人的一些bug,例如有时会出现同一个人连续进入两次电梯、有人没有到达目的地的情况,但出现概率实在太低,既无法复现,提交后也没有hack成功。可见,即使是在强测中获得了不错的成绩的程序,也不一定真正地做到了线程安全。我也有听说有的人为了防止出现线程安全问题而着力于减少线程间交互。我认为,虽然多线程机制的引入的确会为程序带来一些不可预知的风险,但因噎废食同样也是不可取的。如果要做到万无一失,应当从简化线程交互逻辑、明确线程间共享数据开始,而非从一开始便对多线程敬而远之。
可拓展性分析
虽然第七次作业已经是最后一次作业了,但我们仍有必要考虑如何在当前的基础上进一步增加请求(来“造福”学弟学妹们),并让自己的电梯支持进一步的需求。从SOLID原则对我的程序进行审视——
- 单一责任原则(SRP):我的程序的各部分逻辑基本上是分离的,比如请求读入、乘客分配、等待队列、电梯运行等都在不同的模块内进行。
- 开放封闭原则(OCP):这一点我做得并不好,在作业中基本上没有使用层次化设计,在进行迭代时也是直接修改原有代码中的逻辑。
- 李氏替换原则(LSP):如上一条所述,因为没有采取层次化设计,所以也不存在替换与否的问题。
- 接口隔离原则(ISP):我的确没有实现多余的接口,实际上我几乎没有实现任何接口。
- 依赖倒置原则(DIP):这一点我的程序也做得不是很好,我的程序逻辑基本上都依赖于具体的类,而并没有把每个类抽象出来。
由上可见,我的程序的拓展性比较一般,在面临更多需求时可能需要较多的调整。这一点是我在今后编程中尤其需要注意的。
心得体会
感觉经历了几次作业,已经有了一些面向对象编程的思维,并能够应用到实际中(比如评测机的编写就应用了一些面向对象思想)。除此之外,与第一单元相比,我的这几次作业在迭代时不再是次次重构的方式了,虽然代码重用不一定是回报最高的方式,但它的确带来了极致的体验。虽然多线程的单元已经过去了,但我想,我们学习多线程知识的过程才刚刚开始。