Unit2-电梯调度

前言

第二单元在开始前的压迫感是 OO 先导课和目前做的两个单元中最强的,但上手后发现只要开摆甚至不如上单元痛苦。所以这单元我直接选择了原始版的自由竞争策略(进化版的自由竞争是可以省一些电的),也没有写评测机,也懒得写量子电梯卷性能。但这样也留下了一些遗憾:没有尝试调度器这一比较推荐的架构,没有尝试评测机的设计与调试,没有使用可重入锁和读写锁,没有……我学到的新知识也就有限得多,经历也少了很多。这是十分值得反思的一次单元经历。

调度策略

整个单元都采用了自由竞争的“调度”策略,这是懒 + 求稳的结果。

第五次作业

作业要求

模拟一座 11 楼的大楼,内置 6 部电梯,共同处理一定时间内投入的乘客需求。

UML 类图与时序图

第五次作业类图如下。总体思路:

采用单电梯的 LOOK 算法和多电梯之间的自由竞争;使用生产者消费者模式:输入线程 InputThread 是生产者,等待队列作为共享队列/缓冲区 RequestTable ,六部电梯 Elevator 是消费者(一个缓冲区对应一个生产者,六个消费者)。每个电梯内部有属于自己的策略类 Strategy ,实现 LOOK 算法。

具体实现:Elevator 类和 InputThread 类继承 Thread 类,RequestTable 类作为公共托盘,只实例化一次用于存储整栋楼的等待队列。InputThread 处理输入的乘客请求,将乘客放入等待队列中;每个乘客都用 Person 类实例化,记录乘客的出发地、目的地和 id;等待队列获取到乘客后会 notifyAll() 所有电梯线程,让进入 wait() 状态的空电梯开始运动接取乘客。电梯到达一层楼后执行什么方法/动作,由这部电梯独有的策略类 Strategy 判断,判断依据是本电梯和整栋楼的等待队列。

时序图如下。多线程运行时:

先由 main 线程创建 6 个初始电梯线程和 1 个输入线程,这是程序的开始。程序的执行过程在上面描述过。程序的结束逻辑:使用训练题目的观察者模式,输入线程获取到 null 时,设置该线程状态为 OVER,并直接结束输入线程(这一点在接下来的作业中,每次都会有改动);电梯线程在送完所有乘客后,成为空电梯,这时判断等待队列是否为空。若等待队列为空,且输入线程状态为 OVER,则该电梯不可能再接到任何乘客了,不妨直接结束这个电梯线程。6 部电梯线程全部结束后,整个程序也就运行结束了。

电梯调度分析

本次作业多电梯的策略可大致分为:单电梯的运行策略,多电梯之间的调度策略。

单电梯运行策略

对于单电梯的运行策略,我拜读了学长们的优质博客,放心地选择了 LOOK 策略。LOOK 策略脱胎于 SCAN 策略,SCAN 策略是让电梯内有乘客时就保持当前的运行方向,在最高楼层和最低楼层之间不断往返,并接走同方向的乘客,只有在到达最高和最低楼层时才转向。LOOK 策略则稍作变通:SCAN 策略的基础上,当电梯为空,并且电梯前方没有乘客时,就进行转向。可以发现, LOOK 策略与极简的 SCAN 相比,捎带策略是相同的:顺路捎带;而转向策略有差异。

这里就放一下最后一次作业的策略实现:

public Action look() {
    /* 1.优先判断是否maintain */
    if (elevator.isMaintain()) {
        return Action.OVER;
    }
    /* 2.不是只接人电梯 */
    if (elevator.isReadyOutAt(elevator.getFloor())) {
        return Action.OPEN;
    }
	/* 3.只进不出 */
    if (waitQueue.isReadyIn(elevator)) {
        return Action.ONLY_IN;
    }
    /* 4.电梯非空,保持运行状态 */
    if (!elevator.isEmpty()) {
        return Action.MOVE;
    }

    /* 5.电梯和等待队列都空 */
    if (waitQueue.isEmpty()) {
        return waitQueue.isOver() ? Action.OVER : Action.WAIT;
    }

    /* 6.空电梯,但等待队列不空 */
    if (waitQueue.isWaiting(elevator, 1)) { // 电梯前方有等待乘客
        return Action.MOVE;
    } else if (waitQueue.isWaiting(elevator, -1)) { // 后方有等待者
        return Action.REVERSE;
    } else { // 由于线程不安全,极低极低概率会运行到这
        return Action.ONLY_IN;
    }
}

