面向对象第二单元——多线程电梯

董鑫-21374113 学生 2024-04-20 14:39:20

面向对象第二单元——多线程电梯

三次作业中总体架构设计如下:

img


img


img

同步块与锁的选择

在本次第二单元的三次作业中,我初步了解了Java中多线程程序的编写。其中,涉及到了一个多线程临界资源控制的基本问题:
同步与互斥。控制临界资源的访问,在本单元的Java学习与实践中,我使用到了同步块以及锁的内容。其中我的程序又主要以同步块为主,仅使用了少量锁的内容。

在我对同步块的设置中,选择了简单且粗暴的方法,即凡有必要,一律同步块,同时同步块均使用this,或者修饰函数作为锁对象。没有考虑使用不同的对象充当锁,以实现同一对象的不同方法组的同步与互斥的精细控制。

选择不精细的控制,最终的结果就是各种死锁,或者过度的阻塞。前者使我不得不进行更多的调试,找出死锁,然后分析同步块的缩减。后者减慢了部分程序运行的速度,然后由于程序的CPU运行效率还是相对太高了,导致这一点额外的阻塞时间几乎没有体现。

在锁的选择上,我仅使用了可重入锁ReentrantLock用于保证双轿厢电梯输出的先后顺序。

调度器的设计

在调度器的设计上,调度器分为两个部分,一个是总的分配器,另一个是电梯内部的运动控制器。

在第一次作业中,分配器只是单纯将对应电梯的请求分给电梯,而电梯内部的控制器,则控制电梯向当前电梯载入人的方向/最近的等待的请求的方向运动,如果方向相同,则载入,然后带至目标位置。

而第二次作业中,我设计了一个简单但实现复杂的调度策略。即判断当前电梯能否搭载该乘客,并计算到达其目的楼层的时间作为评估,选取最小的到达时间(不计算新加入的人的影响)作为分配的电梯。其中,调度器不参与计算这些内容,能否搭载(是否超载)以及到达时间均由控制器计算并维护。

但是,区别于影子电梯,该方案并不是在乘客到达时计算搭载该乘客的代价的。相反,这是在加入一名新乘客时,维护目前到达每一层楼的人数以及到达时间。

具体而言,首先,通过维护运动序列(接下来直到电梯完全处理完所有请求的操作,开关门,上下单层,进出人),在人加入时,将额外的运动序列插入linkedList(降低插入的复杂度)。然后,通过该运动序列,计算到达每一层楼,以及第二次到达的剩余时间与最小人数(假定了我们的电梯只能接收最多一次改变方向的路径上的请求)。

但是,明显,运动的次数显著多于请求次数,如果每次运动都重算,代价就很高了。但是,事实上,运动过程中,剩余时间只需简单地将每一层楼的剩余时间减去一次运动的时间即可,而人数不变(照常更新电梯内的人数即可)。

这样做的好处是:在我的设计中,允许调度器因为电梯全部不接收(重置或满员),而不暂时不分配请求。而每次人员进出,都会重新唤醒调度,而重新分配请求。同时,分配器分配时,总会遍历整个等待队列,直到有人被接收/无人被接收,因此这样来看,查询请求的代价是频繁的,能减少连续查找的CPU成本。(然而查询代价必须能被分配,而能被分配就会被分配,所以每次查询必有一个请求被消耗而该电梯被重算,因此即使每次查询的时候现算,代价也不会太大)

而在第三次作业中,对于双轿厢电梯的时间,是通过两个单轿厢电梯的时间拼接出来的,根据两部电梯的位置,与方向,分别查询两个时间,拼接得到代价。

此外的一个优化是:在请求输入后,会被打上一个时间戳(第几个),然后返还的请求该时间戳不变。在调度器内,等待队列由优先队列实现,能够将时间戳最早的放在最前面,使得最早到的,用时最短。

如何平衡多个指标

只关注一个指标,即最长等待时间。当最长等待时间较短时,总时间必然较短(最后一个人的等待时间加到达时间即总时间),同时电量与运动距离高度相关,如果运动距离短,运动时间也短,那么等待时间也比较短。因此,我只关注了最长等待时间。

