OO Unit2 Conclusion ——Elevator schedule

倪宇轩 22371305 学生 2024-04-20 19:04:48

总体代码规格

UML类图

img

代码行数

img

复杂度分析

img

img

img

img

协作图

img

作业分析

第一次作业

简述:第一次作业的要求是标准输入中给定一个带有指定电梯的请求,通过合理的多线程分配,实现目标电梯接受目标请求并且完成。

实现过程:

生产者-消费者模型

本次作业(包括之后的作业)使用的是三个主要的线程:

  • 输入线程
  • 调度器线程
  • 电梯线程
    在这些线程中,有一些共享对象将两个线程连接起来。输入线程将输入的数据储存到** 总的请求盘子中 ,调度器将 总的请求盘子 中的请求按照某一种特定的规则将其分配到电梯的等待队列**中,而电梯就消费这些等待队列中的请求(在之后的reset指令加入之后,还需要考虑电梯返回请求到总盘子中,形成了线程的逻辑闭环)。这个思想贯穿了整个电梯的作业,也是生产者-消费者模型的一个实例化。

调度策略

这一次作业是后面作业的基础,因为在输入请求的时候已经规定使用的电梯,因此相当于调度器中就少了选择调度电梯的一步,只需要将请求直接放入对应电梯的队列中即可。

运行策略

本作业中我采用的电梯运行策略是往届大多数学长学姐们采用的LOOK算法,其基本介绍如下:

  • 首先现为电梯规定一个初始方向
  • 电梯每到一层时,先判断要不要开门
    • 如果有人要出去,则电梯开门
    • 如果电梯外面有相同方向的人要进来,并且电梯还没满员,也要开门
  • 接下来判断电梯里面是否有人
    • 如果有人,则电梯继续运行一层
    • 如果没人,则判断电梯的请求队列中:
      • 如果原方向上还有请求,则电梯继续移动
      • 若原方向上没有请求,但是反方向上有请求,则电梯掉头
      • 如果电梯请求为空,判断输入是否结束,如果结束了电梯线程也结束,否则电梯进入等待状态

具体来说,是这样的:

if (open(floor,num,direction,passengers,elevatorWaitQueue)) {
            return Advice.Type.OPEN;
        }
        if (num != 0) {
            return Advice.Type.MOVE; //电梯里面还有人
        } else {
            // 电梯里面没有人
            // 接下来判断请求队列里面是不是空的
            if (elevatorWaitQueue.waitQueueEmpty()) {
                if (elevatorWaitQueue.isEnd()) {
                    return Advice.Type.END;
                } else {
                    return Advice.Type.WAIT;
                }
            } else {
                if (!elevatorWaitQueue.OriginDirectionEmpty(floor,direction)) {
                    return Advice.Type.MOVE;
                } else {
                    return Advice.Type.REVERSE;
                }
            }
        }

如果用一句话概括LOOK策略(也是我当时对LOOK的理解),就是能走就继续走,不到一定要掉头的时候都不掉头。
同时,上述的策略我是作为一个静态方法类储存起来,在电梯线程每次执行run方法的时候,首先调用此方法进行策略的判断。

这个方法是贯穿我的三次作业的电梯的根本调度方法,因为这个方法是能接就接,本身这个方法的目标就是以电梯的消耗量最少为目的的(对立面是以乘客的需求为目的),所以理论上这个算法的耗电量和运行时间的总和会处于一个相对稳定并且低的状态,而且算法理解起来很简单,因此我也推荐LOOK算法

同步块和锁的处理

同步块和锁主要是为了规避多线程运行过程中对于同一个实例对象同时进行读写操作可能引发的冲突问题所设置的一个解决方案。在本次作业中,就如前文所说的,只有RequestTable和ElevatorWaitQueue这两个对象时作为连接线程之间的共享对象,因此在这一次作业中我的选择就是把这两个类中的访问方法全都加上了锁,保证每个时候只有一个线程能对其进行操作。
附带说一句,我对同步块和锁的理解,就是把它们控制的区域块进行原子化,在这其中的执行过程不能有其他的线程进行干扰。

bug&hack

这次的作业比较简单,因此我把那些讨厌的红色的东西运行错误给解决后,交一次就过了(),所以其实我自己并没有找到很多bug。这一次作业主要是让我们了解多线程的构建方法与运行方式,了解多线程的构造,所以相对比较简单但是刚开始真的把我折磨的死去活来的这次的hack时候主要是一些临界测试进行边缘检测,比如刚好在关门瞬间读取请求、电梯满员之后的处理等等。

第二次作业