上面的策略选择方法一共 6 个步骤。大家可能会注意到,整个策略选择方法是没有进行线程安全保护的(即使里面的一些判断方法有加锁)。这也正是我有些害怕的地方:如果判断过程进行到一半,突然:1)加入了新的乘客;2)要开门迎接的乘客被抢了;3)电梯被设为 maintain 状态;4)输入线程被设置为 OVER (这个不影响的) 。出现上面的三种情况,是否会出现策略判断出错,进而导致输出错误呢?这是有可能的。下面列举一些会导致输出错误的特殊情况。

  1. 判断到第 5 步,这时突然加乘客到空电梯所在楼层。如果采用基准的 LOOK 策略,这部空电梯会发现前方没有乘客,然后选择掉头;电梯的 reverse() 不让电梯运行一层楼的话,再判断一次就能接上乘客了;但 reverse() 方法让电梯运行了一楼的话,电梯到达新楼层后要再次转向才能接上乘客,如果这部空电梯是在 1 楼并且运行方向为上,或 11 楼并且运行方向为下(这在后续作业新增电梯是完全可以碰上的,如果初始化没做好的话),甚至会导致电梯运行到不存在的楼层导致输出错误。

    我已知的解决办法有两个:一个是我在策略选择方法最后一句的 ONLY_IN 选择;另一个则是让 reverse() 方法只改变电梯运行方向而不继续移动一层楼。

  2. 判断到第 3 步,发现有乘客可以进来,于是选择开门;但正好这层楼的其他电梯把乘客抢走,使得该电梯出现多余的开关门。这个情况出现的频率可不是一般的高,因此最后我采用了二次判断开门的方式,第 5 次的实现是这样的(后面的作业由于新增需求,实现有小改变):

    synchronized (requestTable) { // 开门应该逐一顺序判断是否开门并操作,因此加锁
        if (strategy.look() != Action.OPEN) {
            continue;
        }
        openWithoutTime(); // 这里把乘客接走了
    }
    waitQueue.peopleInto(this); // 再补一下刀
    closeWithTime();

    这里有一个注意点:千万不要让持锁线程进入 sleep,否则会占用其他线程很长时间。

  3. 判断到第 2 步及以后,电梯被设为 maintain 状态。这时候电梯继续接乘客,会导致一些时间上的浪费。不过课程组也贴心地允许了电梯可以继续运行 2 层楼以内再放出所有乘客,停下维修,因此这里没有维护是不会导致错误的。

    比较常见的解决方法:在电梯接乘客对应的方法:waitQueue.peopleInto(Elevator elevator) 中,先特判电梯的 miantain 状态,再决定是否进入电梯。

    if (elevator.isFull() || elevator.isMaintain()) {
        return;
    }

经过上述特殊情况的列举与总结,我的应对方法如下:在电梯的具体执行方法中,对可能导致出错的情况先进行特判。当然,也可以用鸵鸟策略(对可能性极低的多线程冲突选择忽略)

多电梯调度策略

这次单元我全程采用了自由竞争,也就是放弃了调度器架构,主要原因有二:把握不好多线程,所以不敢一开始用这么高级的方法;没能力和时间写出速度性能优于或接近自由竞争的调度策略。。。

第六次作业

作业要求

相比第五次作业,主要增加两个内容:

  1. 支持增加自定义电梯。电梯运行速度、容量、初始楼层都可自定义;

  2. 支持维护电梯。被维护的电梯要在两楼运行范围内将所有乘客送出,并不再参与后续接送乘客。

UML 类图与时序图

第六次作业类图如下:

整体架构几乎不变,这也是自由竞争的一大好处,只修改了:

  1. 输入线程对三种 Request 请求的 parse 功能;

  2. 输入线程等待结束的方法;

  3. 电梯线程对 maintain 属性的设置与读取;

  4. 电梯线程新增 out4maintain() 私有方法,强制请出乘客并加入未到达目的地乘客到等待队列中;

  5. 电梯线程新增 over() 私有方法,结束时特判是否是因为 maintain 而采取不同措施;

  6. 策略类在最开头增加对 maintain 的判断。

时序图如下:

与上一次作业较大的不同是:Input 线程判断是否结束。作业五是接收到 null 输入后直接结束,作业六要等待到所有被 maintain 的电梯内为空,并且等待队列为空(这次不用的,到了下次作业才增加这个判断) ,时,才设置等待队列的信号为 OVER。

电梯调度分析

自由竞争确实是摆烂大法,这次作业几乎不用修改主题逻辑,主要是满足请求回流,也就是作为消费者的电梯也能作为生产者,将请求放入等待队列中。

所以电梯调度策略和第五次差不多。

第七次作业

作业要求

相比第六次作业,也是新增两个要求:

  1. 自定义电梯可以是残疾电梯,只能在指定楼层开关门(maintain 维护必要的开关门除外);因此这次代码不得不实现乘客换乘功能;

  2. 每一楼层的服务中电梯(开启电梯门)和只接人电梯(开启电梯门且无乘客出电梯)的数量分别最多为 4 和 2。