不过电量与最长等待时间的联系确实不是那么大,应该可以和时间一样,维护总的用电量。

小结

总的来说,我的实现并不是一个好的实现。一方面,没有考虑加入人的影响以及电量的因素。另一方面,实现过于复杂,这种算法几乎毫无扩展性。

三次作业架构设计的变化和扩展能力

三次作业中,我的设计基本沿用了实验课中的流水线架构。通过输入线程->分配器->分发->控制器->运行模拟的流水线结构,实现电梯的模拟运行。

第一次作业的架构基本如实验中的设计。其中运行模拟中,只通过控制器获得下一步行动,自己不进行任何电梯状态,人员的判断。

逐步变化

在之后的作业中,除了上文提到的复杂的运行时间维护,在某些细节上,做出了一定的魔改。

一是、getAndRemove的设计上,进行了一定的魔改。

常规的模式应该如下:


synchronized ReturnType getAndRemove() {
    while (queue.empty()) {
        wait();
    }
    return queue.remove(0);
}

在第二次调度器中的魔改如下:

synchronized ReturnType getAndRemove() {
    boolean allRefuse = false;
    while (true) {
        while (queue.empty() || allRefuse) {
            wait();
            allRefuse = false;
        }
        for (var request : queue) {
            if (someOneAccept(request)) {
                return new ElevatorRequest(queue.remove(request), id);
            }
        }
        allRefuse = true;
    }
}

这样魔改后,调度器拥有了不分配请求的能力,也就可以实现按承载能力分配,避免短时间过多请求分配到同一个电梯。

而另一方面,就是在架构上的一个魔改。

加入了电梯状态变化的回调函数(js写多了的后遗症)。在电梯运行的模拟中,任意运动都不直接修改电梯状态/输出。而是在电梯中定义了一系列回调函数,由电梯自己修改自己的状态,并输出,实现了一定程度上的解耦。如

void Simulationg::moveDown(){
    tryCloseDoor(); // 关门量子化,到必要关门时刻才关门
    elevatorInfo.onMoveDown();
    Thread.sleep(elevatorInfo.getMoveTime());
    elevatorInfo.onDownArriveFloor();
}

void ElevatorInfo::onDownArriveFloor() {
    this.currentFloor--;
    TimableOutput.println("ARRIVE-" + this.currentFloor + "-" + this.id + suffix);
    this.status = Status.IDLE;
    controller.apply();   // 控制器中的一些状态同步
    controller.wake(); // controller.notifyAll()
    distributor.wake(); // distributor.notifyAll()
}

与上一个魔改联系,这样每次有状态变化,都能通过distributor.wake()通知分配器,使得分配器重新看有没有电梯能被分配,使得未处理的请求能被及时处理。

而另一方面controller.wake();是我此次作业中比较失败的一点,本来可能想着应该有两个线程来模拟运动,一个控制人员进出(因为不需要时间),一个控制运动,导致电梯的控制器的getAndRemove也写成了类似Distributorwhile(1)再套正常的逻辑,但是每次调用必然返回/阻塞,再套个while就显得比较迷,而controller.wake()就没有什么作用。

而在第三次作业中,我试图修改我的架构,将一些复杂的类拆成多个不同功能的接口。如:

public class Controller implements MovementSupplier, IController, ControllableController;

将电梯的控制器,拆分为三个不同功能的接口,一个MovementSupplier只有一个getAndRemove方法,为运行模拟器提供下一步运动。IController对分配器暴露的接口,主要用于判断能否接收以及获取到达的时间用于评估。ControllableController用于和电梯的信息同步,由于Controller的部分设计,getAndRemove之后会立即改变Controller内部的电梯部分状态,而电梯的状态是在延迟之后修改的,因此提供一部分接口,用于同步状态。

其余的部分类,也拆分了一部分接口出来。

