BUAA-OO 第二单元作业:电梯调度

北京航空航天大学—计算机学院—面向对象设计与构造—第二单元


前言

OO 第二单元作业的主题是多线程,本单元三次作业的迭代不仅要求我们关注如何解决线程安全问题,还要求我们如何设计调度策略提高性能

最终 UML 类图

UML

最终 UML 协作图(sequence diagram)

sequence diagram


一、第五次作业分析

1. 代码 UML 类图

hw5-UML

2. 代码架构分析

a. Producer-Consumer模型

参考资料:《图解Java多线程设计模式》

充分解读实验课代码,我们深刻体悟如何借助生产者模式来处理本次作业的需求。

我们首先明确,本次作业中,各个类分别充当什么角色:

  • 输入线程InputThread):获取用户请求,充当主托盘生产者
  • 请求托盘RequestTable):充当托盘,是共享对象。其是主托盘MainRequestTable)和处理托盘ProcessingRequest)的父类。
  • 调度器Scheduler):将InputThread传入的请求分配到各个电梯当中,是主托盘消费者处理托盘生产者
  • 电梯线程Elevator):处理用户请求,充当处理托盘消费者

通过上述分析,我们便自然想到应该分别为其建类,并在主类Main)中开 6 个电梯线程调度器输入线程

b. 数据结构

Person 类

基于封装和方便管理,对用户请求单独建类Person,该类中封装请求的所有信息

public class Person {
    private final int id;
    private final int fromFloor;
    private final int toFloor;
    private final int elevatorId;
    // ...
}
RequestTable 类
public class RequestTable {
    private boolean isEnd;
    
	// ...
	
    public synchronized void setEnd(boolean isEnd) {
        this.isEnd = isEnd;
        notifyAll();
    }

    public boolean isEnd() {
        return isEnd;
    } // 原子操作,无需上锁~
}

考虑到主托盘MainRequestTable)和 处理托盘ProcessingRequest)的不同需求和功能,考虑将二者分开作为其子类,在子类实现自己的add(...), del(...), getOne(...), waitFor(...)等方法

  • MainRequestTable 类
    使用ArrayList<Person>方便按时间顺序,以队列的逻辑取出请求

  • ProcessingRequest 类
    使用HashMap<Integer, ArrayList<Person>>方便电梯上下人。这里指key楼层发出请求的队列(value)构成的hashmap

请注意:在我们的架构中,只有托盘是共享对象。所有对托盘的操作应该参考多线程的数据竞争知识,进行合理上锁!!!

  • wait-notifyAll:这里我们只需要在添加请求(本次作业仅涉及add方法)设置终止(setEnd方法) 中加入notifyAll,以唤醒可能处于等待中的线程

c. 电梯捎带策略

电梯捎带问题本身没有完美的算法,有诸如ALS, LOOK, SCAN 等。我们需要挑选一个平均性能占优并且比较稳定的算法
这里我采用了LOOK算法

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动
  • 到达某楼层时,首先判断是否需要开门
    • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客下电梯
    • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入
  • 接下来,进一步判断电梯里是否有人。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯)
    • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动
    • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)
    • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)
    • 如果请求队列为空,且输入线程已经结束,则电梯线程结束

由此可见,运行方向是电梯的一个状态量而不是过程量,用来表示下一次move时的方向。当有新请求进入请求队列时,电梯被唤醒,此时电梯的运行方向仍然是电梯wait前的方向

d. 电梯类与策略类

在本次作业中,基于可扩展性、可读性与解耦等思想,我们采取电梯类与策略类分离的架构。
引入枚举类(Advice):END, MOVE, REVERSE, OPEN, WAIT,用于表示电梯状态
策略类(Strategy):获得当前电梯状态和请求队列状态。借助LOOK算法,主要实现getAdvice(...)方法

/* Strategy */
    
    public Advice getAdvice(int curFloor, int curNum, boolean direction
            , HashMap<Integer, ArrayList<Person>> destMap) {
        // can open for getting on or off?
        if (canOpenForOut(curFloor, destMap) || canOpenForIn(curFloor, curNum, direction)) {
            return Advice.OPEN;
        }
        if (curNum != 0) {
            return Advice.MOVE;
        } else { // no one in the elevator now
            if (processingRequest.isEmpty()) {
                if (processingRequest.isEnd()) {
                    return Advice.END; // elevator thread ends
                } else {
                    return Advice.WAIT; // elevator thread waits
                }
            }
            // someone in the request queue
            if (processingRequest.hasReqInOriginDirection(curFloor, direction)) { 
            // a request sent in front of the elevator
                return Advice.MOVE;
            } else {
                return Advice.REVERSE;
            }
        }
    }

策略类聚合在电梯类中,电梯类根据getAdvice(...)来询问建议,并做出反应