UML 类图和时序图

第七次作业类图如下:

新增的两个要求互相影响不大,下面分别讲述两者的实现。

为了实现同一楼层最多有 4 个服务中电梯、2 个只接人电梯,比较简单的做法是设置 Semaphore 信号量控制。我通过在电梯类里增加 Semaphore 型静态数组变量 serveonlyIn ,以及四个静态方法 acquireServe() , releaseServe() , acquireOnlyIn() , releaseOnlyIn() ,在开门前与关门后分别使用 acquire 与 release 即可。需要注意的是,信号量 Semaphoresynchronize 块之间也会造成死锁,具体示例将在后面说明。

为了实现残疾电梯有效接送乘客,我参考了 fucktree 同学的建议,这里特别感谢一下他的简洁思路。最后我采取的总体策略如下:只要一部电梯能让乘客到最终楼层的最短路径缩短,那么就让乘客上这部电梯。

楼层之间的最短路径我使用简洁易懂的 Floyd 算法得到二维矩阵来存储,每次新增电梯或维护电梯都会根据当前还未结束维修的电梯重新计算并存储。从乘客的角度看,进入每部电梯都有一个离最终楼层最近的可中转楼层。只要这个中转楼层不是乘客当前楼层,乘客就可以乘坐这部电梯,把 ta 放入电梯类的 HashMap<nextFloor, PersonList> 容器中。具体实现如下:

/**
* 乘客乘坐 之后到达floor层的elevator电梯 后的最佳中转楼层
* 若返回0或当前楼层,则不进入该电梯
*/
public int toFloorIfInto(Elevator elevator, int floor) {
    boolean[] access = elevator.getAccess();
    /* 方向是否一致已经不能当做判断条件了. 下面是基础的不接人条件 */
    if (floor != fromFloor || !access[fromFloor] ||
        elevator.isFull() || elevator.isMaintain()) {
        return 0;
    }

    /* 获取最佳的中转楼层 */
    int[] dist2des = routeMap.distances(toFloor);
    int[] dist2now = routeMap.distances(fromFloor);
    int closestDistance = 99999; // 所有中转楼层到目的地的最短距离 的最小值
    int nextFloor = fromFloor;
    for (int i = 1; i <= 11; ++i) {
        /* 这个算法可能可以改进 */
        if (access[i] && dist2des[i] <= closestDistance) {
            if (dist2des[i] == closestDistance) {
                nextFloor = (dist2now[i] < dist2now[nextFloor]) ? i : nextFloor;
            } else {
                nextFloor = i;
            }
            /* 注意closestDistance和nextFloor的更新顺序 */
            closestDistance = dist2des[nextFloor];
        }
    }

    /* 如果电梯不能缩短最短距离 */
    if (nextFloor == fromFloor || dist2des[nextFloor] == dist2des[fromFloor]) {
        return 0;
    }
    /* 如果电梯不为空 且方向不一致 */
    if (!elevator.isEmpty() && getDirection(nextFloor) != elevator.getDirection()) {
        return 0;
    }
    /* 电梯与可缩短距离的中转楼层方向一致,或电梯为空且可缩短距离,则返回中转楼层 */
    return nextFloor;
}

上面方法的简洁性在于,不用求出乘客到达最终楼层的具体路径或所有路径,只要在每一次确定乘客的下一站到哪可以缩短最短距离即可。

第七次作业时序图如下:

从主要交互类上看,增加了路径图类 RouteMap ,其它改变不大,说明自由竞争的架构其实也挺稳定的

电梯调度分析

单电梯运行方面,除了增加信号量约束同一楼层电梯服务数与只接人数,没有其他变化。策略类可以看第五次作业的实现。

多电梯调度方面,仍然是自由竞争。

稳定与易变内容

先看看题目增加了哪些要求。后两次作业总共增加 4 个,会让作业改动较大的只有两个:电梯维护、残疾电梯。下面是变化较大的部分:

电梯维护

  1. 修改了电梯的策略类,在最开头加入是否 maintain 的特判;
  2. 修改了电梯的结束方法,在电梯结束之前放出所有乘客,并将没有到达目的地的乘客修改出发楼层后重新放入等待队列中。

因此这个新要求只是修改了代码的具体方法实现,虽然要做一些工作与痛苦的 dubug,但可以看出 UML 类图的变化是不大的。

