BUAA_OO_U2 - 电梯

彭子峻-23373334 2025-04-14 23:35:44

概况

本单元以某公司大楼的目的选层电梯系统为背景。

具体而言,乘客向一个中央控制器输入目的楼层,控制器告诉乘客登上哪一台电梯。

在此基础上,每一个乘客请求被赋予了一个优先级指数,要求优先级高的乘客请求尽可能快地到达目的地。

整体架构

类关系

按照惯例,先把UML类图奉上:

img

不难发现,本人的Elevator出奇臃肿

在写完三次作业看来,这主要是因为,我将

  • 锁/线程交互逻辑
  • 电梯的运行状态信息

全部放在了Elevator类中。若能将电梯运行状态信息单独提取出来,相信Elevator类就不会这么臃肿了。

SubRequest类是对作业中出现的各类请求的抽象,记录了各类请求中的关键信息。

这里我有一点做得不好:细看不难发现,我将HW6中的临时调度SCHE请求,与HW7的双轿厢升级UPDATE请求完全整合进了SubRequest中。更优美的做法,是将SubRequest的位置替换为接口,随后各种请求实现这一接口。出于偷懒、拒绝重构的目的,我没有这么做,这个问题当然是需要改进的。

在我的架构中,电梯的运行线程状态信息相互分离,由中央的Dispatcher向各电梯分配请求。 整体效果如下图所示:

img

其中,Scheduler向请求池RequestPool(即上文的RequestQueue)的生产者关系,仅在HW6/HW7中出现。这一点我们在后面分析各次作业时,会详细介绍。

在进程协同模式方面,我的架构整体可以视为两层 “生产者-消费者” 模型:

  • 第一层:InputProcessor作为生产者,Dispatcher作为消费者,请求池作为交换容器
  • (某种意义上,这也算是一层)Dispatcher将请求放入对应Elevator的请求表内,ElevatorScheduler根据请求表与当前电梯状态,进行请求处理与电梯运行
  • 第二层:ElevatorScheduler作为生产者,将无法处理的请求写回请求池RequestPool,让消费者Dispatcher重新分配请求对应的电梯;这在HW6与HW7中出现。

协作图

此处只展示电梯于单轿厢状态运行时的进程协作图。双轿厢的升级与协同更为复杂,详见下文HW7相关。

img

架构随作业的更改

说起来你可能不信,从HW5到HW7,除了更改DispatcherElevatorElevatorScheduler,使之能够支持特殊请求/支持双轿厢以外,我的整体架构几乎没有任何重大改动

当然,没有改动是不可能的。比如说,在HW6的SCHE请求与UPDATE请求中,我们要实现电梯“赶人”的逻辑,这就在整体架构中多加了一层“电梯-请求池”的“生产者-消费者关系”。

对于SCHE请求与UPDATE请求,ElevatorScheduler中的电梯运行逻辑,每次作业都需要更改,同时需要Elevator类中记录必要的信息

此外,为了实现双轿厢的协同,以及UPDATE有关的逻辑,我加了不少进程协作上的逻辑。

总而言之,我本单元相对稳定不变的:

  • 整体架构

易变的:

  • 进程协作关系
  • 逻辑的微观实现,如电梯的运行策略,特殊请求的分配与处理

我认为,我的整体架构在三次作业中没有明显变化,主要是由于以下两个原因:

  • 初始架构充分考虑了后续作业的修改,在设计时进行了适当的抽象
  • 我将各种情况的处理,全部交给了微观层面(如电梯运行策略)实现,从而避免了宏观架构的大规模改变

锁/同步块相关

在我的整体设计中,我除了同步块外,还使用了ReentrantLock进行锁的管理。

ReentrantLock负责对Elevator类的对象进行锁管理,而同步块主要用于对请求池RequestQueue进行锁管理。

我的设计中,各线程对Elevator对象的冲突,显著多于对请求池的冲突。因此,ReentrantLock占了锁操作的绝大多数情况,我同步块用得反而很少。

为何选择ReentrantLock

其一,为了使锁的管理更加灵活。

在我的实现中,我的调度算法与“影子电梯”颇有相似之处,需要先获取所有待选电梯的锁,等待确定唯一候选电梯后,再将锁全部释放。如果使用synchronized块实现,我们就会出现高达六层的死亡嵌套