/*Elevator.java*/

	@Override
    public void run() {
        while (true) {
            Advice advice = strategy.getAdvice(curFloor, curNum, direction, destMap);
            if (advice == Advice.END) {
                break;
            } else if (advice == Advice.MOVE) {
                move();
            } else if (advice == Advice.REVERSE) {
                direction = !direction;
            } else if (advice == Advice.WAIT) {
                processingRequest.waitRequest();
            } else if (advice == Advice.OPEN) {
                openAndClose();
            }
        }
    }

3. 代码复杂度分析

复杂度-hw5
总体来说,本次作业代码的整体复杂度较为理想。
在设计时注意遵循单一职责原则,避免出现较长的方法和类。


二、第六次作业分析

第二次作业乘客不指定电梯,且新增了RESET请求,新增RECEIVE约束

1. 代码 UML 类图

hw6-UML

2. 代码架构分析

a. 电梯调度策略

电梯调度的实现十分灵活,但十分重要。其与程序的性能直接挂钩。

  • 影子电梯局部最优分配:在输入线程获得一个请求时,深克隆六部电梯进行模拟,将请求分配给花费时间最少的电梯
  • 随机分配:random 随机分配
  • 均匀分配:开一个计数器记录请求数,加入count % 6电梯的请求队列中

因为receive的约束,自由竞争无法轻易实现。且考虑到耗电量等问题,不做讨论~

我们采用影子电梯的策略。实现影子电梯本质上并不复杂。简单地说,相当于将电梯中运行策略抄过来,将其中sleep(time)变为costTime += time,再进行简单改写即可。但这其中有一些问题需要注意,比如:

  • costTime计算标准:我们很自然地想到,将需要分配的person加入电梯的requestMap(等待队列)中,进行模拟。于是我们有如下选择:
    • 这个person下电梯作为costTime计算结束的标志,直接return
    • 整个requestMap处理完毕(即为空)作为costTime计算结束的标志

其中前者实现起来更加简单一些,但似乎后者考虑更全面一些,但仍需要验证~

  • 如何模拟reset
    这里大有学问。我在实现时只是简单costTime += 1200就结束其静默状态。这其中修改容量和速度参数,然后将电梯内人全部放回请求队列(destMap全部加入requestMap并清空)。但这并不合理,比如在RESET的最后来了一个请求,那就将这个电梯优先级滞后不少。(不过影响不大,就偷懒勒~)
    更加贴近模拟的做法是记录时间戳,或是将1200分成几个片段(会导致频繁调用sleep消耗时间)等

b. RESET 的实现

根据 RESET 的需求,我们考虑为电梯增加resetFlagsilent两个布尔属性,输入线程传入resetRequest时,将resetFlag置为true

