1 概述
本单元我基于Java完成了一套多线程电梯运行与调度模拟系统。本文中我将按本单元3次作业的迭代顺序,总结我的设计思路与关键实现细节。通过本文,你可以了解到:
- 简单的Java多线程设计模式
- 多线程程序的增量开发方法
2 作业回顾
2.1 第一次作业
本单元实现了北航新主楼A-E座共享输入单电梯系统。
2.1.1 代码架构
|- MainClass: 程序运行入口
|- Launcher: 启动器
|- Revceiver: 输入接收器
|- Distributor: 请求调度器
|- Elevaor: 电梯
|- buffer: 输入队列包
|- DistributorBuffer: 调度器输入队列
|- ElevatorBuffer: 电梯输入队列
我设计了如上类以完成本系统。
- Receiver 与 Elevator 是两个必要的线程类。Receiver 需要不断阻塞等待请求输入,Elevator 需要模拟电梯运行中的行动时间,均无法与主线程同步。
- 与主线程同步的是Distributor类,相当于第3个线程。
- 程序启动后,MainClass 调用 Launcher 创建各类一定数目的对象,并启动上述两个线程类的对象,然后将控制权交给 Distributor。
- Distributor 作为 Receiver 的消费者,Elevator 的生产者,不断将从 Receiver 接收到的请求分发给对应楼座的 Elevator。
2.1.2 同步块与锁
由代码架构可见,本次作业中我使用了两级生产者-消费者模式,线程之间使用共享对象进行数据交互。所以,两个线程共享类DistributorBuffer
和ElevatorBuffer
的各个方法即为程序中的所有同步块,我使用synchronized
修饰符对它们加锁。
- 以 Receiver-DistributorBuffer-Distributor 组成的生产者-消费者模型为例进行分析。Disributor 不断使用 DistributorBuffer 的
getAll()
方法获取新的请求,但若队列为空,并且 DistributorBuffer 的receiving
标志仍为true
(若为false则结束运行)的情况,该方法则会使用wait()
将 Distributor 线程阻塞,直到被 Receiver 线程唤醒。Receiver 获取输入请求后,若请求不为空,则调用 DistributorBuffer 的add()
方法,将请求放入队列中;若请求为空,则说明输入结束,则调用 DistributorBuffer 的StopReceiving()
方法,将 DistributorBuffer 的receiving
标志设为false
。以上两个方法均会使用notifyAll()
唤醒被阻塞在 DistributorBuffer 的 Distributor 线程。 - Distributor-ElevatorBuffer-Elevator 组成的生产者-消费者模型与之类似,但有以下两点不同:
- 存在多个楼座的ElevatorBuffer-Elevator,Distributor 根据请求的楼座对其进行分发,属于单生产者-多消费者模型;
- ElevatorBuffer 可以根据一定条件从队列中获取元素。
2.1.3 调度器与策略
由上文可以看出调度器 Distributor 的任务非常简单:根据楼座将乘客请求放入到对应的电梯输入队列 ElevatorBuffer 中。
电梯则以一种类似有限状态机的方式运行,每次停靠在新的一层,电梯进行以下操作:
- 检查是否有乘客要出去。如果有,则开门让他们出去;
- 检查是否有乘客要进入。如果有,先检查门是否打开,若未打开则先打开门,最后让他们进来;
- 根据电梯与等待队列的状态上行或下行:
- 若电梯中有乘客,则以最先到达的乘客的方向为目标方向;
- 若电梯中没有乘客,则使用 LOOK 算法在ElevatorBuffer中搜索下一个目标乘客,以他所在方向为目标方向。
2.1.4 心得体会
本次作业我学习到了如何使用Java创建多个线程并行工作,并且学习到了如何使用锁与wait-notify
让各个线程之间有序且高效地进行交互。刚开始着手设计本次作业的程序时,各种复杂的交互让我难以下手,但通过对生产者-消费者设计模式的学习,一切都变得清晰起来。
2.2 第二次作业
第二次作业加入了(1)在楼座间运行的横向电梯,(2)同楼座/楼层多电梯系统,(3)动态电梯创建请求。
3.2.1 代码架构
|-com
|- distributor
|- Distributor: 调度器基类
|- MainDistributor: 主调度器
|- SubDistributor: 子调度器
|- elevator
|- Elevator: 电梯基类
|- HorElevator: 横向电梯
|- VerElevator: 纵向电梯
|- Launcher: 启动器
|- Receiver: 接收器
|- RequestQueue: 请求队列
|- MainClass: 程序入口
本次作业中我新增入了横向电梯类和子调度器类,并对代码结构做出了一定调整。
- 子调度器存在于电梯于主调度器之间,负责为同一楼层/楼座的电梯分发请求;
- 调度器的运行逻辑仅有请求分发逻辑不同,将这些部分作为抽象方法留给子类重写,其余部分均在集类实现;
- 电梯的运行逻辑仅有出入条件和方向决定逻辑不同,将这些部分作为抽象方法留给子类重写,其余部分均在集类实现;
- 合并所有共享对象为
RequestQueue
类,仅保留基本操作作为方法,复杂访问与修改逻辑由访问者提供;
2.2.2 同步块与锁
本次作业与第一次作业的同步块基本相同,依然采用多级生产者-消费者模式,以生产者与消费者的共享队列作为同步对象。并且将所有共享对象合并为RequestQueue
类,仅保留基本操作作为方法,复杂访问与修改逻辑由访问者提供,以提高实现的可靠性。
2.2.3 调度器与策略
本次作业中主调度器的逻辑与第一次作业调度器相同,只是增又加了子调度器以完成同楼座/楼层多部电梯之间任务的分配。子调度器会将新的请求分发给等待队列最短的电梯。
可见,每个电梯依然有一条独享的输入队列,其运行策略与第一次作业相同(横向电梯类似,但不使用LOOK算法,而使用ASL)。
2.2.4 心得体会
刚开始我以为本次作业十分复杂,但经过分析发现经过请求的分配后,各个电梯之间的运行可以是独立的,它就没有任何难度了。使用生产者-消费者模式再创建一个层级,即可轻松完成。经典设计模式十分实用。
2.3 第三次作业
第三次作业加入了(1)只有通过换乘才能满足的乘客请求,(2)创建个性化电梯的请求。
2.3.1 代码架构
|-com
|- distributor
|- Distributor: 调度器基类
|- MainDistributor: 主调度器
|- SubDistributor: 子调度器
|- elevator
|- Elevator: 电梯基类
|- HorElevator: 横向电梯
|- VerElevator: 纵向电梯
|- DynamicPersonRequest: 动态乘客请求
|- Launcher: 启动器
|- Receiver: 接收器
|- RequestQueue: 请求队列
|- MainClass: 程序入口
本次作业我通过增加DynamicPersonRequest
类,对PersonRequest
类进行扩增,在最小改动已有代码的情况下实现了新的需求。
- DynamicPersonRequest请求重写PersonRequest的
getToFloor()
与getToBuilding()
方法,动态获取单步目的地; - 乘客离开电梯后,会被重新送回主调度器进行进一步处理,这形成了流水线设计模式。
2.3.2 同步块与锁
本次作业与第二次作业仅有一处不同:电梯也可以作为主调度器的生产者,出现了多生产者-单消费者模式。但依然在原架构适用范围内,无需进行改动。
2.3.3 调度器与策略
调度器工作模式与第二次作业相同。唯一值得注意的是,主调度器此时的运行结束条件发生了变化,需要等所有乘客请求完成后才能终止。我通过在主调度器中增加一个集合记录分发出的所有乘客请求,并在该请求被电梯返回且已经完成时删除它,只有该集合为空时主调度器可以结束运行。
本次作业新增了换乘策略,我将它封装在动态乘客请求中。动态乘客请求维护了一个静态变量,记录了所有横向电梯的停靠信息(可以在哪些楼座停靠),以此获得自己的换乘楼层。对于外部,动态乘客请求“伪装”成一个PersonRequest
,在其当前楼座与目标楼座不同时,其getToFloor()
方法返回其换乘楼层;当到达目标楼座后,其getToFloor()
方法返回其最终目标楼层。
2.3.4 心得体会
本次作业加入了最让我担心的换乘需求,由于我程序架构中的各个电梯是独立运行的,所以我并没有想出一个简单的方法让它们之间进行换乘交互。直到理论课上我学到了open-close原则,我开始思考如何更充分地利用现有的代码,而不是在遇到问题时总是推倒重来。
第二次作业中,我的程序架构已经可以完美地满足同一楼座或同一楼层内的请求,要让它完成一个需要多次换乘的请求,势必要将这个请求多次投入到我的电梯系统中。这并不难实现,只需要将从电梯出来的乘客再次投入主调度器的输入队列即可。难点在于如何使我的电梯系统知道换乘乘客需要在何处离开电梯,这会让我原本架构的复杂度极大提高,与安全简单的设计原则相悖。于是我干脆转换思路,不对原来的功能进行修改,而是扩展PersonRequest类的功能,让它在换乘的每一个阶段“伪装”成一个无需换乘的请求,通过多次投喂给我的电梯系统,完成它的最终目的。
我继承PersonRequest类,扩展了DynamicPersonRequest类,重写了它的getToFloor()
与getToBuilding
方法,仅对原有代码进行了最小的修改,就实现了全新的需求。
3 BUG分析与修复
3.1 测试策略
本单元作业我主要使用黑箱测试进行自动化评测。
3.1.2 测试样例生成
我将请求分为5类:纵向电梯创建请求、横向电梯创建请求、纵向乘客请求、横向乘客请求、换乘乘客请求。在生成测试用例时,我在初始时刻会首先生成一定数量电梯创建请求,然后对各类请求,设置参数按照一定时间间隔和一定数目进行生成。
3.1.3 输出结果检查
我为存在的每一个电梯与乘客建立一个跟踪器对其状态进行监控。每当出现新的与该电梯/乘客相关的输出时,对应跟踪器进行以下两项检查:
- 该电梯/乘客当前的状态是否可以执行此动作;
- 该电梯/乘客执行此动作后是否转移至正确的新状态。
3.2 BUG修复
本单元中,我的程序共出现了两处BUG:
- 电梯运行日志输出不同步
- 电梯可进入人数判断错误
第一处BUG出现在各电梯使用官方提供的TimeableOutput
包输出运行日志时,未进行同步处理,导致输出时间戳不递增。这说明我对多线程同步问题尚不够敏感,应该仔细使用 Berinstein 原则对各线程调用的每一个方法进行分析,直到可以熟练地找出所有同步块;
第二处BUG是由于循环中首先进行操作,再进行break判断导致的。不幸的是这一点在我使用随机数据的数百轮测试中都没有暴露出来,这说明随机测试虽然可以检测出大多数BUG,但是绝对不能仅仅依靠随机测试对程序进行测试。
4 架构分析
4.1 UML类图
4.1.1 第1次作业
本次作业使用二级生产者-消费者设计模式。Receiver 向 Distributor、Distributor 向 Elevator 的请求的传递通过对应 Buffer 对象实现,保证了线程交互的安全性。
4.1.2 第2次作业
本次作业新增入了横向电梯与楼座内/楼层内调度器SubDistrbutor
,将请求按最短队列优先原则分发给各个电梯,使每个电梯既可以保持原先的运行策略,又可以互相协作提升效率。同时,将所有共享对象统一为RequestQueue
,降低代码复杂度。
4.1.3 第3次作业
本次作业相较于上次作业继承PersonRequest类增加了DynamicPersonReuqest类,并对Elevator类的构造方法与out()
方法进行了一定修改,用以实现电梯定制化与换乘的需求。本次作业很好地遵循了open-close原则。
4.2 协作图
这是第3次作业形成的最终架构图。各线程间主要使用生产者-消费者模式进行交互,当生产者-消费者形成回路后,我的架构变成了一个类似流水线模式的模型。
4.3 扩展性分析
我的架构在电梯的种类与策略上有较强的可扩展性,这主要体现在两个方面:
- Elevator基类封装了线程交互的同步逻辑,仅将运行和捎带策略留给子类实现。因此在扩展时无需考虑线程安全型;
- 由于DynamicPersnoRequest类的使用,电梯之间的运行彻底解耦,各种种类电梯的实现不会互相影响。
5 心得体会
本次作业中我有以下几点关于多线程程序设计的心得体会:
- 熟练运用经典设计模式可以有效保持程序逻辑结构的清晰性与可扩展性;
- 实现线程同步逻辑与业务逻辑的分离对程序可扩展性有极大的帮助;
- 熟练把握多线程程序设计需要从系统底层了解多线程程序工作原理。