OO第二单元——多线程与电梯调度

安琦22373295 学生 2024-04-20 00:25:22

电梯建模

本次作业中,电梯是整个架构的核心。为便于描述电梯的运行与扩展功能,本次作业使用状态模式对电梯进行建模,即利用枚举类,将电梯的运行抽象成几个特定的状态,并实现状态之间的转化方法。电梯线程启动后,只需获取当时的状态,执行对应方法并完成状态更新(类似有限状态机)

public void Update() throws InterruptedException {
        writeLock.lock();
        try {
            switch (status) {
                case Running:
                    RunningUpdate();
                    if (isReset && status != States.Opening) {
                        status = States.Kernel;
                    }
                    break;
                case Opening:
                    status = States.Closing;
                    break;
                case Closing:
                    ScheduleRequest(true);
                    CloseUpdate();
                    if (isReset) {
                        if (requests.isEmpty()) {
                            status = States.Kernel;
                        } else {
                            status = States.PrepareToReset;
                        }
                    }
                    break;
                case Stop:
                    ScheduleRequest(false);
                    StopUpdate();
                    break;
                case PrepareToReset:
                    location += direction;
                    TimableOutput.println("ARRIVE-" + location + "-" + id + type);
                    status = States.Kernel;
                    break;
                case Kernel:
                    break;
                case DoubleInitialStop:
                    status = States.Stop;
                    break;
                default:
                    throw new IllegalStateException("Unexpected value: " + status);
            }
        } finally {
            writeLock.unlock();
        }
    }

在第五次作业中,电梯所有状态为STOP(未接收到请求),RUNNING(楼层间的运行),OPENING(停靠并开门),CLOSING(门关闭);针对第六次作业的重置需求,我新增了PrepareToReset(预备重置),Kernel(正在重置)两个状态;针对第七次作业的双轿厢电梯,我新增了ForceToStop(原电梯重置后需要强制删除),DoubleInitialStop(新增的双轿厢电梯处于未启动状态)。由此可见,针对更加复杂的请求,只需要新增状态并实现转化方法即可,这种设计有良好的扩展性

读写锁与同步块

在整个架构中,锁用于同步对共享资源的访问,在使用锁时,需要注意以下细节:

  1. 当多个线程对共享对象同时进行写-写操作和读-写操作时,需要对共享对象加锁。
  2. 需要特别避免死锁的情况,死锁的一个经典情景为两个线程分别等待对方释放自己的锁,因此,在本单元的作业架构中,应尽量避免在同步块内调用另一个线程带锁的方法

对于被输入线程和调度器共享的请求队列,本单元全程使用了实验代码中的RequestQueue类,其中所有方法均使用synchronized关键字进行修饰,考虑到对请求队列的读写数目有限,这种方法不会造成过大的性能损失

