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

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


前言

电梯调度问题是北航OO祖传的多线程题目。通过三次的迭代开发,让我们逐渐理解多线程的并发编程和锁的应用。通过自己完成工程代码,经过自测、中测、强测和互测不断发现bug并给予修复,我逐渐对多线程同步有了更加深入的理解。

今年OO课程组很贴心地为我们准备了前置芝士,方便我们更加轻松地入手多线程:

在完成整项工程之前,有必要先提一提“进程”和“线程”的概念了。

进程是计算机中的程序关于某数据集合上的一次运行活动,它是系统进行资源分配的基本单位,也是操作系统结构的基础。线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。进程和线程的区别在于,进程是操作系统分配资源的基本单位,拥有独立的内存空间和系统资源;而线程是操作系统调度的基本单位,共享进程资源,相对进程更轻量级。

在我们大多数人以往写的程序中,都只有一个主线程(从程序开始到程序结尾)。以往我们写的程序多是“调用”,而线程之间有很大的独立性(线程之间只存在协作,不存在调用)。本单元的项目要突破原有思维的桎梏,学习多线程并行执行的程序架构。

多线程程序并行执行过程中,往往会出现一些难以预料(不可复现的bug)的结果。为了让程序的执行结果变得可预测、可控制,我们就需要在一些特定的位置上加锁,让一些不可分割的行为能够原子性地执行。

hw5

本次作业需要模拟一个多线程实时电梯系统。系统基于一个类似北京航空航天大学新主楼的大楼,电梯可以在楼座内 1~11 层之间运行。系统从标准输入中输入请求信息,程序进行接收和处理,模拟电梯运行,将必要的运行信息通过输出接口进行输出。

具体而言,本次作业电梯系统具有的功能为:上下行,开关门,以及模拟乘客的进出。电梯系统可以采用任意的策略,即在任意时刻,系统选择上下行动,是否在某层开关门,都可自定义,只要保证在电梯系统时间不超过系统时间上限的前提下将所有的乘客送至目的地即可。电梯每上下运行一层、开关门的时间为固定值,仅在开关门窗口时间内允许乘客进出。电梯系统默认初始在 11层有六部电梯。比较特别的是,每一位乘客只能由指定的电梯负责接送。

不得不说,相较于往年的第一次作业,今年的作业难度大幅度下降。主要是减少了调度策略的层数(从以前的乘客 ⟶ 分配 \stackrel{分配}{\longrightarrow} 分配电梯电梯 ⟶ 接送 \stackrel{接送}{\longrightarrow} 接送乘客双重策略减少到只有电梯接送乘客一层)。

UML类图与分析

在这里插入图片描述

分析上图可以知道,ElevatorInputDispatcher都继承了Thread。主线程调用这三个线程,完成电梯接人的业务。在三个线程运行的过程中,会依赖于其他线程的数据的变化。
在hw5的架构中,我将策略类、等待队列、电梯线程、读入线程、调配器、乘客类分离开来。每个类各司其职,电梯线程负责管理电梯的上下移动、上下人,等待队列保证线程安全地交互,读入线程负责与总的等待进程交互,调度器负责将乘客分配给指定的电梯(hw5的调配器非常简单,没有任何策略的成分),策略类负责告知电梯下一步的运行方向,乘客类负责维护乘客的信息。

数据结构

  • InputDispatcher中共享一个全局的等待队列(用WaitList类进行维护)。以下就只展示几个比较关键的方法。

    public class WaitList {
    	private ArrayList<Person> waitList;
        private boolean isEnd;
        ...
        public synchronized void addPerson(Person person) {
            waitList.add(person);
            notifyAll();
        }
        public synchronized void setEnd(boolean flag) {
            isEnd = flag;
            notifyAll();
        }
        public synchronized boolean isEnd() {
            notifyAll();
            return isEnd;
        }
        ...
    }
    

    我们为什么要用WaitList而不是用ArrayList<Person>呢?这就涉及到线程安全等问题。全局请求队列需要与InputDispather交互,所以就由访问的先后顺序问题。只有保证加人和删人时保证线程安全,我们才能保证WaitListPerson不会无故丢失。此外,Input线程结束时需要Dispatcher线程结束,该通知也可通过waitList来完成。(isEnd方法,后续会详细讲解)

  • Elevator中维护inList(电梯内部人员,ArrayList<Person>类型),waitList(每个电梯的等待队列)两个数据结构。电梯内部中的waitList与全局waitList相比,不存在过多的线程安全问题。Elevator中的waitList由全局waitList分配。

