BUAA_OO_2020_UNIT2_Summary
本单元的作业为三次电梯系统的迭代开发,输入端定时给出请求,系统需要响应请求并在符合规范的时间范围内输出正确的处理结果。训练重点为多线程并发程序设计,具有一定的难度和挑战性。
一、作业分析
HW5
HW5较为简单,只涉及一部电梯,且无楼层或载客量的限制。难点在于刚接触多线程时,对于线程安全问题的把控。因此此次作业的重点在架构和线程的设计,以及线程安全问题的思考。
1. 设计策略
-
总体架构:总体采用生产者-消费者模式,主线程作为输入线程充当生产者,电梯线程作为消费者,调度器作为中间共享资源
-
线程设计:
- 输入线程:内置于主线程中,将输入请求放入调度器中,每次放入通知调度器唤醒电梯线程。输入结束后需通知调度器适时结束电梯线程。
- 电梯线程:按照一定的逻辑进行循环完成请求,内置一个任务队列。当调度器中有请求/任务队列中有任务时,去接收请求/完成任务,否则wait,等待调度器唤醒。电梯线程运行流程图如下,分为四个处理函数(四个虚线框框起来的部分)
-
资源类设计:
- 调度器:作为输入线程和电梯线程的共享资源,设计为线程安全类,内置
private ConcurrentHashMap
,按楼层划分的请求队列集,键值为请求的> totalQueue fromFloor
,存储尚未被电梯接收的请求。private State workState
,输入是否结束的标志。
- 电梯线程中的任务队列:本次作业中内置于电梯线程中,由于仅被一个线程访问,因此不用设计为线程安全类。内置和调度器相同的请求队列集,存储被电梯接收但尚未完成的请求。
- 调度器:作为输入线程和电梯线程的共享资源,设计为线程安全类,内置
-
调度策略:总体上采用look算法,具体细节如下:
- 当前方向上若有未完成的请求或未接收的请求(不考虑是否同向),则不改变方向,继续move
- 若上一条不满足,则检查反方向是否有未接收的请求,若有,则改变方向,继续move
- 若上两条均不满足,即所有请求均已完成,则原地等待,直至被唤醒
-
电梯线程结束设计(重点):通过设置共享标记变量
- 首先,电梯线程的run方法为循环执行上述四个处理函数,循环条件为
isWorking()
方法,源码如下:
public boolean isWorking() { if (scheduler.isWorked() || !scheduler.isEmpty()) { return true; } return totalTask != 0; }
- 其中方法
scheduler.isWorked()
是对调度器类中变量workState
的判断,表示输入是否结束,而方法scheduler.isEmpty()
则是对调度器中请求队列是否为空的判断,二者均为线程安全方法。之后的totalTask != 0
则是对电梯任务队列是否为空的判断。 - 此外,调度器类的
distributeAim()
方法在每次等待被唤醒后会判断workState
变量的值。若值为END
(输入已结束),则会跳出循环不再wait
,以避免无限等待 - 最后,主线程(输入线程)在输入结束后会调用方法
scheduler.endWork()
以结束工作,其源码如下。会将workState
值设为END
(表示输入结束),同时唤醒电梯线程
public synchronized void endWork() { this.workState = State.END; notifyAll(); }
- 这种设计保证了在输入尚未结束请求却已全部完成后,电梯会继续工作(执行到
scheduler.distributeAim()
方法处wait()
);同时输入结束后,电梯会在把所有请求完成后再结束工作。
- 首先,电梯线程的run方法为循环执行上述四个处理函数,循环条件为
2. 代码度量
- UML类图:优点为结构清晰简单,符合高内聚低耦合;并将常常使用的工具类函数(如sleep)封装到工具类Util中,代码不冗余。缺点为电梯类Elevator复杂度过高,封装了一些不该其处理的方法,(比如任务队列完全可以抽象出来成为一个新的类,去管理任务队列的一些方法),而电梯类应该只包含业务逻辑处理方法。
- UML线程协作图
- Metrics:本次作业方法普遍复杂度不高,但由于电梯类实现了太多功能,因此电梯类的复杂度飘红
- code lines:代码总行数282行
- DesigniteJava工具分析:本次作业的耦合性和扇入扇出性均较好
HW6
HW6将电梯引入了多部电梯、最大载客量以及负楼层。除了需要注意多个电梯线程之间的线程安全问题外,还要考虑电梯调度算法以实现总运行时间最短。此外还需要注意一些细节,比如0层的处理以及载客量的限制。
1. 设计策略
- 总体架构:大体上保持HW5的架构,将输入线程从主线程独立出来,一个调度器作为中间共享资源,多个电梯线程争夺一个调度器中的请求,主线程负责实例化各个对象、创建电梯、启动各个线程
- 线程设计:
- 输入线程:独立于主线程,设计和功能与HW5大致相同,由主线程创建
- 电梯线程:每个电梯线程内部运行逻辑,以及结束电梯线程的设计,与HW5保持相同,且多个电梯线程共享一个总调度器,争夺请求。电梯线程类由主线程读入num后实例化并启动
- 主线程:读入电梯数,实例化各对象并启动各线程
- 资源类设计:
- 电梯中的任务队列:不同于HW5,此次作业中将任务队列抽象出来成为一个类
RequestQueue
,内置同HW5中的按楼层划分的请求队列集和总人数变量totalRequest
,以及作用于他们的方法(包括放入、取出请求,判空,判断某方向上是否有请求,以及getTotalRequest()
),注意通过totalRequest
变量控制是否超载。该类设计为线程安全类(被继承的需要) - 调度器(
SchedulerQueue
类):总体上保持和HW5相同的设计,由于与任务队列有诸多相似点,因此继承自RequestQueue
类。扩展属性为workState
变量,表示输入是否结束;扩展方法为:分配目标楼层方法,根据楼层与剩余载客量分配请求方法,以及HW5中的isWorked()
和endWork()
方法
- 电梯中的任务队列:不同于HW5,此次作业中将任务队列抽象出来成为一个类
- 调度策略:
- 单部电梯与HW5相同,采取look算法
- 多部电梯的任务分配有过多种考虑,包括:依次分配,多个依次分配,任务量最小分配,模拟器计算后最优时间分配,以及不分配自由竞争的策略。经过大量的随机数据测试,以及对架构的性能的权衡,最终选择了不分配随即竞争的策略,就强测结果来看该策略的效果不错(98.9)
- 策略分析:单部电梯的look算法会尽量避免掉头,是大量随机数据下比较好的一种策略。多部电梯间自由竞争可能会导致一些电梯“陪跑”,但大量随机数据下,电梯的经常性的“上下运动”带来的可能的等待时间的缩短,加上单部电梯载客量的限制,会抵消掉陪跑带来的时间损失,因此最终的效果差强人意
2. 代码度量
- UML类图:将电梯类中的任务队列抽象出去之后,电梯类的结构更简单,功能职责更专一;同时由于调度器继承了
RequestQueue
类,调度器类的结构更为简明,代码复用性提高
- UML线程协作图
- Metrics:与前一次对比,电梯类的复杂度由于任务队列的抽象而显著降低;同时由于继承关系的存在,管理资源的两个类
RequestQueue
和SchedulerQueue
复杂度均不高;此外因为输入线程独立于主线程,使得两个类的复杂度均较低
- code lines:代码总行数344行,迭代工程量较低
- DesigniteJava工具分析:本次作业的耦合性和扇入扇出性均较好
HW7
本次作业引入了多种不同类型的电梯,其载客量,上下行时间均不相同;同时对电梯的可达层进行了限制,引入了换乘问题;此外还引入了动态增加电梯指令。此次作业需要注意请求的分派调度,多个线程间的协作问题。
1. 设计策略
-
总体架构:沿用之前的生产者-消费者模式,增加了一个中间处理者——总调度器类
TotalScheduler
,负责暂存输入线程放入的请求,同时按照一定的策略将请求分派给三种电梯的调度器。相同种类的多部电梯争夺同一个调度器中的请求,主线程实例化并启动总调度器线程和输入线程。此外,考虑到换乘,将给定的请求类personRequest
作为成员变量封装到Person
类中。 -
线程设计:
- 输入线程:同HW6独立于主线程。若读入人的请求,则实例化一个
Person
对象,将其放入总请求队列中,增加unFinishedNum
变量的值,并唤醒总调度器线程;若读入增加电梯请求,则调用总调度器的相关方法,增加相应类型的电梯,并更新电梯类别优先级。输入结束后,通知总调度器线程结束电梯线程。 - 总调度器线程:启动初始三部电梯,并将总请求队列中的请求按照一定的策略分派给三个种类的调度器。若总请求队列中无请求,则
wait
直到被唤醒。 - 电梯线程:沿用之前的设计,运行逻辑与结束方法大致不变,同一类型的多部电梯共享同一个电梯调度器,争夺请求(
Person
对象)。完成阶段性请求后,将该Person
对象送入总调度器中,若检测尚未完成整个请求,则将第二阶段请求送入总队列,并唤醒总调度器线程 - 主线程:实例化并启动输入线程和总调度器线程
- 输入线程:同HW6独立于主线程。若读入人的请求,则实例化一个
-
资源类设计:
ElevatorType
类:将电梯的类型特征抽象出来,独立于电梯成为一个单独的类,使得程序的扩展性更强,其属性包括类型名,开关门时间,上下行时间,载客量,可达楼层以及优先级。在本作业中,由于电梯类别固定,该类被设计为Enum
类RequestQueue
和SchedulerQueue
类:基本沿用上一次作业的设计,容器中存储的对象变为Person
类对象,此外注意了对可达楼层方面的限制- 总请求队列:作为输入线程、总调度器线程和电梯线程的共享资源,内置于总调度器类中(此处设计不好,应该独立于总调度器类)。内置
ConcurrentLinkedQueue
容器变量存储请求,totalQueue state
变量以表示输入是否结束,以及unFinishedNum
变量表示未完成的请求数(考虑到换乘请求可能“二进宫”总队列)。输入线程放入请求时unFinishedNum
变量自增;电梯线程完成阶段性请求后,若检查该请求全部完成,则unFinishedNum
变量自减,否则将下一阶段请求放入总队列中。 Person
类:考虑到换乘请求,将给定的personRequest
类,以及人进出电梯时的输出行为,封装到Person
类中。同时,该类在输出人出电梯时,会更新所在楼层nowFloor
并判断是否完成了整个请求,且会通过成员变量arrived
的值表示。此外,该类中还有方法会根据电梯类别优先级,决定乘坐哪一类电梯,同时更新这一阶段的目标楼层aimFloor
-
调度策略:
- 单部电梯调度策略:同之前的look算法
- 同一类型多部电梯的调度策略:同HW6,共享同一电梯调度器,自由竞争抢夺请求
- 不同类型的电梯调度策略:总调度器中实时维护电梯种类优先级队列,
Person
类中会根据该优先级队列以及请求的出发到达楼层选择电梯种类。选择策略为:优先直达,若不能直达,则选择离到达楼层最近的,且总路程最短的楼层作为换乘楼层。 - 电梯种类优先级的设计策略:A类电梯优先级置顶,因为考虑到A类电梯上下行最快,以及A类电梯可达楼层范围最大,需要经常性的“活动”;B类的初始优先级低于C类,因为考虑到B类可达楼层最多,且有载客量限制;在增加B类或C类电梯后将其优先级提前一级,主要是考虑到B类,上下行速度快于C类,初始优先级最低是受限于容量,因此容量增加后需将其优先级提高以求得等待时间最短
- 调度算法分析:优先直达是因为不必要的换乘会增加不必要的开关门时间,若此时电梯中还有其他人,则会大幅增加总等待时间。优先级的设计以及同一类型多部电梯自由竞争的策略,均是为了充分调用各个电梯资源,避免电梯“忙的忙死,闲的闲死”,以达到系统均衡。就结果来看,在大量随机数据下,该策略的性能不错,最终取得的强测分为99.852
-
线程结束设计:
- 总调度器线程:通过判断
state
变量的值是否为WORKING
(表示输入尚未结束),以及unFinishedNum
值是否大于0,二者满足其一则继续工作,否则结束该线程。该设计保证了,在输入结束,有换乘请求尚在第一阶段的电梯中,同时总请求队列为空时,总调度器线程会继续工作,执行到分配请求的代码处,检测到请求队列为空从而wait
,等待电梯完成阶段性请求后将其唤醒。 - 输入线程结束后,通过调用总调度器类的
endWork()
方法来试图结束其他线程,该方法源码如下:
public void endWork() { for (String typeName : schedulerMap.keySet()) { schedulerMap.get(typeName).endWork(); } synchronized (this) { this.state = State.END; notifyAll(); } }
- 该方法首先会调用各个电梯调度器类(
SchedulerQueue
)的endWork()
方法(同HW5),之后会将总调度器对象的state
变量置为END
,并唤醒总调度器线程 - 电梯线程的结束:基本沿用了HW5中的设计,在
Elevator
类的isWorking()
方法中增加了对TotalScheduler
类中方法isWorking()
的判断,若为真则继续工作。后者源码如下:
public synchronized boolean isWorking() { return this.state == State.WORKING || this.unFinishedNum > 0; }
- 此外,电梯线程检测到完成整个请求,将
unFinishedNum
自减后,若检测到unFinishedNum
值为0,且此时总调度器的state
值为END
(意味着所有输入进来的所有请求均已完成,且输入已结束),则会再次调用总调度器类的endWork()
方法,以避免其他线程无限等待 - 总上所述,该设计主要是解决引入中间线程后的线程结束问题,此外引入
unFinishedNum
变量,来解决输入已结束,总队列为空,且此时换乘请求尚在第一阶段的情况下,整个系统需要等待该换乘请求到达第二阶段,不能过早结束的问题。
- 总调度器线程:通过判断
2. 代码度量
- UML类图:优点是合理的继承以及类的功能划分,使得整个结构较为清晰简明。缺点为,没有将总请求队列从总调度器类(
TotalScheduler
)中独立出来,使得该类较为臃肿,业务逻辑不太专一,同时违背了线程类之间最好不要直接交互的原则
- UML线程协作图:
- Metrics:由于各个类的职责划分较好,使得复杂度均不高
- code lines:总代码行数为629行
- DesigniteJava工具分析:本次作业的耦合性,扇入扇出性均较好
二、设计原则
通过SOLID原则对代码进行分析:
- SRP 单一职责原则:三次作业的迭代中体现的较好。从HW5电梯类和任务队列混杂,到HW6将任务队列抽象独立出来,到HW7将
Person
类抽象出来,都是在对类的职责划分上的思考和探索。总体上来看,大部分类实现了功能和职责单一,唯一不足的地方在于,由于HW7时间紧张,在设计TotalScheduler
类时没有做过多思考,导致该类的职责较为混杂。 - OCP 开闭原则:这一点实现的较好。每次迭代开发时,上一次的设计大部分均能沿用,只需针对新增需求做出相应添加修改。同时
SchedulerQueue
类继承自RequestQueue
类,较好地实现了代码复用。 - LSP 里氏替换原则:
SchedulerQueue
类继承自RequestQueue
类,较好地体现了该原则 - ISP 接口隔离原则:本单元作业中涉及的资源类不多,因此没有设计接口来增加代码灵活性,没有体现该原则
- DIP 依赖倒置原则:本单元作业均是依赖的实例,没有体现该原则
三、测试与bug
1. bug
- 自测:主要是线程不能安全结束的bug,以及HW7中,有换乘请求时系统过早结束的bug,后通过上述的设计解决。
- 公测:三次公测均未被发现bug
- 互测:
- HW5:同一时间多条数据的情况下,会出现ctle,即轮询。后发现是结束设计中的关键变量
workState
的线程安全保护措施不到位引起的,通过适当加锁后解决 - HW6:仅一条请求的情况下,会出现ctle,轮询。具体原因至今尚不清楚,猜测仍是结束设计中的相关变量的线程安全问题。后通过让输入线程在输入结束后,调用
endWork()
前,强制睡眠1s解决 - HW7:未被hack出bug
- HW5:同一时间多条数据的情况下,会出现ctle,即轮询。后发现是结束设计中的关键变量
2. 互测hack
- hack策略:黑盒测试广撒网 + 阅读代码后精准打击。此外,还有在数据输入时间上的设计:同一时间大量请求,边缘时间数据,一条数据,开关门边界数据等,来加大hack出线程安全bug的可能性
- hack成果:
- HW5与HW6均未hack出bug(同组大佬太强了!!)
- HW7通过黑盒测试发现大量bug数据,表现为某一请求后的请求均未被响应。阅读代码后,分析原因为锁的使用不当造成的死锁
3. 测评机
通过两个单元作业,发现搭建本地测评机是一种较好的检测自己程序、hack他人bug的方法。同时第二单元的测评机还可以模拟强测数据,分析不同的策略下的性能值,从而选择最合适的策略
测评机的搭建:主要通过python脚本,以及相应java检查程序的辅助
- 测试数据生成:通过python的自动生成,尽可能模拟强测数据(随机性)
- 数据导入代码:使用了python 中的 subprocess.Popen模块创建新进程,运行编译好的.class文件,通过系统命令与输入输出重定向,将数据较为准确的定时投放到输入管道中,同时将输出通过管道写入指定文件
- 检查输出结果:专门另写了一个java项目,使用java异常机制,根据指导书的规定,通过解析输入输出来判断输出是否正确。设计上十分面向对象,且大体符合迭代开发原则,使用异常机制来捕获错误使得代码较为简洁(强烈建议明年将此项目加入寒假pre,训练java异常机制),同时有助于发现作业中潜在的问题。在评测机中使用的是其导出的jar包,可通过cmd命令直接调用
- 结果分析:检查程序出了判断输出的正确性外,还会给出性能值。在python脚本中,通过对检查程序的结果判断是否正确,同时收集性能值,计算大量数据下的性能值均值、方差等,选出最优策略。(在互测时也可以借此和他人比较性能值,估算强测分数)
下附检查程序(elevator_check)的src目录
四、心得体会
- 多线程程序设计与单线程程序设计的思维有和很大的不同,尤其要注意线程安全问题。在思考设计时需要从各个线程的角度思考,考虑线程间的分工协作,以及相互的消息通知。脑内模拟运行时也要抛弃单线程一条道走到底的思维,时刻注意CPU可能会随时被切走。
- 注重设计模式的学习,本单元作业中,生产者-消费者模式以及Worker Pattern模式对我有很大的启发
- 尝试不同的策略并加以比较。没有什么策略是完美的,也不能仅凭思考选出最合适的策略。本单元给我一个很大的启发就是测试驱动开发,HW6以及HW7的架构和调度策略,均是在测试比较多个策略后,择优选出的
- 前面作业中留下的可扩展性,让我在后面迭代开发的过程中享受到了益处,更让我认识到代码的灵活性,可扩展性的重要
- 好的框架和性能是相辅相成的,不要为了所谓的性能不顾框架的设计而大开倒车。面向对象程序的设计一定是先有一个灵活的、好的、可扩展的框架,再去思考性能的问题
- 注重与同学的交流与分享