一、三次作业分析
1. 第一次作业
1.1 需求分析
本次作业,需要完成的任务为单部多线程可捎带电梯的模拟,电梯为目的选层电梯,从标准输入中输入请求信息,程序进行接收和处理,模拟电梯运行,将必要的运行信息通过输出接口进行输出,事先已经提供了电梯请求信息的输入接口和附加时间戳的输出接口。
本次作业电梯系统具有的功能为:上下行,开关门,每运行一层的时间为固定值,开关门的时间也为固定值。电梯系统在某一层开关门时间内可以上下乘客,开关门的边界时间都可以上下乘客。
电梯系统可以采用任意的调度策略,即上行还是下行,是否在某层开关门,都可自定义,只要保证在系统限制时间内将所有的乘客送至目的地即可。
电梯需要实现可捎带。
1.2 设计策略
根据需求,因为是实时电梯系统,所以要用多线程的方法实现,这是一个典型的生产者-消费者模型,创建一个输入处理线程类Input
,一个电梯线程Elevator
,再创建一个调度器类Scheduler
。
输入处理线程是生产者,不断处理标准输入的请求,并放入调度器中的请求队列中,调度器就是公用资源,而电梯线程即是消费者,通过运行来将请求取走,满足乘客请求,因此完全是一个标准的生产者-消费者模型。在第一次作业中,调度器主要承担了请求池的角色,请求池以ArrayList
的方式实现,先来先服务,并实现ALS捎带策略。
具体的实现,调度器是共享资源,要实现单例模式,即整个系统中只能有一个调度器实例。实现addRequest()
和getRequest()
方法,由于是对于共享资源的访问,所以都要用synchronized
关键字修饰,保证互斥访问,即同时只能有一个线程访问调度器。方法内利用wait()
和notifyAll()
来实现线程间的同步控制。调用getRequest()
时若请求队列为空,就wait()
,并等待再次唤醒。
Input
线程不断从标准输入中获取下一条请求,如果获取到了就调用addRequest()
方法加入到请求队列中,如果获取到null就跳出循环,关闭输入接口,并且设置结束输入的标志。
Elevator
线程中实现电梯的运行,电梯内有自己的运行请求队列,代表正在电梯里的人,主请求和捎带请求都包含在电梯中的运行队列中,也就是说电梯内的运行队列中有一个主请求,其余均为捎带请求。电梯处于空闲状态时,调用getRequest()
从调度器的请求队列中取一个请求,作为主请求。在完成主请求的过程中,电梯运行路径上遇到的所有请求均被作为捎带请求,进入电梯内的运行请求队列中。当主请求被完成后,从运行队列中取出一个请求,成为新的主请求。
关于结束电梯线程的判断,如果标准输入中不会再输入请求(即结束输入标志为真),并且调度器中的请求队列为空,并且电梯中的正在运行队列也为空,则跳出循环,结束电梯线程。
程序UML图如下:
1.3 基于度量的结构分析
本次作业结构比较简单,一共四个类:主类、输入、调度器、电梯。输入和电梯两个线程通过调度器中共享数据的方式进行通信。
本次作业的度量如下:
指标 | 平均值 | 总值 | 特殊值 |
---|---|---|---|
Lines of Code(总行数) | 55.50 | 231 | Elevator - 126 |
Lines of Code Method(方法行数) | 8.83 | 203 | |
Essential Cyclomatic Complexity(基本复杂度) | 1.61 | 37 | Elevator.running(int) - 4 , Elevator.run() - 4 |
Design Complexity(设计复杂度) | 1.78 | 41 | |
Cyclomatic Complexity(循环复杂度) | 2.26 | 52 | |
Average Operation Complexity(平均操作复杂度) | 1.96 | ||
Weighted Method Complexity(加权方法复杂度) | 11.25 | 45 | Elevator - 27 |
Cyclic Dependencies(循环依赖) | 0 | ||
Depth of Inheritance Tree(继承树深度) | 1.50 |
通过上面的度量结果可以发现,主要是Elevator类的复杂度比较大,代码行数也比较多。主要是因为我把电梯运行的主要代码都放在了电梯内部,而调度器主要只是承担了一个请求池的角色,所以导致电梯线程复杂度较大。但是在度量上可以看出整体仍是比较均衡的,没有出现个别方法复杂度过大的情况。
1.4 自己程序的bug
第一次作业的功能较为简单,是标准的生产者-消费者模型,难点是如何实现捎带算法,还有线程间的协同和同步控制。刚开始我还不是很理解wait和notifyAll的用法,不知道哪里该加哪里不该加,导致电梯wait后最后没有被唤醒。在仔细思考过后弄明白了使用方法,解决了bug。还有一点就是关于电梯线程的结束,如果标准输入中不会再输入请求(即结束输入标志为真),并且调度器中的请求队列为空,并且电梯中的正在运行队列也为空,则跳出循环,结束电梯线程,否则若三者有一个不满足,都不能结束线程。
最终强测没有错误,互测也没有被hack。
2. 第二次作业
2.1 需求分析
本次作业,需要完成的任务为多部多线程可捎带调度电梯的模拟。本次作业需要模拟一个多线程实时电梯系统,并且有多部(1-5部)电梯,标识分别为A-E,需要首先从标准读入中读取模拟电梯数目,动态建立电梯。并且与第一次相比,楼层也增加了负楼层,-1层和1层之间的跨越需要特别处理,并且还规定了每部电梯的最大载客量,都为7人,电梯上下楼和开关门所用的时间不变。
2.2 设计策略
这次作业相比上一次新增了三个条件:多部(1-5部)电梯,标识分别为A-E;增加了负楼层;规定了每部电梯的最大载客量。
实现多部电梯,程序主体不变,仍然是生产者-消费者模型,只不过需要扩展,即消费者变成了多个。为了建立多个电梯线程,我们需要在主类中,从标准读入中读取模拟电梯数目,动态建立若干个电梯线程。需要注意的一点是,主类中读取电梯数目的输入和Input
线程中读取请求的输入利用的应该是同一个ElevatorInput
实例,即new ElevatorInput(System.in)
语句在程序中最多只能出现一次。否则会出现请求被“吃掉”的错误。
至于负楼层,只需要在-1层和1层之间跨越的时候多走一层,跳过0层即可。而最大载客量只要在将请求加入电梯运行队列时判断一下就可以了。
关于多部电梯的调度,我采用了分离请求队列的方法,即把原来调度器中只存储一个请求队列变为调度器中存储了多个电梯的请求队列,在Input
线程调用addRequest()
方法时就判断加入到哪个电梯的请求队列中,每个电梯还是和上次一样只从自己对应的请求队列中读取请求。具体实现是选择当前请求队列中请求最少的那个电梯,加入其请求队列,这样可以让每个电梯的任务相对平均。
本次作业程序UML图如下:
2.3 基于度量的结构分析
本次作业将三个主要任务下放至三个层次中分别处理该层的对应特征。
本次作业的度量如下:
指标 | 平均值 | 总值 | 特殊值 |
---|---|---|---|
Lines of Code(总行数) | 64.25 | 266 | Elevator - 139 |
Lines of Code Method(方法行数) | 9.75 | 234 | Elevator.running(int) - 25 |
Essential Cyclomatic Complexity(基本复杂度) | 1.58 | 38 | Elevator.running(int) - 4 , Elevator.run() - 4 |
Design Complexity(设计复杂度) | 2.00 | 48 | |
Cyclomatic Complexity(循环复杂度) | 2.50 | 60 | |
Average Operation Complexity(平均操作复杂度) | 2.17 | ||
Weighted Method Complexity(加权方法复杂度) | 13.00 | 52 | Elevator - 29 |
Cyclic Dependencies(循环依赖) | 0.50 | 2 | |
Depth of Inheritance Tree(继承树深度) | 1.50 |
这次作业和上次差不多,都是Elevator类的复杂度比较大,代码行数也比较多。因为我大部分框架沿用了上次,电梯运行的主要代码仍然是在电梯线程内部。但是在度量上可以看出整体仍是比较均衡的,没有出现个别方法复杂度过大的情况。值得注意的是这次出现了调度器和电梯线程的循环依赖现象。
2.4 自己程序的bug
因为第二次作业的框架都是沿用了第一次作业,新增的需求也可以简单地解决,所以没有什么debug的地方。唯一需要注意的一点就是之前提过的,主类中读取电梯数目的输入和Input
线程中读取请求的输入利用的应该是同一个ElevatorInput
实例,即new ElevatorInput(System.in)
语句在程序中最多只能出现一次。
最终强测没有错误,互测也没有被hack。
3. 第三次作业
3.1 需求分析
本次作业,需要完成的任务为多部多线程可捎带调度电梯的模拟。从标准输入中输入请求信息和加入电梯指令,程序进行接收和处理,模拟电梯运行。并且本次多部电梯分为A、B、C三个电梯类型,每种类型的电梯可停靠楼层,运行时间,最大载客量都为不同的固定值。
3.2 设计策略
从需求可知,本次作业有三个需要实现的主要内容:一是系统运行过程中电梯的动态加入,二是每种类型电梯的运行时间和最大载客量不同,三是由于每种类型电梯有各自的可停靠和不可停靠楼层,所以一些乘客请求就需要涉及到电梯的换乘。
为了解决电梯线程的动态加入,我将之前的生产者-消费者模型改进为使用Worker-Thread模式。在调度器中维护一个HashMap
,来存储每个电梯的请求队列,以及一个ArrayList
的线程池,进行电梯线程的保存和动态加入。同时提供一系列包括启动所有电梯线程的startElevators()
方法,加入电梯线程的addElevator()
方法,加入/取出乘客请求的方法等。其中涉及共享资源访问的方法要加上synchronized
关键字。
至于电梯的类型,以及不同类型电梯的运行时间和最大载客量,只需要修改电梯线程的构造方法,在构建电梯时给出即可。
最后,是换乘的处理。最简单的处理是在调度器中将不能单独完成的请求拆分成两个请求,实现换乘,需要注意的是后者请求必须等待前者请求实现后才能加入请求队列中。为此,我实现了一个继承自PersonRequest
类的TransferRequset
类,由于继承自PersonRequest
类,所以它本身就代表一个乘客请求,只不过它代表的是需要换乘的请求的前半段,这个类还包括了一个PersonRequest
的属性,代表换乘的请求的后半段。当一个请求完成时,即对应乘客出电梯时,判断这个请求的类型是不是TransferRequset
,如果是,就把它的后半段再经由调度器加入请求队列中,这样就可以保证换乘请求执行的先后顺序。
当我们用这样的方法实现换乘时,随之而来的还有电梯线程何时结束的问题。这也是我中测第一次提交时遇到的bug,就是需要换乘上的电梯提前结束了线程,导致需要换乘的那个人没有到达最终的目的地。为此,我在调度器类中增加了一个transferNum
的静态属性,表示后续需要换乘的人数,初值为0。请求队列中每加入一个TransferRequset
类型的请求就加1,每有一个TransferRequset
类型的请求的前半段完成,就减1。判断电梯线程结束的条件,除了之前的,还要加上Scheduler.getTransferNum() == 0
,满足条件才结束。
本次作业程序UML图如下:
3.3 基于度量的结构分析
本次作业的度量如下:
指标 | 平均值 | 总值 | 特殊值 |
---|---|---|---|
Lines of Code(总行数) | 77.00 | 399 | Elevator - 175 , Scheduler - 157 |
Lines of Code per Method(方法行数) | 10.85 | 358 | Scheduler.addRequest(PersonRequest) - 59 |
Essential Cyclomatic Complexity(基本复杂度) | 1.45 | 48 | Elevator.run() - 5 , Elevator.running(int) - 4 |
Design Complexity(设计复杂度) | 2.94 | 97 | Scheduler.addRequest(PersonRequest) - 34 |
Cyclomatic Complexity(循环复杂度) | 3.97 | 131 | Scheduler.addRequest(PersonRequest) - 55 |
Average Operation Complexity(平均操作复杂度) | 2.39 | ||
Weighted Method Complexity(加权方法复杂度) | 15.80 | 79 | Elevator - 36 , Scheduler - 34 |
Cyclic Dependencies(循环依赖) | 0.40 | 2 | |
Depth of Inheritance Tree(继承树深度) | 1.80 |
这次作业,Elevator和Scheduler的复杂度明显很大,代码行数也很多,存在很大的问题。Elevator是因为电梯加入了不同的类型和不同的一些属性,导致构造函数变得复杂,而且换乘的处理也使复杂度提高。Scheduler同样是因为处理换乘,我是在Scheduler.addRequest(PersonRequest)
这个方法中实现了换乘请求的拆分,导致拆分和添加请求队列都在一个方法中,完全可以把这两部分分开。其次,我在判断换乘拆分时使用了大量的if...else判断语句,且判断条件也比较复杂,所以导致复杂度居高不下,这也是还没有很好地掌握面向对象的一种体现。
3.4 自己程序的bug
本次作业过程中发现了两个bug,一个是在中测期间有关电梯线程结束的bug。如我刚才所述,就是需要换乘上的电梯提前结束了线程,导致需要换乘的那个人没有到达最终的目的地。为此,在调度器类中增加了一个transferNum
的静态属性,表示后续需要换乘的人数,判断电梯线程结束的条件除了之前的,还要加上Scheduler.getTransferNum() == 0
,满足条件才结束。处理之后顺利通过中测。
第二个是完全由于粗心而引起的低级bug,同时也是导致我这次强测爆炸的bug。就是我在拆分需要换乘的请求时,不小心漏了一种由C到A换乘的情况,所以凡是涉及到这种情况的换乘我都没有处理到,导致强测错了6个点。
原本我测试点的性能分都还不错,本来可以拿到一个不错的成绩,可是就因为一个小小的粗心问题,原地爆炸螺旋升天,现在就是后悔,非常后悔!
二、架构设计的可扩展性
按照SOLID的五个设计原则来分析,我的第三次作业代码还存在不少问题:
首先是SRP原则,即每个类或方法都只有一个明确的职责。这点上我做的不够好,特别是在处理换乘时,我是在Scheduler.addRequest(PersonRequest)
这个方法中同时实现了换乘请求的拆分和将请求加入队列这两个功能,导致拆分和添加请求队列都在一个方法中,违背了SRP原则,需要改进。
其次,关于OCP原则,即无需修改已有实现(close),而是通过扩展来增加新功能(open)。在这一点上也做的不够好,因为我整个系统只有一个调度器,这个调度器同时存储了每个电梯的队列,而每个电梯内部也没有专属于这个电梯的调度器,而是由电梯线程直接管理调度,所以当加入更多新的需求时,可能不能仅仅通过扩展来增加新功能,还是需要一部分的重构,所以没有满足开闭原则。
三、互测策略
在这一单元的互测中,由于不是很会手动构造测试样例,我采取的策略主要是阅读别人程序的代码。由于这一单元三次作业的代码量相比第一单元来说减少了很多,程序结构也较为简单,快速浏览代码是可以做到的。并且在浏览代码的过程中,把重点放在了线程的协同和同步控制,还有线程的结束上。第三次作业时,还关注了换乘策略的处理。事实证明,大部分的bug都是出现在以上三部分的代码中的。
对于如何发现线程安全相关的问题,我采用的主要办法是print,也就是在每个线程相关的操作处打印输出对应的线程操作,这样在测试的时候可以很清楚的看到线程运行的状态到底是什么样的,个人认为非常实用。还有借助一些别的线程分析的软件来监测线程运行状态,我虽然考虑到学习成本,当时没有采用,但后来听了同学的推荐试验了一下,效果也非常不错。
对于本单元的测试策略与第一单元测试策略的差异之处,我认为有以下几点:
测试形态不同:第一单元作业测试不关心求导过程的表达式状态变化,只关心最终的输出,所以可以很方便地自动生成测试样例来进行自动测试。第二单元作业测试还关心运行过程中电梯和队列的状态变化,基于电梯运行状态和请求序列的预期结果推理是重点,也是难点,导致构造测试样例困难了许多。线程测试不提供直接的调用交互机制,通过共享对象,设置共享对象的状态进行测试。
四、心得体会
经过这一单元三次作业的学习,我有许多的心得体会:
-
线程安全:通过这个单元的训练,我对多线程编程有了深入的了解,掌握了实现线程间通信、同步、互斥的方法,知道了如何保证线程的安全性。线程同步除了wait和notifyAll的方法外,还了解了一些其他的方法。幸运的是,我的程序在三次作业过程中并没有出现死锁的现象,但是注意到过可能发生死锁的点,并成功地避免了。
-
设计原则:在第三次作业中,存在不少违背了设计原则的地方,
Scheduler.addRequest(PersonRequest)
这个方法中同时实现了换乘请求的拆分和将请求加入队列这两个功能,导致拆分和添加请求队列都在一个方法中,违背了SRP原则,需要改进。其次,关于OCP原则,在这一点上也做的不够好,因为我整个系统只有一个调度器,而每个电梯内部也没有专属于这个电梯的调度器,所以当加入更多新的需求时,可能不能仅仅通过扩展来增加新功能。但是,这三次作业的架构是有共性的,一步一步扩展。输入放入请求,电梯取出请求,这种"生产者-消费者"模型的核心没有变。只要能设计好架构,让耦合度降到最低,那么任意电梯数量、各种限制要求都可以很好满足。而调度算法也应该封装起来,想用哪种算法单独替换即可,不要因为算法而改变架构本身。
总之,第二单元关于多线程的学习也暂时告一段落了,经过三次作业的递进学习,我收获了许多关于线程控制和设计原则的知识,这些在以后的学习中都是很重要的基础,接下来还要继续加油!