本单元我们开始学习多线程程序设计。这对于我来说是一个全新的领域,在这之前我对于多线程这一方面几乎没有任何了解,因此这需要我继续认真学习。本单元需要我们模拟电梯系统的运行,这一个典型的生产者-消费者模型,具有清晰的结构,能够很好地让我们学习多线程相关知识,体会多线程编程的乐趣。
第一次作业的基本目标是模拟单部多线程电梯的运行,以下是我基本的架构设计:
架构中共有两个线程,分别是Inputthread和Schedule类。Inputthread类是输入线程,用于输入乘客的请求并存储到Waitqueue类中,Waitqueue为总的等待队列。Schedule类是调度器线程,负责从Waitqueue类中取出请求装入电梯并控制电梯的移动。Elevator类是电梯类,里面内置了一个queue数组,用于表示电梯中的乘客。
Java多线程中的synchronized关键字可以用来修饰方法,也可以用来修饰代码块。在本次作业中,我选择使用synchronized关键字来修饰代码块,主要是从以下几个方面考虑的:
(1)采用synchronized关键字修饰代码块的程序代码可读性高,逻辑简单,理解起来较为简单,在debug时能更容易找出其中的bug,在修改时更为方便。
(2)相对于synchronized关键字修饰方法,修饰代码块的程序同步区范围较小,也更加精准,可以更精确地控制冲突限制访问区域,提升代码的运行效率。
(3)synchronized关键字修饰方法实际上是一个this锁,若一个类中存在较多的被synchronized关键字修饰的方法,当创建这个类的一个对象时,它们实际上会被加上同一个锁。在运行时大大提高了阻塞的可能性,降低了运行效率。
锁实际上就是一个确保线程安全的工具,在给同步块加锁后,它能够确保同步块中的语句在执行途中不会被其它线程中途插入打断,产生数据竞争,读写不一致的问题,做到了某个对象一次只能由一个线程访问的目的。
理清了选择锁的思路后,就要设置同步块了。在本次作业中,由于作业要求较为简单,因此程序中只有一个共享对象Waitqueue,它是用于存储乘客需求的。我在输入线程和调度器线程中分别使用了一个同步块,输入线程中同步块的操作为将需求装入共享对象,调度器的则是取出需求。
由于第一次作业较为简单,调度器似乎“无事可做”,因此,我为调度器赋予了更多的职能。在本次作业中,我将电梯封装为一个纯“机械结构”的类,对外提供控制电梯的接口,由调度器使用这些接口全权控制电梯的运行。
在作业要求中,电梯有3中运行模式,分别是Morning,Night和Random,因此我也分别设计了这三种模式下的电梯运行策略。这样的好处是降低了不同运行模式下的耦合度,代码逻辑较为清晰,更易读,出现bug时也方便修改。但它也要一些缺点,主要是代码量大,行数多,不断增量容易变成“屎山”。
本次作业的调度器本身就是一个线程,它与输入线程的交互主要是通过Waitqueue这个存储需求的类来完成的,它本质上是一个生产者-消费者模型,输入线程不断获取需求,调度器不断取出需求,装入电梯,并控制电梯的移动。当输入结束,输入线程就会改变Waitqueue中表示输入结束的变量的值,并提醒调度器线程,调度器线程在该变量为真且电梯已将所有人运抵后结束运行。
第二次作业要求模拟多部同型号电梯的运行,并要求能够响应输入数据的请求,动态增加电梯,以下是基本的架构设计:
第二次作业的Inputthread类和Waitqueue类基本沿用了第一次作业的设计。在第一次作业的基础上,新增了Elevatorwaitqueue类,由Schdule类控制,用来存储被调度器分配到各个电梯的请求,也就是电梯等待队列。由于第二次作业出现了多部电梯,Schedule类还需要担负着分配需求的重任,再由Schedule线程全权控制电梯显得负担过重,因此我将电梯控制模块移动到Elevator类中,将它变成一个线程,由电梯根据各自的等待队列运行,运行逻辑与第一次作业的调度器控制类似。
我在第二次作业中沿用了第一次作业的设计,使用synchronized关键字来修饰代码块,具体原因不再赘述。
在本次作业中,总的等待队列Waitqueue是一个共享对象,设计与第一次作业相同。电梯局部等待队列Elevatorwaitqueue也是一个共享对象,由它对应的电梯与调度器共享,设计与Waitqueue的类似。
由于输入端可以新增电梯,而调度器又需要给所有的电梯合理分配需求,因此我把电梯等待队列存储到一个Arraylist容器中,并将这个容器作为输入线程和调度器线程的共享对象。容器中的第一个元素对应的是第一个电梯的电梯等待队列,以此类推。当新增电梯时,输入线程创建一个新的电梯线程以及它对应的等待队列,并将这个等待队列放入容器中,调度器在分配需求时遍历整个容器进行分配,解决了这一问题。
在本次作业中,调度器的职责仅为分配需求到各个电梯的局部等待队列,负担较小。三种运行模式采取的分配策略也不一样。Night模式下,由于需求一次性全部到达,因此分配模式为将需求的出发地进行排序,然后从高到低平均分配给所有的电梯。Morning模式和Random模式为每次分配时寻找所有电梯局部等待队列中需求数最少的那一个,然后给它分配两个需求。这种调度器的需求分配方法较为简单易懂,实现起来也不复杂,但缺点是缺少了对需求的具体分析,导致出现极端情况时,电梯的运行效率会降低,很容易超时。
调度器与输入线程的交互与第一次作业类似。调度器与电梯局部等待队列的交互通过一个共享的Arraylist容器完成,该容器中存储着电梯局部等待队列,需要将需求分配到哪一部电梯,就从容器中取出这部电梯对应的电梯局部等待队列即可。
第三次作业要求模拟多部不同型号电梯的运行。型号不同,指的是开关门速度,移动速度,限载人数,以及最重要的——可停靠楼层的不同。以下是基本的架构设计:
第三次作业中,由于出现了多部型号不同的电梯,因此我设置了3个电梯的局部等待队列,分别存放3种电梯对应的需求,电梯沿用第二次作业的设计,仅仅增加一个变量表示类型,以确定电梯运行速度,载客量等特性。不同类型的电梯从对应的局部等待队列中获取需求,调度器保证这些需求都是与电梯类型相适应的。当有新增电梯时,根据电梯的类型将与之适应的等待队列分配给它。同类型的电梯采用自由竞争的方式获取局部等待队列中的需求。
锁的选择与前几次作业一样,同步块设置也是如从。稍有不同的是,输入线程在获得新增电梯指令时,不再创建局部等待队列,而是从容器中获取与之类型对应的局部等待队列分配给它。
本次作业的调度器与第二次作业略有不同,由于出现了不同类型的电梯,因此需要保证分配到电梯局部等待队列的需求与电梯的类型相对应。因此调度器的基本逻辑是取出等待队列中的需求后,将其按照类型分配,并分别放入相应的电梯局部等待队列中。我也同样尝试根据这一架构设计相应的换乘方法,但由于在完成第七次作业时时间较紧,只完工了一半,并没有投入使用,比较遗憾。
调度器与线程的交互与第二次作业相似,在此不再赘述。
UML类图和UML协作图:
关于可拓展性的总结与分析:
在换乘上,由于时间问题,我没有进一步设计换乘算法,但是在源代码中我已经部分实现了换乘的方法,可以在原有基础上进行进一步的拓展,但是我设想的方法主要为通过打表法找到可以换乘的需求,对需求进行换乘处理,这说明我的架构在增加换乘上可拓展性一般;
在请求调度算法上,由于我已将算法单独整合进电梯中了,因此需要对算法进行修改或者拓展,只需要改动相应的方法即可,不涉及其它方面,可拓展性较好;
对于不同的电梯型号,我的做法是新增一个代表电梯型号的变量,并由调度器保证分配给该型号电梯的需求是符合电梯的型号特点的,这样做耦合度较高,可拓展性不是很好。
在第一次作业中,最开始我对于多线程还是处于懵懵懂懂的状态,没有过多关注电梯的调度算法,一开始在Random模式中只写了个傻瓜式调度,然后就没有再管了,结果在强测中超时了,这才使我注意起电梯调度算法来。后来我仿照ALS写了一种类似的算法,顺利修复了bug。还有一个bug是有关电梯开关门的。在Morning模式下需求较多时,电梯载满客后就会先执行运客的指令,我由于粗心大意在电梯运客返回后忘了让它开门,造成bug,不过也很快修复了。
第二次作业,我优化了Morning算法的流程,但悲剧的是优化好之后的算法又出现了电梯忘了开门的情况,同样是一个很小的错误,导致了这一情况,实在是相当可惜。其它的是关于调度算法的问题,由于我的调度算法并不是最优的电梯调度算法,因此获得的性能分很低,甚至有一些危险,这是需要进一步学习的。
第三次作业,在第二次作业的基础上添加了电梯的类型,结果,我又在细节上出问题了。不同的电梯载客量也不相同,我在大多数的地方都做了修改,唯独漏了一个偏僻的角落,那里仍保留着电梯只能搭载6人的约束。由于是多部电梯,并且电梯之间自由竞争需求,因此一开始也没有测出bug,直到公测才暴露出bug。
寻找他人bug方面,我一开始也尝试过运行他人的程序寻找bug,但是由于没有设计相关的评测机以及定时输入的程序,因此最终无功而返。之后的两次作业由于时间问题也未能寻找他人bug。
(1)多线程编程对于我来说是一种全新的编程思路,本单元作业需要考虑同步块设计,线程间的协作,多电梯调度等问题,也要避免出现死锁,数据竞争等情况发生,对于从来没有接触过多线程的我来说是个不小的挑战。从一开始只是想着“通过中测就是胜利”,到后面基本能够应对多线程问题,这也算是一种成长。
(2)在开始写作业之前,思考和设计一个好的框架是十分重要的。一个可拓展性高,结构清晰的架构能够给后续的开发提供很大的便利。我在第一单元的作业中就有着惨痛的教训,当时由于架构设计不合理,第二和第三次作业都是直接重构的。这一单元的作业没有重构,也让我体会到了架构的重要性。
(3)细节在程序设计中相当重要。相对于第一单元,本单元更需要对细节下足功夫。无论是线程的休眠唤醒,共享数据的处理,还是电梯开关门,上下行,电梯额定乘员数量等,都是需要设计者充分考虑的。我在这几次作业的强测中出现的大多数bug,都是细节出现问题导致的,这也提醒了我细节的重要性。``