oo第二单元电梯总结

曾骋-24371408 2026-04-25 15:39:49

第二单元三次作业的核心变化,是从“固定电梯 + 指定请求”,逐步走向“动态维护、更新、双轿厢、回收”的复杂并发系统。回头看,我最大的收获不是某一个调度策略多么精巧,而是逐渐意识到:多线程程序的正确性首先来自清晰的共享边界,其次才是策略优化。

本文基于我三次迭代的代码进行总结。

一、同步块设置与锁的选择

1. 第一次作业:用队列对象作为锁

第一次作业中,线程结构比较直接InputThread读入请求,放入总请求队列RequestQueueDispatchThread从总队列取出请求,按elevatorId分发到每部电梯自己的ProcessingQueue;每个ElevatorThread消费自己的队列并运行。

因此同步主要集中在两个队列类中:

  • RequestQueue:作为输入线程和调度线程之间的缓冲区。
  • ProcessingQueue:作为调度线程和电梯线程之间的缓冲区。

我采用的方法是直接在队列方法上使用synchronized,即队列对象本身就是锁。offerpollpollNowpollWithTimeoutsetEndisEndisEmpty都进入同一把锁保护的临界区。

这种选择比较朴素,但很适合第一次作业:共享数据只有ArrayList请求队列和isEnd结束标记。所有对这两个状态的读写都放在同一把锁下,可以避免“队列为空”和“输入结束”之间出现竞态。

同步块中的语句也尽量保持短小。比如offer只做三件事:加入队列、修改共享状态、notifyAllpoll只做等待条件判断和取出队首元素。真正耗时的电梯移动、开关门、乘客上下车并不放在队列锁里。这样做的原则是:锁只保护共享数据,不保护业务流程。

2. 第二次作业:共享乘客池与电梯事件队列分离

第二次作业加入了维护请求,乘客请求不再天然指定给某部电梯。我的设计从“调度线程统一派发乘客”变成了“乘客放入共享池,空闲电梯主动认领”。

这时同步对象主要变为:

  • SharedRequestPool:所有电梯线程共同访问的乘客池。
  • ProcessingQueue:每部电梯自己的维护事件队列。

SharedRequestPool中的pendingclaimedTasks是共享状态,因此 addPassengerclaimOnerequeuesetInputEndisInputEndisEmpty都使用synchronized。这里的锁粒度仍然是对象级别,而不是手动创建多个锁。原因是乘客池中的操作具有整体一致性:取出乘客、标记已认领、打印RECEIVE必须在同一个临界区完成,否则就可能出现重复认领或输出顺序错误。

另一方面,电梯内部的waitingonboardfloordirection等状态只被对应的ElevatorThread自己访问,不需要加锁。这是我在第二次作业中很重要的一个认识:不是所有状态都需要同步。只要能通过结构保证“单线程拥有”,就比到处加锁更可靠。

3. 第三次作业:共享池增加筛选谓词,井道线程内部管理双轿厢

第三次作业加入更新和回收后,一条井道里可能出现主轿厢和备用轿厢。我的代码中仍然没有把两个轿厢设计成两个线程,而是让一个ElevatorThread管理同一井道内的mainCabinbackupCabin

这样做的好处是:双轿厢之间最危险的共享状态,比如楼层范围、交汇楼层、安全距离、是否启用,都在同一个线程内判断,不需要为两个轿厢之间再设计复杂的互斥锁。ElevatorCabin只是状态对象,真正的并发边界仍然是:

  • 乘客池SharedRequestPool
  • 每条井道的事件队列ProcessingQueue

第三次的SharedRequestPool.claimOne增加了Predicate<PassengerTask>参数,用来让轿厢只认领自己当前服务范围内合适的乘客。这个筛选、移除、标记和RECEIVE输出仍在同一把锁中完成。这里锁和同步块内语句的关系非常紧:如果先在锁外判断可服务性,再进锁移除,就会出现判断时可服务、移除时状态已变化的问题;如果输出RECEIVE不和认领绑定,也可能导致日志与真实所有权不一致。

总体来说,三次作业中我的锁选择遵循了两个原则:

  1. 队列和共享池这种跨线程通信对象必须加锁,并且条件等待要用while重新检查。
  2. 电梯运行状态尽量由单个线程独占,避免把业务对象全部暴露成共享对象。

二、调度器设计与线程交互

1. 第一次:显式调度线程

第一次作业的调度器是DispatchThread。它从RequestQueue阻塞式取请求,然后根据请求中的elevatorId放入对应的ProcessingQueue。整体线程交互如下:

InputThread -> RequestQueue -> DispatchThread -> ProcessingQueue -> ElevatorThread

这个模型的优点是职责清楚。输入线程只负责读入,调度线程只负责分发,电梯线程只负责运行。由于第一次作业中的请求已经带有目标电梯编号,调度策略本身并不复杂,重点在于正确传递结束信号:当总队列为空且输入结束时,调度线程向所有电梯队列调用setEnd

电梯内部采用LOOK的策略:优先沿当前方向服务能上下车的乘客和前方任务;如果前方没有任务则反向;如果没有任何任务则等待。这个策略兼顾了时间和开关门次数:同向捎带可以减少反复掉头,减少平均完成时间,也避免无意义开门。