第二次作业我个人认为是整个电梯调度系统中相对来说比较难的一次作业,相对于第一次作业的变化主要有三点:

  • 不再给出请求需要乘坐的电梯,我们需要通过自己实现一个策略进行电梯的分配
  • 新增reset指令,电梯进行reset时可以理解成报废一段时间,之后调整电梯参数
  • 新增receive约束,要求电梯在接人或者是有接人的趋势之前,必须先把乘客分配给某个电梯

因此,这次作业我们需要实现的要点和难点如下:

  • 合适的电梯分配策略
  • reset的单独实现(需要提前清空人)
  • 易错点:receive和reset的时机(其中,注意电梯接收到reset信号之后不能输出receive,因此reset具有很高的优先级)

调度策略

理论上来说最优的应该是自由竞争,但是由于本次作业的限制,无法实现
其次的应该是影子电梯,其本质就是将电梯的所有状态完全拷贝一份出来,一模一样的模拟电梯跑6遍(每次不同点只在于新加的请求分配电梯的不同),对于其中的sleep()改为Total+=Time。但是因为那个时候事情有点多并且有点懒本人能力有限,怕为了性能分而丢掉了正确性,所以我就没采取这种方法。
其余的方法一般就是三种:

  • 平均分配:使得每个电梯里面的乘客以及等待队列中的乘客的数量都大致相等
  • 随机分配;对于每个乘客对可以分配的电梯随机选一个
  • 代价函数:自定义一个花费函数,计算每个电梯接到这个请求的代价
    这一次作业中我选择的是代价函数,但是呢因为一些不可抗因素,导致我错过了这次作业的强测提交,所以也不太懂这个的性能咋样。
    我代价函数有两个评判标准:一个是不考虑电梯停下来的开关门时间,到达请求楼层的时间;另一个是电梯已经在等待的人数。这两个按照一定比例相加,越小的优先级越高。

reset的实现

对于Elevator类中的reset实现,我在电梯类中新增了两个私有属性:isReset和ResetRequest,前者是用来代表这个电梯是否正在处于reset的状态,后者是用来储存要被reset的状态。然后对于电梯的reset实现,要分为两步:

  • 将电梯里的人以及电梯等待队列里的人全部赶出去(其实只为了正确性的话,电梯等待队列的人不赶出去也行)
  • 电梯属性的改变
    这个实现起来其实不会太难
