2025-BUAA-OO-Unit2-总结

罗春宇-22371220 2025-04-18 10:59:18

多线程电梯系统设计演进

在面向对象课程的电梯系列作业(第五、六、七次)中,我们逐步构建了一个从简单到复杂的多线程电梯模拟系统。这个过程不仅是对电梯调度逻辑的挑战,更是对多线程编程中线程安全、协作和架构设计的深度实践。本文将总结这三次作业的设计思路、关键技术点以及演进过程。

一、 同步块与锁的设计

线程安全是多线程编程的核心问题,其关键在于如何管理对共享资源的并发访问。三次作业中,同步块和锁的使用随着系统复杂度的增加而演进。

  1. 第五次作业(单电梯调度):

    • 同步块设置: 主要的共享资源是请求队列。InputParser 线程向 mainQueue 添加请求,Scheduler 线程从中取出请求并放入对应电梯的 elevatorQueueElevator 线程从自己的 elevatorQueue 取请求并处理。因此,对 RequestQueue 的所有修改操作(addRequest, getNextRequest, iterator 操作中的 remove)以及 wait/notifyAll 都被包裹在 synchronized(this)synchronized(queue) 块中。电梯内部 passengers 列表的修改(boardPassengers, unboardPassengers)也需要同步,虽然第五次代码中 boardPassengerselevatorQueue 加锁间接保护了 passengers 的添加,但更严谨的做法是也对 passengers 的访问加锁(如果存在其他并发访问点)。
    • 锁的选择: 主要使用 Java 内置的监视器锁(synchronized关键字)。这种锁简单易用,与 wait/notifyAll 配合,适用于经典的生产者-消费者模式。
    • 关系分析: 锁保护的是共享数据(队列、乘客列表)的一致性。同步块包裹了对这些共享数据的读写操作,确保了操作的原子性和内存可见性。wait/notifyAll 则用于线程间的通信,当队列为空时消费者(电梯/调度器)等待,当队列有新请求时生产者(输入/调度器)唤醒消费者。
  2. 第六次作业(多电梯调度 + SCHE):

    • 同步块设置: 延续了第五次作业对 RequestQueue 的同步保护。新增了 SCHE 指令,电梯在执行 SCHE 期间状态特殊。为此,Elevator 类引入了 onScheLock 对象和 isOnSche 标志位。在判断是否接收请求 (addRequest) 和电梯主循环决策前检查 isOnSche 时,需要获取 onScheLock,确保状态判断和后续操作的一致性。sche() 方法开始和结束时修改 isOnSche 也在此锁的保护下。
    • 锁的选择: 仍然以 synchronized 为主,并为 SCHE 状态引入了专门的对象锁 onScheLock,实现了更细粒度的控制。
    • 关系分析: onScheLock 专门用于保护电梯是否处于调度维护状态这一关键标志,防止在维护期间接收新请求或执行普通运行逻辑。其他锁逻辑与第五次作业类似。
  3. 第七次作业(双轿厢改造 + UPDATE):

    • 同步块设置: 复杂度显著增加。
      • RequestQueue 的同步保护依然存在。
      • Elevator 类增加了 onUpdateLockisOnUpdate 标志位,逻辑类似 onScheLock,用于保护 UPDATE 状态。
      • 引入了 java.util.concurrent.locks.ReentrantLock 作为 transferLock。这个锁用于保护双轿厢电梯在 transferFloor 的通行权,防止碰撞。电梯在接近、到达和离开 transferFloor 时需要获取和释放此锁。
      • Update 类内部使用 synchronized(updateLock)wait/notifyAll 机制,协调参与 UPDATE 的两部电梯,确保它们都准备好后才开始执行改造,并在结束后唤醒对方。
    • 锁的选择: 混合使用了 synchronizedReentrantLocksynchronized 用于保护队列、电梯内部状态标志。ReentrantLock 用于实现更灵活的锁操作,特别是跨方法的锁获取与释放(虽然这里主要在 move 方法内管理),并且其可重入性有时也很有用(虽然在此场景非关键)。Update 类中的 updateLock 使用对象监视器锁协调两个特定线程。
    • 关系分析:
      • synchronized(queue) 保护请求队列数据。
      • synchronized(onScheLock/onUpdateLock) 保护电梯是否处于特殊模式的状态标志。
      • ReentrantLock (transferLock) 保护关键物理资源(换乘层通道),防止两个电梯同时穿越。
      • synchronized(updateLock) in Update 协调两个电梯线程的同步点,确保 UPDATE 操作的原子性和双方的同时性。
      • 锁与同步块内的语句紧密相关,锁的范围必须覆盖所有对共享资源的读写访问,以及依赖共享状态的判断和后续操作,以防止竞态条件和数据不一致。

