BUAA OO 第二单元总结
- 关键词:电梯、多线程、调度
前言
- 在乎用户体验的电梯,不是好电梯(x
- 总算是跌跌撞撞走完了第二单元——学长学姐们所说的OO难度的顶峰?(实在不敢乱立flag
- 不得不说,多线程不论是debug还是找bug都挺
晦气难的,有的时候脸白一点就能浑水摸鱼(逃),但更多的时候是脸色跟着测试点一起时绿时红(最后变黑
综述
- 本单元一共包含3个homework,其难度依次递增,总体符合迭代式开发的需求。通过这单元的作业,我学习到了有关多线程的基本知识,了解到了
死锁的可怕,线程同步的玄学,与几种电梯调度算法。同时,我还头一次真正地实现了继承、单例模式、接口等内容,当真是有所收获。 - 这单元作业并没有太过于注重性能(太菜),而是注重于实现功能,实践一下学习过的知识(卷不动
- 此次作业的题目分别是:
- Homework5:完成一个支持连续读取请求,并支持三种运行模式(Random、Night、Morning),支持捎带(以ALS为标准)的电梯,电梯的人数有限制。(ALS = A Little Smart)
- Homework6:完成一个支持连续读取请求,能模拟多部同型号电梯的运行(单个电梯的要求同Homework5),并要求能够响应输入数据的请求,动态增加电梯的电梯系统。
- Homework7:完成一个支持连续读取请求,能模拟多部不同型号电梯的运行的电梯系统。(型号不同,指的是开关门速度,移动速度,限载人数,以及最重要的——可停靠楼层的不同。)
- 同时,这一单元作业的完成并没有经历大的重构,对于结构的修改仅仅只是在Homework6的一次有所改动,其余的基本是做到了迭代开发,拓展性设计的还是较为合适的。在公测以及互测的阶段也没有遇到很多bug,仅仅是在Homework5的强测挂了一个点,这说明程序的架构实现还是有所进步。
(实际上是课程组大发慈悲)
各作业分析
Homework5
架构
- Homework5的架构实现完完全全就是一个生产者消费者模型。其中Input是一个生产者线程,Elevator是一个消费者线程,schedule是一个线程安全类(也只有这一个线程安全类)。多设计了一个另类的Planner,本来是想提前弄一个调度器,可后来发现在本次作业的题目下用处不大,(不需要调度),因为我将捎带之类的工作完全交给了电梯类,所以最后这个类就变成了一个纯方法类,配合Elevator来完成捎带。(最难的实际上是怎么捎带)
算法
-
对于电梯的捎带,我是按照标准ALS算法来实现的。就是设置一个主请求,在前往这个请求的目的地的路上(或者是前往这个请求出发地的路上),在每一层在schedule搜索,看是否有能够捎带的请求,如果有就加入到捎带队列里(也就是客厢里)。而判断是否可以捎带的判断也很简单,只判断了起始层和运行方向:
if ((getDirection(request) == curDirection) && (request.getFromFloor() == curFloor)) { additionalList.add(request); }
-
而对于Random、Morning、Night三种模式的实现也十分简单,Random就是完全的ALS,而Morning模式由于都从一楼出发,所以要在一开始一层接人的时候多等等
(至少一开始我是这么想的),由于每两个请求最多间隔两秒,所以我的策略是接到一个人就机械的再等两秒。而Night模式由于可以从上至下地捎带,效率才能最大化,所以把Random模式找请求的方法改为找最高请求即可。
复杂度分析
- 只取了复杂度很高的部分:
-
复杂度分析由idea的插件MetricsReload生成,手动进行了分析。
OCavg代表类的方法的平均循环复杂度。OCmax代表类的方法的最大循环复杂度。WMC代表类的总循环复杂度。
-
可以看到电梯Elevator类的复杂度爆炸了。这是因为电梯的运行本质上是一个状态机,但是我并没有将状态分离出来,而是杂糅在了一个方法里,这使得电梯运行的细节全部显示出来了,包含了大量的if-else结构,但是由于每一步其实还都比较明确,所以当时写的时候并没有觉得有什么不妥。(后来发觉了,也懒得改了,并且这部分
代码屎山也重复了三次作业。(闻着臭,吃着香,诶,就是玩儿)。屎山伪代码如下所示,大概感觉就是不是在送主乘客的路上,就是在去接主乘客的路上。public void randomMode() throws InterruptedException { while (true) { // 前往起始点 if (mainRequest == null) { parseRequest(mainRequest); getDirection(fromFloor); basicMove(fromFloor); //到达了起始floor getDirection(toFloor); open(); getInPerson(personID); close(); add2addition(); // 捎带 addListOut(currentFloor); } //接到了,去送 getDirection(toFloor); proveMove(toFloor); //到了 open(); getOutPerson(personID); close(); addListOut(currentFloor); } }
Homework6
架构
-
这次的作业由于需要实现把输入的请求分发给不同的电梯来实现,考虑到这个分发行为如果由电梯来自主实现(基本使用hw5),虽然可以大概做到平均分配,但是没有办法为每个电梯安排最好的请求。所以就考虑到应当设置一个分发器(Planner),负责把不同请求分配给不同电梯。又考虑到电梯很多,需要动态增加减少电梯,所以肯定需要一个集合来存贮管理这些电梯,也就是Elelist。又考虑到由于当请求分配完毕之后,仍然希望电梯能自己进行捎带,不全部依赖Planner
(其实是还想重复利用hw5),所以每个电梯还需要自己的请求队列Queue,从中捎带。
-
其中 Input、Planner和各个Elevator是线程类,在最开始就会Run起来。当Input读取到请求时,会按照请求的类别在Elelist里加新的电梯,或者在Schedule加新的请求,同时,Planner会根据Elelist里各个电梯的状态和Schedule的请求,进行分析,把请求分配给某个电梯的Queue,再让Elevator自己去跑。
-
长话短说:
生产者 | 共享资源(线程安全类) | 消费者 |
---|---|---|
Input | Elelist,Schedule | Planner |
Planner | Queue | Elevator |
算法
-
由于hw5解决了电梯的捎带(至少是我以为解决了),所以此次的算法需要解决的只有 请求到底在什么时候给哪个电梯
-
此时就会有两个思路了,一个是预分配:也就是说当新的请求来了,不管三七二十一,直接派给哪个电梯,分配完了就是电梯自己的事情了。另一个是等待分配,来的请求不立即分配,而是在电梯需要的时候,需要考虑目前已有的请求与哪个电梯最合适,进而分配。由于我比较懒,而且觉得性能可能差别并不大?,所以选择的是预分配策略,直接分配(无脑先过正确性)。分配策略如下:
public void randomStrategy() throws InterruptedException { while (true) { PersonRequest request = schedule.getRequest(); if (request == null) { break; } -》 Elevator targetEle = getEmptyEle(); if (targetEle != null) { targetEle.getQueue().addRequest(request); continue; } else { -》 targetEle = getPiggyEle(request); if (targetEle != null) { targetEle.getQueue().addRequest(request); } else { -》 targetEle = getLazyEle(); targetEle.getQueue().addRequest(request); } } } }
-
——对于一个请求,按照先后顺序选择空电梯,可捎带电梯,和请求最少电梯(最懒电梯),分配请求。
复杂度分析
- 只截取了复杂度很高的几个部分:
-
可以看到,这次作业的圈复杂度主要是集中在了Planner里面,这是由于需要遍历整个Elelist才能够获取所有电梯的状态,从而选择请求分配对象,所以便使用了大量的循环。而Schedule的getHighestRequest方法平均复杂度高的原因是其使用了大量的循环嵌套if-else来找到最高请求,但我并没有找到很好的方法来解决,
if ((requests.isEmpty()) && (finished)) { return null; } else if (requests.isEmpty()) { requests.wait(); } else { PersonRequest tempRequest = null; for (PersonRequest p : requests) { if (tempRequest == null) { tempRequest = p; } else { if (p.getFromFloor() > tempRequest.getFromFloor()) { tempRequest = p; } } }
Homework7
架构
- 此次作业的电梯被分成了三类,其运行速度、承载人数、到达楼层均不同。为了解决这个问题,我将速度、人数变为电梯的参数,把三种电梯继承电梯类,可以很好地解决。对于楼层,我是将这个任务交给了Planner,也就是说,电梯是不知道能跑多少层的,只会完成调度器交给他的任务。
- 所以,本次作业的难度就是在于如何确定在什么时候将请求分配给哪类电梯,再考虑到性能,这时候还会涉及到一个换乘的问题。对于这个问题,首先是需要在Planner写新的方法找到应该分配给哪类电梯。另外,我把Queue中的请求储存形式变成了请求队列,就是一个请求实际上是一个请求队列。当Planner分配任务的时候,会将特定的请求进行拆分,拆成两个请求,交给一个电梯的Queue,当电梯运行完一个Queue中的请求的前一半时,会把后一半push回Schedule里,让Planner重新分配。
- 类图如下所示:
- 各个线程的协作如下图所示:
算法
-
算法主要是确定一个标准,来将一个请求拆分,并且应该分配给哪类电梯。(只需考虑楼层)
-
对于这个需求,首先直接符合三类电梯的就肯定直接分配。如果移动层数小于5层就直接给A类电梯。由于B类电梯(只到奇数层)比A列电梯跑得快,所以我会将起点或终点为偶数的请求进行拆分,偶偶->偶奇 + 奇奇 + 奇偶。
public int bsplit(int fromFloor, int toFloor, PersonRequest request, ArrayList<PersonRequest> splittedRequest) { int derivation = getRequestDirection(request); if ((fromFloor % 2 == 0) || (toFloor % 2 == 1)) { PersonRequest p1 = new PersonRequest(fromFloor, fromFloor + derivation, request.getPersonId()); PersonRequest p2 = new PersonRequest(fromFloor + derivation, toFloor, request.getPersonId()); splittedRequest.add(p1); splittedRequest.add(p2); return 1; } return 0; }
-
这个算法不管是强测还是研讨课都被怼了,确实效果不好,甚至还不如不换乘5555555555 orz
复杂度分析
- 只截取了复杂度高的部分:
- 除去之前遗留下来的屎山实在是懒得修改,可以发现和Planner新增功能相关的函数都挺复杂的,但是我并没有想到太好的不改架构改进他们的办法,其实在可以接受的范围之内。但是我仍然发现大概Planner类极其的复杂时,debug变得十分复杂,甚至浏览完整的代码都得各种点来点去,实在不方便。
BUG相关
我的BUG
-
幸运的,这次不论是公测还是互测,还是自己的测试,都没有出现过死锁的问题。我感觉这是因为我听取了讨论区学长的建议,并且听到了太多死锁是怎样玄学的传闻,直接就按照最保险的模式编写的原因,少走了不少弯路。如此看来,把线程安全类写好,想清楚架构,做尽量充足的保护,是可以保证线程的安全性的。
-
这次被hack出来的BUG是在hw5的强测,有一个Morning的点超时了。究其原因,是因为指导书说每两个请求的间隔时间不超过2秒。于是为了一“锅”电梯能运更多的人,我让电梯接到一个人之后都会无脑等两秒。但是考虑到,当电梯凑满一“锅”,运送一趟的过程中,请求大多会在一层等候,其人数已经很多了。于是当指令很多的时候,每次等待的这两秒就很影响性能了,最终导致了超时。而当我把等待值设置为在最初始等待两秒后,该BUG就被修复了。
别人的BUG
- 很遗憾,这次没有hack到别人的BUG,哪怕一次。
- 测试工具这次没有写评测机,但是根据评论区抄了一个自动输入工具。
- 对于hw5,hw6来说,我的hack策略都是相似的。就是把屋里人的代码下下来,然后拿自动输入工具把我自己错过的测试点输进去测试。但是很遗憾,我的bug大多貌似不具有普遍性。而且由于我没有遇到过太多的死锁问题(没有双重锁),所以对于死锁的hack策略也不是很懂。于是到了hw7,便干脆没有hack别人,算是有些遗憾。
不足之处&改进
-
首先是Elevator的屎山。这个通过观察讨论区,通过听同学的课上讲解,发现可以用状态模式完美解决。状态模式的好处就在于将模式分割,美观且扩展性很强。另外我们也可以及其轻松地获取当前电梯的状态,对其进行监听。另外,对于hw6的请求分配方式,有一种模拟运行比较运行时间的方法,这就需要状态模式来获取电梯状态信息,再进行模拟。
-
其次是各个线程的结束方式。这次作业我主要利用的是信号传递来结束各个进程,由于线程的关系到了第三次作业已经变得十分的复杂,所以这种方式来结束线程需要不停地传递信号notify,极其繁琐且容易出错。下图就是我的结束逻辑手稿,十分明显。
但后来经过OO课上实验,我发现了一种新的思路:可以利用Mutex,当所有人都到达了自己的目标楼层,就自动结束线程。我认为这是一种很好的处理方式。
- 还有是Planner类最后变得实在是太过复杂了,没有可拓展性。考虑到接口分离原则,应该设置多个Planner,来分别处理电梯类别选择,电梯选择,捎带选择,不仅能够有效的降低Planner的复杂度,又可以减轻Elevator的压力。
心得体会
- 总的来说,这次作业的难度变化比较平稳,要求符合迭代式开发需求。每一次作业大致需要3-5小时的构思,和5-7小时的写代码+调试,才可以通过中测。另外这一单元比较贴合现实生活,有种真的在做项目的感觉,不得不说课程组肯定是花了心思来改进的。但是还是感觉得到,这一单元的难度较上一单元有了提升,因为我至少有两周的周五熬到了一点左右,代码量和思考量都有了质的提升。不过令人感到欣慰的是,由于我这一次的策略:一定要提前构思,构思成熟了再动笔写代码,我其实并没有遇到多少bug。仅仅是在第一次作业中,由于对于线程的相关知识掌握不够熟练,出现了些许bug,在第二次、第三次作业基本上是直接就通过了中测,并没有de很久。以后写代码写作业也一定要多多注重构思。
- 还有就是,我感觉到这一单元的作业对我最大的帮助其实有三点:
- 一点是我明确了线程的运行的感觉。在以往写代码的时候,比如大一学c,大二学数据结构,都感觉仅仅只有main函数是顺序执行的,其他的代码都像是静态方法,没有运行的感觉。但是经过这一单元的学习,我明确的感受到了代码是由自己的思想的,人家怎么跑是人家的事,而我们自己所能做的是划清界限,规定顺序,保护弱小(线程安全)。
- 另一点是对于面向对象的特征有了真正的实践。就比如继承,实际上第一单元我并没有真正的写出继承来,一方面是我觉得貌似意义不大,一方面也是有点心虚。但在本单元的第三次作业中,我把ABC三类电梯完成了对于Elevator父类的继承,不论意义,起码成功的照葫芦画瓢地实践了。
- 最后一点是我对于SOLID和设计模式有了更多的了解。特别是我在单元结束的时候做了一次课堂分享,准备分享的过程中查阅了不少资料,了解了不少。对于单例模式,我成功的用它解决了代码的需求;对于状态模式,我明白了他的功能和写法;对于观察者模式,在课上完成了其阅读,有了一定的了解。
- 这次作业的难度很大,尤其是第一次作业简直是不知道从何下手,但之后两次的迭代显得刚刚好。我认为自己对于任务的处理也达到了自己的预期,但唯一美中不足的是,我没有像部分同学那样花费大量精力去写测试机,测试机其实是对于题目加深理解,锻炼自己的批判能力的一种很好的锻炼,这里有些遗憾。
之后很长一段时间可能都不能正视电梯了,不会把人踢出去的电梯给爷爬