本单元三次作业内容为模拟电梯的运行,主要涉及的知识点为多线程。第一次作业为单部电梯,需要模拟电梯的开关门、上下行、乘客的进出等;第二次作业增加为3部电梯,并可以根据指令动态增加电梯;第三次作业在第二次作业的基础上设置了不同的电梯类型,不同的电梯运行速度、可到达楼层不同,并且乘客在运送的过程中允许换乘。总体来说,三次作业都侧重于多线程的处理,并且每次作业都是在上一次的基础上进行,因而需要设计一个良好的架构,确保线程安全,同时也需要一个优秀的调度算法,使其有更好的性能。
一、同步块的设置和锁的选择
在本单元的三次作业中,均选择了synchronized关键字对共享对象进行加锁,并使用wait()和notifyAll()的方法使线程阻塞或唤醒。
在synchronized语句块中,根据是否需要对共享对象进行读写,将语句块尽可能最小化。
基本结构如下:
while(true) { synchronized(obj) { ...; } ...; synchronized(obj) { ...; } ...; }
synchronized修饰语句块实际上是对括号中对象加锁,即在执行语句块中的语句时,保证同一时刻只有本线程访问,从而确保了线程的安全。
二、调度器的设计
在本单元的三次作业中,有两种不同的调度器:
- 请求分配调度器:负责从输入线程接收请求并分配给电梯
- 电梯内部调度器:负责选择分配到电梯的请求的执行顺序
1.请求分配调度器
第一次作业
在第一次作业中,我选择了分布式的调度,即既有请求分配的调度器,也有电梯内部的调度器,线程的结构如下图所示:
由于本次作业只有一部电梯,所有的请求只能由这部电梯完成,因此调度器的设计比较简单,从输入线程中获取请求后直接加入到电梯的等待队列即可。为了方便扩展,我将调度器设计为一个单独的线程。
第二、三次作业
第二、三次作业的调度器设计类似,故在此合并讨论。
在多部电梯的调度中,有两种不同的架构:
- 集中式:没有请求分配调度器,只有电梯内部调度器,所有电梯共享同一个等待队列,自由竞争所有请求。
- 分布式:既有请求分配调度器,也有电梯内部调度器,请求分配调度器按某种算法将请求分配给不同的电梯,不同的电梯再各自通过内部调度器选择请求执行。
在第二次作业中,我分别实现了上述两种架构,并进行了比较。
基于第一次作业的架构,我首先实现了分布式的设计,线程的结构如下:
对于分配方式,我选择了比较简单但容易实现的均匀分配,即在电梯类中创建getNum()方法,获取每部电梯等待队列和电梯内部请求的数量,将新的请求加入至请求最少的电梯的等待队列中。
完成了分布式设计后,我又实现了集中式设计。其实两种设计之间需要修改的代码量并不大,只需将电梯类中创建的新的等待队列更换为共享等待队列的引用,再去掉请求分配调度器,直接由输入线程将请求加入共享等待队列即可,线程的结构如下:
对于集中式设计,就不需要分配的方式了,只要写好电梯内部的选择方式即可。
在进行测试之前,我也无法判断哪种设计更性能更好,原本以为二者应该相差不大,但在测试后发现集中式的性能在绝大多数情况下都比分布式要好。分析其原因,无论是分布式的均匀分配方法,还是集中式的自由竞争,其本质上都是希望实现电梯运行的均匀化,即不要让某一部电梯累死,其他电梯太闲,分布式的均匀分配仅仅是从请求的数量上进行均匀分配,而集中式的自由竞争则是针对当前电梯的状态,确定当前时刻的最优均匀分配,距离电梯真正运行时刻的状态更近,有点类似贪心的思想,因此可能集中式的性能要优于分布式的性能。
但集中式的设计也有缺点,只有一个共享等待队列的设计,架构没有分布式的清晰,且更容易出现线程安全问题。
综合所有因素的考虑,我最终选择了集中式的设计。
2.电梯内部调度器
电梯内部调度器并不是一个单独线程,只是电梯线程中的一个方法,目的是根据算法选择电梯接下来要执行的请求。
在本单元的作业中,为了更好的可扩展性,根据单一职责原则,我创建了strategy的策略类作为电梯内部调度器,并将其作为电梯的私有属性,同时,也按照工厂模式,根据乘客到达模式的不同创建不同的策略。
下面总结一下选择请求的算法:
为了实现可捎带电梯,我设计了主请求(MainPerson)和副请求(SubPerson),电梯运行时,先选择主请求,有了主请求,其他请求默认为副请求,再在运送主请求的过程中,捎带可以捎带的副请求,主请求完成后,再重新选择主请求。具体的选择策略如下:
在Night模式中,每次选择最高的请求作为主请求,尽可能实现更多的捎带。
在Morning模式中,每次选择最低的请求,并等待电梯满载后再运行。选择最低的请求是为了让电梯最后一次运行前往较高的楼层,节约回到最底层的时间。
在Random模式中,第一次作业采取了指导书中的ALS算法,结果最后性能一般,因此在第二、三次作业中更换为LOOK算法,即捎带同方向的请求,当前方无同向请求时再换向,在没有同方向请求时选择最近的请求,最后性能有了较大的提升。
三、功能设计与性能设计的平衡以及架构的可扩展性
在本单元的作业中我尝试了两种不同的架构:在第一次作业中使用了分布式(有调度器线程),在第二次作业中实现了分布式和集中式(没有调度器线程),并进行了比较,发现集中式的性能更优,因此在第二、三次作业中均使用了集中式。
第一次作业的类图如下:
第二、三次作业的集中式类图如下:
在实现两种架构的过程中,我更加感受到了功能设计与性能设计二者之间微妙的关系。
在功能设计上,分布式的设计架构更加清晰,更容易实现,可扩展性也更强,但如果只是实现简单的均匀分配方式,性能往往不尽如人意;对于集中式的设计架构,通过类图可以发现,所有的类都指向了电梯类,由于没有应用好策略模式和状态模式,一切都以电梯类为中心,使得在程序中出现了面向过程的代码,如果需要修改,只能在电梯类内部改动,可扩展性较差。
而在性能设计上,集中式的设计架构由于实现了调度时的动态性,即多个电梯“抢”请求,使得性能大大提升,甚至可以说是完胜一般分布式的分配方式,这也是为什么我在第一次作业采用分布式后又在后续的作业中尝试了集中式并最终采用。在第三次作业中,我发现由于可到达层数的限制,B、C两种电梯几乎“抢”不到请求,因此,我又设计了一种尽可能多的换乘方式,具体如下图所示。实事证明,这种”集中式+尽可能多的换乘“方式在性能方面确实足够优秀,帮助我在第三次作业中得到了接近满分的强测成绩。
尽管利用”集中式+尽可能多的换乘“能够获得较好的性能,但我并不认为这是一个优秀的设计。正如指导书中所说,”真正靠谱的架构,一定是可以做到兼顾正确性和性能优化的“。现在想来,利用集中式的架构结合经典的设计模式,例如策略模式、状态模式,使其满足单一职责、开放封闭等原则,这才是更加优秀的设计。
三次作业的协作图如下:
三次作业的协作图还是较为清晰的。第一次作业因为采用的是分布式,所以与第二、三次相比多了调度器(scheduler)线程.第三次作业相比第二次作业创建了不同的电梯类型。第三次作业中,主线程(MainClass)创建共享的等待队列(WaitQueue)、3个初始的电梯类(Elevator)以及输入线程(InputThread),电梯类再根据类型创建各自不同的电梯轿厢内部的队列(ElevatorQueue)。在实际运行过程中,输入线程从官方包中获得请求,将其加入至共享队列,每个电梯线程再从共享队列中获得请求。在线程的协作方面,我较好地使用了synchronized关键字,并使用wait-notify方式,通过严谨的编程确保了线程安全,在整个单元的作业中几乎没有出现因为线程不安全导致的轮询、死锁等问题,这也是本次作业中较为满意的部分。
四、BUG分析
在本单元三次作业的强测和互测中,仅在第三次互测中发现了一个ctle的bug,但其实并不是线程安全问题,而是一个简单的逻辑错误,由于Morning模式在前两次作业中所有请求均从1层出发,因而在等待请求进入电梯时采用了一个默认FROM为1的逻辑控制的循环,而当第三次作业出现换乘后,有可能出现输入结束但出现换乘的情况,此时再次进入等待请求的部分便会进入死循环。出现这样的bug并不仅仅是粗心,也与调度换乘算法太过复杂而导致的面向过程编程有关。
此外,在本地测试中,也多次出现由于逻辑错误导致电梯运行状态出错的bug。也再次提醒我,追求性能的同时也要保证架构的优秀,这样才能更加符合面向对象设计的思想,避免因为面向过程而出现的一些逻辑错误。
五、互测策略
在本单元的作业中,因为时间原因并没有写自动测评机,因此互测的策略主要是阅读代码。由于本单元大家的bug多出在线程安全上,因此重点关注与线程安全相关的代码,具体细节如下。
- wait和notify的使用
- synchronized关键字的使用,尤其注意循环加锁的现象(多个synchronized嵌套)
尽管每次互测都尽量去阅读代码,并尝试可能出现bug的样例,但还是没能hack成功:(,原因之一可能是多线程的不确定性使得bug难以复现。
六、心得体会
本单元的作业是我第一次接触多线程的编程,尽管只是学习了synchronized、wait、notify等简单的语法,但却对多线程思想有了从无到有的理解,也对线程安全问题有了一定的认识,在编写多线程程序时,一定要摒弃原先单线程编程时单一代码串行的思想,需要时刻想着多个线程并行的情形,并考虑到由此带来的影响。
对于层次化设计,由于在本单元作业完成的过程中尝试了两种层次化程度不同的架构(分布式和集中式),因此对于层次化的设计有了更进一步的思考。良好的层次化设计有利于架构的清晰和可扩展性,符合”逻辑依从“,但并不意味着与计算性能无缘,对于一个层次化的设计,我们也可以在其中设置全局共享的对象,使其能够平衡”逻辑依从“和”计算依从“。面向对象思想不仅仅意味着一个多层次的设计架构,这仅仅是”逻辑依从“的表现。真正面向对象的程序既可以满足”逻辑依从“,也可以满足”计算依从“,在接下来的学习中,还要通过实践将这种思想贯穿到自己编写的代码中。