public synchronized Request getFirstAndRemove() {
        if (!isEnd && requests.isEmpty()) {
            try {
                wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
        if (requests.isEmpty()) {
            return null;
        } else {
            Request tem = requests.get(0);
            requests.remove(0);
            notifyAll();
            return tem;
        }
    }
}

public synchronized void addRequest(Request request) {
        requests.add(request);
        notifyAll();
 
}

以获取最新请求的方法为例,调度器会进入该同步块,若队列为空,为避免轮询,线程会进入wait状态以等待新请求的到来;新请求到来之后,输入线程会调用addRequest()方法,唤醒等待的调度器线程并获取第一个请求

于此相比,针对电梯的同步控制在每次作业均进行了迭代升级

在第五次作业中,只需将电梯自身的请求队列用synchronized关键字进行保护,从而保证调度器分配请求和电梯运行两个线程的安全

在第六次作业中,由于影子电梯需要克隆电梯状态,对电梯参数的读写大幅增加,若仍然使用synchronized关键字则会造成大量的性能损失,因此我使用读写锁进行同步控制。相较于JVM自带的锁,读写锁更加灵活,可以自由控制锁的增加与释放,尤其适用于读数据居多的情景。在本次作业中,凡是涉及到电梯自身信息改变的情景,如电梯状态的改变、请求调入电梯、乘客进出,均需要加写锁;与此同时,凡是涉及到读取电梯信息的情景,如克隆影子电梯,判断电梯请求队列是否为空,均需要加读锁。需要强调的是,锁需要及时释放,不允许在带锁的情况下调用sleep()方法

在第七次作业中,双轿厢电梯需要检查换乘楼层是否被占用,在进入后标记占有,在离开后标记释放,针对这一需求,我依然采用读写锁进行控制。同时,在将原有电梯分裂成双轿厢电梯后,需要向电梯的存储容器中移除原有电梯并添加新电梯,对此我使用ConcurrentHashMap存储电梯,这是一个线程安全的容器,在读写冲突数较少的情况下非常实用

调度策略

单个电梯策略

针对单个电梯,本次作业全程使用look策略,具体实现思路为:电梯每到达一层都会遍历一次请求队列,若在运行方向上存在请求,则维持当前速度;反之检索反方向的请求,若存在则改变电梯方向,若不存在则令电梯保持静止。捎带方面,若本层存在乘客,乘客请求方向与电梯同向且电梯未满则捎带该乘客。

细节方面,为减少线程安全问题,若电梯处于RUNNING状态,则只会在到达每一层后和关门前的瞬间对请求队列执行look策略,其中到达每一层后的执行不会使速度为0。若电梯处于STOP状态,考虑到输入数据保留一位小数,电梯会每隔0.1秒对请求队列执行look策略。这种架构避免了调度器将电梯线程从wait状态中唤醒,可以减少因此导致的各种线程问题

综合调度策略

针对请求在电梯之间的分配,我采用了影子电梯的策略

影子电梯

影子电梯,即在请求分配时克隆电梯信息,得到ElevatorCopy对象,该对象中用于模拟电梯的运行策略。具体模拟时,只需将sleep()方法替换为时间的增加,其余运行策略与状态变化方式和Elevator保持一致即可

电梯优劣评价指标

在本单元的性能分评价中,课程组选取了三个指标:最大请求等待时间,程序运行时间,电梯耗电量,我们可以将其转化为多目标规划问题,即寻找满足min(请求等待时间之和,电梯耗电量之和)的调度方式

具体实现方面,采用贪心策略,即针对每个请求,我们应试图最小化请求等待时间与因新增请求而增加的耗电量,具体实现如下:当每个请求到来时,我们依次执行:

  1. 拷贝影子电梯,获取未添加请求时的电梯完整运行的耗电量W0
  2. 向影子电梯中添加请求,计算此时电梯完整运行的耗电量W1,请求等待时间T,针对正处于重置状态的电梯,我们令T=T+1.0,以模拟平均因重置而增加的耗时(注:这种方式允许向已重置的电梯添加请求,可以防止大量电梯同时重置而导致的请求堆积)
  3. 计算cost=0.4*(W1-W0)+0.6*T
  4. 将请求添加至cost最小的电梯,若cost相等,则添加至已调度请求数最少的电梯(注:这里的权值来自性能分计算公式)

这样,我们就实现了针对单轿厢电梯的调度策略

扩展至双轿厢

双轿厢的实现难点在于,乘客在换乘楼层下电梯后,不一定会进入相同ID的另一部电梯。为实现最佳策略,我们需要获取到乘客换乘时所有电梯的状态,即令影子电梯模拟运行特定时长,考虑到双轿厢电梯的冲突问题,这会导致代码量激增。与此同时,考虑到第一阶段请求一般需要2s以上执行完成,在这段时间新增的输入请求会导致模拟失真

针对这一问题,我采用了只追求第一阶段最优的策略,具体实现如下:在计算cost时,若存在双轿厢电梯导致的换乘现象,设换乘楼层为Trans,目的地为to,则进行以下修改:

  1. T更改为从请求发出到在换乘楼层下电梯的时间(其实在代码中,人到了换乘楼层就会下电梯,只是便于理解),其余与普通电梯一致(若调度进未启动的双轿厢电梯,我们只需令T=T+1.4s)
  2. 生成一个Trans->to的请求,计算请求的期望等待时间ET(这个也在性能分计算公式里给出了),因这个请求而新增的电梯耗电量W2 = 0.2*0.3*abs(Trans-to)
  3. 计算平均因冲突而*新增的运行时间Tcon(我这里取0.15s)
  4. 计算cost=0.4*(W1-W0+W2 )+0.6*(T+ET+Tcon)

这样,如果乘客可以被放进该双轿厢电梯的某个轿厢中,我们也可以计算出cost。由此可见,这个策略并不关心换乘之后的状态,因为换乘时乘客会被重新添加至待调度队列,重新调度即可

作业架构与迭代

第五次作业

本次作业架构较为简单,main启动输入,调度器和电梯线程,输入线程获取请求并加入RequestQueue,调度器从RequestQueue中获取请求并分派至电梯,输入线程与调度器共同构成生产者—消费者设计模式。同时电梯根据请求队列和自身状态进行运行,不受其他因素干扰。协作图如图所示:

 第六次作业

第六次需要考虑Reset和Receive的处理

针对Receive,一种稳定的方法是在每次电梯内部执行调度策略之前,先对接收到的请求进行遍历,若未receive过则输出

针对Reset,本次作业新增了两个电梯状态,分别用于描述电梯准备重置和正在重置两种状态。在接收到重置请求时,会将isReset置为true,并保存此时的重置请求。同时,电梯停止的条件也需要修改,当检测到输入结束且所有电梯的请求队列为空且所有电梯均处于STOP状态时,调度器和电梯的线程才会同时结束。在执行运行时的状态更新时,若检测到isReset为true,会发生以下情况:

  • 若电梯处于STOP状态,则直接进入重置状态
  • 若电梯处于CLOSING状态,请求队列为空时直接重置,反之进入准备重置状态
  • 若电梯处于RUNNING状态,则直接进入重置状态

在准备重置状态下,电梯会在当前方向上运行一个楼层,再进入重置状态。重置开始时,若电梯不为空,则会放出内部的所有人,并将修改始发楼层的乘客请求重新加入调度器中的待分配请求队列。迭代后的协作图如图所示:

 第七次作业 

本次作业中,调度器被修改为了单例模式 

电梯在接收到重置为双轿厢电梯的请求瞬间,会将自身从调度器的电梯存储容器中移除,并向其中加入未启动的子电梯,此后其余操作与一般重置请求相同。重置结束后,电梯会向调度器发出信号,启动子电梯线程,最终使自身退出运行。为便于处理,电梯状态中新增了标志双轿厢电梯重置完成的ForceToStop状态和标志未启动的子电梯的DoubleInitialStop状态。针对双轿厢电梯,其运行细节将在下一章节中详细描述。迭代后的协作图如图所示:

 

重置完成双轿厢电梯的处理

针对双轿厢电梯,我使用DoubleElevator类作为Elevator类的继承,其中使用another存储互补的另一半双轿厢电梯。当到达换乘楼层时,会获取另一半电梯的位置(注:此时另一部电梯调用getLocation方法时添加了读锁,因此要释放本电梯的写锁),若它处于换乘楼层,则试图驱赶它,并令自身sleep100毫秒,然后再次获取另一半电梯的位置,如此循环直至它离开

对于另一半电梯,当接收到驱赶信号时,若处于STOP状态,则修改自身速度,并将isEmergency标志置为true(这个标志用于下次执行look策略但请求队列为空时将电梯复位至STOP);对于其他状态,只需令其维持原本的运行,等待离开或转为STOP

Bug与debug策略

针对自身代码

在第一次作业中,由于第一次接触多线程,在实现时遇到了一系列问题,例如:调度器未接收到请求时处于轮询状态,结束条件设置不合理导致调度器未停止,电梯内部数据未进行同步控制导致bug无法复现,对此我当时只能进行反复运行试图定位bug。在互测时,我发现如果0.1秒内有大量请求同时到来,在删除完成的调度策略时会报ConcurrentModification异常,对此我只能使用CopyOnWriteArrayList进行存储

在第二次作业中,我对第一次作业的架构进行了大幅优化,因此在自测阶段未发现bug。在强测阶段,我暴露出了以下问题:

  • 为简化设计,我在电梯的请求队列接收到请求时输出RECEIVE,这要求电梯在处于重置状态时不允许接受请求,因此,若五部电梯均重置,所有请求均会堆积到另一部电梯,导致超时
  • 接上条,由于一些诡异的原因,电梯会在重置时输出RECEIVE,推测原因为电梯和调度器线程未同步,导致请求进入时电梯已经输出RESET_BEGIN
  • 在判断电梯是否结束运行时,我只考虑请求队列为空,未检验电梯是否处于STOP状态,导致六部电梯同时重置时程序提前结束

对此,我进行了以下修改:

  • 将RECEIVE输出放到look策略执行阶段之前,即先遍历一次电梯的请求队列以输出RECEIVE,从而使请求能被调度进重置状态的电梯
  • 增加对电梯是否处于STOP状态的判断,避免提前结束

在第三次作业中,线程出现了死锁问题,在修改读写锁的释放逻辑后得到解决

测试数据制造

在针对代码进行测试时,我写了一个数据生成器和自动评测机,只需在运行之后等待校验结果即可。同时,我还针对一些极端情况进行测试,例如大量数据在同一时刻到达,所有电梯同时RESET,所有电梯均重置为双轿厢电梯,这种策略在第二、三两次作业取得了较好的互测结果

架构不足

本次设计中最大的问题在于Elevator类过大,与ElevatorCopy类有过多的共同方法。最佳解决方法是抽象出一个单独的策略类来容纳单部电梯的调度策略及相关方法,然后将要处理的请求以及下一时刻的电梯速度和状态返回给电梯,令电梯遵循指令运行。不过在设计时,考虑到要传递过多的电梯信息,我并未加以实现。除此之外,可以将一些sleep修改为wait以缩短运行时间,不过由于对多线程操作不够熟练,我最终没有进行修改

感想与体会

感觉这是最坐牢的一个单元了>-<

在第五次作业中,由于第一次接触多线程,我对线程协同完全没有理解,只能模仿实验代码,导致发生了好多诡异的bug(中测还被扣了过程分qwq),好在强测运气不错,没出现太大问题。然后第六次作业主要就改第五次作业的架构,导致对receive和reset的处理一团糟,调度策略还有问题,然后强测就炸了:(

直到第七次作业我才设计出了一个不错的调度策略,然后搓了一个评测机,一路测试下来,好在最后没出现bug,强测结果也不错。不过经过这三周的坐牢,我感觉确实对多线程有了更深的理解,然后还学到了有用的设计模式,收获还是很大的。

 

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

301

社区成员

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

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