309
社区成员
发帖
与我相关
我的任务
分享第二单元三次作业的核心变化,是从“固定电梯 + 指定请求”,逐步走向“动态维护、更新、双轿厢、回收”的复杂并发系统。回头看,我最大的收获不是某一个调度策略多么精巧,而是逐渐意识到:多线程程序的正确性首先来自清晰的共享边界,其次才是策略优化。
本文基于我三次迭代的代码进行总结。
第一次作业中,线程结构比较直接InputThread读入请求,放入总请求队列RequestQueue;DispatchThread从总队列取出请求,按elevatorId分发到每部电梯自己的ProcessingQueue;每个ElevatorThread消费自己的队列并运行。
因此同步主要集中在两个队列类中:
RequestQueue:作为输入线程和调度线程之间的缓冲区。ProcessingQueue:作为调度线程和电梯线程之间的缓冲区。我采用的方法是直接在队列方法上使用synchronized,即队列对象本身就是锁。offer、poll、pollNow、pollWithTimeout、setEnd、isEnd、isEmpty都进入同一把锁保护的临界区。
这种选择比较朴素,但很适合第一次作业:共享数据只有ArrayList请求队列和isEnd结束标记。所有对这两个状态的读写都放在同一把锁下,可以避免“队列为空”和“输入结束”之间出现竞态。
同步块中的语句也尽量保持短小。比如offer只做三件事:加入队列、修改共享状态、notifyAll;poll只做等待条件判断和取出队首元素。真正耗时的电梯移动、开关门、乘客上下车并不放在队列锁里。这样做的原则是:锁只保护共享数据,不保护业务流程。
第二次作业加入了维护请求,乘客请求不再天然指定给某部电梯。我的设计从“调度线程统一派发乘客”变成了“乘客放入共享池,空闲电梯主动认领”。
这时同步对象主要变为:
SharedRequestPool:所有电梯线程共同访问的乘客池。ProcessingQueue:每部电梯自己的维护事件队列。SharedRequestPool中的pending和claimedTasks是共享状态,因此 addPassenger、claimOne、requeue、setInputEnd、isInputEnd、isEmpty都使用synchronized。这里的锁粒度仍然是对象级别,而不是手动创建多个锁。原因是乘客池中的操作具有整体一致性:取出乘客、标记已认领、打印RECEIVE必须在同一个临界区完成,否则就可能出现重复认领或输出顺序错误。
另一方面,电梯内部的waiting、onboard、floor、direction等状态只被对应的ElevatorThread自己访问,不需要加锁。这是我在第二次作业中很重要的一个认识:不是所有状态都需要同步。只要能通过结构保证“单线程拥有”,就比到处加锁更可靠。
第三次作业加入更新和回收后,一条井道里可能出现主轿厢和备用轿厢。我的代码中仍然没有把两个轿厢设计成两个线程,而是让一个ElevatorThread管理同一井道内的mainCabin和backupCabin。
这样做的好处是:双轿厢之间最危险的共享状态,比如楼层范围、交汇楼层、安全距离、是否启用,都在同一个线程内判断,不需要为两个轿厢之间再设计复杂的互斥锁。ElevatorCabin只是状态对象,真正的并发边界仍然是:
SharedRequestPool。ProcessingQueue。第三次的SharedRequestPool.claimOne增加了Predicate<PassengerTask>参数,用来让轿厢只认领自己当前服务范围内合适的乘客。这个筛选、移除、标记和RECEIVE输出仍在同一把锁中完成。这里锁和同步块内语句的关系非常紧:如果先在锁外判断可服务性,再进锁移除,就会出现判断时可服务、移除时状态已变化的问题;如果输出RECEIVE不和认领绑定,也可能导致日志与真实所有权不一致。
总体来说,三次作业中我的锁选择遵循了两个原则:
while重新检查。第一次作业的调度器是DispatchThread。它从RequestQueue阻塞式取请求,然后根据请求中的elevatorId放入对应的ProcessingQueue。整体线程交互如下:
InputThread -> RequestQueue -> DispatchThread -> ProcessingQueue -> ElevatorThread
这个模型的优点是职责清楚。输入线程只负责读入,调度线程只负责分发,电梯线程只负责运行。由于第一次作业中的请求已经带有目标电梯编号,调度策略本身并不复杂,重点在于正确传递结束信号:当总队列为空且输入结束时,调度线程向所有电梯队列调用setEnd。
电梯内部采用LOOK的策略:优先沿当前方向服务能上下车的乘客和前方任务;如果前方没有任务则反向;如果没有任何任务则等待。这个策略兼顾了时间和开关门次数:同向捎带可以减少反复掉头,减少平均完成时间,也避免无意义开门。
第二次作业中,乘客请求不再由DispatchThread分给某部电梯,而是放入 SharedRequestPool。每部电梯线程在运行循环中主动调用claimOne,谁先有能力处理,谁就认领乘客。
线程交互变为:
InputThread -> SharedRequestPool -> ElevatorThread
InputThread -> ProcessingQueue -> ElevatorThread // 维护请求
这里的“调度器”不再是一个单独活跃线程,而是被拆成了共享池中的认领规则和各电梯线程中的运行策略。维护请求仍然是定点事件,因此通过每部电梯自己的ProcessingQueue投递。
这种设计让调度更分布式。它的好处是实现简单,电梯空闲时能及时拿到任务;缺点是全局最优性较弱,可能出现某部电梯先抢到但并不是全局最近的情况。我的取舍是优先保证正确性和响应性,再用电梯内部的同向捎带策略降低时间消耗。
维护场景下,我的策略是电梯收到维护信号后进入维护状态机:先回到F1,清空乘客,把未完成乘客重新放回共享池,然后维修、测试、结束。这个过程的调度重点不是“快”,而是“不能丢人、不能重复、不能违反维护协议”。
第三次作业的调度核心进一步演化为井道状态机。ElevatorThread中的状态包括:
NORMAL:单轿厢运行。REP_ACCEPT、REPAIR、TEST:维护流程。UP_ACCEPT、UPDATE、DOUBLE:更新为双轿厢流程。REC_ACCEPT、RECYCLE:回收备用轿厢流程。在DOUBLE状态下,同一线程内轮流调用两个轿厢的一步运行逻辑。主轿厢服务上半区,备用轿厢服务下半区,并通过ElevatorCabinOps.canMove保证两个轿厢不会越界或相撞。
第三次的乘客调度也加入了范围适配:轿厢认领乘客时会判断起点是否在自己的服务范围内,终点若不在范围内则先送到换乘层,再通过requeue重新进入共享池。这使得调度策略能适应双轿厢的空间约束。
从性能指标看,我的策略主要做了以下平衡:
这个策略不是全局最优算法,更像是一个“规则清晰、局部贪心、状态机兜底”的工程方案。它的优势在于可解释、容易debug,也比较适合迭代式增加新需求。
多线程电梯最难的bug往往不是语法错误,而是某个状态在某个时间点没有被正确交接。结合三次作业,我主要遇到或重点防范过以下几类问题。
第一类是结束条件bug。输入结束并不等于程序可以结束,因为共享池里可能还有乘客,电梯本地waiting和onboard里也可能还有任务,维护、更新、回收状态机也可能没有跑完。因此第三次作业中的shouldStop判断比第一次复杂很多,必须同时检查输入结束、队列为空、共享池为空、轿厢本地任务为空、特殊任务为空,以及当前井道状态是否允许退出。
第二类是乘客丢失或重复的问题。维护、更新、回收时,电梯可能需要把已经认领但尚未完成的乘客释放回共享池。如果只清空本地队列而不requeue,乘客会丢失;如果claimedTasks没有同步移除,又可能导致乘客回到池中但永远无法再次被认领。因此我把requeue也放入SharedRequestPool的同步方法中,并让它同时处理认领标记。
第三类是门状态和事件响应问题。开门必须持续至少 400ms,但这段时间内又可能收到维护、更新、回收信号。如果简单sleep(400),事件响应会变慢;如果过早关门,又可能违反时间约束。因此我使用pollWithTimeout(remain),在等待门时间的同时接收事件,并根据剩余时间继续等待。
第四类是双轿厢安全问题。第三次作业中如果把两个轿厢当成完全独立对象,就容易在换乘层附近出现越界或相撞。我的处理方式是把同一井道的两个轿厢放在同一线程中调度,并在每次移动前调用canMove。这样debug时也更容易复现,因为同一井道内部没有两个线程交错执行带来的不确定性。
我的debug方法主要有三种:
多线程debug给我的一个教训是:不要一开始就怀疑所有地方。先找共享对象,再找等待条件,再找结束条件,效率会高很多。
第一次作业时,我对线程安全的理解还比较朴素:共享队列加锁,wait/notifyAll写对,程序就能跑。到第二、三次作业,我逐渐意识到线程安全不只是“有没有锁”,而是“有没有清楚的所有权”。
在我的设计中,跨线程共享的对象被压缩到很少几个:
RequestQueue或ProcessingQueue用于线程间传递事件。SharedRequestPool用于所有电梯共同认领乘客。而电梯自身的运行状态,比如楼层、方向、门状态、载重、本地等待队列、轿厢内部乘客,都由单个电梯线程拥有。这种设计比给每个字段加锁更清晰,也更符合层次化设计。
层次化方面,我的三次迭代也有明显变化:
尤其第三次把ElevatorCabin和ElevatorCabinOps拆出来后,ElevatorThread更像是在管理井道级状态,而不是同时塞满所有上下客细节。这个拆分让我更容易定位“这是井道事件问题,还是轿厢局部运行问题”。
当然,我的设计还有可以改进的地方。比如第二次和第三次的DispatchThread基本已经失去实际调度作用,历史包袱比较明显;Object类型队列虽然方便兼容多种事件,但类型安全不够好。如果重新设计,我会考虑定义统一的事件接口,让维护、更新、回收事件有更明确的类型层次。
本单元中,我使用的大模型主要是ChatGPT/Codex,使用方式不是让它直接替我完成整个作业,而是把它当作一个代码阅读助手、方案讨论对象和debug陪跑工具。
我和大模型的分工大致是:
面对多线程电梯这样的复杂任务,大模型的优势很明显。它很擅长帮我把零散的状态整理成状态机,也能提醒我一些常见风险,比如wait 要用while包裹、结束条件不能只看输入是否结束、共享队列要在修改后notifyAll。当我描述一个bug现象时,它也能给出一组排查顺序,让我少走一些弯路。
但大模型也有明显困难。首先,它不天然知道课程评测机的所有细节,如果提示不完整,就可能给出看似合理但不符合规格的建议。其次,多线程程序的bug和具体时序强相关,大模型无法直接“看见”某次运行中的线程交错,只能基于代码推断。再次,它有时会倾向于给出过度设计的方案,比如引入更复杂的调度器或锁结构,而作业中真正需要的可能是一个更稳定、更容易验证的实现。
我自己的感受是,大模型最适合参与“想清楚”和“查遗漏”,不适合替代“拍板”。尤其是电梯作业这种规格严格、输出格式敏感、线程时序复杂的任务,最后一定要由自己对照规则和测试结果做判断。它像一个很有耐心的讨论伙伴,可以把混乱的问题摊开,但不能替我承担正确性责任。
第二单元给我的真实感受是:强度很高,但确实能逼着人建立并发程序的工程意识。第一单元更多是在训练对象建模和表达式处理,第二单元则明显进入了“程序会同时发生很多事”的世界。电梯作业让我第一次很强烈地感受到,代码不是按我脑子里想象的顺序执行的,线程调度、等待、唤醒、结束条件都会影响最终行为。
三次迭代中,第三次压力最大。维护、更新、双轿厢、回收叠在一起后,很多bug不是单个功能写错,而是功能之间的状态交接写漏。比如一个乘客在更新前被认领、更新中被释放、双轿厢模式下又要换乘,这种场景很容易让人头大。但也正因为如此,做完之后对“状态机”和“所有权”的理解会深很多。
我对课程的建议主要有几点。
第一,希望在指导书中对一些复杂动态事件给出更多边界样例。例如开门过程中收到维护、更新后仍有跨区乘客、回收时备用轿厢载人等,这样能帮助同学更早意识到问题边界。
第二,希望讨论课或实验课能更系统地讲一讲多线程debug方法。不知道如何复现和定位并发bug,如果能展示一些最小复现样例、日志设计方法、状态机检查方法,会很有帮助。
总体而言,第二单元是痛苦但有价值的。它让我意识到,线程安全并不是在代码上撒一层synchronized,而是要先设计清楚哪些对象共享、哪些对象独占、线程之间通过什么协议通信。这个认识应该会比某一次作业的分数更长久。