OO第二单元作业——电梯系统调度
OO第二单元作业——电梯系统调度
一、总体设计架构
OO课程第二单元的主题为电梯系统的设计。这个电梯系统包含了A,B,C,D,E五个楼座和1~10十个楼层。对于每一位请求乘坐电梯的乘客,需要有电梯将该乘客从其出发地点运送到目的地。由于多部电梯在同时运行,我们需要采用多线程的编程模式来设计电梯调度系统。
我在完成第六次作业时对第五次作业进行了重构,在完成第七次作业时又对第六次作业进行了重构,其原因会在后文详细解释,但在这三次作业中,我的设计思路并未发生大的变动。我为每一部电梯设计了一个调度器,每个调度器上运行着一个线程。这里,调度器负责接收并分析乘客的请求,并发送命令给电梯;而电梯只负责执行调度器发送来的命令,不会自主分析下一步应该执行的操作。在调度器的调度策略上,我选择了ALS策略,或简称为主请求策略,我会在本章的“调度器”一小节详细介绍这个策略。在此架构基础上,由于新需求的不断加入,每次作业的具体实现上会有很大区别。
下面我将以第五单元作业为例,逐个地介绍我的基础架构组成元素。它们分别是:电梯、调度器、乘客、等待队列与输入输出。这五个元素缺一不可,它们共同维护着电梯系统的运转。
1、电梯
电梯当然是电梯调度系统中的首要元素。在我的设计中,线程并不运行在电梯类上,因此电梯无法自主分析其下一步应该去的地点,只会执行调度器发送来的命令。在第五单元中,电梯可以执行的命令有七种:开门、关门、上楼、下楼、进人、出人和寻找主请求。调度器发送来的命令也只可能是这七种命令中的一种,具体来说就是调用相应的方法。本着设计简单的原则,我没有为这些方法设置返回值来表示该方法是否能正确执行,也没有在方法内部进行判断、抛出异常。因此电梯在执行命令时,不会分析该命令是否可以将乘客运送到目的地,也不会分析该命令是否是逻辑正确的(假如电梯在10楼,调度器发送上楼命令,电梯会前往11楼)。既然如此,这一切就应该由调度器来考虑,即调度器能正确判断乘客的请求,并将每位乘客在规定时间内送往目的地;调度器会时时记录电梯的运行状态,不会发送不合理的命令。
在有些架构设计中,不存在调度器这一元素,电梯可以自主分析乘客命令并执行。这种设计肯定是正确的,而且实现起来比我的思路要简单些。我在设计之初也考虑过这个思路,但是我希望把设计架构的不同组成元素之间的关系划分地更加清晰,并提高代码的可读性和易扩展性,因此最终采用了“调度器决策——电梯执行”的设计架构(虽然没能实现最初的设计目标)。
2、调度器
调度器负责接收、分析乘客的请求,而后发送命令给电梯,控制着电梯来完成乘客的请求。在我的设计中,一个调度器只对一部电梯负责,因此调度器对电梯实行的是“全面管理”。在宏观上,调度器分析当前在电梯内的乘客和楼座中的乘客等待队列,根据一定的调度算法来确定电梯下一步的运行方向;在微观上,调度器记录着电梯当前的运行状态(如开关门等),并据此发送命令,让电梯以合理的方式运作。举个例子:电梯刚启动时在一楼,若这时10楼出现了乘客请求,那么调度器会控制着电梯上到10楼,这就是宏观调度;到达10楼后,调度器需要命令电梯开门,乘客进入电梯后再命令电梯关门,这就是微观调度。
我们所说的调度策略是指宏观上的调度方式。我采用的是主请求调度策略(简称ALS)。该策略分为主请求和捎带请求两个部分。主请求,顾名思义,就是电梯以最高优先级来完成的请求。当电梯内有乘客时,最早进入电梯的乘客就是该电梯的主请求,此时电梯的目标楼层是该主请求的目的地楼层;当电梯内没有乘客时,最早进入楼座等待队列的乘客就是该电梯的主请求,此时电梯的目标楼层是该主请求的出发楼层。电梯总是朝着上面定义的目标楼层行进,直到电梯内没有乘客并且楼座等待队列为空,此时该调度器线程被阻塞,直到等待队列中出现一个新的请求。这里我没有讲调度器线程是如何结束的,因为这属于具体细节问题,将在第二章作业分析部分详细讲述。
被捎带请求,也就是电梯在完成主请求的同时捎带完成的请求,需要满足以下几个条件:电梯的主请求存在;被捎带请求的出现时间早于电梯到达其所在楼层的时间;被捎带请求的目标方向与电梯的运行方向一致;电梯此时没有满员。在这几个条件同时满足时、电梯会接纳该请求,并将该请求定义为被捎带请求。捎带策略的设计在很大程度上决定了电梯系统的运行效率,良好的捎带策略在同样的任务下可以大大缩短电梯系统的运行时间。
3、乘客
每当系统接收到一个请求时,就建立一个乘客类,记录该请求的一些具体信息,并据此将该乘客投入到相应的等待队列中。乘客类中需要记录的信息有五个:该乘客的出发楼座、出发楼层、目的地楼座、目的地楼层、目标方向。其中目标方向是专门为ALS调度算法的捎带策略而设计的。当一个乘客到达其目的地之后,就将相应乘客类对象的引用移除,JAVA垃圾管理器会自动将该对象清理。
4、等待队列
每一个等待队列是一组乘客的集合,其内部使用阻塞队列BlockingQueue来管理这些乘客。每一个楼座都对应着一个乘客等待队列,比如A座就相应地有一个乘客等待队列,这个等待队列中的乘客的共同特点是:他们的出发楼座为A座,目的地楼座也是A座,由一部运行在A座中的楼座电梯即可完成他们的请求。等待队列的数量是在程序开始运行时就确定了的,在运行时不会动态变化,但是等待队列中的乘客数量没有上限。
等待队列元素的设计构成了一个生产者——消费者模型。下一节要介绍的输入线程相当于生产者,它不断向等待队列的尾部投入请求;调度器线程相当于消费者,它不断从等待队列中取出请求并完成。但本次设计与传统的生产者——消费者模型,在生产者与消费者的行为上,有很大的不同。
首先来看生产者的行为。传统的模型中,在线程共享队列已满时,生产者线程睡眠,而消费者线程在取出一个对象后会唤醒生产者线程,使其继续进行生产行为;而我的设计中,由于乘客等待队列是采用LinkedBlockingQueue来实现的,因此其没有数量上限,生产者不会在生产过程中陷入睡眠,消费者也无需唤醒生产者。而由于生产者需要投放的请求是由外部输入决定的,因此生产者在将这些输入投放完毕后直接死亡。
再来看消费者的行为。传统的消费者每次会在队列的头部取出一个对象并执行一定的操作。而在我的设计中,消费者从共享队列中取出请求的方式有两种。第一种是以捎带请求的方式取出。当某一请求满足上面描述的被捎带请求的条件时,调度器就会从等待队列中取出这一请求,而不管这一请求在队列的哪个位置。这种取出请求的方式不符合传统模型中的消费者行为,这是电梯的ALS策略所带来的特殊消费行为。第二种是以寻找主请求的方式取出。当电梯内不存在乘客时,调度器会在共享队列中寻找主请求并将其取出。这种消费方式与传统的消费者行为一致,当乘客等待队列为空时,调度器线程陷入睡眠;而当输出线程向等待队列中投入一个请求时,会唤醒调度器线程。
5、输入与输出
我为输入类单独设立了一个线程,这也是在乘客等待队列中采用生产者——消费者模型所必需的,因为输入类需要模拟生产者线程。输入类的作用有两个:一是解析用户输入的请求、二是将这些请求在相应时间投入相应的等待队列中。其中第一步已经由官方提供的输入包所完成,我们只需要完成第二步就可以了。
由于官方输出包是线程不安全的,因此我们需要设计输出类来同步不同线程的输出。输出类不需要单独设立线程。
二、具体实现
上面介绍的是这三次作业一脉相承的设计架构,下面将要介绍每次作业具体的实现细节。
我们都知道,对于一个需要代码实现的任务,我们可以采取两种编程方式:一是自底向上、二是自顶向下。这两种实现风格各有千秋。在这三次作业中,我使用的都是前者——自底向上。
自底向上的编程方式锻炼我们将已有简单功能进行组合,完成复杂功能的能力。“底”指的是具体的实现细节,我们需要从具体的底层部件入手,将以后可能需要的一些基本功能实现。然后将这些基本功能看做是一个个的模块进行封装,通过组合与微调,实现上一层的功能。就这样逐步上升,最终实现顶层任务。在“上升”的过程中,如果发现之前设计出来的下层功能不够或者不合适,需要进行补充与调整。
自顶向下的编程方式锻炼我们整体分析的能力。“顶”指的就是我们需要完成的顶层功能。我们需要将这个顶层功能进行逐步拆分,形成很多基本功能。由于在顶层设计的时候已经考虑好了不同模块之间的耦合关系,因此在设计底层模块的时候,只需要着眼既定的接口,进行局部功能设计就可以了。但是,如果发现无法在底层实现某些功能,我们就需要对设计架构进行调整,最终要保证拆分后的模块都是可实现的。该方法的核心就是顶层功能的拆分,我们需要运用整体思维,使各个底层模块的功能单一化,并控制耦合度,以达到复杂度的分解。
我采用自底向上法的原因很简单——我在没着手开始写代码之前不能完整清晰地梳理出顶层模块的设计框架。我在设计时,将电梯,乘客,等待队列和输入输出作为底层模块,将调度器作为顶层模块。我先将这四个底层模块完成,后根据这些模块的设计来实现调度器,同时根据调度器的既定功能来修改这些底层模块的设计。
在详述三次作业的具体实现之前,我用一张图来展示这三次作业之间的迭代关系。
图1 三次作业的迭代关系
第五次作业
如上图所示,第五次作业的需求量较小,旨在让我们初步了解多线程的编程模式。在乘客的请求方面,乘客的出发楼座与目的地楼座必须相同;在电梯设计方面,五个楼座中各有一部纵向电梯,初始位于各楼座的一层,且电梯的数量不可以增加。在第一章中介绍的架构设计就是以此次作业的要求为基础来设计的。下面我举个例子,来说明电梯的运行流程。下图是我的电梯运作流程图,除了“更换主请求”之外的其他部分已经在上文的“调度器”一小节中介绍过。
图2 电梯运作流程图
主请求的更换
电梯系统在刚开始运行时,某个电梯位于1楼,且电梯内没有乘客,此时调度器会在相应的等待队列中寻找主请求。我们假设最先投放的请求在10楼,那么调度器在以寻找主请求的方式访问等待队列时,会把该请求定义为主请求,之后调度器会操纵着电梯从1楼移动到10楼来接这个乘客。
当电梯运行在1到9楼时,其运行方向被设置为“向上”,因为电梯下一步确实是要上楼。运行方向的设置用来迎合ALS算法的捎带策略。电梯的运行方向可以被设置为“向上”、“向下”或“不确定”,其中“不确定”状态是在主请求更换时发生的瞬间状态,用于某些函数的特判。同样,每个乘客的目标方向也会被设置为“向上”或“向下”,当乘客的目标方向与电梯的运行方向一致时,该乘客可作为被捎带请求进入电梯。
但是我们注意到在这个情况下,电梯内部没有乘客,主请求在等待队列中,按照我的算法,若此时有乘客作为被捎带请求进入电梯,电梯的主请求会更改为第一个进入电梯的乘客,而暂时放弃之前在等待队列中的主请求。比如,当电梯运行到4楼时,发现4楼有一名乘客想去7楼,那么此乘客会进入电梯,成为主请求,电梯运行到7楼,该乘客出电梯。之后10楼的乘客重新成为主请求,电梯继续向10楼行进。在这里,我要定义一个概念,叫做“主请求的完成”:当电梯内有乘客时,主请求一定是最早进入电梯的乘客,此时“主请求的完成”指将该乘客送到他的目的地;当电梯内没有乘客时,若主请求存在,则一定是等待队列中的首个乘客,此时“主请求的完成”指电梯运行到该乘客的出发地,将该乘客接入电梯中。在上述情况下,电梯运行到4楼时还没有将之前定义的主请求从10楼接入电梯中,因此此时电梯还没有完成主请求,但电梯在4楼更换了主请求。“在没有完成主请求的情况下更换主请求”是我对ALS捎带策略的一个拓展,这样的拓展有利于使主请求的概念更加清晰,使调度器的目标更加明确,虽然无法带来电梯系统运行时间的缩短。
在上面的例子中,我考虑了另外一个问题,就是当4楼的这名乘客想去1楼时,到底要不要把他接入电梯,并更改主请求?我并不能确定这种操作能不能在统计学意义上缩短电梯的运行时间,由于我不希望电梯频繁地更换运行方向,因此我没有采用这种做法。
满载问题的避免
上面对于更换主请求的策略并不是我在第一次设计时就考虑到的,而是经过了一次策略微调后想到的。在最初的设计中,调度器一旦选定了一个主请求,就不会更改,直到完成了这个主请求。
在上一小节我举的例子中,我们并不能直观地看到这种方法的好处,因为对于这个简单的例子,更不更换主请求对结果毫无影响。但是我们考虑下面一个稍微复杂一点的例子:电梯系统开始运行后,对于一个容量为6人的纵向电梯,开始时位于1楼。先有1名乘客想从4楼去10楼,后又来了6名乘客想从3楼去5楼,假设这6名乘客来的时间早于电梯运行到3楼的时间,这时调度器会做出怎样的决策呢?
显然,这6名以3楼为出发点的乘客都符合被捎带请求的要求。因此,若不加特判,不管我们有无更换主请求的策略,都会将这6名乘客接入电梯。接着,电梯运行到了4楼,调度器分析出这是电梯主请求的目标楼层。调度器可能做出的行为有两种:一是不加判断地将主请求接入电梯中,但是这样会发生超载错误,因此这样肯定是不行的;二是不接主请求,但是由于我的调度器总是命令电梯向着目标楼层移动,这时调度器会不断地判断当前楼层与目标楼层的关系,但不做出任何行为,形成轮询。
解决这个问题的思路有两种:第一种需要作一系列特判,当电梯的主请求位于等待队列中时,在捎带其它请求时,需要始终保持电梯内有至少一个空余位置,以免出现上面的现象;第二种就是我所采用的策略——更换主请求。这时,更换主请求策略的优点就凸显出来了:我们不需要做任何特判。因为在上述现象发生之前,电梯的主请求已经更换成了第一个进入电梯的乘客,只要在乘客进入的时候判断一下是否超载就可以了。
第六次作业
在第六次作业中,乘客可以在同一楼座的不同楼层间移动(即出发楼座与目的地楼座相同,出发楼层与目的地楼层不同),或者可以在不同楼座的同一楼层间移动(即出发楼座与目的地楼座不同,出发楼层与目的地楼层相同)。为了满足乘客的需求,在原有纵向电梯的基础上增加了横向环形电梯,并且可以动态增加电梯的数目。在最开始时,在A到E座中各有一部纵向电梯。纵向电梯初始时位于各楼座的1层、横向电梯最开始时位于A座的各层。所有电梯的容量与移动速度都是固定的值,不可以更改。
第六次作业相比于第五次作业,开发增量主要体现在电梯的运行策略上。在纵向电梯上,可以有自由竞争、ALS两种策略;在横向电梯上,可以有自由竞争、ALS、单方向运行三种策略。我在纵、横两个方向上都采用了ALS策略。下图形象地展示了这些策略的应用关系。
图3 横纵向电梯的运行策略
主请求策略的改进与比较
纵向电梯的运行策略包括自由竞争策略、ALS策略和预先分配策略三种,我的选择是ALS策略。
ALS策略的基本原理在上文中已经详细介绍过,在第六次作业中我仍然沿用了这个策略,但是一些具体的实现细节需要修改。我们假设一个楼座中有两部纵向电梯。电梯系统开始运行后,每个电梯都会寻找主请求,而后确定目标楼层。为了避免“一部电梯到达目标楼层后发现主请求已经被另一部电梯接走”的现象发生,这两部电梯选择的主请求应该总是不同的,并且一部电梯不能将另一部电梯的主请求作为被捎带请求带走。为了实现这两条要求,我设计的调度器在从等待队列中选择主请求后,就将这个乘客从等待队列中删除,并用专门的变量来存储这个(来自等待队列的)主请求。但是由于我的调度器仍然会进行更换主请求的行为,因此一旦将主请求更换后,需要将之前从等待队列取出来的主请求重新放回等待队列中。
“将之前从等待队列中取出来的主请求重新放回等待队列中”这一步操作需要小心,因为我们需要注意放回的位置。在我的设计中,当输出线程结束后,会在每个等待队列中放入一个null,当调度器检测到null时,就会知道所有请求都已经处理完了,于是就是结束当前的线程。上面重新放回主请求时,必须要注意一下,不能将有效的请求放在null的后面,否则该请求将会无法处理。其实这种线程结束方式有很大的改进空间,我在第七次作业中会详述。
自由竞争策略没有以上我对主请求的特殊操作,每部电梯总是独自地运行,可能出现一部电梯接走另一部电梯主请求的现象,但是由于两部电梯的竞争,系统总是能较快的速度处理单个出现的请求(非密集型)。预先分配策略是把请求队列均分成两部分,每部电梯处理一部分。这种策略相当于每部电梯都在小范围内执行纯ALS策略,由于第五次作业的铺垫,这种方式出现bug的几率很低,但是有可能出现每部电梯接收的乘客数量上相同,但工作量上大不相同的情况。这两种策略都是以ALS策略为基础的,根据大量测试,其综合效果与ALS策略相当,更多地取决于实现细节。
横向电梯的运行策略包括自由竞争策略、ALS策略、预先分配策略和单方向运行策略,我选择的仍然是ALS策略。
在横向电梯中、电梯的运行方向可以分为左和右。当电梯在A座时,B座和C座在右边,E座和D座在左边。但是由于横向电梯时环形的,因此左和右并不是绝对的,这也是单方向运行策略的由来——电梯仅向一个方向运行。这种策略简单易懂,不会出现任何有关主请求处理不当的bug,但是可能延长乘客到达目的地的时间。
等待队列的设计必要性——浅谈锁和同步(synchronized)
在这次作业中,我对架构设计中“等待队列”这一元素的必需性有了一个更加深入的理解。
在第五次作业中,其实可以不单独设立“等待队列”这样一个元素。由于每个楼座只有一部电梯,因此可以由电梯类来存储等待队列,这样的话输入线程的作用就改变为了直接对电梯类中“等待队列”这一结构成员的操作。但是这样会带来一些隐蔽的问题。在多线程的编程模式下,对于一个方法,只要它对类的属性进行了读或写操作,就要对其进行同步。对某个类的一个方法进行同步后,对于某一个该类的对象,在任意时刻,只能有一个线程访问此对象的同步方法,其它在此时申请调用此对象的任何同步方法的线程都会被阻塞。如果将等待队列这一数据结构放在电梯类中,由于对等待队列这一共享数据的操作方法一定是要同步的,因此对等待队列的操作与对电梯的操作都成为电梯类中的同步方法,所有这些方法被上了同一把锁,其结果就是在调度器操作电梯时,输入线程被阻塞;当输入线程在等待队列中加入请求时,调度器线程被阻塞。由于输入线程在向等待队列中投入一个请求后,调度器会立马调度电梯来完成这个请求,因此在调度器完成这个请求之前,输入线程无法向等待队列中投入另一个请求,表现为电梯每次只能处理一个请求,事倍功半。
多线程的锁和同步机制赋予等待队列以设计必要性。我在这三次作业中,通过给一些方法加上synchronized修饰词,来给它们进行同步。这些方法的共同点是:读或者写相应类的非静态成员,并且有多个线程可能调用这个方法。另外,我们最好只对线程不安全的方法加锁,而对于线程安全的方法不加锁,否则会造成多余的等待,极其浪费时间。
在第六次作业中,由于同一楼座(楼层)多部电梯的出现,等待队列这一元素的必要性就是显而易见的了。就拿同一楼座中的两部电梯来说,我们可以为每一部电梯设计一个调度器,这些调度器都是以等待队列为共享队列的“生产者——消费者”模型中的消费者,每个调度器的作用就是取出请求并调度电梯以完成请求。我们也可以为这两部电梯设计一个公共的调度器,该调度器是“生产者——消费者”模型中唯一的消费者,它要从等待队列中取出请求并分配给两部电梯,还要调度两步电梯。我采用的方式是前者。但不管是哪种设计方式,“等待队列”这一元素必须作为单独的一个类被设计。
第七次作业
在第七次作业中,乘客的出发地点和目的地可是任意楼座的任意楼层。在电梯的设计上,相比于第六次作业,不但电梯的数量可以动态增加,其容量和运行速度也可以自定义。另外,对于横向电梯,其可达性信息也可以自定义,即自主选择该电梯可以到达的楼座。
由于乘客的出发地点与到达地点可能既不在同一楼座,又不在同一层,因此乘客的换乘问题是我们必须面对的。
乘客的路线规划
我所采用的乘客路线规划方法俗称为“静态三段法”,即对于需要换乘的乘客,先乘坐纵向电梯到达换乘楼层,再乘坐横向电梯到达目的楼座,最后再乘坐纵向电梯到达目的楼层。我的设计核心在于换乘楼层的选择:给定某一乘客当前所在的楼层,若当前楼层可以进行横向换乘,则乘客会首先乘坐横向电梯进行换乘。若当前楼层无法进行换乘,则考虑以下情况。假设乘客需要从A座5层前往B座10层,则乘客的换乘楼层优选5到10层,若5到10层无法进行换乘,则乘客会依次考虑从4,3,2,1层进行换乘,由于最开始1层有一部可达5个楼座的横线电梯,因此乘客的换乘需求是一定可以被满足的。
对于一个请求,在乘客类的内部设置路线规划方法,分析该请求需要进入的等待队列和下一步应该前往的地点。对于以上例子,我们假设7层为换乘楼层。则当该请求生成时,输入线程调用乘客类内部的路线规划方法,分析出该乘客需要前往A座7楼进行换乘,于是将其投入A座的楼座等待队列,并设置目的地为A座7楼。i号电梯将其运送到A座7楼后,i号电梯调度器会更新乘客的当前位置,再次调用路线规划方法,检测到7楼可以进行换乘后,调度器会将该乘客投入7层的楼层等待队列,并设置目的地为B座7层。j号电梯将其运送到B座7楼后,j号电梯调度器再次更新乘客的当前位置,又一次调用路线规划方法,发现该乘客不再需要换乘,于是将其投放到B座的楼座等待队列,并设置目的地为B座10楼。k号电梯将其运送到B座10楼后,通过判断发现该乘客已经到达目的地,这时就可以将该乘客对象送给垃圾回收器了。
根据上面的描述,我们可以发现,调度器线程会在乘客下电梯后分析该乘客是否发到目的地,若未达到目的地,则为乘客规划下一步需要前往的地点。因此,乘客的路线是逐步被规划出来的,其中第一步路线规划是由输入线程完成的。这样做的优点就是不需要在开始时记录下以后每一步需要前往的地点,也不需要记录当前乘客已经完成到了第几步。在每一时刻,只需要记录乘客的当前位置和下一步需要前往的位置即可。但是,这样做也会有一些细节性问题,就是路线规划的拼接。
根据上面的描述,我们知道,每一个线程只负责根据乘客的当前位置和目的地来规划乘客下一步应该前往的地点。每个线程只为该乘客规划了一步路线,每一步路线拼接起来,应该以最优的方式构成了乘客的总路线。什么叫以最优的方式拼接呢?就是说,调度器在更新乘客的当前位置后,可以确定乘客已经行进到了下图的哪一个阶段,下一步该进行哪一个阶段,不会重复执行一个阶段,也不会跳阶段。
图4 乘客的换乘步骤
那么线程是如何知道乘客当前处于哪一个阶段呢?我们假设某一乘客当前位置是A座5层,目的地是B座7层,而5层不能换乘,那么此时乘客位于第一阶段,应乘坐纵向电梯前往换乘楼层;假设某一乘客当前位置是A座1层,目的地是C座5层,1层可以换乘,那么此时乘客处于第二阶段,应乘坐横向电梯前往C座1层;假设某一乘客当前位置是A座1层,目的地是A座2层,由于当前楼座与目的楼座相同,无需换乘,因此乘客位于第三阶段。这样一来,不同的调度器就可以在乘客的路线规划问题上完美地配合,从而为乘客规划出静态最优路线。
静态路线规划法需要在请求生成的时候就确定乘客是否需要换乘以及选择换乘楼层,与此相对的是动态规划法。动态规划法的思路是时时为乘客规划最优的换乘楼层。我们假设某一乘客需要从A座10层前往B座10层,而在该请求生成的时候,只有1层可以换乘,那么该乘客只能从A座10层前往A座1层进行换乘。假如当该乘客到达A座9层时,8层突然新增了一部可达A,B座的横向电梯,那么动态规划法可以为该乘客重新选择换乘楼层,使该乘客从8层换乘,而不是1层。
既然动态规划法需要时时判断最优的换乘楼层,因此最合适的实现方法是把每一名乘客设计为一个线程,每个乘客线程会在乘客到达新的一站或者新增一部横向电梯后进行搜索,获得沿当前电梯运行方向的,最近的换乘楼层。在上例中,当乘客到达A座9层时,若10楼突然新增了一部可达A,B座的横向电梯,这时的操作会比较复杂,我也并不清楚此时改变电梯的运行方向是否可以缩短电梯运行的整体时间,毕竟电梯内可能还有其他乘客。但是按照我之前的思路,即使我可以设计出动态规划法,我也不会让电梯在这种情况下改变运行方向。
我终究未能设计出动态规划法,其根本原因在于我无法驾驭复杂度过高的算法,并且对于动态规划法中一些情况的处理方式也并不是很清楚。为了避免过多bug的出现,我采取了简单但是可靠度极高的静态三段法。
横向电梯的可达性问题以及由此引出的多次换乘策略
在第七次作业中,横向电梯可以自定义可达性信息,即自主选择一部横向电梯可以停靠的楼座。假如一部横向电梯的可达楼座为A,B,C,D座,那么按照我之前的静态三段规划法,想换乘到E座或者想从E座换乘的乘客就不能乘坐这部电梯,因为它不会在E座停靠。
这引出了一个小bug,这里我分享一下。假如7楼有两部横向电梯,第一部在A,B,C,D,E座可达,第二部在A,B,C,D座可达。有一名乘客想从A座7楼去E座10楼,经路线规划后,调度器认为7楼可以换乘,于是将这名乘客丢到7楼的等待队列中。但是,既然这名乘客的目的地楼座是E座,而第二部电梯不在E座停靠,因此这名乘客不能以任何方式进入第二部电梯。即,第二部电梯不能选择这名乘客为主请求,也不能将这名乘客捎带进入电梯。为了解决这个bug,每一部横向电梯在接入乘客时需要明确,该乘客的出发楼座和目的地楼座都是可达楼座。
静态三段法到这里就介绍完了。静态三段法可以有两种拓展模式,一种是从时效性的维度,即将静态算法改进为动态算法;另一种是从精确性的角度,将“三段”改进为“多段”。
假如某乘客想从A座10楼前往E座10楼。10楼有四部横向电梯,第一部在A,B座可达;第二部在B,C座可达;第三部在C,D座可达;第四部在D,E座可达。这名乘客可以选择依次乘坐这四部电梯到达E座10楼,也可以选择在1楼换乘。在本情景下,显然前一种选择要更优一些。为此,可以引入迪杰斯特拉算法。下图直观地描述了上面的例子:
图5 电梯系统的迪杰斯特拉算法
根据迪杰斯特拉算法得到的可达性图如上图所示。从一个站台到另一个站台有一条边当且仅当有一部电梯可以在这两个站台停靠,这条边的权重为该部电梯的运行速度乘以站台间距,如A1到C1的边上,电梯运行速度为0.6,站台间距为2,则边的权重为1.2。设计好这张图后,根据可达性矩阵,就可以使用迪杰斯特拉算法来规划乘客的行进路线了。如果有多部电梯都可以到达某两个楼层,则相应的两个节点之间有多条边,但是这些边的权重可能各不相同。
迪杰斯特拉算法没有考虑到一些其它的可能影响乘客运送时间的因素。比如,电梯在途径某一站时需要开门让其他乘客进出;乘客在换乘时,长时间等不到电梯,或者电梯到达这一站时的方向不符合乘客的捎带要求;或者电梯已经满员。在这些情况下,乘客到达目的地的时间会远远大于通过算法所计算出来的时间。为此,我们在通过迪杰斯特拉算法进行路线规划时,需要尽可能地减少乘客换乘的次数,并尽可能地减少乘客途径的站台总数。这两个要求往往无法同时得到满足,这就需要我们综合考虑,其中的权衡和比较会更加复杂。
线程的安全结束
线程的安全结束是这三次作业都需要考虑的问题。在第五次和第六次作业中,输入线程在请求解析完毕后会向每个等待队列中放入一个null,当调度器线程在以提取主请求的方式进入等待队列,并提取到null后,就会认为当前等待队列已经处理结束,并且不会有请求再进入该队列,于是结束当前线程。当所有调度器线程都结束后,整个程序也就完成运行了。
但是在第七次作业中,这种方法行不通了,因为其他调度器可能随时向这个等待队列中放入一个换乘乘客,因此所有调度器线程都需要保持运行状态,直到所有乘客请求都处理完毕,再一起结束。为此,我设计了一个乘客请求跟踪类,并在其上运行一个线程,专门用来计数当前剩余的乘客请求。每当输入类生成一个乘客请求时,计数器的值加一;每当一个调度器线程发现一名乘客已经到达目的地后,计数器的值减一。当计数器的值为零,并且输入线程已经结束时,该跟踪类就会调用相应方法,结束所有调度器线程,再结束本线程,使程序运行结束。
总结
我在本章中主要介绍了电梯调度算法的具体实现,并在细节上提出了一些注意事项。我对所有可以使用的算法都进行了介绍,但只对本次作业所采用的算法进行了详细解说,因为我对其他算法并不是很了解。通过比较,在第五次和第六次作业中,我选择了复杂度最高,效果较好的算法;在第七次作业中,我选择了复杂度适中,效果稳定且不容易出错的算法。这比较能反映我的编程风格:对于较简单的问题,我愿意采用更精确的算法来以最优的方式解决;对于较复杂的问题,我一般会舍弃算法的精进,而采用稳妥的方式解决。我会在接下来的两个单元中继续保持这种习惯。
三、代码风格分析
这一部分进行代码风格分析,我会展示三次作业的类图,代码复杂度,并进行相关的分析说明。
类图
下面展示三次作业的类图。由类图可以明确不同类之间的组合,继承等关系,也可以对代码复杂度有个初步的认识。
图6 第五次作业类图
图7 第六次作业类图
图8 第七次作业类图
由这三次作业的类图可以看到,我对类之间作用的划分是比较清晰的:电梯、调度器、乘客、等待队列、输入、输出都被设计为单独的类,每个类的功能明确。第七次作业对第六次作业进行了改进,将横向电梯、纵向电梯设计为电梯类的子类,杜绝了调用方法不合理的问题,也使类之间产生了更深刻的内在联系。
但是我的设计缺点也是显而易见的:方法数量太多。每个类的内部方法设计不是很合理,导致不同类之间的耦合关系复杂,当程序出现错误时不容易找到起因。另外,我对电梯系统的初始化是在主类中完成的,这导致主类的功能有些复杂,不利于其行驶“统领全局”的功能。
复杂度分析表
复杂度分析表定量地表现了类的复杂度与耦合度。我在下面仅会展示每次作业中关键方法的复杂度。
CogC | ev(G) | iv(G) | v(G) | |
---|---|---|---|---|
Elevator.close() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.downFloor() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.findMajorRequest() | 2.0 | 1.0 | 2.0 | 2.0 |
Elevator.in(boolean, boolean, boolean) | 24.0 | 1.0 | 16.0 | 16.0 |
Elevator.open() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.out() | 4.0 | 1.0 | 4.0 | 4.0 |
Elevator.upFloor() | 1.0 | 1.0 | 2.0 | 2.0 |
Operation.run() | 22.0 | 1.0 | 12.0 | 13.0 |
Elevator.hasOut() | 3.0 | 3.0 | 2.0 | 3.0 |
Station.run() | 5.0 | 3.0 | 4.0 | 4.0 |
Elevator.hasIn(boolean, boolean) | 6.0 | 4.0 | 5.0 | 6.0 |
Total | 75.0 | 46.0 | 83.0 | 91.0 |
Average | 1.92 | 1.18 | 2.13 | 2.33 |
表1 第五次作业复杂度分析
CogC | ev(G) | iv(G) | v(G) | |
---|---|---|---|---|
Controller.finishMajor() | 0.0 | 1.0 | 1.0 | 1.0 |
Controller.locationJudge(Direction) | 4.0 | 1.0 | 2.0 | 5.0 |
Elevator.close() | 0.0 | 1.0 | 1.0 | 1.0 |
Elevator.down() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.findIn(Direction) | 12.0 | 1.0 | 13.0 | 14.0 |
Elevator.hasIn(Direction) | 8.0 | 1.0 | 11.0 | 12.0 |
Elevator.hasOut() | 5.0 | 1.0 | 4.0 | 5.0 |
Elevator.in() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.left() | 6.0 | 1.0 | 5.0 | 6.0 |
Elevator.open() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.out() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.right() | 6.0 | 1.0 | 5.0 | 6.0 |
Elevator.up() | 1.0 | 1.0 | 2.0 | 2.0 |
MainClass.addElevator(ElevatorRequest) | 19.0 | 1.0 | 5.0 | 15.0 |
MainClass.addPassenger(PersonRequest) | 19.0 | 1.0 | 18.0 | 18.0 |
Input.run() | 6.0 | 3.0 | 6.0 | 6.0 |
Controller.run() | 38.0 | 15.0 | 15.0 | 16.0 |
Total | 163.0 | 68.0 | 154.0 | 177.0 |
Average | 3.26 | 1.36 | 3.08 | 3.54 |
表2 第六次作业复杂度分析
CogC | ev(G) | iv(G) | v(G) | |
---|---|---|---|---|
BuildingElevator.down() | 1.0 | 1.0 | 2.0 | 2.0 |
BuildingElevator.isPersonIn(String, boolean) | 14.0 | 6.0 | 7.0 | 11.0 |
BuildingElevator.isPersonOut() | 5.0 | 1.0 | 4.0 | 5.0 |
BuildingElevator.up() | 1.0 | 1.0 | 2.0 | 2.0 |
BuildingOperator.run() | 22.0 | 7.0 | 6.0 | 10.0 |
Elevator.close() | 0.0 | 1.0 | 1.0 | 1.0 |
Elevator.in(Passenger) | 0.0 | 1.0 | 1.0 | 1.0 |
Elevator.open() | 1.0 | 1.0 | 2.0 | 2.0 |
Elevator.out(Passenger) | 0.0 | 1.0 | 1.0 | 1.0 |
FloorElevator.isPersonIn(String, boolean) | 14.0 | 6.0 | 8.0 | 12.0 |
FloorElevator.isPersonOut() | 6.0 | 2.0 | 4.0 | 6.0 |
FloorElevator.left() | 3.0 | 1.0 | 3.0 | 3.0 |
FloorElevator.right() | 3.0 | 1.0 | 3.0 | 3.0 |
FloorOperator.run() | 16.0 | 7.0 | 6.0 | 8.0 |
Input.run() | 9.0 | 3.0 | 6.0 | 6.0 |
TrackerOperator.run() | 3.0 | 1.0 | 3.0 | 3.0 |
Total | 209.0 | 130.0 | 172.0 | 239.0 |
Average | 2.55 | 1.59 | 2.10 | 2.91 |
表3 第七次作业复杂度分析
被我选出当做“关键方法”来展示的方法分为两类:一类是与多线程有关的方法,如各个线程的run方法;另一类是复杂度较高方法,如调度器判断在某一站台是否有人进出的方法。第五次作业由于所需功能较少,设计起来也十分简单,方法的复杂度普遍较低。第六次作业中由于出现了同一楼座(楼层)中多部电梯的可能性,我对主请求策略进行了改进,由于“主请求的取出与放回”这一系列操作十分繁琐,导致方法间的耦合度大大增加。我在第七次作业中对此进行了整体上的改进,但由于换乘现象的存在,仍然无法将复杂度降到一个较低水平。复杂度分析表仅能展示代码的设计情况,需要与具体问题结合或者与他人代码比较,才能评判设计的优劣。
UML协作图
图9 第五次作业线程协作图
图10 第六次作业线程协作图
图11 第七次作业线程协作图
重构的原因
在这三次作业中,我认为我的设计思路还是比较清晰的,在设计后面的作业时,我总是沿用前一次作业的设计框架,并在此基础上添加新需求。因此我虽然在第六次和第七次作业中进行了“重构”,但也不是完全地推倒重来,在已经明确设计框架的基础上,再用代码实现一遍,花费的时间并不太多。
我重构主要是由于细节原因。在设计第六次作业时,我认为我在第五次作业中,很多细节的处理方式不优雅,十分繁琐。就比如对被捎带请求的判断,相应方法内分支数量极大,且不同方法间的依赖关系十分复杂,曾出现过很多bug。于是在第六次作业中,我对第五次作业的细节处理进行了优化,但同时,主请求的相关操作带来了更大的复杂度,这使我在第七次作业中不得不再次进行重构。我承认,在第七次作业中,我对好多细节(如路线规划)的处理并不是我所选择的设计思路下的最优实现方式,但是我也没有时间修改了。
对已有代码进行功能上的扩展是我们以后经常要面临的问题。代码的可扩展性差主要来源于两个方面:一是设计思路的低效;二是细节实现的繁琐。设计思路的低效是大伤,第一单元的第一次作业中,我用字符串处理的方式解决表达式解析问题,就属于设计思路的低效。当设计思路出现问题时,我们会发现代码根本无法进行扩展,于是毫不犹豫地选择重构。细节实现的繁琐是小伤,混乱的细节使我们在扩展时需要花费大量时间阅读已有代码,并且由于代码扩展对代码风格是一种破坏性操作,扩展后的代码,其风格必定不如之前。就这样,经过多次扩展,代码就会进入再也无法理清细节的地步,这时,这份代码就达到了功能扩展的上限。如此看来,细节实现的繁琐是极具隐蔽性的问题,我们一开始可能不会选择重构,但是随着细节问题的积累,再想重构就可能不会那么容易了。因此,对于这些小伤,也不能轻视,要尽早修改或重构,免得“千里之堤,溃于蚁穴”。
四、功能测试
在本单元的三次作业中,我用JAVA语言编写了测试程序。测试程序分为两部分:一部分负责随机生成请求;另一部分负责根据乘客请求和我的程序输出来检查电梯的行为是否正确。这两部分构成了我的半自动化测试程序,使用时,我需要在命令行中不断输入命令,测试结果则显示在命令行窗口中。
请求的随机生成
程序会首先确定随机生成的乘客或者电梯的数量,并对其进行编号,同时随机赋予这些请求以出生时间,即在测试时的输入时间。
第五次作业的乘客请求生成比较简单:先随机选择乘客所在的楼座,再随机生成乘客的出发楼层的目的地楼层(并保证这两个楼层是不同的),最后将这些请求按时间顺序排列并输出,就可以了。其中随机选择的范围是任意合法的楼座和楼层。
第六次作业的请求生成比较困难。从本次作业开始,用户输入的请求可以包括乘客请求和新增电梯请求。而在1到10楼没有新增横向电梯之前,乘客不可以在这些楼层申请横向移动,这使得乘客请求生成的随机性大大受限。
我首先随机生成电梯新增请求,并根据这些新增的电梯请求,分析每个时间点上乘客可以进入的等待队列。在生成乘客请求时,确定了该请求出生的时间之后,会在该时间点上可以进入的等待队列中随机选择一个队列,然后再按照该队列的特点随机生成具体的出发地和目的地信息。比如,我在5秒的时候生成了一部1楼的横向电梯,而在此之前只有最开始的五部纵向电梯。那么,当乘客请求被随机确定为4秒后,该乘客就会被随机投入五个楼座对应的五个纵向队列中的一个;若乘客请求的时间被随机确定为5秒,则该乘客会被投入六个等待队列之一,这六个队列分别是五个楼座对应的纵向等待队列以及1楼对应的横向等待队列。纵向电梯中的乘客会被随机生成不同的出发楼层与目的地楼层;横向电梯中的乘客会被随机生成不同的出发楼座与目的地楼座。假如该乘客被丢入A座等待队列中,又随机确定了出发楼座为1楼,目的地楼座为2楼,则相应的请求会被输出为“[5.0]1-FROM-A-1-TO-A-2”(最前面的1是已经确定的编号)。
在第七次作业中,由于1楼存在可达五个楼座的横向电梯,因此乘客的移动请求总是可以满足的。电梯请求和乘客请求可以分别随机生成,然后按照时间顺序依次输出即可。注意,新增电梯并不是完全随机的,若发现某一等待队列对应的电梯数量等于3,则对于下一个随机生成的新增电梯请求,不会再将其应用到该等待队列中。在此次作业中,电梯容量,运行速度,以及横向电梯的可达性信息也是随机生成的。横向电梯的可达性信息是一个在1到31之间的数字,又由于一部横向电梯至少需要在两个楼座可达,因此实际的生成范围是:3,5~7,9~15,17~31。
电梯行为检查
检查程序只进行电梯行为的检查,通过检查程序的测试只能保证电梯在行为上是没有逻辑错误的,但不能保证是完全合理的。这是什么意思呢?加入有一名乘客需要从A座1楼前往A座2楼,一部A座纵向电梯可以直接将其运往A座2楼,也可以将其运往A座三楼,再由另一部纵向电梯将其运往A座2楼。这两种行为都是逻辑上正确的,但只有前一种行为是逻辑上合理的。虽然如此,我的检查程序在检查输出后,会给他们都返回以“accepted”。
我在下面将会一一列举该程序能够检查出来的问题。我们以第七次作业为例。
表4 请求输入检查信息
表5 输出合法性检查信息
表6 纵向电梯行为检查信息
表7 横向电梯行为检查信息
表8 线程结束检查信息
我的测试程序在运行时,会给出程序输入请求的数量,程序输出命令的数量,系统运行的总时间,以及运行是否正确等信息。若不正确,则会返回第一处错误的发现位置以及错误类型。如下图所示:
图12 正确评测返回信息
图13 错误评测返回信息
自造测试程序的利与弊
在“电梯系统的模拟”这一单元的作业中,没有测试程序将会举步维艰。评测机开放的测试点都比较简单,仅靠这些测试点很难发现程序的bug。测试程序使我们更容易发现问题,定位问题,大大缩短我们debug的时间,降低难度。多线程模式下的一些问题具有随机性,大量的测试也会使这些问题得以显现。
但是自造测试程序有一定的局限性。对于测试程序,我对(我认为)可能会出错的地方进行了检查。但既然我知道这个地方可能会出错,我在写代码时就会有所注意,因此在很大概率上是不会犯这个错误的。也就是说,测试程序的制作融入了我对题目设计框架的理解,它很难跳出我的思维模式,因此很难检测出我自己的程序问题。同学之间互相用别人的测试程序倒是个好办法,但是我的测试程序是面对我自己的代码而设计的,在给别的同学用之前,需要根据他的设计思路进行一定的修改,反之亦然。
当测试程序报告错误时,并不一定是提交的代码有问题,也可能是测试程序的问题,因此我们既要检查源程序,也要检查测试程序。另外,随机数据生成程序也可能有问题,因此在测试程序真正进入使用阶段前,需要做到“源程序——测试程序——数据生成程度”三位一体,正确接合。
五、心得体会
在这一单元作业之前,我完全没有听说过“多线程”这一概念,因此在听说本单元涉及多线程编程模式后,我在两天之内突击学习多线程有关知识。我阅读了《JAVA编程思想》中多线程的有关章节,将其中的代码均在电脑上重复了一遍,但是这些知识点忘得很快。开始真正写代码之后,好多深层次的理论知识就不记得了。我在编程过程中,机械地设计了多个Runnable接口的实现类作为线程,又将所有方法都同步(后来我在知道这样是不对的)。在代码的调试过程中,我才逐渐明白了一些原理性知识,并可以付诸使用。目前我所掌握的多线程相关知识也仅限于此。
对于设计模式,我仅对“生产者——消费者”模式比较熟悉(OO和OS课都在讲),因此这种设计模式贯穿我的三次作业。我不太善于应用一种新的算法,但是对于我已经熟悉的算法,我比较擅长将其进行拓展或改造,以适应特定的题目背景。
这一单元是我第一次自造测试程序,虽然花费了不少时间,但还是值得的,因为它将我“对着屏幕发呆”的无效时间投入转化成了“制作测试程序”的有效时间投入。我在下一单元会继续尝试写测试程序。