synchronized(elevator1) {
    synchronized(elevator2) {
        synchronized(elevator3) {
            ...
        }
    }
}

而使用ReentrantLock进行锁管理的话,这个问题可以得到完美的解决:

for (Elevator elevator : elevators) {
    elevator.getLock().lock();
}

// do sth

for (Elevator elevator : elevators) {
    elevator.getLock().unlock();
}

不过,ReentrantLock必须手动、显式地释放锁,使用时因此要多加小心。


其二,配合Condition使用,可以实现粒度更小的进程交互。

在同步块下,我们使用wait()/notify()来实现进程交互;这一做法有一显著缺点:**notify()唤醒的进程完全随机,无法指定唤醒一个特定的进程**。

在使用ReentrantLock实现时,就完全是另外一种风景了:一个可重入锁可以挂多个Condition,每一个Condition都有一套独立的await()/signal();这就允许我们将不同的线程挂在不同的Condition上,并使用不同的Condition.await()/signal(),做到对指定线程的控制

这一特性对我的HW7实现尤为重要;没有它,我就没法在HW7中,用纯线程交互的方法解决UPDATE请求同步,以及双轿厢楼层冲突这两个问题。

锁与同步块中处理语句间的关系

同步块相当于获取对象的锁

以下两种情况可以认为等价:

public class Object1 {
    public ReentrantLock lock = new ReentrantLock();

    public void method1() {
        this.lock.lock();
        foo();
        this.lock.unlock;
    }

    public void method2() {
        bar();
    }
}

testMethod() {
    object1.lock.lock();
        object1.method1();
        object1.method2();
    object1.lock.unlock();
}
public class Object1 {

    public synchronized void method1() {
        foo();
    }

    public void method2() {
        bar();
    }
}

testMethod() {
    synchronized(object1) {
        object1.method1();
        object1.method2();
    }
}

锁/同步块的设置

对请求池RequestQueue,使用同步块;

