一、从多线程的协同和同步控制方面,分析和总结自己三次作业的设计策略。
1、第一次作业
第一次作业为最简单的傻瓜电梯,完全按请求来到的先后顺序执行请求,本次作业电梯的基本不需要任何运行策略,在架构方面除主线程之外设计两个线程,一个是电梯线程一个是输入线程,调度器相当于一个托盘,输入线程将请求放进调度器,电梯从调度器中将请求取出并执行,在结束程序方面当输入线程读入了一个null就将其放入调度器中并结束,当电梯读入一个null也就结束了。
2、第二次作业
第二次作业仍然只有一部电梯,但是不同的是加入了捎带策略,我的电梯调度策略是在标准的ALS调度策略上进行改进。标准的ALS算法在去接主请求的时候不会进行捎带,而我的设计中在主请求进入电梯之前只要电梯的运行方向和主请求目标方向一致就会进行同方向捎带,如果不一致当方向与电梯运行方向相同且该人可以在接到主请求之前下电梯的我会进行同方向捎带,除此之外在接到主请求之前,当有新的请求距离当前位置更远并且与主请求同方向就会将其重新定为主请求。这样可以更好的处理先后来2->1,3->1,4->1,5->1...,避免其在这种情况下完全变成傻瓜电梯。架构基本和上一次相同,仍然是一个输入线程一个电梯线程一个调度器作为托盘。
3、第三次作业
第三次作业相比前两次作业复杂度明显升高,首先电梯变为了三个,每个电梯能停的楼层各不相同,并且都有最大载客量。本次作业由于测试用例是随机生成的,所以我选择的电梯调度算法是look算法,即选定一个方向后就在一个方向走并进行同方向捎带,直到这个方向上不再有请求就换向。本次作业的重点我认为并不在电梯调度算法上,而是架构上,因为有三部电梯,如何处理好三部电梯的协同工作是个重要的问题。为了进一步明确思路分配请求我做出了如下三条假设。
(1)人的运送策略上
能一次到达的不要换乘
无法一次到达的最多只换乘一次,并且尽量不改变起点和终点的方向
(2)电梯的工作过程
使用Look调度策略(在一个方向上如果没有请求就换向)。并且只进行同方向的捎带
每一个电梯只管分配给自己的任务,不会和其他电梯有信息往来。
(3)请求的分配上
若有多个电梯可以满足一个请求则把这个请求交给最“闲”的一个。
此时若仍有多部电梯满足则按A > B > C的优先级顺序分配任务。
通过三条假设我进一步明确了电梯的工作方式,因此我设计的架构是一个输入线程,一个主调度器线程负责向电梯调度器分配任务,每个电梯都有一个调度器作为托盘,电梯之和托盘有通信,主调度器将请求(如果不能一次送到先进行拆分处理)送到合适的托盘里,电梯再到托盘中取。同时主调度器通过电梯调度器来获取电梯的实时信息从而据此分配任务。分配的策略就是选择能满足请求的电梯中人数加上等待电梯人数最少的一个电梯。具体的架构图如下图所示。
图一 第三次作业架构图
二、基于度量来分析自己的程序结构度量类的属性个数、方法个数、每个方法规模、每个方法的控制分支数目、类总代码规模计算经典的OO度量画出自己作业的类图,并自我点评优点和缺点,要结合类图做分析通过UML的协作图(sequence diagram)来展示线程之间的协作关系(别忘记主线程)从设计原则检查角度,检查自己的设计,并按照SOLID列出所存在的问题
1、第一次作业
(1)类规格分析
图二 第一次作业类规格分析
可以看出由于此次作业非常简单,因此类的长度都比较短,测试和验证比较方便。
(2)方法复杂度分析
图三 第一次作业方法复杂度分析
由于结构简单,各类复杂度也不高,相当较高的是电梯类的run方法可能涉及到了一些开关门操作复杂度相对比较高。
(3)类图
图四 第一次作业类图
从类图中可以清楚地看到托盘结构,Input线程向Manger托盘中放请求,电梯线程从中拿请求。
(4)UML协作图为
图五 第一次作业UML协作图
2、第二次作业
(1)类规格分析
图六 第二次作业类规格分析
由于这一次电梯的运行算法相对复杂,所以电梯类的长度明显增大,并且由与这次电梯每次从队列中拿请求的方式也比较复杂,所以调度器的长度有所增加,但整体还保证在可控范围之内,并且每个类功能比较单一。
(2)方法复杂度分析
图七 第二次作业方法复杂度分析
从复杂度分析可以看出Manger的changemain方法和Elevator的gomain、runmain方法总复杂度很高,原因是在判断主请求、更换主请求或者在捎带的过程中需要层层判断,不同的情况选取不同的捎带策略,所以复杂度会比较高,我认为为了降低复杂度可以将其进行进一步地拆分。
(3)类图
图八 第二次作业类图
从类图可以看出本次作业的架构和上一次基本相同,没有本质上的改变。
(4)UML协作图
图九 第二次作业UML协作图
从UML协作图中可以看出由于架构没有改变,各线程之间的关系也没有变,唯一不同的是由于本次电梯运行的策略比较复杂,所以为了取出合适的请求电梯需要将自身的状态传入调度器。
3、第三次作业
(1)类规格分析
图十 第三次作业类规格分析
这次设计由于电梯遵循着比较简单的look算法,所以电梯和电梯调度器的长度较上一次明显降低,主调度器长度也比较短,调试和检查都比较方便。
(2)方法复杂度
图十一 第三次作业方法复杂度
本次方法复杂度比较高的是主调度器的run方法以及电梯调度器中的getnext方法,主调度器中由于要对三个电梯情况进行判断一次有很多的if-else结构,而getnext是决定了电梯下一步是想哪个方向走,是look算法的核心,需要综合考虑电梯中的请求和电梯外面的请求。
(3)类图
图十二 第三次作业类图
如图所示,本次作业架构中最主要的关系就是电梯、电梯调度器和主调度器的关系。通过类图可以看出本次作业各个对象没有过多的横向之间的关系。总体上体现了“高内聚、低耦合”的原则。
(4)UML协作图
图十三 第三次作业UML协作图
再考虑SOLID原则即
单一责任原则(SRP)
开放/封闭原则(OCP)
里氏代换原则(LSP)
接口分离原则(ISP)
依赖反转原则(DIP)
由于本单元的作业中我并没有用到继承和接口,所以重点考察单一责任原则,和开放封闭原则。
对于单一责任原则,这三次作业中我认为电梯对象有一点违背了这个原则,因为正常来说电梯的对象应该是只接受上下楼、开关门等命令,而在我的实际设计中相当于是把调度算法也嵌入到了电梯中,与单一责任原则相违背。因此我的改进想法是从目前的电梯和托盘中提出一个真正的“调度器”,作用就是从托盘中拿到请求并分析将指令下达给电梯,电梯线程只负责执行。
对于开放/封闭原则,也是同样的问题,比如现实中电梯作为一个硬件是不会改变的,但是调度算法可以升级,若像现在这样把调度策略嵌入到电梯中每一次升级都要修改电梯中的代码,与开放/封闭原则相违背。
三、分析自己程序的bug分析未通过的公测用例和被互测发现的bug:特征、问题所在的类和方法特别注意分析哪些问题与线程安全相关关联分析bug位置与设计结构之间的相关性
本单元我的程序在第二次和第三次作业中各出现一个bug
1、在第二次作业中我的bug主要是对象发布时共享引起的问题。如下面代码所示
1 public synchronized 2 ArrayList<PersonRequest> 3 getmessage(int floor, int dire, boolean arrive) { 4 if (req[floor].isEmpty()) { 5 return req[floor]; 6 } else { 7 ArrayList<PersonRequest> array = new ArrayList<>(); 8 boolean tag = false; 9 for (int i = 0; i < req[floor].size(); i++) { 10 if ((req[floor].get(i).getFromFloor() < 11 req[floor].get(i).getToFloor() && dire == 0) 12 || (req[floor].get(i).getFromFloor() > 13 req[floor].get(i).getToFloor() && dire == 1)) { 14 tag = true; 15 break; 16 } 17 } 18 if (tag || arrive) { 19 for (int i = 0; i < req[floor].size(); i++) { 20 array.add(req[floor].get(i)); 21 allreq.remove(req[floor].get(i)); 22 } 23 req[floor] = new ArrayList<>(); 24 } 25 return array; 26 } 27 }
这是电梯从调度器取出请求时调用的方法,我在每一个楼层都维护了一个请求队列,在第5行中若请求队列为空我就直接将这个队列的引用返回了,这个对象的发布过程中就产生了不应该有的共享,这个队列的引用进入电梯之后,我的输入线程向该队列中放请求的时候这个请求就会在未经过getmessage方法的情况下进入电梯,造成错误。
分析:该错误是经典的共享导致的错误。因此在以后编程中当遇到可变对象的发布时一定要注意共享问题,除非有意进行共享,否则在发布的时候应该复制一份传出去。
解决:将该队列复制一份再传出去。
2、第三次作业我的bug是有关线程调度的问题。我的主调度器有两个方法可以往队列放东西,一个是输入线程在放,一个是电梯线程把分段的第二段放进去,在结束之前输入线程放了一个东西执行唤醒操作,之后并没有直接切到调度器线程而是接着切到了电梯线程又放了一个,但我的调度器认为只放了一个,所以他在分配了一个请求之后又看了一下电梯没有要放的请求之后就结束了。
分析:造成该错误的一个原因在于我对于线程调度方式存在误解,我想当然地以为执行notifyall的线程执行完应该切换到wait的线程,但实际上并不一定。
解决:在主调度器线程结束的条件中加入请求队列为空即可。
四、发现别人的bug。
由于最近事务比较繁忙,所以本单元没有写测试脚本,导致我并没有发现别人的bug(这里指的是其他人发现而我没有发现的),但是我在对别人以及自己的程序进行测试的时候还是有一定的方法的。
1、要进行高并发测试,就是同一时刻投入大量请求,来检查线程安全性,因为处理不当就有可能产生“电梯吃人”或“电梯生人”事件。
2、进行边界数据测试,这里的边界数据指的是最低楼层或最高楼层以及其他换向的时候。换向处理不好有可能出现“上天”、“入地”或在陷入几个楼层之间的死循环。比如先后输入(-3->20,20->-3,-3->20...)。
3、对于第三次作业还要进行超载测试,比如同时投放20条-3->20指令来检查电梯是否会出现超载现象。
4、第三次作业还要进行换乘测试,尤其要注意换乘时会不会出现冲突(一个人还在一个电梯中就已经上另一个电梯了),由于有的人没有任务分配机制所以在构造换乘的时候要把不同的换乘情况考虑全(A->B,B->C,C->A...)。
5、最后要对于程序能够正常结束进行判断,要分别测试不输入任何请求直接输入文件尾,输入请求之后立刻输入文件尾(可能需要用程序投放),执行请求过程中输入文件尾,所有请求执行完过一段时间输入文件尾。
五、心得体会
本单元作业主要练习的是多线程的编程。因此本单元作业的一个最大的特点就是程序运行带有不确定性。同一个输入同一个程序可能会有很多种不同的输出。因此本单元的调试也是异常困难。以我自己为例,之前编程下如果被发现了一个bug,并且还有测试用例的话,只要将用例输入单步调试看一看哪里出问题了,很快就可以调出来。然而本单元两次被发现bug,重跑测试用例发现bug根本不可复现。后来经过同学和老师们的讨论我知道了我们的JVM线程调度器版本和评测机用的并不相同,所以有可能在评测机上出了问题本地测不出来。后来的解决方法是我将我执行请求的每一步都打到了标准异常里面,重新提交,神奇的是之前出错的几个点都对了,之前对的几个点错了(这也充分体现了不确定性)。但是毕竟这次有了出现错误的调试信息,因此我才成功地找出bug所在。因此在之后的多线程编程中我可以将每一步的调试信息都输出到标准异常里面,这样即使将来评测出现错误,也很容易通过调试信息来判断问题所在。此外在本地测试的时候还是需要进行自动化大量测试(这也是我这次比较失误的一个地方)。我以为这次没有了格式的问题,只检查功能正确性很容易,没有必要生成很多都是类似的测试用例,却忽视了线程问题。只跑几个测试用例,即使涵盖了所有的情况可能都不会出问题,只有大量的测试才有可能将问题复现。