流程架构

整体采用生产者—消费者模式。Input线程不断产生新的乘客,而6个电梯负责接收乘客,并负责把乘客送到目的地。所以,我们将整个流程理解为“1个生产者——多个消费者”模式。

public void run() { //From Input.class
    while(true){
        if(){
            ...
        } else {
            ...
			int id = personRequest.getPersonId();
        	int start = personRequest.getFromFloor();
            int dest = personRequest.getToFloor();
            Person person = new Person(id, start, dest);
            waitlist.addPerson(person);
        }
    }
}

从这里可以看出,Input线程不断接收输入输出包的投放,并把person投入总请求队列中。

	public synchronized Person getFirstPerson() { //From WaitList.class
        if (waitList.isEmpty() && !isEnd) {
            try {
                wait(); //如果读入线程未结束且等待队列为空,将锁交出去,等待其他线程的唤醒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        if (waitList.isEmpty()) { //如果无法获取,可以说明Dispatcher可以结束
            return null;
        }
        Person person = waitList.get(0);
        waitList.remove(0);
        notifyAll();
        return person;
    }

以上的代码就是一个经典的生产者—消费者模式。

	public void run() { //From Dispatcher.class
		Person person = waitList.getFirstPerson();
        if (person != null) {
                elevators.get(person.getId() - 1).addPerson(person);
        } else {
            	...
        }
    }

本次作业的调配器是一个“假”的调配器,直接负责把get到的第一个person直接塞到对应的电梯中即可。

总结上述流程,我们可以把本次作业的流程架构总结如下:

乘客到达
加入总等待池
分配到对应电梯
电梯送乘客

这个流程虽然简单,但是要实现线程和数据之间的交互,不出现轮训等待的情况(否则会出现CTLE),也是有一定挑战性的。

Dispatcher结束条件

Dispatcher线程什么时候结束呢?其实,只要输入线程结束并且请求队列为空即可终止Dispather线程,然后给每部电梯的isEnd属性标true。当电梯的isEndtrue且等待队列和电梯内乘客队列同时为空时,便可结束电梯线程。具体的写法如下:

	while(true){
        ...
		synchronized (waitList) {
        	if (waitList.isEmpty() && waitList.isEnd()) { //如果waitList为空且已结束,便可通知电梯总线程已结束
            	for (Elevator elevator : elevators) {
                	elevator.setEnd();
                }
                return;
            }
            waitList.notifyAll();
        }
        ...
    }   

同步块的设置和锁的选择

Dispatcher中的同步块

dispatcherrun方法中,我们需要原子性地判断Input是否终止以及乘客是否完全被分配到相应的电梯中。在这一过程中,为了防止Input线程对共享对象waitList的修改,为我们需要对共享对象waitList加锁。

public void run() {
     while (true) {
            synchronized (waitList) {
                if (waitList.isEmpty() && waitList.isEnd()) {
                    for (Elevator elevator : elevators) {
                        elevator.setEnd();
                    }
                    return;
                }
                waitList.notifyAll();
            }
            Person person = waitList.getFirstPerson();
            if (person != null) {
                elevators.get(person.getEl()-1).addPerson(person);
            }
    }
}
WaitList中的同步块

WaitList类中,对addPersonsetEndisEmptyisEndgetFirstPerson等方法都要锁,这个锁加的对象是"this",防止出现对WaitList类既添加又删除的问题。

Elevator中的同步块

在Elevator类中的run方法的实现中,

	public void run() {
        try {
            while (true) {
                if (isEnd && waitListU.isEmpty()) {
                    power.addu();
                    return;
                }
                exchange();
                setDir();
                if (direction == 0) {
                    synchronized (waitListU) { 
                        if (!waitListU.isEnd()) { //当等待队列为空且输入线程未结束时
                            waitListU.wait();
                        }
                        waitListU.notifyAll();
                    }
                } else if (direction == 1) {
                    floor++;
                    sleep(400);
                    TimableOutput.println("ARRIVE-" + floor + "-" + id);
                } else if (direction == -1) {
                    floor--;
                    sleep(400);
                    TimableOutput.println("ARRIVE-" + floor + "-" + id);
                }
            }
        } catch (
                InterruptedException e) {
            e.printStackTrace();
        }
    }

我也对waitListU对象加锁,防止在判断语句和执行语句的过程中waitListU被改变。

Bugs分析

本次作业在强测和互测中都没有Bugs。

调度器

正如前文中所提到的那样,本次作业的调配是单层调配。我们只需要让每部电梯单线程地处理好自己的业务就行,即只需要关注电梯的上下和接客,而不需要考虑电梯之间的调配问题。而关于如何实现具体调配,我们就需要结合调度策略进行一一分析了。

在我看来,本次作业为之后的迭代开发设定了一个基本的框架。无论电梯请求如何变化,其输入线程,电梯线程和调配器线程三者的关系是不会有变化的。所以,这次作业实现的消费者—生产者模式在今后的作业中也一定适用。这个应该是三次作业中比较稳定的内容。

可以预见到,第二次作业和第三次作业不再会为每一个乘客指定特定的电梯,所以调配器类必然是要经过重写的。所以,调配器如何把乘客分配到效率最高的电梯,这是三次作业中需要不断修改和完善的。

调度策略

ALS:

ALS是课程组给的电梯调度的基准方法。ALS的思想是在先来先服务算法的基础上,尽可能增加捎带的情况。可以用这张图来解释:

电梯运行
电梯是否有打算接的乘客
接同方向乘客
接最先到的乘客
按照最先到乘客的方向前进
LOOK:

LOOK策略是大部分人都会采用且性能较优的算法。LOOK本质是优化的Scan算法,他的理论基础是在尽量少变化方向的基础上接完同方向乘客,同时在该方向没有乘客时及时转向。具体思路可以用下图解释:

电梯运行
电梯是否决定了运行方向
接同方向乘客
按照最先到达乘客方向确定方向
按照确定方向前进
电梯中是否还有未完成的请求
保持原方向
转换方向

在本次作业中,我采用了LOOK算法作为电梯调度的基础算法。大量的随机数据证明,LOOK算法在时间和电量上的表现都很良好。这是为什么呢?LOOK算法首先保证了转向次数尽可能少,这就让捎带的次数得到大幅的提高,从而使得总体的时间变短。而且LOOK算法并不盲目地从最底层到最高层运行,而是在确定原方向没人的情况下能够尽快调转方向,大大减少了电量的消耗。

hw6

本次作业在上次作业的基础之上,删除了“指定电梯”机制,允许电梯之间互相协调接送乘客。并且,请求也从单一的乘客请求增加为了乘客请求和电梯重置请求。电梯重置持续1.2s,允许我们改变电梯的载客量和运行速度。

难点分析

本次迭代开发在上一次作业的基础上增加了乘客 ⟶ 分配 \stackrel{分配}{\longrightarrow} 分配电梯这一调度策略层次,让调度策略更加复杂。此外,重置请求也让电梯和调度器之间的关系耦合度提高(不再只是单方向的调度器给电梯分派乘客,电梯中途下人也会把乘客塞给调度器重新分派)。这就需要我们在调度器和电梯之间同步的时候,在特定的位置上加锁,防止出现线程不安全的问题。

在本次作业中碰到的另一个难点就是调度器(Dispather)和电梯(Elevator)线程何时结束的问题。正如前文中提到的,电梯和调度器队列存在相互塞人的情况。所以如果调配器请求队列为空,我们并不能判断调度是否已经完成(可能存在一些将要RESET电梯还要塞人进调配起的请求队列中)。

UML类图与分析

在这里插入图片描述

本次作业没有大改代码架构,所以整体的UML类图大致是一样的。

调配器的设计

正如前文中提到的那样,本次作业需要双重的调配。一层是把乘客分配到最合适的电梯中,一层是电梯把乘客尽快送到目的的楼层。第二层次我们在hw5中已经实现。(由于本次作业已经ban掉了自由竞争机制,所以我们必须要预先把乘客分配到电梯中,当然啦,自由竞争能不能做?能做,但是比较麻烦,这需要我们在每部电梯运行前给它预分配一个乘客,然后在电梯跑动的过程中再逐渐添加乘客。

在权衡了代码性能、可扩展性、可读性等指标后,我最终选择了双层的调配机制作为本次作业的解决方案。

那么双层中的第一层——即把乘客分配到最合适的电梯采用什么策略呢?阅读往届大量学长学姐的博客之后,为了卷一下性能分,我采用了影子电梯的方式来实现。这里可以简单介绍一下我理解的影子电梯和量子电梯:

  • 影子电梯

    顾名思义,就是模仿真实电梯的行为的“假想“电梯。阅读作业中的性能分细则,我们可以知道,性能分的评定主要与系统运行总时间、系统总耗电量和期望等待时间之差三个因素有关。

    “期望等待时间”这个因素往往难以衡量,因为它与乘客出现的位置有关,不太好实施针对性的优化。但是系统运行总时间和系统总耗电量是一个非常好测量和模拟的指标。所以我选择用一个影子去模仿当下电梯的行为(该行为与真实电梯的行为必须完全一样),在很短的时间(不用模拟电梯等待时间)内求出当下系统的总时间和总电量。然后再用一定的参数比例,调整出最佳的比例。

  • 量子电梯

    量子物理是一门非常有趣的学科。所谓“量子性”就是在观测的前一刻完全不知道物体属于什么状态,只有观测的时候才能够确定。那么,在电梯上下楼的0.4s,我们是否可以利用这个机制,在0.4s后再判断电梯的移动方向呢?

    答案是可以的。这就是量子电梯的基本思路,“尽可能延后决策的时间点,尽可能忽略电梯因为决策所等待的时间”。显而易见,在掌握更多信息的情况下,我们的决策会更加的准确。所以,与其在电梯运行前就做出上行或下行的决定,我们不如等0.4s后再量子行的给出电梯上行还是下行的决策(反正也只是输出一句话,没人管你程序内部是如何实现的)。

在本次作业中,我主要在加入乘客的过程中采用了影子电梯模拟。一个人一定要进入6台电梯中的1台,那进入哪台呢?这是由我的调配器说了算。所以,我们不妨进行如下的决策过程:

  • 尝试将这个乘客分别加入到6台电梯的等待队列中。
  • 加入完这个乘客之后,按照LOOK策略模拟6台电梯的行为,算出总时间和总耗电量。由于有6种加入方法,所以我们需要动态的模拟6种可能的情况。
  • 算出6台电梯各自的总时间和总耗电量之后,我们将总耗电量累加起来得到E,把总时间取个MAX得到T。(因为性能与系统的运行时间有关,而非与电梯的运行时间之和有关)。
  • 根据E和T,用一定的参数比例算出最终的评估函数值。(调参大法,我最终选择的是题目中性能分的比例,即3:4)

选择评估函数值最优的方案,将乘客分配到那台电梯。

值得注意的是,我的算法在一开始实现的时候是忽略正处于RESET状态的电梯的。(一个伏笔)也就是说,有可能正在运行的电梯没有6台,在这种情况下,我并没有模拟RESET电梯的行为。(事实上是可以做的,通过记录RESET开始的时间戳,用currentTime减一下就可以算出系统的总时间)

性能 or 复杂度

在本策略中,由于影子的实现,我们可以全真模拟出每种分配方案的时间和电量,以便我们做出更加精准的决策。

电梯优化是一个永无止境的过程。只要我们想要更优,那么一定有更优的方案。我觉得课程组的本意并不是想让我们设计一个“完美“的电梯,而是让我们在设计电梯的过程中体会到多线程编程的思想。

想通了这一点之后,我就没有在一些细节上过于优化(比如影子电梯模拟时间戳,LOOK策略的二重影子优化,模拟RESET等等)。这些细节的优化确实可以提高总体的性能,但是复杂的代码往往缺乏可靠性、可读性和可扩展性。

Bugs分析

很不幸,这次作业出现了两个bugs,导致我在互测中被攻击了4次。

  • 调配方式有可能TLE。不知道是哪个同学发明的在49.5s重置5部电梯,然后在50s来几十个请求的hack数据。在我一开始程序的处理中,我是忽略正处于RESET状态的电梯的,这导致我会在50s把剩余的请求全部塞给剩余的1台电梯,造成系统总运行时间过长的问题。(更不幸的是,有些同学还会把剩余的那台电梯的参数调得尽可能差,让我的运行时间雪上加霜~😢)
  • 调配器的结束方式有问题。我以为只要输入线程结束,全局等待队列为空且电梯都不处于RESET状态,那么调配器线程就应该结束了。但事实并非如此。如果说有个Elevator已经接到RESET指令,但仍处于过渡状态。那么它仍然有可能往总的请求队列里面塞人。这时候结束了线程调配器,那个新塞进waitList中的人就永远也无法到达目标楼层了。

Dispatcher结束条件

为了修复以上的bugs,我设计了一个完全严谨的结束条件——到达目的地人数等于进入一次进入等待队列人数(被Elevator重新塞入等待队列中的不算)且输入线程结束。

同步块的设置和锁的选择

在hw5作业中,我描述了DispatcherWaitListElevator类中的一些同步块。现在就着重分享一下这次迭代中新加的同步块的设置和锁的选择。(一些新增的锁是在修正互测bugs时添加的)

在讲同步块的设置和锁的选择之前,我首先要讲讲本次作业一些实现细节,方便后面对加锁的理解。

  • 首先,本次作业中新增的RESET的请求是一个比较特殊的扩展点。在hw5中,我选择用person的形式往全局waitList中插入数据。在次迭代开发中,我们也可以把RESET看成一个广义概念上的请求。那么,是不是以为这我们可以把RESET请求当做一个广义的Person加入到等待队列中呢?对!其实Java中有多态的概念,我们完全可以将Person设置为一个接口,然后通过两种不同的形式去实现这个接口就可以实现在waitList中同时维护Person请求和RESET请求啦!

  • 其次,由于本次作业时双层调配机制。调配方法如上一节所论证的那样。与hw5一样,还是把“乘客”加入电梯(这里的“乘客”可以是这人,也可以是重置请求)。我更改了hw5中的addPerson方法,让它能够处理加人和重置两种请求。

  • 最后,为了解决TLE的bug,我牺牲了少许性能,让6部电梯都处于运行状态时再开始乘客的分配。

在本次作业中,我新加入了状态锁,确定6台电梯都处于运行状态后再进行乘客的分配。

此外,为了在电梯中“原子性地”加入重置请求,我对电梯的waitList上锁,对它进行同步控制。具体实现如下:

		synchronized (waitList) {
            if (person.getStat() >= 1) {
                count = 0;
                ocapacity = person.getFrom();
                ospeed = person.getDest();
                ostatus = person.getStatus();
                ofloor = person.getExtra();
                waitList.getWaitList().remove(person);
            }
            waitList.notifyAll();
        }

hw7

在上次作业的基础上,增加了一种新的RESET请求,即重置双轿厢电梯请求。(竟然还真给助教找到了双轿厢电梯的例子)双轿厢耗电量是普通电梯的1/4。每部双轿厢电梯有一个换乘的楼层F,其中电梯A只能在楼层1 ~ F中运行,电梯B只能在楼层F ~ 11中运行。比较重要的是,不允许两台电梯同时处于换乘楼层。

难点分析

本次作业的难点主要体现在两方面,分别是性能方面的进一步优化和双轿厢电梯互斥锁的设计。

为了不让同学们忽视双轿厢电梯,课程组特意把双轿厢电梯的电量拉得很低。为了性能分,我们不得不将很大一部分请求分配给双轿厢电梯。在我的方法中,这就涉及到双轿厢影子电梯的实现。

显而易见,双轿厢电梯是要用两个异步的电梯线程去模拟的。所以,为了防止两台电梯在换乘楼层“亲密接触”,我们必须使用设置适当的同步块并选择相应的锁。

UML类图与分析

在这里插入图片描述

可以看到,相比于前两次作业,我增加了ExchangeWhichDirection两个类。从SOLID原则出发,我的目标是设计出一个低耦合高内聚的代码结构,并让代码对修改保持封闭。所以,我将一些于Elevator主业务相关不大的类分了出去(比如说判断电梯是否开门isExchange、乘客是否进和乘客是否出),大大简化了电梯内部结构。

  • WhichDirection类主要负责决定电梯的运行方向。我只需要把电梯的一些相关参数传入就可以获得下一步的运行方向。
  • Exchange类主要负责电梯的上、下乘客操作。同样,我只需要把哪些乘客和当前方向等信息传入就可以知道哪些人进,哪些人出。

调配器的设计

总体调配器的思想没有变化,还是沿用了双层调配器的结构。增加了双轿厢电梯的影子电梯的设计。具体是如何设计的呢?

  • 取出当前双轿厢电梯的状态,即两个轿厢分别处于哪个楼层,等待队列,电梯里有哪些人等。

  • 考虑让两步电梯同时动(我们难以模拟多线程的影子电梯),以0.4s为周期,模拟电梯的运动。具体电梯移动的策略采用LOOK

  • 如果两部电梯同时到达同一楼层(一定是换乘楼层),那么贪心的选择让轿厢中乘客较少的电梯让乘客较多的电梯。

  • 与单步电梯一样,最终统计电梯运行的总时间和总耗电量。

最终,我们也可以如同hw6一样,衡量出加到哪部电梯之后,系统总时间T和总电量E整体最少,然后就选择对应的电梯就可以。

通过了大量的数据测试,我把T和E的比例调整成4:3时,整体性能最优。

架构中易改变的地方

通过三次作业的迭代,我发现输入线程、等待队列、乘客类的架构是不太需要改变的。每次迭代主要改变的是ElevatorDispatcher类。(维护电梯的不同状态,将乘客调配到合适的电梯)

究其原因,还是人的请求类型相对固定,都是从几楼到几楼。而电梯的行为往往更容易更改(重置容量、速度、电量),所以就需要我们根据不同的电梯修改电梯的运行逻辑,修改不同类型的影子电梯。

性能漫谈

说实话,性能问题一直困扰着我的整个电梯单元。平衡代码复杂度和性能得分,往往是一个不小的考验。当我费尽心思,冒着巨大的风险写影子电梯的时候,有人用random轻轻松松就拿到了更高的分数。

所以说,在这种不知晓未来的情况下,很难有更优的策略。我们还是要回扣课程组的思想,设计出一款“让自己满意”的电梯。每个人心中的最佳都不一样,我们没必要特意“卷”性能分。做好层次化设计,保证性能安全,在此基础上尽量减少电梯电量的消耗和系统时间,这才是在电梯单元屹立不倒的基础。

同步块的设置和锁的选择

本次作业中,RESET-DCElevator需要做一个电梯的“分裂”操作。也就是说,我们要把原来的一部电梯分解成两部电梯。当一台电梯到达换乘楼层时,它需要把电梯中的乘客全部放下,并把他们加到另外一台电梯的等待队列中。

为等待队列设置同步块
 	public ArrayList<Person> getOut(ArrayList<Person> list, ArrayList<Person> out, int pos,
                                    int cnt, int id, String c, int opos, WaitList waitList) {
        ArrayList<Person> newinList = new ArrayList<>();
        for (Person person : list) {
            if (person.getDest() == pos || cnt == 2 || pos == opos && waitList != null) {
                TimableOutput.println("OUT-" + person.getId() + "-" + pos + "-" + id + c);
                int pid = person.getId();
                if (person.getDest() != pos) {
                    person.changeFrom(pos);
                    if (cnt == 2) {
                        out.add(person);
                    } else {
                        if (waitList != null) { //如果是双轿厢电梯,把请求加入到另一台电梯
                            if (c.equals("-A")) {
                                TimableOutput.println("RECEIVE-" + pid + "-" + id + "-B");
                            } else {
                                TimableOutput.println("RECEIVE-" + pid + "-" + id + "-A");
                            }
                            waitList.addPerson(person);
                        }
                    }
                } else {
                    ouT++;
                }
            } else {
                newinList.add(person);
            }
        }
        return newinList;
    }

从上面的代码中,我们可以看出,双轿厢电梯有可能需要访问对方的等待队列。为了防止线程不安全问题,我们要在适当的位置设置同步块。

	public synchronized boolean isExchange() { //From Elevator.class
      	WaitList waitList1 = status == 0 ? null : elevator.getWaitList();
        boolean reTurn;
        synchronized (waitList) { //waitList共享对象,可能被同轿厢另一部电梯修改
            reTurn = exchange.isExchange(waitList.getWaitList(),
                    inList, direction, floor, ofloor, count, capacity, waitList1);
            waitList.notifyAll();
        }
        notifyAll();
        return reTurn;
	} //该函数的作用是判断电梯是否需要
	public synchronized void getOut() { //From Elevator.class
        String cur = status == 0 ? "" : (extra == 0 ? "-A" : "-B");
        WaitList waitList1 = status == 0 ? null : elevator.getWaitList();
        if (waitList1 == null) { //waitList为空,说明没有与该电梯同轿厢的电梯
            inList = (ArrayList<Person>) exchange.getOut(
                    inList, newOut, floor, count, id, cur, ofloor, waitList1).clone();
        } else  
            synchronized (elevator.getWaitList()) { //加同步块控制
                inList = (ArrayList<Person>) exchange.getOut(
                        inList, newOut, floor, count, id, cur, ofloor, waitList1).clone();
                elevator.getElevator().notifyAll();
            }
        }
        notifyAll();
    }
	public synchronized void getIn() { //From Elevator.class
        String cur = status == 0 ? "" : (extra == 0 ? "-A" : "-B");
        setDir();
        synchronized (waitList) { //对waitList加同步块控制
            exchange.getIn(waitList, inList, floor, direction, count, capacity, id, cur);
            waitList.notifyAll();
        }
        notifyAll();
    }

实现两个轿厢不碰撞

这确实是本次作业的一大难点。从宏观的角度来看,两个轿厢是两个独立的线程,分别在跑。简单的控制逻辑可能难以保证两个轿厢不相撞在一起。在hw7中,我把换成楼层当成一个抽象的“临界区”,在访问该“临界区”的时候进行同步块控制。

为了实现双轿厢电梯“互斥”地访问换乘楼层,我抽象了一个锁来管理换乘楼层的访问。

public class Lock { 
    private int own; //own为-1表示无人占有,own为0表示被电梯A所占有,own为1表示被电梯B所占有
    private int request; //request为0表示无人在换乘楼层外等待,request为1表示有人在换乘楼层外等待

    public Lock() { 
        this.own = -1;
        this.request = 0;
    }

    public synchronized int getRequest() {
        notifyAll();
        return request;
    }

    public synchronized void setRequest(int val) {
        request = val;
        notifyAll();
    }

    public synchronized int getOwn() {
        notifyAll();
        return own;
    }

    public synchronized void setOwn(int val) {
        own = val;
        notifyAll();
    }
}

以下是具体如何实现互斥访问的办法。

	public void locking() {
        setDir();
        if (status == 1 && direction + floor == ofloor) { //当一部双轿厢电梯要进入换乘楼层
            boolean seta = false; 
            synchronized (lock) {
                if (lock.getOwn() + extra != 1) { //当前没人占着换乘楼层或自己占着换乘楼层
                    lock.setOwn(extra); //占用换乘楼层的锁
                    seta = true;
                }
                lock.notifyAll();
            }
            if (seta) { //已经顺利占有换成楼层,可以退出
                return;
            }
            lock.setRequest(1); //在换乘楼层外等待
            while (lock.getOwn() + extra == 1) { //如果换乘楼层一直被别人占着
                elevator.getWaitList().notifying(); //提醒另一台电梯有人在等待
                try {
                    sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (lock) {
                lock.setRequest(0);
                lock.setOwn(extra);
                lock.notifyAll(); //原子性地改变lock的参数
            }
        }
    }

经过上述代码的设计,最终能够“线程安全地”访问换乘楼层,保证在任意时刻,换乘楼层都只有一部电梯。

Bugs分析

与上次一样,强测没测出来的bugs让互测给测出来了。这次出bugs主要还是因为写代码时候不小心。我只考虑了DispatcherElevator之间的线程安全问题,而忽略了双轿厢电梯之间的线程安全问题。

正如前文中所提到的,双轿厢电梯中的两部电梯共享对方的waitList(可以修改,访问)。所以,就不得不在每次访问waitList时加锁。否则,有可能在遍历ArrayList时出现ConcurrentModificationException异常。

综合看hw6和hw7两次出现的bugs,很大程度上还是出在对多线程同步和异步关系上。因为以前习惯了用主程序流来写程序,所以在碰到多程序流的时候,往往不那么适应。

互测&&评测机搭建

如果说在上一单元评测机在互测中还收效甚微的话,那本单元的自动化hack可谓是大有收益。首先,我就一些评测机的实现代码,来讲讲本单元评测机搭建的基本思路。

generator

数据生成器,用于生成合法的检测数据。通过分析标准读入,我们发现输入数据由两部分组成,一是时间戳,二是请求指令。为了控制时间戳不减并覆盖到所有的指令情况,我们可以考虑实现一个获取时间差的函数:

def get_time_gap(begin):
    if begin:
        return 0
    chance = random.randint(0, MAX_INT) % 100
    if chance < 15 or chance >= 85:
        return random.uniform(0, MAX_GAP)
    else:
        return 0

这个数据保证了时间差是一个大于等于0且小于等于MAX_GAP的随机数。

为控制请求的两种类型,我们同样可以采用随机的方式来决定:

		q = random.randint(0, 7)
        p = random.randint(1, 6)
        g = random.randint(3, 8)
        h = random.randint(2, 6)
        if q != 0:
            print('[' + str(format(time + 1, '.1f')) + ']' + id + '-FROM-' + from_floor + '-TO-' + to_floor)
        else:
            print('[' + str(format(time + 1, '.1f')) + ']' + 'RESET-Elevator-' + str(p) + '-' + str(g) + '-0.' + str(h)) #以上是两种不同的请求形式

checker

class Elevator:
    def __init__(self, id)
    class Request
	class Reset
def processInput(file_path)
def show_error(err, line=-1)
def check(in_path="stdin.txt", out_path="stdout.txt")

正确性检测器由以上几个部分组成。Elevator类用来保存每部电梯的运行参数,RequesetReset则是用来维护不同的请求类型。processInput是用于将输入的数据转换成数据结构,然后通过check模拟电梯每部的行为,如果中间有出现违背题目意义的输出,后者出现异常,则直接通过show_error报错。

归根结底,checker还是模拟六部电梯运行的行为,然后根据每一步行为来判断其是否符合题目的要求。

互测——真正的圣杯战争

在这里插入图片描述

hw6 的互测可谓是八仙过海各显神通。所有人都有成功hack的经历。我注意到,其中的一条数据hack成功率非常之高。

[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.0s时RESET了5台电梯。如果没有考虑过这种情况,容易把49.5s后的所有数据强塞到一台电梯上,导致RTLE

这组数据启发了我,有时候评测机random出来的未必能达到如此之效果。在这条数据的启发下,我在hw7炮制出了一条类似的数据。

[2.6]RESET-DCElevator-1-9-3-0.6
[40.0]RESET-DCElevator-2-9-3-0.6
[40.0]RESET-DCElevator-3-9-3-0.6
[40.0]RESET-DCElevator-4-9-3-0.6
[40.0]RESET-DCElevator-5-9-3-0.6
[40.0]RESET-DCElevator-6-9-3-0.6
[40.6]1-FROM-1-TO-11
[40.6]2-FROM-1-TO-11
[40.6]3-FROM-1-TO-11
[40.6]4-FROM-1-TO-11
[40.6]5-FROM-1-TO-11
[40.6]6-FROM-1-TO-11
[40.6]7-FROM-1-TO-11
[40.6]8-FROM-1-TO-11
[40.6]9-FROM-1-TO-11
[40.6]10-FROM-1-TO-11
[40.6]11-FROM-1-TO-11
[40.6]12-FROM-1-TO-11
[40.6]13-FROM-1-TO-11
[40.6]14-FROM-1-TO-11
[40.6]15-FROM-1-TO-11
[40.6]16-FROM-1-TO-11
[40.6]17-FROM-1-TO-11
[40.6]18-FROM-1-TO-11
......

这一个数据直接刀中房间里的两个人,看来大家的想象力还是比评测机丰富啊~~

本单元作业互测的强度相比于上一个单元提高了很多,大家的互测积极性被完全调动了起来,对此我也有深刻体会,强测难以检测出来的bugs在同学们的密集轰炸下也可能会显现出来。

心得体会

线程安全

无论在自测还是互测的过程中,我遇到最多的线程问题是ConcurrentModificationException异常。这也不难理解,三次作业迭代的过程中线程与线程之间的对象共享是非常之多的。只要在任意一个共享对象的地方没有加同步锁控制,都可能出现这个异常。(最恶心的一点是这个bug在本地往往难以复现

分析完项目程序结构和本单元出现的所有bugs后,我梳理了一张线程间对象共享的图。

Global_WaitList
Global_WaitList
Single_WaitList
Single_WaitList
Lock
Partner_WaitList
Input
Dispatcher
Elevator
Partner_Elevator

根据上面这张图,可以清晰地看到不同类之间共享对象关系。通过分析这些对象之间的关系,我们可以准确地同步临界区,不会出现数据冒险之类的情况。

层次化设计

层次化设计是一种将复杂的系统或问题分解为多个层次或模块,并对每个层次或模块进行独立设计和实现的方法。这种方法的基本思想是将大问题划分为小问题,然后逐级解决。在设计过程中,上层模块或层次主要关注高层次的功能和实现,而低层次的模块或层次则负责实现具体的细节。这种分工合作的方式使得设计过程更加简单和可控。

在本次电梯作业的迭代场景下,层次化设计显得尤为重要。电梯的移动、接人、重置、策略、分配等都离不开层次化的设计与分析。在我的架构设计中,完全考虑了“低耦合,高内聚”的设计思路,将电梯的每种行为都进行了分层。这种分层的好处可以让功能设计变得更加的简洁,也让debug更加的方便。

可以说,通过OO第一、二单元的学习,我已经逐渐领悟到层次化设计的精髓了。层次化设计能大大减少项目编写的复杂性,分工明确的结构也会带来效率上的提升。所以,在未来设计大项目程序时,应优先考虑层次化设计。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值