需求分析
官方需求
- 本次作业需要模拟一个多线程实时多电梯系统,从标准输入中输入请求信息,程序进行接收和处理,模拟电梯运行,将必要的运行信息通过输出接口进行输出。
- 本次作业电梯系统具有的功能为:上下行,开关门。本次多部电梯的可停靠楼层,运行时间,最大载客量都不相同。
- 电梯系统可以采用任意的调度策略,即上行还是下行,是否在某层开关门,都可自定义,只要保证在系统限制时间内将所有的乘客送至目的地即可。
- 电梯系统在某一层开关门时间内可以上下乘客,开关门的边界时间都可以上下乘客。
简要分析
这次作业有几个关键点:实时系统,正确调度,多线程交互。这就要求在设计中需要提前定义好运行逻辑,而且经历了第一单元的历练,在第一次作业就需要对后续作业可能出现的情况进行设计——即尽量最大化程序的可拓展性,降低程序模块之间的耦合程度,这样在需求变化时就能尽量少的修改代码达到需求。
本次作业三个阶段性安排为:- 第一阶段: 多线程单部电梯,先来先服务原则运行(即电梯一次执行一个任务),楼层号连续
- 第二阶段: 多线程单部电梯,可捎带策略运行(即电梯中允许携带多人,无最大人数限制),包括地上(正数)和地下楼层(负数)【要求使用wait/notify机制,不能使用进程轮询,下同】
- 第三阶段: 多线程多部电梯,可捎带策略运行,电梯有最大人数限制,电梯可停靠楼层、运行时间有不同限制
本单元作业输入输出接口均已给出,因此重点就在于程序进程交互逻辑以及任务分配执行的算法。
逻辑设计
因为有了第一单元设计的经验,为了减少每次的修改量,在每次作业的实现中都尽量采用逻辑分离式设计,每个模块独立的从队列中取出信息、处理信息、放置信息(反馈)。模块之间只通过队列交互,然后独立的实现一套逻辑。针对各个部分,设计变化如下:
- 第一阶段: 直接按照推荐的模式将需求和任务进行了分离,即线程交互分为两个过程:
- 第一过程使用单独的线程处理输入,并创建请求队列,由Submission和Scheduler交互访问——Submission读入输入放置请求,Scheduler取出请求布置任务。
- 第二过程由Scheduler与Elevator进行交互,共享Mission队列与ElevatorState电梯状态板——Scheduler取出请求后读取状态板,分配任务给电梯(因为只有一部,所以这个阶段并没有具体实现分配算法函数,只是返回当前电梯名);电梯根据任务队列执行任务,运行时主动更新状态板信息。
- 为了保持多电梯的扩展性,我在Scheduler中将可调度电梯设置成了“注册”模式,即主线程创建电梯对象后,调用
addElevator(Elevator ele)
函数添加一部电梯使得电梯可被调度。
- 第二阶段: 第二阶段除将轮询交互改为wait/notify机制之外,主要增加的需求为捎带和负数楼层且-1->1的楼层变化并不连续。
针对楼层需求,我设置了有序列表使得楼层之间的变化保持连续性:
List list = Arrays.asList(-3,-2,-1, 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20);
这样在程序中修改楼层所以即可实现楼层之间的连续变化。- 针对捎带需求,在我的设计中将可捎带判定与捎带执行分配到了电梯自己的线程中,因此这部分基本只对Elevator类进行了修改,在电梯开门、关门、变化楼层、任务开始时遍历Mission队列决定可捎带的任务并判断下一个停靠的目标楼层,以适应不断到来的任务变化。
- 需要注意的是,这次因为捎带的发生,需要进行Mission状态的记录——即人在电梯内或者电梯外->进一步为了简化逻辑,将这个状态的判断转化为“Mission在执行队列或等待队列中”。这样做的好处是可以将上下电梯统一起来,基于上下电梯需要的电梯行为表现(到达-开门-关门)是统一的这点,可以使得电梯逻辑变得简单,至于上或下只需要体现在输出即可。
- 第三阶段: 第三阶段实现了真正的多电梯交互,而且提高了楼层不连续性的限制,并对电梯人数进行了限制,这些相应的限制其实是对调度器提出了需求;除此之外,换乘情况的增加我直接采用了任务分解处理。同上,这个单元依旧不需要更高Submission模块以及其与Scheduler的交互,甚至Scheduler与Elevator的交互逻辑也不需要更改,只需要更改各自的处理算法即可。
Scheduler中需要修改的就是之前预留的
String determine(Mission request)
函数/*** 考虑:电梯运行状态+电梯选项+目标电梯* 输入Mission:目标换乘楼层(需要换乘时)* 1. 如果选择只有一个,直接上* 2. 如果选择有多个,基于“换乘点是一致的”的条件* 分支树判定(方向、捎带、远近、人多少)* @param request* @return 目标电梯名*/private String determine(Mission request)
另外,因为不同的电梯线程都需要访问调度器的部分方法,将Scheduler作为了Main类中的静态实例处理,不知道算不算单例的一种实现,但因为Scheduler中保证了线程安全,而且只在主线程中实例化,因此这个静态实例变量可以正确使用Elevator和ElevatorState两部分配合修改实现了楼层可达性控制‘’‘’‘’‘及Mission分阶段执行。在原来楼层的list的基础上在构造方法中增加了
this.availableFloor = new ArrayList<>();this.availableFloor.addAll(floors);
来判定可停靠的楼层,至于具体是哪些,在创建电梯时指定,电梯中还增加了相应的接口进行楼层判断。实现整套逻辑。Mission部分为了利用现有的逻辑完成换乘设计,将请求按照换乘需要拆分成了不同的Mission阶段,request与Mission扔保持一一对应关系来保证乘客不会出现“分身”的情况,执行完一个阶段由电梯将此Mission交还给Scheduler重新参与调度。因此,在电梯状态板增加了“是否可达的”判定:
public Boolean canDeliver(int floor) { if (!this.availableFloor.contains(floor)) { return false; } return true;}
相应的在Scheduler中提供了函数判断是否可直达电梯任务分配算法可表示为:
/*** 可直达? |--yes--> 使用直达电梯* |--no---> 拆分成两阶段** 当前阶段性任务有多部电梯可完成:1.当前方向上可捎带? ---> 2.电梯中人数较少?*/
这样就能够较为合理地规划电梯的任务分配并处理好换乘情况。
最终得到的整体逻辑的时序图如下:
BUG分析
本次作业第一阶段和第三阶段都未发现bug,第二阶段强测中发现了一个bug,主要是因为第二阶段对调度性能要求较高,可捎带的情况必须要捎带上才行,而我在设计Mission队列以及Elevator正在执行的Mission存储两部分同步不是很好,导致Mission队列中有任务,却因为检查不及时Elevator没有检查并执行这个任务,尤其是某一层有很多人上电梯时(同时有很多人同一楼层的用户请求到来)。这个bug的出现和输入的时机有关,因此自己测试的时候没有检测到。
第三阶段我花费了很长时间在debug上,因为评测机一只返回RUNNTIME_ERROR,但我的程序并不会出错,为此,我还实现了简单地随机测试脚本:import randompair = []l = [-3, -2, -1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]for i in l: for j in l: if i == j: continue else: pair.append((i,j))n = 0time = 0.0with open("data.txt", 'a') as f: for t in random.sample(pair, 50): print("[{:.1f}]{}-FROM-{}-TO-{}".format(time, n, t[0], t[1])) status = f.write("[{:.1f}]{}-FROM-{}-TO-{}\n".format(time, n, t[0], t[1])) n += 1 if random.random() > 0.6: time += random.random() f.write("\n")
以上使用python自动生成数据,借助提供的可以进行简单地测试,数据生成可以通过调节参数控制
@echo offset num=0:startset /a num+=1echo ================No. %num% time======================= >> err.txtpython gen.py | "C:\Program Files\Java\jdk-11.0.2\bin\java.exe" -cp out\production\oo_course_e_2019_16071064_homework_6;..\..\elevator-test-suit-0-3.jar Main >> err.txt 2>&1echo ================ over ======================= >> err.txtgoto start
虽然使用了大量的黑箱测试我的代码却依然有RUNTIME_ERROR的问题,于是我又尝试其他的debug,终于发现是因为程序退出时Scheduler发生了轮询导致CPU时间超时(这一点评测机并没有反馈,而且错误种类不应该是这个啊),这里,JProfile的线程分析帮了我大忙
这个界面显示了每个线程实际运行的时间,如果有线程使用轮询导致CPU时间过程可以明显的看出并直接定位。
代码分析
类图
相比较之前的设计,这个单元我认为作为比较好的是在第一次代码就确定了整个的设计框架,也证明在新需求到来时可以较快的适应改动。
从图中可以看出各个类的功能划分是比较清晰地,关于共享对象的设计以及线程类的实现是和预期设计一致的。而且每一个线程类都不依靠其他线程,是一个独立的逻辑体,这大幅降低了模块之间的耦合性,也使得代码逻辑更为清晰易懂。
度量分析
代码量分析
这次的代码相比较第一单元有了很大的改观,首先是类的代码量,大部分功能类的代码量分布还是比较均衡的,代码量最多的类源码没有超过300行,而且在完成过程中我也尝试使用Javadoc风格的函数与类功能注释,注释比例有了很大的增加。重要的是,使用这样的注释之后在下一个阶段需要更新代码的时候能够很快的回忆起函数的功能与实现。这次类间代码量的均衡得益于功能上的分工明确,即将不同的功能实现交由不同的模块完成,但其中因为电梯的运行策略最为复杂,代码行数较多。但整体上还是比较合理的。方法复杂度分析
这次代码的复杂度分析呈现出以下结果,共出现了三个复杂的方法,一个复杂的条件判断,一个长参数列表问题,这些问题的应该是可以解决的。整体来看,代码的循环复杂性(CC)为2.49122807,其中循环复杂性最高的函数依旧是iterMission,高达13。这个函数的功能是遍历Mission队列并添加Mission到电梯的执行队列中,从这一点来看,这个函数还是有可以简化的空间的。
Class Name | Method Name | Code Smell |
---|---|---|
Elevator | Elevator | Long Parameter List |
Elevator | checkDirection | Complex Method |
Elevator | iterMission | Complex Method |
Scheduler | run | Complex Method |
Scheduler | determine | Complex Conditional |
- 类复杂度分析 整体来看,类之间还是比较均衡的,和最初的设计一致,只有Main和Submission较为简单,这两个类本身也没有什么复杂的逻辑也不需要完成什么实质性的算法功能,因此还是比较合理的。
Type Name | NOF | NOPF | NOM | NOPM | LOC | WMC |
---|---|---|---|---|---|---|
域数量 | public域数量 | 方法数量 | public方法数量 | 行数 | 类加权方法数 | |
Elevator | 8 | 0 | 15 | 4 | 302 | 58 |
Main | 1 | 0 | 3 | 3 | 37 | 4 |
Mission | 6 | 0 | 10 | 9 | 118 | 17 |
Scheduler | 5 | 0 | 11 | 7 | 200 | 33 |
Submission | 2 | 0 | 2 | 2 | 46 | 4 |
ElevatorState | 9 | 0 | 16 | 16 | 121 | 26 |
总结与感悟
这是我第一次接触多线程程序,之前只是在学习操作系统的时候了解过原理,但并没有动手写过多线程程序。通过这次实践我不仅掌握了多线程程序的设计方法,而且了解到设计模式在多线程交互中的关键性。无论是线程安全还是死锁预防,或者是线程之间wait/notify的等待唤醒机制,都让我对线程交互有了更深层的理解,而且认识到使用JProfile分析程序运行状态进行优化的必要性。
真正测试代码时,白盒测试是比较直接的方式,但黑盒测试优势也能提供意想不到的效果与便捷性,而且从某种程度上讲更能达到压力测试的效果。另外,在设计之初就考虑可扩展性是很有必要的,一套好的架构一定是能够支持不断扩展的架构,进行好架构设计对功能扩展无论是工作量还是安全性都有很好的帮助。