残疾电梯

  1. (辅助类)新增了 RouteMap 类,用于存储与更新楼层之间的最短路径,并可以被乘客 Person 类访问,被输入线程 InputThread 更新修改;
  2. (乘客上电梯)修改了 Person 类一个乘客能否进入电梯的公共方法。原本只是用 isReadyInto(Elevator) 判断乘客能否被电梯顺路捎带;现在要考虑这部电梯能否帮乘客缩短到达最终楼层的最短距离,因此改为 isReadyInto(Elevator, Floor) ,判断如果电梯到达 Floor 楼层,能否顺路把乘客带到最佳中转楼层;
  3. (乘客下电梯)修改了电梯的乘客容器种类,把 ArrayList 改为 HashMap,便于存储乘客的中转楼层,而不必修改乘客内部属性;
  4. (线程结束)修改了输入线程 InputThread 类的 wait4over() 方法的判断结束的标准:所有未被维修电梯内部没有乘客,并且等待队列为空。否则一些电梯会提前结束,导致部分楼层不可达。

因此这个新要求修改了原代码的整体架构,并且对乘客是否上电梯这一核心方法进行大改。


综上所述,这单元稳定的内容是生产者消费者模型的基础类(InputThread, RequestTable, Person, Elevator)的关系,以及这些基础类的基础方法(如电梯各种实现运行的方法,加入乘客与接走乘客的方法等,虽然内部实现变化大,但这些方法不会被删除或替换,也就是功能不变),以及策略类。

易变内容:除了第七次作业新加入的 RouteMap 辅助类以外,电梯类的属性变化特别频繁,生产者消费者模型的各个类内部也要新增一些方法,并且原有的方法内部的实现逻辑也会经常改变。

锁与同步块

这单元的多线程编程中,要对可能被同时读写的共享对象加锁。从第七次作业来看,如果采用自由竞争,会被不同线程同时读写的对象有:等待队列 waitQueue(被输入线程或电梯改写,被电梯读取),最短路径矩阵 routeMap(被输入线程改写,被乘客读取)。因此就要对读取以及改写的代码块加锁。锁的对象就是上述两个实例对象。

加锁方式有这几种:synchronize 块,可重入锁,读写锁等。上届学长的博客提到读写锁的效率与 synchronize 块相差不大,所以为了保持代码的简洁性(主要是写起来容易),我采用了 synchronize 块。

synchronize 块要加在哪些地方呢?主要是放在读取和修改共享对象的地方。以等待队列为例,addRequest(Person), peopleInto(Elevator), setOver(boolean), isReadyIn(Elevator), isOver() 这些方法都要加 synchronize。

同步块不仅可以防止多线程之间同时读写,还可以在同步块中使用 wait(), notify(), notifyAll() 方法让读写线程进入等待队列与被唤醒,而不至于让一些线程在没有待处理事项时空转。什么时候 wait() 很好判断,但这单元中什么时候 notify/notifyAll() 是个难点。如果 notify 加多了,会导致 CPU 时间过长;如果加少了,会导致一些线程不会被唤醒,最后导致多线程错误。我在整个单元包括课下都没出现过与之相关的 bug,在这里介绍一下判断的方式。关键点在于观察哪些地方使用了 wait() 以及判断要 wait() 的逻辑 。比如电梯,电梯在本身乘客为空且等待队列为空时就会 wait() ,那么就在破坏这个判断条件的地方 notify 即可:等待队列新增请求。最后,为了让电梯线程能够结束,要额外在等待队列的 setOver(true) 方法也 notifyAll() 一次

同步块还有一个要注意的地方就是死锁。简单来看,当不同锁的同步块之间有嵌套时,就有可能发生死锁。不仅如此,其实信号量和 synchronize 块之间也可能发生死锁。比如在等待队列的同步块内使用信号量,我的解决方式是尝试获取信号量,如果没有获取到信号量就进入 wait() ,其他线程在增加信号量的时候再 notify 它就可以了。文章之前本来说要举例子的,但由于时间不够了,这边就浅浅地跳过叭(悲)。

bug 分析

这个单元的强测与互测我都没有出现 bug,也没有 hack 成功(实际上就没 hack1,本地没找出来)。但第五次作业仍有位房友被 hack 了 4 次。数据是同一时刻大量涌入从 1 楼道 11 楼的请求,电梯运行过程中再加入少量从 11 楼到 1 楼的请求。阅读代码发现是该同学的一个方法没有加锁,导致满载 + 边界 + 新请求 情况下产生的错误。

 

心得体会

本单元让我学习到了多线程编程与几种设计模式,还有 SOLID 思想。这届助教创新能力在 OS 和 OO 上都给了我很深的印象。OS 的 lab 课下代码大改;OO 虽然取消了令我倍感压迫的横向电梯,却加入了残疾电梯,对路径规划的需要也增强许多。不仅如此,debug 过程中还可能遇到死锁,我就在第七作业碰到了信号量与 synchronize 块结合的死锁,最后是逐行 print 找到的 bug。最后,也十分感谢讨论区同学们贡献的各种思路,以及学长们的博客思路分享。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值