if (request instanceof PersonRequest) {
	PersonRequest personRequest = (PersonRequest) request;
    Person person = new Person(personRequest.getPersonId(), personRequest.getFromFloor(), personRequest.getToFloor());
    mainRequestTable.addRequest(person);
} else if (request instanceof ResetRequest) {
    // ...
    ResetRequest resetRequest = (ResetRequest) request;
    elevators.get(resetRequest.getElevatorId()).setResetFlag(true, resetRequest);

我们将reset当作电梯的一个动作,在Adivce中加入RESET,并修改策略类和电梯运行方法,当电梯准备好重置后开始重置,这静默时间内将silent置为true(主要为 RECEIVE 约束服务)。
重置电梯时,若电梯线程在开门状态中被通知 reset,则直接所有人离开电梯,并重置
RESET 需要我们将该电梯的请求全部扔回,重新调度。且考虑到个人等待时间问题,我们将其放到mainRequestTable队首。
这其中最复杂的是加锁逻辑。第六次作业中共享对象急剧增加,需要仔细思考其中逻辑并合理加锁。这其中不乏:

  • 对于电梯重置状态参数-resetFlag静默状态参数-silent,它们会被输入线程、电梯线程修改,需要被调度器线程读取,应该上锁
  • 在电梯调度中,我们采用了影子电梯策略,这需要深克隆电梯的参数,其中还有destMaprequestMap这些容器,显然我们需要在克隆时对其分别上锁,否则可能发生ConcurrentModificationException报错~

这里我们需要思考线程结束条件的问题,如果输入线程最后一个请求为 RESET,我们可能往mainRequestTable扔回请求,那么之前setEnd()的条件不再适用。这里我们考虑开一个计数器辅助我们结束线程的判断(计算器也需要加锁notifyAll的考量~)

c. RECEIVE 约束

当被调度的电梯不处于silent时,直接输出即可。但若其处于静默状态(比如6部电梯都在静默中),我们就需要实现延迟输出
具体实现也不困难。静默结束后由电梯输出其请求队列中请求即可
注意不同类输出 RECEIVE 时数据竞争的问题

3. 代码复杂度分析

hw6-复杂度分析

  • ElevatorShadow类中的costTime方法复杂度较高。因为电梯调度策略采用了影子电梯,相当于模拟电梯运行,且因为只是模拟没有将电梯与策略分开,较为复杂…

二、第七次作业分析

第三次作业新增第二类重置请求—将电梯修改为双轿厢电梯

1. 代码 UML 类图

hw7-UML

2. 代码架构分析

a. 双骄厢电梯的实现

因为成为双骄厢电梯后(普通电梯的“有丝分裂”),两个轿厢电梯独立运行。所以我们要**结束之前的普通电梯线程,开启新的两个轿厢电梯线程**。而因为两个轿厢电梯之间有着联系,且共用普通电梯的`id`,我这里考虑将两个轿厢电梯作为普通电梯的两个属性,可以在调度器中通过普通电梯来对其进行调度,这样不需要修改电梯容器,避免了不少麻烦
考虑到双骄厢电梯特性,我们考虑抽象出电梯类并实现Runnable接口,之前的电梯类重构为NormalElevator类,继承电梯类并重写run()方法,双骄厢电梯DoubleCarElevator类同样继承电梯类。
经过思考,DoubleCarElevator运行策略NormalElevator完全一致(LOOK 算法),只需在乘客上电梯后将乘客toFloortransferFloor进行比较,选取合适的值作为destMap的索引加入乘客。

// ...
	if (attribute == 'A') {
        	getOffFloor = Math.min(personGetOn.getToFloor(), transferFloor);
	} else {
			getOffFloor = Math.max(personGetOn.getToFloor(), transferFloor);
	}
// ...

换乘楼层下电梯的乘客进特判,若toFloor == transferFloor,到达目的地;否则和 RESET 一样扔回mainRequestTable重新调度

// ...
	if (person.getToFloor() != curFloor) {
        Person transferPerson = new Person(person.getId(), curFloor, person.getToFloor());
        mainRequestTable.addRequest(transferPerson);
    } else {
        // ...
    }
// ...

静默结束后,我们需要将调度器分配到该电梯中的请求根据换乘楼层标准分派给两个轿厢电梯。
且双骄厢电梯不会再发生 RESET,我们就不需要在DoubleCarElevator的运行中考虑该行为。
接下来就是实现如何不让两个独立的轿厢电梯在换乘楼层相撞
我们考虑在普通电梯中设置一个对象锁Object lock = new Object();将其传入两个轿厢电梯中,对它们在换乘楼层的行为进行加锁约束。
为了简化逻辑,我们不让两个轿厢电梯在换乘楼层进行结束等待行为,于是,当电梯需要执行移动行为时,若此时电梯想前往换乘楼层,将这个行为锁住即可

	else if (advice == Advice.MOVE) {
        if ((attribute == 'A' && direction && curFloor == (transferFloor - 1))
                || (attribute == 'B' && !direction && curFloor == (transferFloor + 1))) {
            transfer();
        } else {
            move();
        }
    }

其中,transfer()行为被加上了lock锁。这个行为中,我们一口气实现——移动到换乘楼层、开关门、翻转方向、开关门、离开换乘楼层。这样就可以有效解决相撞问题~

b. 影子电梯如何模拟双骄厢电梯

因为多出了普通电梯“有丝分裂”为双骄厢电梯的操作,我们的调度策略就需要进行合理修改,我们需要模拟:

  • 普通电梯
  • 处于NormalReset中的普通电梯(silent
  • 处于DoubleCarReset中的普通电梯(silent
  • 双骄厢电梯

其中,第一种、第二种我们沿用之前的策略即可~
对于双骄厢电梯,一种追求局部最优的思路是:采用深度搜索。即如果该乘客的请求跨越了换乘楼层,记录时间并让将剩余电梯运行这么长的时间,然后在换乘楼层下电梯的乘客再分配到剩下电梯中继续模拟,递归实现,最后将时间求和。
这种策略看似近乎完美实现了模拟,但实现起来十分复杂,且递归的复杂度是指数型上升,面临CTLE爆内存的风险。经过进一步的思考,在电梯运行时可能外来其他请求,这时去递归只能对过去状态的电梯模拟,并不能真正实现完美的模拟
我们考虑简化问题。因为双骄厢电梯具有耗电量低的优势,我在实现其模拟时直接将两个轿厢电梯当作全能电梯模拟,也就是与普通电梯几乎一致,想法是耗电量低的优势和换乘楼层开关门耗时长的劣势相抵消。
对于第三种,和普通重置基本一致,我多了一步瞬移 curFloor的操作

3. 代码复杂度分析

hw7-复杂度

  • ElevatorShadow类中需要模拟各种情况下电梯运行,复杂度较高…
  • 静默结束后,我们需要将调度器分配到该电梯中的请求根据换乘楼层标准分派给两个轿厢电梯。这需要遍历请求表并和换乘楼层进行比较,逻辑清晰但少不了if-else的判断

总结

未来拓展能力

本单元我们采用了Producer-Consumer模型,遵循高内聚、低耦合的原则,在三次作业的迭代中没有对架构进行大规模重构。若在 hw7 基础上进一步迭代扩展,增加如横向电梯类型,可应用工厂模式等实现;增加如电梯停靠楼层约束,可通过增加乘客属性和方法辅助实现,调度时采用深度搜索等方法。

BUG 总结

多线程中用于共享对象的存在,十分常见的问题就是数据冒险。我在课下测试程序时曾碰到ConcurrentModificationException报错。这种问题本质上是对共享对象的读操作没有加锁,此时如果遍历共享对象时别的线程对该对象进行了修改,就会报错。
在三次作业的互测中,大家最常见的问题其实是调度策略导致的 RTLE
hw6 这个点刀了无数人~

[2.0]RESET-Elevator-1-3-0.6
[49.0]RESET-Elevator-2-3-0.6
[49.0]RESET-Elevator-3-3-0.6
[49.0]RESET-Elevator-4-3-0.6
[49.0]RESET-Elevator-5-3-0.6
[49.0]RESET-Elevator-6-3-0.6
[49.5]1-FROM-11-TO-1
[49.5]2-FROM-11-TO-1
[49.5]3-FROM-11-TO-1
[49.5]4-FROM-11-TO-1
[49.5]5-FROM-11-TO-1
[49.5]6-FROM-11-TO-1
[49.5]7-FROM-11-TO-1
[49.5]8-FROM-11-TO-1
[49.5]9-FROM-11-TO-1
[49.5]10-FROM-11-TO-1
[49.5]11-FROM-11-TO-1
[49.5]12-FROM-11-TO-1
[49.5]13-FROM-11-TO-1
[49.5]14-FROM-11-TO-1
[49.5]15-FROM-11-TO-1
[49.5]16-FROM-11-TO-1
[49.5]17-FROM-11-TO-1
[49.5]18-FROM-11-TO-1
[49.5]19-FROM-11-TO-1
[49.5]20-FROM-11-TO-1
[49.5]21-FROM-11-TO-1
[49.5]22-FROM-11-TO-1
[49.5]23-FROM-11-TO-1
[49.5]24-FROM-11-TO-1
[49.5]25-FROM-11-TO-1
[49.5]26-FROM-11-TO-1
[49.5]27-FROM-11-TO-1
[49.5]28-FROM-11-TO-1
[49.5]29-FROM-11-TO-1
[49.5]30-FROM-11-TO-1
[49.5]31-FROM-11-TO-1
[49.5]32-FROM-11-TO-1
[49.5]33-FROM-11-TO-1
[49.5]34-FROM-11-TO-1
[49.5]35-FROM-11-TO-1
[49.5]36-FROM-11-TO-1
[49.5]37-FROM-11-TO-1
[49.5]38-FROM-11-TO-1
[49.5]39-FROM-11-TO-1
[49.5]40-FROM-11-TO-1
[49.5]41-FROM-11-TO-1
[49.5]42-FROM-11-TO-1
[49.5]43-FROM-11-TO-1
[49.5]44-FROM-11-TO-1
[49.5]45-FROM-11-TO-1
[49.5]46-FROM-11-TO-1
[49.5]47-FROM-11-TO-1
[49.5]48-FROM-11-TO-1
[49.5]49-FROM-11-TO-1
[49.5]50-FROM-11-TO-1
[49.5]51-FROM-11-TO-1
[49.5]52-FROM-11-TO-1
[49.5]53-FROM-11-TO-1
[49.5]54-FROM-11-TO-1
[49.5]55-FROM-11-TO-1
[49.5]56-FROM-11-TO-1
[49.5]57-FROM-11-TO-1
[49.5]58-FROM-11-TO-1
[49.5]59-FROM-11-TO-1
[49.5]60-FROM-11-TO-1
[49.5]61-FROM-11-TO-1
[49.5]62-FROM-11-TO-1
[49.5]63-FROM-11-TO-1
[49.5]64-FROM-11-TO-1

问题在于调度策略没有很好地模拟重置情况,反而采用随机调度的程序不会因为这个点而被 hack。

多线程中,debug 时建议采用 print大法~

心得体会

  • 写代码时遵循SOLID原则、采用层次化架构,有助于程序的可扩展性可读性
  • 多线程的编程中,我们需要对共享对象做同步处理,但上锁意味着并行效率的降低。因此我们要理清代码逻辑,尽量缩短同步块,有意识地将共享对象的读写方法封装
  • 多线程单元的学习十分具有挑战性,但也大大提高了我们的能力~
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值