二、 调度器设计与调度策略

调度器(Scheduler)在系统中扮演着中央协调者的角色,负责将外部请求合理地分配给电梯。

  1. 调度器设计:

    • HW5: 调度器非常简单。它作为 mainQueue 和各个 elevatorQueue 之间的桥梁。从 mainQueue 获取请求,根据请求指定的 elevatorId,直接将请求放入对应的 elevatorQueue
    • HW6: 调度器变得更智能。对于 PersonRequest,它不再根据指定 ID 分配,而是实现了一种简单的负载均衡策略:选择当前已接收请求数(包括电梯内乘客和等待队列请求)最少的电梯进行分配。对于 ScheRequest,则直接根据其 elevatorId 分配给目标电梯。调度器还需要在输入结束且所有请求(包括 SCHE)处理完毕后,通知所有电梯结束运行。
    • HW7: 调度器采用了更复杂的启发式评分策略来分配 PersonRequestcalc 方法综合考虑了多个因素:
      • 距离: 电梯到达请求发出楼层的距离(考虑方向)。
      • 负载: 电梯当前接收的请求数。
      • 方向匹配: 电梯当前运行方向与请求去往方向的一致性。
      • 速度: 电梯当前速度(改造后可能变化)。
      • 可达性/换乘: 电梯是否能直接服务该请求(couldReceivePassenger),以及请求是否需要换乘 (needsToTransfer)。
      • 特殊状态: 电梯是否处于 SCHEUPDATE 状态。
        调度器选择评分最高的电梯。对于 ScheRequestUpdateRequest,仍然是直接分配给指定电梯。调度器同样负责判断系统终止条件,现在需要考虑 passenger, SCHE, 和 UPDATE 请求都完成。
  2. 与线程的交互:

    • 输入线程 (InputParser): 生产者,产生请求放入 mainQueue,并通过 notifyAll 唤醒可能在 mainQueue 上等待的调度器。
    • 调度器线程 (Scheduler): 消费者(mainQueue)和生产者(elevatorQueue 或直接调用电梯 addRequest 方法)。它从 mainQueue 获取请求,处理后分发给电梯线程。当 mainQueue 为空时,调度器 wait()
    • 电梯线程 (Elevator): 消费者(自己的请求队列/接收到的请求)。接收调度器分配的请求,并根据内部策略运行。当无事可做时,在自身的 elevatorQueuewait()
    • 主线程 (Main): 创建并启动所有线程(输入、调度器、电梯)。
  3. 调度策略与性能指标:

    • HW5: 策略是 LOOK 算法(AdaptiveLookStrategy)。电梯沿一个方向移动,响应当前方向上的所有请求,到达最远请求后反向。简单高效,主要关注响应时间。
    • HW6: 策略仍然是 LOOK,但调度器引入了负载均衡。这试图让电梯负载更均匀,可能减少某些电梯的等待时间,从而改善整体平均响应时间。SCHE 请求具有高优先级,电梯必须先完成 SCHE,这会暂时牺牲普通乘客的效率。
    • HW7:
      • 调度策略: 调度器的启发式评分策略是核心。它试图在多个维度上进行优化:
        • 时间: 优先选择距离近、方向匹配、速度快的电梯,减少乘客等待和运行时间。惩罚需要换乘的分配(-3 分),因为换乘增加总时间。
        • 电量: 虽然代码没有直接模拟电量,但更优的调度(少空跑、少绕路)通常意味着更少的移动距离和开关门次数,间接节省电量。优先分配给非 SCHE/UPDATE 状态的电梯也避免了中断带来的额外能耗。速度快的电梯虽然单次移动耗时少,但评分公式中速度项 - (double) speed / 100 表明速度越快评分越低(可能是为了平衡负载或假设高速更耗电,但系数较小)。
        • 负载均衡: receivedRequestsNums 因子仍在起作用,避免单个电梯过载。
      • 电梯策略: AdaptiveLookStrategy 增加了处理换乘的逻辑,在换乘层会判断是否需要让乘客下电梯换乘。

    总的来说,调度策略从简单分配到负载均衡,再到复杂的启发式评分,逐步适应了更复杂的场景和多维度的性能考量。尤其在 HW7,策略试图在时间、(间接的)电量、负载之间找到一个较好的平衡点。

