OO第二单元总结
OO第二单元总结
〇、单元内容简述
本单元的作业任务围绕多线程程序设计展开,主要需求为:设计一套多线程框架的电梯调度以及模拟运行系统,完成对电梯乘员的接送服务。迭代需求为:
-
第五次作业:模拟单部多线程电梯。
-
第六次作业:模拟多部同型号电梯的运行,并要求能够响应输入数据的请求,动态增加电梯。
-
第七次作业:模拟多部不同型号电梯的运行。电梯有不同的开关门速度,移动速度,限载人数,以及可停靠楼层。
一、设计简述及架构分析
1. 设计要点简述
-
电梯调度算法
三次作业中,电梯调度均采用"LOOK"算法,由于百度中对这个算法的解释模棱两可,导致每个人都有不同的实现,以下简述我的实现:
- 运行伊始,电梯会确定一个运行方向(上行),并到等待队列非空之后再开始运行
- 等待队列以及电梯内部非空时,电梯会将等待队列中需求方向与电梯当前方向一致(条件1),且出发楼层高于或与电梯当前所在楼层相同(条件2)的乘务需求中出发楼层以及电梯内部乘员的目标楼层中取最小值,作为此时电梯运行的目标楼层并运行。
- 电梯空,且等待队列中不存在同时满足条件1与条件2的需求时,电梯调转运行方向,再次扫描是否有同时满足条件1与条件2的需求。若有,则回到上一步。
- 若还是没有同时满足条件1与条件2的需求,则将条件2放宽,即扫描满足条件1且出发楼层不限的需求,若有,则将电梯目标楼层定为这些需求中出发楼层最远的楼层,并运行。
- 若还没有,则再次调转电梯运行方向(扫描方向),扫描满足条件1且出发楼层不限的需求,若有,则同上一步,若无,则说明队列为空,电梯等待。
经实践,该调度算法表现不错,在第六、七次作业中分别取得了99、98分(第七次作业甚至没有设计换乘)的好成绩。(第五次作业表现不佳,但不是调度算法的问题)
2. 第七次作业扩展性分析
- UML类图
- 协作UML
- 功能设计
- 生产者-消费者模式:Scheduler、ToGetTable与Elevator 构成一组生产者-托盘-消费者,需求来临时,Scheduler 会将需求分类,若为PersonRequest,则将其包装为PersonRequestWithType,其中,Type由其起止楼层决定。若起止楼层均为A类电梯的可到达楼层,则其Type中添加字符串"A",B、C同理。之后再由其需求方向以及出发楼层通过托盘ToGetTable中addRequest同步方法将需求放入upDownGoingMap中的指定位置,方便消费者来取。消费者Elevator则定期通过托盘的getFutureList同步方法获得待服务队列,放入自己的outBroadList中,进行服务。
- 集中式调度:考虑到LOOK算法调度电梯时需要获得电梯的运行状态——当前楼层、运行方向等,因此若采用分布式调度会强制Scheduler与Elevator之间进行信息交流,这会增加模块耦合度,打破生产者消费者模式带来的低耦合设计。因此将具体电梯将要服务哪个需求之类的问题交由电梯自身决定,电梯之间共享ToGetTable,而自由竞争乘客需求,通过自身条件做出最佳决策。
- 性能设计
- 不采用换乘:由于换乘调度本身会增加复杂度,因此若没有一个好的换乘策略,则很可能使电梯性能不升反降。经过与同学的讨论以及我的慎重考虑(
实际上是由于我没学会动态规划),决定不采用换乘。 - 提高需求到达电梯的效率:第五次作业中,我采用了InputHandler-WaitingQueue-Scheduler-ToGetTable-Elevator 两对生产者消费者的设计,而这大大降低了需求从发出到被电梯接受的效率,进而导致电梯常常不能满载而空跑。在第六、七次作业中,我直接砍掉了前两个类,由Scheduler直接接受需求,发送给ToGetTable,结果使性能有很大的提升(性能分从个位数甚至0直接飙到了18、19分)
- 不采用换乘:由于换乘调度本身会增加复杂度,因此若没有一个好的换乘策略,则很可能使电梯性能不升反降。经过与同学的讨论以及我的慎重考虑(
- 扩展性
- 功能方面,由于封装较好,类的耦合度并不高,实际上扩展性较好,这也体现在了我从第五次作业到第六、第七次作业中只要很小的改动即可满足需求。以后若要添加新种类电梯,只需在Elevator方面进行改动即可,其他类几乎不需要改动,扩展性相对不错。
- 性能方面,由于未采用换乘,扩展性是非常差的,不同种类的电梯之间协作几乎没有,很可能会导致部分电梯闲死,而部分电梯累死的低性能局面。而且由于采用了集中式调度,电梯在量比较少的时候还好,若电梯有一百台一千台,则电梯由于共享一个ToGetTable,期间获取待服务队列时阻塞引起延迟带来的影响也会非常大,因此我的设计实际上只适用于少量,电梯可到达楼层差别不大的情况,扩展性差。
二、多线程综合分析
1. 同步块设计
-
三次作业中,我都是采用了集成线程安全类的方法,在线程Scheduler 和 Elevator 之间的共享的临界区ToGetTable 中,封装线程安全的同步增、减方法(addRequest等方法),将同步块语句集成在了一个类中,而外部无需同步即可调用其同步方法,实现线程安全。事实证明,这种方法有以下优点:
- 外界无需关注同步问题,直接调用方法即可,该类中的所有方法本身已完全能够保证线程安全。
- 可以自由控制安全类中的操作,相比于调用Java自带线程安全容器,这种方法可以完全自定义容器中的各种访问方法,便于模块协作。
当然也有缺点:
- 业务逻辑完全抽象于自定义类中,会导致类的复杂度上升,且在其内部要自行控制线程的运行状态,容易出线程安全问题
2. 锁的设计
- 采用了前述的自定义线程安全类的方法后,设计锁也非常简单,仅需锁ToGetTable即可(我这里锁的是该类中的upDownGoingMap属性,因为它才是实际上的容器,不过其实效果相同)。也无需考虑容器中的元素的锁,因为实际上程序是将这些元素视为了不可变对象,不会对其进行修改,而只对容器本身进行修改。
- 至于锁的种类,我本来考虑过读写锁,但是分析可知Scheduler对于ToGetTable是仅进行写操作,而Elevator则既要读又要写,先读后写本身就是一个原子操作。而读写锁仅能提升读者较多的情况下的性能,因此读写锁不适用。
3. 锁与同步块的关系
- 使用同步块进行线程同步的根本目的,是通过保证对临界区域访问以及修改操作的原子性,进而防止多线程运行时同时访问临界区导致的数据竞争。
- 因此同步块中语句就是要保证的对加锁对象的原子操作,临界区一次只能由一个线程访问以及修改。
- 具体到我的代码就是:Scheduler在ToGetTable中添加需求,与Elevator在ToGetTable中读取、删除需求这一对操作产生了写-读以及写-写相关,因此对ToGetTable对象,即临界区进行加锁,并将前述“添加需求”以及“读取、删除需求”这两个方法改为同步方法,或是内部实现为同步块语句,保证了临界区的同步访问,进而避免数据竞争导致的Bug。
三、调度器设计及其交互
1. 第五次作业
-
调度器设计以及交互:正如前述,我采用了InputHandler-WaitingQueue-Scheduler-ToGetTable-Elevator 两对生产者消费者的设计。
- 其中,Scheduler作为调度器实际上只是一个“传令兵”,它负责将前一个托盘WaitingQueue中的需求转发给ToGetTable。
- 而另一部分电梯本身的运行决策的调度功能则由Elevator类自身完成,它通过自己所在楼层以及运行方向从ToGetTable中获取最合适的需求进行服务。
-
评价:此次设计是我未经成熟的思考而完成的,调度器Scheduler完全没有用,尤其是在这次作业中,而且还降低了程序运行效率,可以说百害无一利。
2. 第六次作业
- 调度器设计与交互:程序整体架构进行了精简,只保留了Scheduler-ToGetTable-Elevator一组生产者-消费者
- 调度器Scheduler功能有二:获取输入乘客需求,加入ToGetTable;响应增加电梯需求,同时管理电梯线程的运行(
指调用start()方法) - 电梯本身的调度仍然由其内部完成,这是集中式调度的要求
- 调度器Scheduler功能有二:获取输入乘客需求,加入ToGetTable;响应增加电梯需求,同时管理电梯线程的运行(
- 评价:相比于上一次作业,调度器不至于那么没用,不过对于电梯的调度还是用处不大,但这也是集中式调度必然导致的。本次作业中我决定保留它主要是考虑到下一次作业会用得上。
3. 第七次作业
- 调度器设计:在第六次作业的基础上,Scheduler增加了对输入乘客需求贴标签的功能。
- 交互: Scheduler获得输入,通过输入的PersonRequest的出发与目标楼层,将其贴上不同的标签以区别。接着加入ToGetTable。不同的电梯分别扫描它们可以单独完成的需求(根据标签判断即可),将其加入自己的侯乘队列。
- 评价:本次作业中调度器才真正名副其实——通过贴标签来将不同的乘客需求分配给不同的电梯来完成。而具体的调度仍然由电梯本身完成。不过由于没有实现换乘,调度器对于性能的提升仍然帮助不大。
四、Bug分析
1. 自我分析
-
由于互测没人出刀,仅分析公测以及自测中出现的问题 -
功能Bug
1. 电梯未关门:完成第五次作业初版后发现。这是由于我对题意理解不深,没有理解清楚正确性判定导致的。
- 特征:电梯类本身的运行逻辑出现问题
- 问题所在类与方法:电梯类Elevator中的线程运行方法run方法中
2. 电梯超载:完成第五次作业初版后发现。
- 特征:电梯类本身的运行逻辑出现问题,本身是一个typo
- 问题所在类与方法:电梯类Elevator中的乘员上机方法passengersGetOn方法
-
线程安全Bug
1. 伪轮询:第六次作业中出现。原因:三个电梯共享一个ToGetTable,而开始时三个电梯均等待输入。为了避免轮询,在电梯初次访问ToGetTable时发现返回一个空List就应该Wait。然而,由于我多线程编程理解不清,notifyAll方法滥用,导致我在每次电梯访问完ToGetTable时都要先notifyAll再wait,这样在程序刚开始,需求还没进入时就会出现如下情况:
- 电梯一——访问ToGetTable——结果为空——等待
- 电梯二——访问ToGetTable——结果为空——notifyAll唤醒了电梯一——等待
- 电梯一——访问ToGetTable——结果为空——notifyAll唤醒了电梯二——等待
- ……
实际上电梯一和电梯二在第一个需求到达前都进行了轮询,导致CPU Time达到了非常危险的8s甚至9s,所幸我AC后看了眼CPU Time,不然很可能被Hack到爆炸
- 特征:多线程共享临界区时,由于notifyAll使用不当,导致线程之间相互唤醒,wait无效,产生伪轮询占用CPU
- 问题所在类与方法:ToGetTable类中的getFutureList方法——即临界区访问方法
2. 死锁:由于”线程安全包装类“的方法足够鲁棒,没有出现其他同学那样在特定条件下就出现死锁的问题。
2. 互测分析
-
由于时间紧张,加之我没有完成评测机,互测中我没有出刀
-
不过仍然对部分同学的代码进行了线下的分析与测试,针对多线程肉眼进行了一定的Debug以及白盒测试,尽管没有成效,还是简单介绍下策略
1. 轮询检测
- 重点检查电梯与调度器线程交互时的wait、notifyAll方法使用是否得当,能否避免轮询,是否滥用而产生与我前面所述差不多的问题
2. 死锁检测
- 检查程序中是否有典型的死锁问题——线程相互持有对方请求的资源
- 运行手头已有的评测数据,通过辅助工具JProfiler检查是否有死锁、轮询问题
-
测试策略同第一单元完全不同,主要因为测试重点不同
- 第一单元重在逻辑分析,重点检查圈复杂度较高的方法
- 本单元则重在多线程分析,重点检查线程交互同步块以及临界区对象的访问控制
五、 心得体会
1. 线程安全
- 自定义线程安全类很香!不过设计起来还是要具体问题具体分析,不能无脑套用,而且也不是万能的。
- 多线程程序的架构要理性安排,本系列作业中我才用集中式调度,主要就是考虑到电梯数量少,其自由竞争导致的效率损失可以忽略,而若电梯数量再加多,则应当首先考虑分布式调度,因为这个条件下电梯竞争导致的效率降低则是程序低效的重要原因。这些问题在设计之前就要考虑清楚。
- 避免死锁,也不要滥用notifyAll,滥用本身就说明你对于多线程编程的理解不到位——不知道哪里该用哪里不该用。今后在多线程编程时要重点考虑哪里notifyAll,哪里wait,想清楚这样做的后果。
2. 层次化设计
- 模块设计要综合各种因素,灵活一些:如我在设计调度器与电梯之间的交互时,仅考虑了生产者-消费者模式,为了避免耦合而完全拒绝两者之间的交互。实际上模块设计要综合各种因素,设计不能太死板,不能只考虑耦合性而不顾效率等其他因素。正如前述,这种设计会导致调度器失去作用,而在电梯数量增加时,程序运行效率也会很低。适当的耦合也是可以考虑的,只是保证耦合度不要太高,能恰好完成任务即可。



浙公网安备 33010602011771号