2. 第二次:中心调度弱化,电梯主动抢占

第二次作业中,乘客请求不再由DispatchThread分给某部电梯,而是放入 SharedRequestPool。每部电梯线程在运行循环中主动调用claimOne,谁先有能力处理,谁就认领乘客。

线程交互变为:

InputThread -> SharedRequestPool -> ElevatorThread
InputThread -> ProcessingQueue -> ElevatorThread   // 维护请求

这里的“调度器”不再是一个单独活跃线程,而是被拆成了共享池中的认领规则和各电梯线程中的运行策略。维护请求仍然是定点事件,因此通过每部电梯自己的ProcessingQueue投递。

这种设计让调度更分布式。它的好处是实现简单,电梯空闲时能及时拿到任务;缺点是全局最优性较弱,可能出现某部电梯先抢到但并不是全局最近的情况。我的取舍是优先保证正确性和响应性,再用电梯内部的同向捎带策略降低时间消耗。

维护场景下,我的策略是电梯收到维护信号后进入维护状态机:先回到F1,清空乘客,把未完成乘客重新放回共享池,然后维修、测试、结束。这个过程的调度重点不是“快”,而是“不能丢人、不能重复、不能违反维护协议”。

3. 第三次:井道状态机 + 轿厢局部调度

第三次作业的调度核心进一步演化为井道状态机。ElevatorThread中的状态包括:

  • NORMAL:单轿厢运行。
  • REP_ACCEPTREPAIRTEST:维护流程。
  • UP_ACCEPTUPDATEDOUBLE:更新为双轿厢流程。
  • REC_ACCEPTRECYCLE:回收备用轿厢流程。

DOUBLE状态下,同一线程内轮流调用两个轿厢的一步运行逻辑。主轿厢服务上半区,备用轿厢服务下半区,并通过ElevatorCabinOps.canMove保证两个轿厢不会越界或相撞。

第三次的乘客调度也加入了范围适配:轿厢认领乘客时会判断起点是否在自己的服务范围内,终点若不在范围内则先送到换乘层,再通过requeue重新进入共享池。这使得调度策略能适应双轿厢的空间约束。

从性能指标看,我的策略主要做了以下平衡:

  • 时间:采用同向优先和主动认领,减少等待和空跑。
  • 电量:避免无任务移动,电梯空闲时短暂等待;双轿厢模式下尽量让上下区分别服务,减少长距离移动。
  • 开关门次数:只有当前楼层有可下或可上的乘客才开门,同向捎带减少重复停靠。
  • 动态事件响应:开门等待和空闲等待时仍然轮询事件队列,尽量及时响应维护、更新、回收请求。

这个策略不是全局最优算法,更像是一个“规则清晰、局部贪心、状态机兜底”的工程方案。它的优势在于可解释、容易debug,也比较适合迭代式增加新需求。

三、Bug与多线程Debug方法

多线程电梯最难的bug往往不是语法错误,而是某个状态在某个时间点没有被正确交接。结合三次作业,我主要遇到或重点防范过以下几类问题。

第一类是结束条件bug。输入结束并不等于程序可以结束,因为共享池里可能还有乘客,电梯本地waitingonboard里也可能还有任务,维护、更新、回收状态机也可能没有跑完。因此第三次作业中的shouldStop判断比第一次复杂很多,必须同时检查输入结束、队列为空、共享池为空、轿厢本地任务为空、特殊任务为空,以及当前井道状态是否允许退出。

第二类是乘客丢失或重复的问题。维护、更新、回收时,电梯可能需要把已经认领但尚未完成的乘客释放回共享池。如果只清空本地队列而不requeue,乘客会丢失;如果claimedTasks没有同步移除,又可能导致乘客回到池中但永远无法再次被认领。因此我把requeue也放入SharedRequestPool的同步方法中,并让它同时处理认领标记。

第三类是门状态和事件响应问题。开门必须持续至少 400ms,但这段时间内又可能收到维护、更新、回收信号。如果简单sleep(400),事件响应会变慢;如果过早关门,又可能违反时间约束。因此我使用pollWithTimeout(remain),在等待门时间的同时接收事件,并根据剩余时间继续等待。

第四类是双轿厢安全问题。第三次作业中如果把两个轿厢当成完全独立对象,就容易在换乘层附近出现越界或相撞。我的处理方式是把同一井道的两个轿厢放在同一线程中调度,并在每次移动前调用canMove。这样debug时也更容易复现,因为同一井道内部没有两个线程交错执行带来的不确定性。

我的debug方法主要有三种:

  1. 先画状态机,再看日志。对于维护、更新、回收,我会先确认每个状态的进入条件、退出条件和输出语句,再用输出日志对照状态转移。
  2. 构造小规模极端样例。例如只输入一个维护请求、一个跨区乘客、一个需要换乘的乘客,比随机大样例更容易定位问题。
  3. 把共享数据边界缩小。发现并发问题时,我优先检查“这个变量到底被几个线程访问”,如果只有一个线程访问,就不把它当成锁问题;如果多个线程访问,就检查它是否只通过同步方法读写。

