OO第二单元电梯作业总结
BUAA_BladeMonster_002
前言:
本单元作业考察的是多线程的相关内容,相较于上一单元的表达式求导,更加易于进行代码复用和扩展,也更加利于理解面向对象编程的思想。经过了上一单元对面向对象的洗礼,在完成本单元三次作业的过程中,笔者感到初窥门径逐渐上手。对于编程的学习,细心思考,大胆实现,就能有所收获。而通过这一单元的学习,笔者不但对面向对象思想有了更深入的理解,对人生也有了一些感悟。话不多说,进入正题。
一、设计思路
(一)Homework 5
本次作业的内容为模拟单部电梯的运行,总体上难度不大,但是由于刚刚接触多线程,对很多关键字的理解都不够深刻(例如最典型的synchronized, wait, notify)使得在刚开始的时候不知从何入手。所以笔者在学习多线程知识(尤其是线程安全)方面花费了一定时间,但在掌握了多线程相关机制和关键字的使用后。总体代码编写的难度不大。
虽然本次作业本身的难度不大,但是作为一个单元的第一次作业,很可能决定了接下来两次迭代开发的基本框架和实现方式。因此也应当仔细思考,严密论证。
笔者在这次作业中使用的设计如下:
1. 总共三个线程:
数据读入线程Reader,
调度器线程Controller,
电梯线程Elevator
2. 三个共享变量:
读入线程和调度器之间的请求等待队列WaitQueue,
电梯执行的请求队列Actions,
保存电梯运行状态的共享变量Status
UML图如下所示:
协作图如下所示:(MainClass类创建并运行线程,在这里被省略)
调度算法:本次作业采用look算法,效果尚可,但只能说差强人意,强测得分为94.788。当时选择look算法也是因为好写,但接下来的两次作业均为采用look算法。
总结:本次作业是电梯系列的开始,起到的作用是入门和定基调,难度不大。值得一说的是,在这里采用的调度器线程其实从效果上来看是完全没有必要的, 两个线程(读入线程和电梯线程)足以胜任。之所以采用三线程的模式,是为了给之后的扩展留出足够的空间。笔者在此次作业中存在的设计问题将在下文描述,在这里不做赘述。
(二)Homework 6
本次作业是模拟多电梯(1~5部)运行。总体来看有一定的难度,难点主要在于多进程的协同控制、线程安全以及请求的分配策略。
本次作业设计仍然沿用上一次作业的三线程类,三共享变量的模式,但是相较于上一次有了一些变化:
1. 本次不再使用Controller而是Dispatcher。显而易见,这个类在职责上发生了变化,在第一次作业中调度器的职责是告诉电梯往上走、往下走还是停住不动。这样的设计其实更偏向于面向过程,而且调度器与电梯的耦合过紧,设计得并不好。更重要的是一开始笔者沿着这样的思路编写代码,没有办法很好地实现线程控制,于是笔者对代码进行了部分重构。重构之后调度器得职责不再是“控制”而是“分派”,也就是说调度器不再告诉电梯它具体应该怎么做,而是将相应的请求分派给它处理,让它自己决定自己的运行状态。通过这种改变,调度器和电梯之间得以解耦, 也就是使用层次化处理的方式降低耦合。
2. 采用请求队列组RequestsList作为调度器与电梯之间交互的共享变量,用以将请求从调度器传递给电梯。在这里RequestsList包含n个请求列表分别对于n个电梯。电梯们只需要“各取所需”, ”适得其所“。
P.S. 这样的设计在第一次作业中是没有意义的,因为第一次作业不需要分派,所以从某种角度上来说,做出部分重构是必然的。
P.P.S.为了扩展性最好使电梯线程间解耦。因为这次作业电梯的运行时间没有差异,可以是同步的, 所以可以使用统一调配电梯的方式,但是这样很难处理异步的电梯(无法应对第三次作业)。
UML图如下:
协作图如下:(MainClass类创建并运行线程,在这里被省略)
算法:在这种层次化处理的机制下,响应一条请求的过程可以分为两个部分:分派和执行。由于分派算法通常是建立在执行算法的基础上的,所以我们先讨论执行再讨论分派。
执行算法:
在层次化处理的思想下,执行的过程是被封装起来的,在执行的过程中程序不需要关心分派的部分,只管自己分内的事情。理解这一点,我们就可以随心所欲地使用单个电梯运行的调度算法了。例如scan算法,look算法等。鉴于此次作业的性能标准是电梯运行的总时间,而且请求到来的时间和请求本身的内容是不确定的,所以我们可以采用贪心的思想来解决问题。其中心思想是:如果不知道将来的情况,就不管将来如何,只把现在手头上的事情做到最好,结果就会很好(人生哲学2333)具体来说,就是用最短的时间去遍历电梯需要到达的楼层,而作为一个电梯,它运动的自由度只有一个,所以可以很方便地计算出它地运行路径,我们只需要找到它在该时刻应该运行的方向即可。值得注意的是,这里的待执行请求的楼层包含的是电梯内部请求的toFloor以及外部请求的fromFloor。
分派算法:
分派过程要做的就是将请求分派给“合适”的电梯,算法的核心就在于判断什么样的电梯是“合适”的。出于性能考虑,我们想要电梯的总运行时间尽可能地短,就应该让电梯之间尽可能地“并发”执行请求,也就是尽量避免只有一个电梯在跑的情况,使电梯负载均衡。要做到这一点,我们可以不断地补齐电梯各自运行时间的短板,也就是将请求分配给当前将最快运行完的电梯。而要达成这一目的,我们需要对电梯进行代价预估,模拟电梯在当前算法下的运行情况,找到用时最短的电梯,但需要注意的是,由于电梯采用的是贪心算法,本质上只能根据当前状态判断下一时刻状态,因为没有人能知道未来会发生什么,所以我们在这里对电梯进行的模拟也只是依照当前状态(电梯内的请求和电梯外的请求)来拟合电梯的运行开销而已,并不能做到百分百的准确(其实大致准确就很好了, 这是一种权衡)。
模拟电梯运行得到运行开销的方法为int getPathLength(from, to, max, min, now, ioFloorNum),毕竟至多电梯运行一个来回就能使当前存在的请求得到相应(不考虑电梯外请求的toFloor,因为执行算法中就没有考虑),所以我们只需要知道当前待分配请求的起始楼层,目标楼层;电梯目前待执行请求的最大楼层,最小楼层;电梯目前的位置和电梯执行这些请求所需的开关门次数。就可以预估出电梯运行的开销。当然还需要注意:在这里统计待相应请求中的电梯外请求时需要考虑是否满载,如果满载,则忽略。
代码如下(十分简单):
1 private int getPathLength(int from, int to, int max,
2 int min, int now, int ioFloorNum) {
3 int pathLength = 0;
4 int a = getMin(min, from);
5 int b = getMax(max, from);
6 int c;
7 if (getAbs(a - now) < getAbs(b - now)) {
8 pathLength += getAbs(a - now);
9 c = a;
10 } else {
11 pathLength += getAbs(b - now);
12 c = b;
13 }
14 if (!((now < from && from < c) || (now > from && from > c))) {
15 pathLength += getAbs(from - c);
16 if (c == a) { a = from; }
17 if (c == b) { b = from; }
18 c = from;
19 }
20 a = getMin(a, to);
21 b = getMax(b, to);
22 if (getAbs(a - c) < getAbs(b - c)) {
23 pathLength += getAbs(a - c) + b - a + ioFloorNum;
24 } else {
25 pathLength += getAbs(b - c) + b - a + ioFloorNum;
26 }
27 return pathLength;
28 }
总结:本次作业相较于上一次作业难度提升的情况因人而异,笔者因为上一次作业设计上的缺陷,走了一些弯路。但总体完成情况还是很不错的。但是很可惜的是,笔者因为线程安全的一个问题(问题很小,后果很严重)在强测中得到了0分,没错,0分。20个点一个都没对,出现的错误都是相同的,bug修复一次就过了(仅仅加了几行代码)。根据bug修复后得到数据的运行时间,和同学的强测运行时间,笔者估计出如果没有这个bug笔者的得分将是99+,所以除去这个“大”问题之外,笔者的设计还是没有问题的。至于bug的具体分析和心得体会,将放在下文来谈。
(三)Homework 7
本次作业依然是模拟多电梯运行,不同的是增加了以下需求:
1. 约束电梯停靠楼层,将电梯分为ABC三类,电梯只能停靠在相应类允许停靠的楼层。
2. 乘客可以进行换乘操作
3. 可以在中途增加电梯
并且本次作业的性能评价标准变为了电梯总运行时间和乘客总等待时间(乘客请求的周转时间)总和。
本次作业可以在上一次作业的基础上进行增量开发,笔者的设计只进行了一些小的修改,印证了上次作业的可扩展性。电梯停靠楼层的约束问题很好解决,在这里不进行赘述。而在中途增加电梯,也只需要让调度器处理相应请求是,新建一套电梯(电梯及其相关的requestList和status)并将它们加入相应的list中即可。而乘客如何完成换乘操作,下面的算法部分中详细讨论。
先放上UML类图:
协作图如下:(MainClass类创建并运行线程,在这里被省略)
算法:在这里我们要解决一个很关键的问题,如何完成乘客的换乘?而解决这个问题的利器就是层次化处理。我们可以先看一下需要换乘的请求和可以直达的请求之间有何异同,它们之间的差异很明显,一个需要换乘一个不需要换乘(废话),而它们之间的相同点我们可以通过解构换乘这个操作来进行,所谓换乘也就是坐多次电梯而已。所以我们只需要让需要换乘的乘客乘坐两次电梯就可以了(因为在这次作业中换乘一次就可以做到任意可达,且换乘时间代价不可控应尽量减少换乘次数),也就是把一个请求拆分成两段请求:从起始楼层到换乘楼层和从换乘楼层到目标楼层。在完成第一段请求后生成第二段请求投放到waitQueue中,重复第一段请求的过程即可。
执行算法:本次作业虽然性能指标有所变化,但是如果我们只考虑单个电梯的运行情况,笔者采用的算法是没有问题的。其实乘客的总等待时间作为指标和平均等待时间作为指标是等效的,而且如果我们把乘客的请求看作一种作业,其实对乘客请求的响应对应的就是OS中的作业调度。乘客的平均等待时间也就对应着OS中作业的平均周转时间。而我们知道平均周转时间最少的算法是短作业优先算法(反正总有人要等的,就让麻烦的人等久点吧),而笔者采用的贪心算法使用的就是一种抢占式的短作业优先原则,所以两种标准对于笔者单个电梯调度的代码来说并没有矛盾。
分派算法:本次作业分派算法的基本原则与上次作业一致:分配给最快执行完请求的电梯。不同的是,需要进行可达性判断,可能需要二次分派(对于需要换乘的人,设置换乘层, 不需要换乘的人,换乘层置0)。而在究竟在哪一站换乘这个问题上,笔者采用的是静态策略,也就是根据fromFloor和toFloor来确定transFloor(1, 5 或 15),优点是稳定,较为高效,而且贴合生活实际(生活中人进电梯的时候换乘路线以及自我规划好了)缺点是,这可能不是最优解。其实这是一种在代码简洁性,可扩展性,稳定性,与较高耦合的效率提升之间的选择,笔者选择前者,很多时候耦合越低,抽象层次越高的代码,在具体执行效率上来说可能不及一些高耦合的代码,但却更易于维护和迭代,可以做成更多的事情。程序设计也是设计,less is more 的原则同样适用!
重新说回请求分派,程序处理请求所要做的就是找到能完成这段请求(可能分段)并且代价最小的电梯,并且将请求分派给它。这里的代价预估是核心,如果我们不能或很难做到精确的代价预估,就尽量拟合。(差不多有时候就是最好的)我们需要考虑的代价是:电梯运行时间的代价和电梯内乘客等待时间(至于电梯外的等待时间,我们没法管,也不必管)。另外一定要注意对于电梯预期载荷的处理,如果同时到来大量相同请求,由于电梯还没有进行状态更新,调度器又可能会将它们全部分配到一部电梯中,所以我们要依照等待队列的人员数量和电梯内部实际载荷之和作为预期载荷进行判断。总之就是,满则不分配,实在没有空的,再分配给满的,这样就能使电梯的处理更加均衡。
电梯选择的实现代码如下(并不完全,仅给出关键部分):
1 private int selectElevator(Person p) {
2 int from = p.getFromFloor();
3 int to = p.getToFloor();
4 int trans = transTable[from][to];
5 if (trans == 0) {
6 synchronized (waitQueue) {
7 waitQueue.out(p.getId());
8 }
9 return selectOne(from, to);
10 } else {
11 p.setTransferFloor(trans);
12 return selectOne(from, trans);
13 }
14 }
15
16 private int selectOne(int from, int to) {
17 int minLength = 2000;
18 int index = -1;
19 int kind = 1;
20 int expLoad = 0;
21 for (int i = 0; i < elevatorList.size(); i++) {
22 Status s = statusList.get(i);
23 int nowKind = s.getKind();
24 if (canAccess(nowKind, from, to)) {
25 int nowReqNum;
26 synchronized (requestList) {
27 nowReqNum = requestList.getEleReqNum(i);
28 }
29 int now = s.getNowFloor();
30 int max = s.getMax();
31 int min = s.getMin();
32 int nowExpLoad = s.getNowLoad() + nowReqNum;
33 int ioTime = s.getIoTime(from, to);
34 int pathLength = getPathLength(from, to, max, min, now)
35 * getRunTime(nowKind) + ioTime;
36 if (pathLength < minLength ||
37 (expLoad >= getMaxLoad(kind) &&
38 nowExpLoad < getMaxLoad(nowKind))) {
39 if (expLoad <= getMaxLoad(kind) &&
40 nowExpLoad >= getMaxLoad(nowKind) && index != -1) {
41 continue;
42 }
43 minLength = pathLength;
44 index = i;
45 kind = nowKind;
46 expLoad = nowExpLoad;
47 }
48 }
49 }
50 return index;
51 }
总结:本次作业较上一次作业有了一些变化,但只要在之前做好架构,本次作业完全不需要有什么大改动就可以很好的完成。本次作业笔者得分99.881分(其实这不是最高的,有99.999分的大佬,但我们的做法类似),可以看出笔者代码的设计还是很有效的。附上详细得分情况(也算是一雪前耻了):
二、代码分析
(一)六大设计原则分析
通过本单元的作业,笔者对SOLID原则重要性的认知更为深刻,接下来笔者会逐条进行分析:
S: 单一职责原则(Single Responsibility Principle):本次作业笔者每个类的作用都算明确,例如Reader负责读取和投放请求,Dispatcher负责分配请求,Elevator负责执行请求等,但是再一些小的方面上(例如一些方法)可能还有所瑕疵,而且有职责过于泛化而不具体的嫌疑。
O: 开闭原则(Open Closed Principle):对于本单元第一次与第二次作业之间的迭代笔者没有做到该原则,更改了架构,但在第二次和第三次作业的迭代中,笔者基本做到了这一点,仅仅修改和增加了个别方法(如电梯分配算法)增加了一些成员变量(如电梯的access数组)
L: 里氏替换原则(Liskov Substitution Principle):本单元笔者继承的类仅有Thread类,并且没有对父类进行什么更改,符合该原则。
L: 迪米特法则(Law of Demeter):本单元线程之间的数据交互,笔者都采用共享变量的方式,来降低耦合,基本符合该原则。
I: 接口隔离原则(Interface Segregation Principle):本单元作业中笔者未设计接口,暂不谈论该原则。
D:依赖倒置原则(Dependence Inversion Principle):本单元的底层模块可以视为具体的乘客,电梯并不依赖乘客,而是依赖于乘客类;读入也不依赖于具体请求而是依赖于请求接口(官方给出的代码实现)我认为基本符合该原则(因为乘客都是一样的,所以没有必要增加一个抽象类来强行贴近该原则)
(二)代码度量分析
第一次作业:
可以看出Controller的getAction方法和Elevator的run方法复杂度过高,这的确是我设计中存在的问题,初入多线程对代码不熟悉,所以一不小心就写成了面条代码,实在不应该。
第二次作业:
第二次作业中存在的问题依旧是Elevator的run方法复杂度过高,尽管我已经试图进行抽象和简化逻辑相对上一次作业有所改善,但看来做得还是不够。
本次作业问题依旧,可能是因为本单元最后一次作业,有点点放飞自我:D
(三)第三次作业扩展性分析
第三次作业的具体设计与实现在上文已经阐述,由于基本遵循SOLID原则进行设计,所以可扩展性有基础保证,例如加入走楼梯的方式,只需要增加一个楼梯类(与电梯类似)并且让楼梯类与电梯类都继承自一个“上下楼途径”抽象类,再由调度器统一调配即可;再如加入维修,只需要增加电梯的一种状态即可。
三、BUG分析
本单元一定要注意线程安全,如果线程安全做不到位,其他一切都会变成无用功。笔者在本单元遇到的唯一一个bug(也是一下把我拍死的bug)就是线程安全问题。尽管笔者已经十分注意线程安全避免死锁和死循环,但是却忽略了程序最开始读入的问题。在第二次作业中由于需要现在main线程读入电梯个数,笔者没有详细阅读输入接口文档和讨论区相关的讨论贴(容我说一句jycnb!)所以在程序中调用了两次system.in而没有释放, 导致在reader线程开始时main还未结束出现了对象,共享一开始的请求输入读不进来,就导致了程序出错。
扎心.jpg
其实本单元第二次作业给我的打击还是挺大的,尤其是在自己性能做得不错得情况下并且本地测试未发现任何问题,并且顺利通过了中测的情况下,因为一个小错误就“百米跳水”,属实难受。但是这个问题也的确值得重视(虽然第一次和第三次都不会出现这种问题)这个bug也的确是我自己得问题,我能做的就是从中吸取教训,痛定思痛,知耻后勇。
另外第一次和第三次作业均未发现bug
四、测试策略
(一)python评测机
本单元的三次作业笔者均采用搭建评测机自动测试的策略,因为请求无非就(floorNum - 1)* floorNum种,直接随机生成即可(官方评测机的数据也是如此)然后通过python的subprocess类运行jar包投放输入获取输出,再通过评测机按照输出进行模拟,判定输出正确性即可。总体上难度不大,但是测试正确性部分代码略显复杂(分支语句过多)。另外笔者在进行测试时,评测机有两种模式:一种是生成数据->运行jar包->获取输出->测试正确性, 另一种是(生成数据)->读入input和output -> 判定正确性。二者相互配合进行测试效果还是不错的(不要提第二次作业)
(二)java投放器
如果评测机运行jar包超时,可以在程序中加入FReader类来替代Reader类的作用,功能是根据带有时间戳的输入文件定时向waitQueue中投放数据,可以有效跟踪验证代码的问题。
五、心得体会
(一)在程序设计方面
本单元的三次作业,让笔者对多线程编程有了初步的实践,从什么都不会到现在基本可以编写正确的多线程程序,笔者学到了很多,无论是线程安全的同步,等待,唤醒,还是设计模式的生产者消费者模式,都让我在实践的过程中有了一些思考,总结如下:
1. 一定要降低程序的耦合程度,让程序的每一部分各司其职,保持代码的简洁,高效,可维护性。
2. 一定要进行层次化处理,层次化处理问题,是计算机科学思想的精髓,利用层次化降低耦合可以解决很多问题。
4. less is more 在程序设计中依然适用。
3. 理解问题时要对问题进行解构,理解问题的构成,才能解决问题,和创造价值(钢炼既视感)
(二)在人生方面
通过本单元的学习,笔者也深刻理解了一些人生上的道理,这里放上思考来警示自己:(鸡汤?)
1. 对于生活的理解其实并不是付出了就可能得到回报,而是就算竭尽全力依然有可能得到最差的结果,生活没有保底,而且你仍然需要面对它。
2. 痛苦是成长的必要条件,只不过有的漫长,有的突然。但无论如何都会有所成长。
3. 尽管为了前程竞争不可避免,但竞争不是生活的全部。学计算机本身不应该仅仅是为了保研为了赚钱,而是为了学到真正的东西,用技术用知识去带给这个世界一点点改变。我不应该本末倒置,最起码我自己应该认可自己。
最后,OO加油!