三、 架构设计演进与扩展性

第五次作业架构:

HW5.png

第六次作业架构:

HW6.png

第七次作业架构:

HW7.png

  1. 三次作业架构设计的逐步变化:

    • HW5: 典型的生产者-消费者模式 + Actor 模型。Input -> MainQueue -> Scheduler -> ElevatorQueue -> Elevator。各组件职责清晰。ElevatorStrategy 接口将电梯运行策略分离。
    • HW6: 架构主体不变。Scheduler 职责加重(负载均衡、处理 SCHE)。Elevator 内部状态和逻辑增加(处理 SCHE)。引入了新的请求类型 Sche
    • HW7: 架构进一步复杂化。引入 Update 请求类型和相应的处理逻辑。Elevator 状态管理更复杂(增加了楼层限制、换乘逻辑、UPDATE 状态)。引入了 ReentrantLock 进行更精细的同步控制。Scheduler 的调度算法变得非常核心和复杂。

    整体上看,架构保持了分层分离关注点的特点:输入解析、中央调度、电梯执行、运行策略是相对独立的模块。这种设计使得每次迭代时,可以在不大规模重构的情况下,修改或增强特定模块的功能。

  2. 未来扩展能力:

    • 增加电梯类型: 可以方便地添加新的 Elevator 子类(如果需要不同行为)或通过配置(如容量、速度)创建不同实例。
    • 改变调度策略: SchedulerschedulePassengerRequest 方法是核心,可以替换其中的评分逻辑以实现不同的优化目标(如最短等待时间优先、能耗最低优先等)。
    • 改变电梯运行策略: 实现新的 ElevatorStrategy 接口即可更换电梯的内部运行逻辑(如 SCAN、SSTF 等)。
    • 更复杂的交互: 如果需要电梯间直接通信,可以为 Elevator 类增加相应接口和逻辑,但需注意线程安全。
    • 多楼座/区域: 可能需要引入更复杂的调度器和区域管理,但现有分层结构提供了一个扩展基础。
  3. 稳定与易变内容分析:

    • 稳定内容:
      • 核心概念: 电梯、乘客请求、请求队列这些基本模型。
      • 线程角色: 输入、调度、执行(电梯)的基本分工。
      • 基础交互模式: 生产者-消费者模式(输入->调度->电梯)。
      • 接口: ElevatorStrategy 接口提供了稳定的扩展点。
      • 基础同步需求: 对共享队列的访问需要同步。
    • 易变内容:
      • 调度算法: 从简单分配到负载均衡再到复杂启发式,是变化最显著的部分。
      • 电梯内部逻辑: 随着 SCHEUPDATE、换乘的引入,电梯的状态管理和行为决策变得越来越复杂。
      • 同步机制: 从单一 synchronized 到混合使用 synchronized、对象锁、ReentrantLock,同步策略随需求变化。
      • 请求类型: 从只有 PersonRequest 到增加 ScheRequestUpdateRequest
      • 系统终止条件: 判断逻辑随着新请求类型和状态的加入而日趋复杂。