多线程debug给我的一个教训是:不要一开始就怀疑所有地方。先找共享对象,再找等待条件,再找结束条件,效率会高很多。

四、线程安全与层次化设计的理解

第一次作业时,我对线程安全的理解还比较朴素:共享队列加锁,wait/notifyAll写对,程序就能跑。到第二、三次作业,我逐渐意识到线程安全不只是“有没有锁”,而是“有没有清楚的所有权”。

在我的设计中,跨线程共享的对象被压缩到很少几个:

  • RequestQueueProcessingQueue用于线程间传递事件。
  • SharedRequestPool用于所有电梯共同认领乘客。

而电梯自身的运行状态,比如楼层、方向、门状态、载重、本地等待队列、轿厢内部乘客,都由单个电梯线程拥有。这种设计比给每个字段加锁更清晰,也更符合层次化设计。

层次化方面,我的三次迭代也有明显变化:

  • 第一次:输入层、调度层、电梯执行层。
  • 第二次:输入层、共享乘客池、电梯状态机、维护流程。
  • 第三次:输入层、共享池、井道状态机、轿厢对象、轿厢操作工具类。

尤其第三次把ElevatorCabinElevatorCabinOps拆出来后,ElevatorThread更像是在管理井道级状态,而不是同时塞满所有上下客细节。这个拆分让我更容易定位“这是井道事件问题,还是轿厢局部运行问题”。

当然,我的设计还有可以改进的地方。比如第二次和第三次的DispatchThread基本已经失去实际调度作用,历史包袱比较明显;Object类型队列虽然方便兼容多种事件,但类型安全不够好。如果重新设计,我会考虑定义统一的事件接口,让维护、更新、回收事件有更明确的类型层次。

五、大模型使用心得

本单元中,我使用的大模型主要是ChatGPT/Codex,使用方式不是让它直接替我完成整个作业,而是把它当作一个代码阅读助手、方案讨论对象和debug陪跑工具。

我和大模型的分工大致是:

  • 我负责理解课程规则、判断输出合法性、决定最终架构和策略取舍。
  • 大模型负责帮助我梳理状态机、检查潜在竞态、解释复杂代码片段、生成一些测试思路。
  • 在写总结博客时,大模型可以帮助我从代码演进中提炼结构,而不是只停留在“我写了哪些类”的流水账。

面对多线程电梯这样的复杂任务,大模型的优势很明显。它很擅长帮我把零散的状态整理成状态机,也能提醒我一些常见风险,比如wait 要用while包裹、结束条件不能只看输入是否结束、共享队列要在修改后notifyAll。当我描述一个bug现象时,它也能给出一组排查顺序,让我少走一些弯路。

但大模型也有明显困难。首先,它不天然知道课程评测机的所有细节,如果提示不完整,就可能给出看似合理但不符合规格的建议。其次,多线程程序的bug和具体时序强相关,大模型无法直接“看见”某次运行中的线程交错,只能基于代码推断。再次,它有时会倾向于给出过度设计的方案,比如引入更复杂的调度器或锁结构,而作业中真正需要的可能是一个更稳定、更容易验证的实现。

我自己的感受是,大模型最适合参与“想清楚”和“查遗漏”,不适合替代“拍板”。尤其是电梯作业这种规格严格、输出格式敏感、线程时序复杂的任务,最后一定要由自己对照规则和测试结果做判断。它像一个很有耐心的讨论伙伴,可以把混乱的问题摊开,但不能替我承担正确性责任。

六、第二单元真实体验与建议

第二单元给我的真实感受是:强度很高,但确实能逼着人建立并发程序的工程意识。第一单元更多是在训练对象建模和表达式处理,第二单元则明显进入了“程序会同时发生很多事”的世界。电梯作业让我第一次很强烈地感受到,代码不是按我脑子里想象的顺序执行的,线程调度、等待、唤醒、结束条件都会影响最终行为。

三次迭代中,第三次压力最大。维护、更新、双轿厢、回收叠在一起后,很多bug不是单个功能写错,而是功能之间的状态交接写漏。比如一个乘客在更新前被认领、更新中被释放、双轿厢模式下又要换乘,这种场景很容易让人头大。但也正因为如此,做完之后对“状态机”和“所有权”的理解会深很多。

我对课程的建议主要有几点。

第一,希望在指导书中对一些复杂动态事件给出更多边界样例。例如开门过程中收到维护、更新后仍有跨区乘客、回收时备用轿厢载人等,这样能帮助同学更早意识到问题边界。

第二,希望讨论课或实验课能更系统地讲一讲多线程debug方法。不知道如何复现和定位并发bug,如果能展示一些最小复现样例、日志设计方法、状态机检查方法,会很有帮助。

总体而言,第二单元是痛苦但有价值的。它让我意识到,线程安全并不是在代码上撒一层synchronized,而是要先设计清楚哪些对象共享、哪些对象独占、线程之间通过什么协议通信。这个认识应该会比某一次作业的分数更长久。

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

309

社区成员

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

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