同步块中内容起到了如下作用:

  • 将请求加入请求池(ElevatorScheduler/InputHandler
  • 移除一个请求(Dispatcher
  • RequestQueue上等待(Dispatcher
  • 唤醒在RequestQueue上等待的线程(ElevatorScheduler/InputHandlerDispatcher

对电梯类Elevator,使用重入锁;

在以下情况加锁:

  • ElevatorScheduler:进行一次电梯运行(爬升/下降一层,停留下客一次)
  • Dispatcher:对各电梯进行评估,找出最佳选择时

调度器设计

如何交互

前面提到,我们实现了类似“影子电梯”的调度策略;这要求我们先获取所有电梯的锁,找到最佳电梯,完成分配后,再释放所有获得的锁

整个过程依次如下:

  • 获取所有电梯的锁
  • 利用已有的计算策略,找出最佳电梯
  • 将请求分配给电梯
  • 使用Condition.signal(),唤醒当前可能正在休眠的电梯运行进程ElevatorScheduler
  • 释放所有获得的锁

特殊请求也由调度器处理

针对HW6的SCHE请求与HW7的UPDATE请求,我都选择交由Dispatcher实例进行处理。

原因无他,为了减少进程交互的复杂性。 我的电梯运行线程Scheduler与电梯信息类Elevator是分离的,我希望Elevator只有DispatcherScheduler两者在竞争。

这两类特殊请求都将直接加入请求池。那我们怎么保证Dispatcher优先处理这两种特殊请求呢?

答案是,将请求池改为用优先队列实现。这是因为,优先队列的比较条件(提供了Comparator接口)可以自行实现,这为我们当前情况的需求提供了极大的便利。

我们需要保证:

  • SCHE/UPDATE请求在优先队列的首位
  • 剩下的普通请求在后面。

示例如下:

import java.util.Comparator;  
  
public class RequestComparator implements Comparator<SubRequest> {  
    @Override  
    public int compare(SubRequest o1, SubRequest o2) {  
        int o1Index = calcIndex(o1);  
        int o2Index = calcIndex(o2);  
        return o1Index - o2Index;  
    }  
  
    private int calcIndex(SubRequest subRequest) {  
        if (subRequest.isSche()) { return 0; }  
        else if (subRequest.isUpdate()) { return 0; }  
        else {  
            // 按你自己的方法,处理普通请求!
        }  
    }  
}

在Dispatcher从头遍历优先队列时,我们此时即可保证特殊请求被优先处理。

对应的,我在Dispatcher中实现了分配这两个特殊请求的逻辑。

这样当然有坏处,就是某种意义上违反了单一职责原则

调度策略

我的这个策略在思路上与影子电梯有颇多相似之处,只不过:

  • 只需要计算(模拟)当前请求的运行时间
  • 已指派给电梯的其它请求的估计,利用优先级机制实现

是否允许加入

进行计算之前,Dispatcher需要得知当前正在评估的电梯,能否接受当前待加入的请求。

可加入的规则如下:

  • 若电梯此时在IDLE状态下,直接允许加入,否则进行下述流程
  • 请求方向需与电梯当前运行方向一致;方向有关的说明见上文。
  • 电梯同向,且电梯此时没有超过请求的起点
  • 如果状态为(RESERVED)/(WAITING,且前一个状态是RESERVED),则:
    RESERVED状态需允许捎带
    若预约请求方向与当前运行方向相反,则待加入请求的终点不得超过预约请求的起点
  • 加入后,不会出现电梯满员的情况

“电梯满员”视个人实现不同,判断逻辑可能不同;我这里就以我的实现为例,谈谈我的判断逻辑。

正如上文所述架构实现,我们一个电梯的子请求队列不仅包含了当前在电梯内的请求,还包含了将来电梯要捎带的请求;

我们的判断逻辑是,假设请求加入了子请求表,得出各楼层处,最多同时出现的请求数;

这一“同时出现的请求数”,反映了那一运行区间内,电梯内的人数。

若各区间的同时出现请求数的最大值,大于电梯容量,则认为此时加入请求会导致超员。

img

如图所示,待加入请求在F2-F3区间会导致超员,故我们不应加入这一待加入请求。

需要注意的是,如果采用了上文RESERVED状态的实现,若预约请求方向与电梯空放方向一致,此时还需将预约请求放进上面的图里进行比较。

计算评价指数

在上述复杂的判断后,我们终于可以开始计算了...

我们的计算公式如下:

$$ index = index_{base} + priorityPenalty * elevStopTime $$

index_base

我们先需要得知:

  • dis :电梯所在位置,与请求起点的楼层数
  • travel :请求跨越的楼层数
  • stopCount :请求沿途需要停靠,以让自己进电梯/捎带其它请求;stopCount即中途停靠的次数

随后即可算出当前请求的运行时间,作为我们评价指数计算的第一步;

$$ index_{base} = (dis + travel)*elevSpeed + stopCount * elevStopTime $$

priorityPenalty

优先级惩罚,是所有请求的优先级惩罚之和:

$$ priorityPenalty = \sum_{i=1}^{n}{penalty_{R_i}} $$

对于一个请求 R ,设其优先级为 priority ,楼层跨度为 travel ,则我们规定该请求允许的中途停靠次数 maxAllowedStops 如下:
$$ maxAllowedStops = round(travel * (1 - \frac{priority}{100})) $$
其中, round(x) 取 x 最靠近的整数。

在我们进行评价指数的计算时,我们先假设待加入请求已被加入;随后,对原有的请求 R_i ,算出加入后的总停靠次数 stops 。

  • 若 stops <= maxAllowedStops:$$ penalty_{R_i} = 0 $$
  • 否则:$$ penalty_{R_i} = (stops - maxAllowedStops) * priority $$

由此,我们就可以算出各个(可加入请求的)电梯的评价指数,选择评价指数最低者加入。

HW7对调度策略的影响

我在本单元求稳为先,选择的方法不一定最优

我的原调度逻辑类似影子电梯,在出现双轿厢的时候因此也犯了难。比起编写新逻辑,我选择最大程度复用原有的调度逻辑。

在原有的调度基础上,我们需要添加是否在运营范围内的判断。

调度第一步,沿用原有逻辑,对所有电梯评估请求是否可分配,选评价指数最低者。

同时,在这一步中,记录下所有的双轿厢电梯。

此处记录的并不是双轿厢电梯对,而是其中的每一个电梯。 例如:2、3是一对双轿厢电梯,我们这里记录的是:电梯2、电梯3,而不是[电梯2,电梯3]

若第一步未能分配,则进行第二步,“拆请求”。在第二步中,我们只对双轿厢电梯进行评估。

  • 将原有请求,以当前评估电梯的目标楼层为节点,按先后顺序分成两段;
  • 在第一段请求完成后,再由Dispatcher分配第二段请求。

“拆请求”如何实现?我们需要修改我们已有的乘客请求类passengerRequest,在其属性中加一个passengerRequest followingRequest,存储当前请求的后半段。

在下电梯的时候,需要判断是否包含后续请求,有则OUT-F,并将后续请求放回请求池,没有则OUT-S

判断是否可加入,与计算代价指数的逻辑不变。 不过,我们选取电梯的逻辑发生了变化,选取优先级如下:

  • 可行楼层跨度最大
  • 原设计中,评估指数最小者

选取楼层跨度最大者,是因为,我们无法保证后续换乘的次数;换乘可能会造成额外的等待时间,且额外等待时间难以预知。楼层跨度尽可能大,可以尽可能减少换乘次数,一定程度缓解这一问题。

事后分析

我在设计调度策略的时候,主要设定了这么两个目标:

  • 对于当前请求,选择完成当前请求耗时最少的电梯
  • 加入的请求,对电梯先前已经接收的请求的完成时间影响最小

“完成当前请求耗时”中,包含了电梯从不同楼层,空放到起点接乘客的时间,这背后反应的是电梯的移动距离,对应了耗电量指标;

“请求耗时最少”本身也是性能指标之一,单个请求耗时最短,绝大多数情况也可使得另一指标:系统整体的耗时减短。

也就是说,我们只关心我们最开始的两个指标,就足以处理绝大部分的性能指标。

至于优先级,我通过构建了一个简单的数学模型,按照优先级设计了“罚时”机制,从而解决了优先级评判指标的问题。

HW7相关更改

本人实现的亮点在于:UPDATE请求的处理,与双轿厢目标楼层的规避,全部利用线程交互实现

UPDATE请求的处理

我们的UPDATE请求放在了Dispatcher处理;此时不难发现一些问题:

  • 在UPDATE请求处理完成前,Dispatcher是不是会被阻塞,不能分配其他请求?
  • UPDATE显然需要一个类似总控的东西,来控制有关的交互;这个总控难道就是Dispatcher线程本身?

这样肯定不对。因此,我们在Dispatcher内引入一个子进程,作为UPDATE请求处理的“总控”。

这个子线程怎么创建呢?使用Lambda表达式,可以快速创建新线程

Thread th = new Thread(() -> {  
    // Do something!
});  
th.start();

这样,Dispatcher线程就可在处理升级请求的过程中,继续分配其他可分配的请求,而不会发生阻塞。

如果你不放心,还可以用th.join(),在Dispatcher线程的某处,强制处理UPDATE请求的子线程完成后,再继续执行。

总控与两台待升级的电梯的整体交互逻辑如下:

  • 告知两台电梯需要升级,等待两电梯就绪
  • Scheduler读取Elevator中信息,得知电梯需要升级,进行疏散乘客等“预处理”操作
  • 处理完成后,通知总控该电梯已就绪,等待总控对其进行升级
  • 总控在两台电梯均就绪后,输出UPDATE-BEGIN,随后修改电梯的运行属性,完成升级

具体实现上,我的电梯信息类Elevator采用了ReentrantLock实现锁管理,上文中的两个等待,我们通过创建两个Elevator锁的Condition实现。

电梯如何指示自己已就绪?我采取给电梯增加一个UPDATE运行状态;在电梯完成了“预处理”后,电梯就将进入这一运行状态,并使用Condition.signal()唤醒总控。

下述伪代码仅作示意之用。更进一步的细节,需要各位根据自己的架构实现。

// Elevator
ReentrantLock lock;
Condition schedulerUpdateCond = lock.newCondition()
Condition dispatcherUpdateCond = lock.newCondition()

setUpdateStatus() {
        this.status = new Status("UPDATE");
        this.dispatcherUpdateCond.signal();
    }

updateA() { // updateB()同理
    // 修改属性,并将电梯运行状态设为IDLE
    this.schedulerUpdateCond.signal()
}
// Scheduler
elevator.lock.lock();
...
if (elevator.shouldUpdate()) {
    evacuatePassengers();
    elevator.setUpdateStatus;
    elevator.schedulerUpdateCond.await();
    elevator.respondUpdate; // 解除指示电梯需要升级的flag
    // await()结束后,电梯已被更新为新的状态,按照原有的运行逻辑运行即可!
}
// Dispatcher
Thread th = new Thread(() -> {  
    Elevator elevA, elevB;

    // 由两层await(),实现两电梯均就绪后再继续
    elevA.lock.lock();
    if(elevA.getStatus() != "UPDATE") {
        elevA.dispatcherUpdateCond.await();
    }
    
    elevB.lock.lock();
    if(elevB.getStatus() != "UPDATE") {
        elevB.dispatcherUpdateCond.await();
    }

    // UPDATE-BEGIN
        //重新分配被取消RECEIVE的请求
        elevA.updateA();
        elevB.updateB();
    // UPDATE-END

    // 放elevA/B的锁
});  
th.start();

楼层冲突规避

基本分析

很遗憾,我们不能通过不到“目标楼层”的方法,规避楼层冲突的问题,否则全部都被改造成双轿厢,且目标楼层均在同一层时,会导致跨越目标楼层的请求全部无法完成。

产生冲突的情况,我们大致可以分为三类:

  • 相向而行:两电梯同时将要到达目标楼层
  • 同向而行:一个电梯尚未完全离开目标楼层,而另一电梯即将到达
  • 一动一静:一个电梯闲置于目标楼层,或正在目标楼层下客,而另一电梯即将到达

在我的实现中,与电梯移动有关的逻辑是这样,相信各位的实现与其大同小异:

// Scheduler 
while (true) {
    elevator.lock.lock;
    try {
        if (/*一般情况*/) {
            normSchedule();
        }
    } finally {
        // 以防中途开锁
        if(elevator.lock.isHeldByCurrentThread()) { elevator.lock.unlock(); }
    }
}

normSchedule() {
    if (elevator.status == RUNNING)
        runningSchedule;
}

runningSchedule() {
    elevator.lock.unlock();
    // 唤醒Dispatcher;开锁是为了允许Dispatcher此时为其分配可加入的请求
    sleep(speed);
    elevator.lock.lock();
    // 更改楼层
    // 输出ARRIVE
    // 判断下一步状态:是下客的WAITING,还是继续RUNNING?
}

为了防止不必要的判断,这一判断只应在当前电梯即将进入目标楼层时进行。

在判断之前,我们自然要获取对方电梯的锁,以免对方电梯的状态在判断时改变。

观察我们上面实现的锁管理情况,我们不难发现:

  • 为了避免死锁,我们尽量确保一个线程一次只拿一把锁;
  • runningSchedule()中恰好有一个开锁的窗口,为一次只拿一把锁提供了可能
  • 对方电梯正前往目标楼层时,由于我们上面的锁机制,读取到的对方当前楼层,一定是目标楼层的前一层
  • 在对方电梯的当前楼层为目标楼层时,读出的状态不可能为RUNNING,只可能为闲置的IDLE,或正在下客的WAITING

因此,上述三种冲突情况,我们只需要对“相向而行”这一情况特殊考虑;其余情况,都可以通过判断对方当前位置是否为目标楼层判断。

“相向而行”特殊在哪呢?特殊在无法直接确定需要等待的电梯。其他两种情况中,我们可以确定等待的是当前不在目标楼层的电梯,这一情况不行。

对此,我规定:下方的电梯,规避上方的电梯。

为了减少复杂度,我并没有考虑轿厢中乘客的问题,性能可能因此下降;如各位希望争取这一部分性能,可以以这一条件为最低级条件,将轿厢内请求加入判断条件中。

具体实现

自然,为了避免死锁,我们的判断放在在runningSchedule()那一开锁的窗口内。

下文中以cooper称呼对方电梯。(coop-er,能想出的最简洁称号就这个了,笑)

为双轿厢电梯单独写一套运行逻辑自然不大明治。我们将这一判断逻辑融入原有的运行逻辑中,判断只在电梯已被改造为双轿厢(对方电梯cooper !=null)时进行。

runningSchedule() {
    elevator.lock.unlock();
    // 唤醒Dispatcher;开锁是为了允许Dispatcher此时为其分配可加入的请求
    
    if (elevator.willEnterTargetFloor && cooper != null) {
        cooper.lock.lock();// 获取对方的锁
        judge(); // 判断
        // 放锁;这样放锁当然有目的,详见下文
        if(cooper.lock.isHeldByCurrentThread()) {cooper.lock.unlock(); } 
    }
    sleep(speed);
    elevator.lock.lock();

电梯避让的等待/唤醒,使用挂在电梯信息类Elevator的锁上的Condition实现,命名为conflictCond

整体交互如下:

  • 在判断出我方需要等待后,我方执行conflictCond.await(),同时通知对方需要规避
  • 通知对方规避时,唤醒对方的Scheduler,以处理对方正处在IDLE,Scheduler正休眠的问题
  • 每当对方远离目标楼层时,就让对方执行我方方法conflictCond.signalAll(),唤醒等待冲突解除的我方。

随后,我们就可得出如下判断逻辑:

judge() {
    if (elevator.isElevB()) { // 是在下方的B电梯  
        switch (cooper.getStatus().getStatusType()) {  
            case RUNNING:  
                // 相向而行
                if (cooper.getFloorBeforeTargetFloor() == cooper.getCurrentFloor() 
                    && cooper.towardsTargetFloor()) {  
                    cooper.setAvoid();  // 通知对方规避
                    cooper.lock.unlock();  // 避免同时获取两把锁
                    elevator.lock.lock(); // 没锁就await(),会发生什么? 
                    elevator.conflictCond.await();  
                    elevator.lock.unlock();  
                }  
                break;  
            default: break;  
        }  
    }  
    // 其他情况
    if (cooper.getCurrentFloor() == cooper.getTargetFloor()) {  
        cooper.setAvoid();  
        cooper.setUnlock();  
        elevator.lock.lock(); 
        elevator.conflictHangUp();  
        elevator.lock.unlock();  
    }
}

判断写好之后,该通知对方规避了,响应机制当然也得有:

// Elevator
public void setAvoid() {  
    this.awaitingAvoid = true;  
    this.idleCall();  
}

public void respondAvoid() {  
    this.awaitingAvoid = false;
}

改完这些还不够,我们需要对WAITING/IDLE两个状态的转移规则稍加修改,使得在一动一静的情况下,两种状态转移后,可以响应规避请求:

waitingSchedule():

... // 原有实现
// 接在原有实现之后

if (entersIdle) {
    if(shouldAvoid) {
        /*让电梯移动到下一层,类似SCHE,但到了之后不开门*/
        respondAvoid()
    } else {
        setIdle()
    }
}
idleSchedule(): 
if (keepsIdle) {
    if (shouldAvoid) {
        /*让电梯移动到下一层,类似SCHE,但到了之后不开门*/
        respondAvoid()
    } else {
        // 原逻辑
    }
} else {
    ...
}

对于同向而行的情况,我们还需要继续修改RUNNING下的运行逻辑,使得避让请求被正确响应:

runningSchedule() {
    ... // sleep()完成
    /*更改楼层*/
    /*在楼层更改时,若远离了目标楼层,暂存一个flag*/
    .... // 状态转移
    // normSche原有逻辑结束
    if (flag) {
        elevator.setUnlock(); // 同样,此处开锁是为了保证一个线程只拿一把锁
        respondAvoid()
        cooper.lock.lock();
        cooper.conflictCond.signal();
        cooper.lock.unlock();
    }
    // 需要Scheduler线程的`while(true)`处能够正确处理中途开锁的锁释放问题,可以看看本文前面的实现
}

做到这里,我们就成功的使用纯线程交互的方法,完成了楼层冲突的处理,不需要引入任何新的类来进行管理。

BUG相关

自己犯过的错 - 多把锁混用

这应该是我在本单元中犯下的最大的一个错,直接导致我HW5严重失利。

本人在HW5中,我在已经实现了Elevator类的synchronized块的锁机制后,才决定换成ReentrantLock;但在修改代码的过程中,我出于偷懒的目的尝试synchronizedReentrantLock两把锁混用,以期实现与原有代码的兼容性。

然后,Elevator类中出现了这样的方法:

public synchronized int example2() {
    this.setLock();
    try {
        return 0;
    } finally {
        this.setUnlock();
    }
}

随后就不会出意外地出意外了,程序中出现了多个死锁点,直到作业提交截止前我都没有找完,然后...

img

最后我还是决定,去掉电梯有关所有的synchronized。这样改以后,既方便维护,也顺便解决了所有的死锁问题。

最后,再次告诫诸位:
!!谨慎对一个对象加多把锁!!,这是血的教训......

在此之后,我对锁的使用十分谨慎:除Dispatcher情况特殊外,确保每个进程同时只拥有一把锁。

这一锁的使用习惯在后续的两次作业中非常好用,我后两次作业基本没有出现大规模的死锁问题。

唯一值得说的BUG就这个,其它的都是不仔细看指导书要求导致的。

Debug技巧

君子生非异也,善假于物也。

使用IDEA Profiler

IDEA Profiler是一个强大的工具,它可以通过线程转储(Dump threads)功能,获取当前程序各线程的运行状况,并列出线程执行到了源码的哪一位置,为找死锁提供了极大的便利:

img

评测机轰炸

我于本单元正式使用评测机,对我写好的程序进行随机测试,以测试程序在多种样例下的表现。

此外,本人的评测机支持多线程运行,这不仅有利于提高测试效率,更能模拟复杂的程序运行环境,一定程度上提高死锁等情况的发现几率。

一个样例,多次测试

由于多线程调用次序的随机性,程序能够正常运行一次,并不能证明程序中不存在线程交互上的漏洞。

针对这一问题,我的解决办法就是“一个样例,多次测试”;多跑几次,万一出问题了呢?(笑)

评测机轰炸,也可以覆盖这一情况,让评测机多次测试统一样例即可。

心得体会

线程安全而言

本次作业,让我了解了如何处理边读边写,读后判断等常见的多线程冲突情况。

我在本单元中的锁策略相对保守,要求一个对象最多只有一个线程进行操作(读/写),并没有采用读写锁。这样的确安全,不过可能产生一定的性能损失。

我认为本单元对我让我受益最大的方面,是培养了我如何设计多线程之间交互的能力。通过使用纯线程交互方法实现UPDATE请求的同步,并解决双轿厢电梯楼层冲突问题,我对多线程交互有了更深刻的认识,在此方面积累了宝贵的经验。

层次化设计

  • 有远见的设计总是好的。我在HW5实际就完成了HW6的架构搭建,为后续开发省下了不少精力。
  • 适度的抽象,是有利于后续开发的。 本人架构中,对于乘客请求不在当前位置的空闲电梯,移动至乘客起点的空放过程进行了抽象,构建了RESERVED运行状态;这一运行状态可以设置中途是否允许捎带,允许不存在预约者;这为后续实现SCHE请求的运行过程提供了极大的便利
  • 静态信息,最好与动态运行分离。 本人架构中,Elevator类专用于记录电梯有关的信息,而ElevatorScheduler线程,则专注于电梯的运行。这有利于进程交互的设计。

其他感受

  • 本单元,我感觉最需要的其实是细心,不然线程间交互很容易出现问题。

  • 本单元亦让我直观地感受到了算法和数学模型,于程序的重要性何在。

    本人实现中,判断电梯是否满员的问题,可以抽象为计算一个二维空间内,所有线段的最多重叠次数;没有算法的帮助,计算效率很难提升。

    处理优先级问题的方法,其实就是简单的数学建模。

  • 感受到了AI的力量。 使用AI生成checker+人工修改的效率,远比纯手搓要高。

    为了进行正确性检验,本单元的checker需要构建一整套状态机,其工作量无异于重做一遍本单元作业;但在AI工具的帮助下,本人节省了不少开发checker的时间,为我对自己的程序进行更充分的测试提供了宝贵的时间窗口。

  • 我的编码能力有待提高。

    虽然我的整体设计思路十分清晰简洁,但在具体实现时,我在不少实现细节上的复杂度实在太高,因此带来的BUG与维护的不便不在少数。

    上面的UML图也能看到,我的Elevator类十分臃肿。在我看来,虽然我的程序运行效果还算不错,但它事实上就是一个bloatware。这一感受在我进行互测,观摩同学们的代码时尤为强烈:我实现本单元的所有功能,用了近2000行,而同学们只用数百行即可实现...

    设计思路有了,是时候想想,怎么才能让自己写出更优美的源码了...

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

269

社区成员

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

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