四、 双轿厢的实现(HW7)

双轿厢的同步和防撞是 HW7 的难点。

  1. 同步开始改造 (UPDATE):

    • Update 类扮演了协调者的角色。当调度器将一个 UpdateRequest 分发给两个相关的电梯后,每个电梯会在其方便的时候(通常是完成当前任务后)调用 update() 方法。
    • update() 方法内部,电梯会调用 Update 对象的 signalReadyAndWait(this) 方法。
    • signalReadyAndWait 方法内部使用 synchronized(updateLock) 保护状态变量 elevatorAReadyelevatorBReady。当一个电梯调用时,它将自己的就绪状态设为 true
    • 如果此时另一个电梯尚未就绪,该电梯线程会在 updateLockwait()
    • 当第二个电梯也调用 signalReadyAndWait 并设置其状态后,条件 elevatorAReady && elevatorBReady 满足。此时,第二个电梯线程会打印 UPDATE-BEGIN,执行 sleep(1000) 模拟改造时间,打印 UPDATE-END,然后调用 updateLock.notifyAll() 唤醒第一个等待的电梯线程。
    • 这样,两个电梯线程就同步地完成了等待和改造过程。UPDATE-END 之后,它们才各自继续执行后续的清理和状态更新工作。
  2. 运行时不碰撞:

    • 关键在于共享的 transferFloor。两个改造后的电梯,一个服务于 transferFloor 及以上,一个服务于 transferFloor 及以下。它们都可能需要穿越 transferFloor 这个临界点(例如,上面的电梯要去 transferFloor 接人,下面的电梯也要去 transferFloor 接人)。
    • 使用 java.util.concurrent.locks.ReentrantLock 类型的 transferLock 来控制对 transferFloor 的访问。这个锁实例在 Update 对象创建时生成,并通过 signalReadyAndWait 方法返回给两个电梯,使得它们共享同一个锁实例。
    • Elevatormove() 方法中:
      • 当电梯将要移动到 transferFloor 时(即 currentFloor + direction == transferFloor),它会尝试获取 transferLock.lock()。这会阻塞,直到持有锁的另一个电梯释放锁。
      • 获取锁后,标记 isHoldingTransferLock = true
      • 然后电梯完成移动(sleep(speed)),到达 transferFloor 并打印 ARRIVE 消息。
      • 当电梯离开 transferFloor 时(即它之前在 transferFloor,现在移动到 transferFloor + direction),在 sleep 之后、打印 ARRIVE 消息之前(或者之后,只要保证在让出通道前完成移动即可,代码实现在打印ARRIVE之后),它会释放 transferLock.unlock(),并设置 isHoldingTransferLock = false
    • 这个机制确保了**任何时候只有一个电梯能持有 transferLock**,也就保证了只有一个电梯能够正在穿越或停留在 transferFloor 的“通道”上,从而避免了碰撞。

五、 Bug 分析与调试

多线程程序调试困难是常态,主要问题包括:

  • 竞态条件 (Race Condition): 未充分同步的代码,执行结果依赖于线程调度顺序。例如,检查队列是否为空后,在获取锁之前队列状态可能已改变。
  • 死锁 (Deadlock): 两个或多个线程互相等待对方持有的锁。例如,如果电梯 A 持有锁 X 等待锁 Y,而电梯 B 持有锁 Y 等待锁 X。在本次作业中,通过良好的锁设计(如 Update 的协调机制避免了双方互相等待、transferLock 是单一资源锁)可以规避常见死锁。但如果锁获取顺序不当或嵌套锁过多,仍有风险。
  • 活锁 (Livelock): 线程不断重试但无法前进,例如两个电梯在换乘层都想让对方先行。
  • 线程饿死 (Starvation): 某个线程(如低优先级请求或特定电梯)长时间得不到执行机会。