同时,第三次作业中,对于双轿厢电梯控制器的写法,采用了组合而非继承的写法,因为几乎对外暴露的方法都需要重写,而且继承的话,数据和另外一部电梯的状况比较尴尬。因此采用了组合的方法,将两个控制器组合成双轿厢电梯控制器,所有方法只是对原有方法的拼接,采用类似分治的方法,在canAccept()以及getTime()中,根据当前两个轿厢的运行方向,分了四种情况,分别讨论。

第三次作业中,电梯的控制器方面,采用了继承的写法,由于原本的控制器大体不变,只是上下楼需要加锁(不然几乎相近时间的输出会不按顺序),因此继承的工作量更小。

类图

UML类图如下:

img

可扩展性分析

由于第三次作业中,对主要的一些类进行了接口的拆分,同时原本在电梯运行模拟中,使用回调作为实际的状态控制以及输出的设计。使得我的设计有一定的可扩展性。面对更多不同电梯的控制设计,以及多样的输出/状态控制,可以通过实现IController等接口,以及继承原本的ElevatorInfo修改回调函数,实现一定程度的扩展。但是,在没有实现的方面,可能需要将ElevatorInfo的各种方法也接口化,而且,对于Elevator类的实现不够清晰,本来的想法,是对ElevatorInfo, Controller以及Simulation三个类的组合,可以拼装出多样的电梯。然而在后续修改中,Elevator变成了对Controller的简单包装。

线程之间的协作关系

img

识别出三次作业稳定的内容和易变的内容,并加以分析

三次作业中,较为稳定的是最开始输入的简单处理,电梯的各种基本信息,以及电梯运动模拟的部分。这些部分在三次作业中,几乎没有变化,电梯的信息,除了第二次作业中加入的Reset将某些Static的常量修改为变量,而运动的基本逻辑与第一次完全一样。

易变的部分,主要是电梯运动的控制部分,包括新添加的Reset,双轿厢电梯的处理等。这些内容,不够基础,而且可能的需求复杂多变,因此容易变化。

两个轿厢不碰撞的

  1. 在返回新的行动时,双轿厢电梯截获单轿厢电梯的行动,判断是否到达换乘层,以及另一个电梯是否在换乘层。如果另一个电梯在换乘层且没有指令,则添加上/下楼,然后等待,直到另一个电梯离开换乘层。
  2. 由于在我的设计中,电梯行动开始时,就能知道该行动结束时的位置,因此可以两个一起动,此时输出可能会出现小问题,上下两轿厢公用一把锁。这时,在电梯到换乘层时,Lock一下,然后输出到达。离开时,解锁。

出现的bug与Debug方法

  1. 程序无法正常结束,结束条件设置有误,导致无法结束。IDEA java调试,给wait增加超时,方便调试某些参数的值。
  2. 人数和楼层的计算错误,控制器中的一个行动被返回并移除后,info中的楼层是直到行动结束后才更新的,而控制器需要的信息必须与行动同步。单步调试进去看的。
  3. 维护行动队列,以及运行路径,由于if过多,没有考虑周全出错。加入调试输出并添加单元测试,直到符合自己的需要为止。
  4. 加入量子关门后,关门后新进入的人错误的导致额外关门。观察调试输出信息找到问题。
  5. 电梯控制器返还请求时,线程死锁。通过线程快照/JVisualVM找出死锁函数。

心得体会

本次作业可以说是相当失败,两次强测爆零,主要是自己想的太过复杂,对总体规划的能力还是不足,而难以下手,导致没有时间。在实际的操作中,体会了多线程编程的困难。

但是,在学习,实践过程中,我对线程安全这一知识有了初步的了解,通过使用锁机制,约束方法之间的同步与互斥关系,从而维护临界资源的正确性。而且,锁的机制需要合理地规划以及使用,既要避免未锁,也要避免滥用锁机制。需要合理地对自己的调用关系以及锁的获得进行分析,避免盲目加锁。

同时,对程序有全局的了解,对程序进行分层,分抽象设计,在写代码前,就将可能用到的模块规划好,能够更好的保持面向对象的抽象与封装,提高程序的可维护性,可扩展性。

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

301

社区成员

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

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