public void reset(...) {
        openAndCloseForReset();
        TimableOutput.println("RESET_BEGIN-" + this.elevatorId);
        setMax(max);
        setMoveTime(moveTime);
        try {
            sleep(resetTime);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        TimableOutput.println("RESET_END-" + this.elevatorId);
        setReset(false);
    } //电梯重置 

接下来就是一个很容易错的点:reset要怎么通知电梯呢?
一般来说就只有两种想法,一种是在输入线程直接通知电梯reset,另一种是在调度器中通知电梯reset。理论上来说这两种方法都行,要看具体实践;但是如果使用的是一个request作为接口的话,我觉得还是直接在输入线程中通知电梯,因为这里的关键是 当输入线程读取到reset后会直接输出reset-accept,在这之后这个电梯就不能receive,如果放在调度器里就必须使得reset一有就立刻提取出来的问题。也就是说reset的优先级要比其他的请求高,这个是必须意识到的。
因此,为了方便解决,我个人是直接把reset通知电梯放在输入线程中

if (request instanceof PersonRequest) {
                    MyPersonRequest Myrequest = new MyPersonRequest(...);
                    waitQueue.addRequest(Myrequest);
                } else if (request instanceof ResetRequest) {
                    MyResetRequest Myrequest = new MyResetRequest(...);
                    elevators.get(Myrequest.getElevatorId()).setResetRequest(Myrequest);
                    elevators.get(Myrequest.getElevatorId()).setReset(true);
                }

同步块与锁

这次作业继承了上一次作业的锁,并且由于电梯的isReset状态需要被调度器所知道,因此我就在电梯的这个reset相关部分都加入了锁。其余的与第一次作业大体相同,没有太大的改动。

bug&hack

因为一些不可抗原因,我错过了这个作业的提交,因此hack的时候没有参加,但是我在自己debug的时候,找到了一些容易错的点(处理上文说的reset和receive):

  • 轮询问题
    这个是我在这次作业中才明显地感觉到的一个事情。轮询就是在系统运行的过程中,由于没有恰当地让系统等待,就会出现CPU在一段时间内连续快速的进行循环的访问,但是这个访问是不必要的,也就是每次轮询后的结果都是一样的。我出现轮询的问题的地方在调度器之中:
while(true){
...
if (requestQueue.isEmpty() && requestQueue.isEnd() && TotalReset() /*为了防止reset打回*/) {
                for (int i = 0; i < elevatorQueues.size();i++) {
                    elevatorQueues.get(i).setEnd(true);
                }
                return;
            }
getAndRemove();
...
}

我的wait被封装在这个函数之中,但是我的函数的进入wait的条件是请求总盘子没了但是输入还没结束。这样子的话就会出现轮询,我们考虑一下这种情况,等到输入线程结束后,最后一个请求还在电梯中,这个时候不满足wait的条件,就会导致直接跳过这个wait的地方,而导致这个while(true)循环不断进行,不断消耗CPU的资源。
PS:检查轮询的简单方法:在每个while(true)中加入一个print,如果一直输出就是轮询。

  • 时间问题
    如果五个电梯在reset的话,调度器理论上来说只能分配给一个电梯,那么这时候其实可以选择进行sleep,等到可以分配的电梯多一点了再分配,否则会出现一个电梯里面包含了输入进去的所有请求的bug

第三次作业

简述:第三次作业主要就是加入了双轿厢电梯的处理,但是其实双轿厢电梯除了换成楼层的问题外,跟普通的电梯其实没有区别,因此我们可以沿用之前的思路,仅仅对分轿厢的时候进行着重考虑。

Split实现方法

因为普通电梯和双轿厢电梯的一个其实没有很大的区别,因此我们可以不用新建一个类,而仅仅在原来的电梯类上新增一个标识符,用来表示是原来的电梯还是双轿厢电梯。其余的做出对应的简单改变即可。
对于Split的对电梯的通知,我跟reset一样放在了输入线程中。
需要注意的是,由于Split方法的存在,我们需要重写电梯的出人(out)方法,再换乘层的时候将所有的乘客打下去:

public void out() {
        if (Objects.equals(dc, "0") || this.curFloor != transFloor) { //如果是普通的电梯 或者是双轿厢但是不是位于交换层
            if (curPassengers.containsKey(curFloor)) {
                for (MyPersonRequest request : curPassengers.get(curFloor)) {
                    outprint();
                    this.num--;
                }
                curPassengers.remove(curFloor);
            }
        } else if (Objects.equals(dc, "A")) { //下层双轿厢且位于交换层
            synchronized (totalWaitQueue) {
                for (int i = transFloor; i <= 11; i++) {
                    if (curPassengers.containsKey(i)) {
                        for (MyPersonRequest request : curPassengers.get(i)) {
                            outprint();
                            if (i != transFloor) {
                                MyPersonRequest r = new MyPersonRequest(request.getPassengerID(),transFloor,i);
                                totalWaitQueue.addRequest(r);
                            }
                            this.num--;
                        }
                        curPassengers.remove(i);
                    }
                } //如果是要继续往上走的,就给把他们扔下来,放入总请求队列中
            }
        } else if (Objects.equals(dc, "B")) {
            synchronized (totalWaitQueue) {
                for (int i = transFloor; i >= 1; i--) {
                    if (curPassengers.containsKey(i)) {
                        for (MyPersonRequest request : curPassengers.get(i)) {
                            outprint();
                            if (i != transFloor) {
                                MyPersonRequest r = new MyPersonRequest(request.getPassengerID(),transFloor,i);
                                totalWaitQueue.addRequest(r);
                            }
                            this.num--;
                        }
                        curPassengers.remove(i);
                    }
                }
            }
        }
        totalWaitQueue.wake();
    } //电梯让别人出去

然后还有一个就是如何分裂,我选择的是将原来的电梯直接变成双轿厢电梯中的下层电梯,然后再split方法中为电梯数组增加一个新的电梯线程

如何防止双轿厢碰撞

由于双轿厢电梯不能同时处于某一楼层中,我们需要同一个id的两个双轿厢电梯共享一个数据,作为双轿厢电梯的换成楼层是否被占用的标识。因此我先建了一个类,来表示这个标识。

public class Occupy {
    enum State { OCCUPIED, UNOCCUPIED }

    private State state;

    public Occupy() {
        this.state = State.UNOCCUPIED;
    }

    public synchronized void setOccupy() {
        waitForOccupy();
        this.state = State.OCCUPIED;
        notifyAll();
    }

    public synchronized void setUnOccupy() {
        this.state = State.UNOCCUPIED;
        notifyAll();
    }

    private synchronized void waitForOccupy() {
        notifyAll();
        while (state == State.OCCUPIED) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

之后,再上一节说到的增加电梯线程的同时,我们需要将这个occupy类的实例对象加入到另一个电梯中,作为两个电梯的共享对象。
当一个电梯需要到换成楼层时,检查occupy标识,如果时false就正常移动;如果时true则进入waitForOccupy() 函数中进行等待。
这样一来,我们就完成了双轿厢电梯的主要职责。

同步块与锁

这次在第二次的基础上增加了部分地方的同步块,主要原因是电梯的调度器的退出条件我选择的是:

            boolean flag = true;
            synchronized (elevators) {
                for (Identity i : elevators.keySet()) {
                    if (!elevators.get(i).elevatorEmpty()) {
                        flag = false;
                        break;
                    }
                }
            }
          if (requestQueue.isEmpty() && requestQueue.isEnd() && TotalReset() && flag) {
                for (Identity i : elevatorQueues.keySet()) {
                    elevatorQueues.get(i).setEnd(true);
                }
                return;
            }

也就是,我们需要访问电梯的内部状态来判断,因此,在电梯内部对于reset和split对总请求队列进行修改时,要在外面增加一个总请求队列的锁,如:

synchronized (totalWaitQueue) {
            if (passengerEmpty()) {
                clearPassenger();
            } else {
                openAndCloseForReset();
            }
            TimableOutput.println("RESET_BEGIN-" + this.elevatorId);
        }
//openAndCloseForReset()中调用了totalWaitQueue

同时,电梯的容器也要被外部访问。因此在涉及对电梯的增加过程中,也要对电梯加锁:

synchronized (elevators) {
            changeThis();
            ElevatorWaitQueue parallelQueue = new ElevatorWaitQueue();
            synchronized (elevatorWaitQueues) {
                changeThisQueue();
            }
            startNew();
        }
//简易描述

bug & hack

这次我测出自己的bug和hack别人用的大多数是进程无法正确退出的例子。

  • 单个reset无法正确退出。可能的原因:设置调度器退出条件时因为没有唤醒直接卡死。
  • 双轿厢电梯+一个请求。同上
  • 重置五个双轿厢电梯的同时读入大量请求。可能的原因:将请求全部分给一个电梯了。
    这次hack还比较成功,搜刮获得了12分。

总结及可拓展性分析

迭代分析

在三次作业中,我并没有经历大面积的重构,基本上都是稳步迭代上升

  • 第六次作业中我主要是增添了调度器中分配电梯的功能,与实际生活中的电梯更加贴切
  • 第七次作业中我主要是对于电梯类进行修改,增加另一种电梯的类型
    在整体中,一直不变的是电梯的根本运行策略与生产者-消费者模型所主导的线程总框架,这是不容易改变的,也在未来的迭代中基本不会改变
    易变的内容主要是调度策略与电梯具体的属性。不同目的的电梯可能所需要的调度策略是不一样的,LOOK算法主要是基于电梯自己的消耗,而其余策略可能是根据用户的需要,比如先进先出,先到先得等标准进行,符合乘客的利益。

    可拓展性

    未来可能需要增加一个新功能:(只是我的猜测,不过课程组也可以考虑一下来祸害帮助下一届)
    电梯的乘客直接可能会存在优先级,类似于打印机的顺序,在给出请求的同时给出这个请求是否属特殊请求。要求特殊请求尽快被优先实现(可以以增大消耗量或者是卡时间来实现),也就是更优先实现这个请求。
    在这个迭代背景下,我可以在我的电梯中在设置一个特殊的等待队列,对于策略的判断优先利用这个队列即可,这样我就不用对整体的结构进行大改。

心得体会

这次的电梯感觉起来比第一单元的难度提升了一个档次,主要因为第一单元是单线程问题,而这一单元的多线程问题就必须考虑同步与互斥的问题,并且在debug的时候会变得相对比较困难(代码的bug不一定能复现)。我自己没有啥特殊的debug的技巧,我一般都是对着那次出错的数据结论,根据电梯的id进行筛选,观察一个电梯经历的变化过程。虽然好像确实不是很有用,但是这样确实帮我找到了挺多的bug()。
下面从线程安全和层次化两个角度说一下我的感受:
首先是线程安全。线程不安全其实是导致电梯主要的不容易发现的bug的所在,这个主要是因为同步块和锁的不恰当的加入(一般是少加了),导致在一个地方执行对共享对象的修改的一半地方,突然被另一个线程中的对这个共享对象的改变所打断,最后造成这个共享对象的值的不确定性。因此,我们需要仔细确定线程之间的共享对象,对其进行同步与互斥处理。
然后是层次化设计。这个其实也是面向对象编程的一个很重要的思想,我们只关注这个类应有的职责,而不去过多的考虑它与其它类的耦合之处。就比如电梯作业,我们在写的时候,输入线程、调度器线程、电梯线程都是相互独立的,我们只需考虑他们自己的职责进行编写,只不过在其中增加一些共享对象进行连接。
这次电梯作业说实话还是很有挑战性的,主要是多线程的不确定性让这个电梯更有了神秘色彩。

...全文
77 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