我遇到的 Bug (示例性,结合代码可能出现的问题):

  • HW6 Scheduler 的 Bug 隐患: 这指的是,当电梯因 SCHE 强制清空乘客和请求队列时,这些请求被重新加入 mainQueue。如果此时调度器正好判断 mainQueue 为空且输入结束,可能会错误地认为系统可以终止,而实际上还有被重新调度的请求未处理。这需要确保终止条件的判断足够鲁棒,考虑到所有可能的请求来源和状态。
  • 时序问题: TimableOutput 要求输出严格按时间戳递增。但 sleepWithAdjustment 逻辑如果计算不精确,或者线程调度延迟过大,仍可能导致输出时间戳混乱。尤其是在涉及多个线程协作的点(如 UPDATE-BEGIN/END,乘客进出),需要仔细管理 lastPrintTimesleep
  • 同步遗漏: 忘记对某个共享资源的访问加锁,或者锁的粒度不当(过大导致性能下降,过小导致保护不全)。
  • wait/notify 错误: 在错误的锁对象上调用 wait/notify,或 notify 后没有再次检查等待条件(Spurious Wakeup)。

调试方法:

  1. TimableOutput / println 大法: 这是多线程调试最常用的方法。在关键位置(获取/释放锁、状态改变、线程交互点、循环开始/结束)打印带有线程 ID 和时间戳的信息,追踪程序的执行流程和并发行为。
  2. 代码审查 (Code Review): 仔细检查每一处共享资源的访问,确认是否都有正确的同步保护。思考锁的获取顺序,是否存在死锁可能。
  3. 简化场景: 如果出现问题,尝试构造最小化的复现场景,减少并发线程数量和请求复杂度,定位问题根源。
  4. 耐心与细致: 多线程 bug 往往难以复现且现象诡异,需要耐心分析日志、推演执行路径。

六、 心得体会

  1. 线程安全:

    • 识别共享资源: 首先要明确哪些数据是会被多个线程访问和修改的,这是线程安全的前提。
    • 选择合适的同步机制: synchronized 简单方便,适用于大多数情况;ReentrantLock 提供更强的功能(如尝试获取锁、可中断获取、公平性选项),适用于更复杂的场景;volatile 保证可见性但不保证原子性,适用于简单标志位;Atomic 类提供原子操作。需要根据具体需求选择。
    • 最小化锁范围: 锁的范围应尽可能小,只覆盖必要的临界区代码,以提高并发性能。但必须保证覆盖完整,不能遗漏。
    • 避免死锁: 注意锁的获取顺序,尽量避免嵌套锁,或者保证所有线程以相同顺序获取多个锁。
    • 理解 wait/notify/notifyAll: 它们必须在持有锁的同步块内调用,并且 wait 后通常需要循环检查等待条件。
  2. 层次化设计:

    • 分离关注点: 将系统划分为输入处理、调度逻辑、电梯执行、运行策略等层次/模块,每个模块负责单一职责。
    • 接口化设计: 使用接口(如 ElevatorStrategy)定义协约,降低模块间的耦合度,提高灵活性和可扩展性。
    • 封装: 将内部实现细节(如电梯的具体移动逻辑、队列的同步细节)封装在类内部,对外提供清晰的接口。
    • 可测试性: 良好的层次结构和接口设计使得单元测试和集成测试更容易进行。
    • 可维护性: 结构清晰的代码更容易理解、修改和维护。当需求变更时(如 HW5 -> HW6 -> HW7),可以在较小的范围内修改代码。

通过这三次作业,深刻体会到设计良好、线程安全的多线程系统是一项挑战。它不仅要求掌握并发编程的基础知识,更需要细致的分析、周全的设计和严谨的实现。层次化设计是管理复杂性的有效手段,而对线程安全的深刻理解和实践则是保证程序正确运行的关键。

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

272

社区成员

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

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