304
社区成员
发帖
与我相关
我的任务
分享stdin → InputThread ─┬─ PersonRequest ─→ [centralQueue] ─→ Dispatcher ─→ RECEIVE
│ ↓
│ [carriageQueue_i] (i=1..12)
│ ↓
├─ MaintRequest ─→ Shaft.receiveMaint Carriage_i
├─ UpdateRequest ─→ Shaft.receiveUpdate ├── performMove (受 shaft.lock 保护)
└─ RecycleRequest ─→ Shaft.receiveRecycle ├── DoorService (出/换向/进)
└── CarriageTransition (MAINT/UPDATE/RECYCLE)
│ kickback + rerouted
└─→ [centralQueue]
| 线程 | 数量 | 职责 |
|---|---|---|
| InputThread | 1 | 读 stdin,分发 Person/Maint/Update/Recycle |
| Dispatcher | 1 | 快照打分、动态分配至 12 个轿厢 |
| Carriage | 6 + 6 | 主轿厢始终运行;副轿厢按 UPDATE-END 激活、RECYCLE-END 退出 |
第二单元三次作业的核心难点并不只是“让电梯跑起来”,而是让多个线程在复杂状态变化下仍然保持可解释、可证明的正确性。我的三次作业大体经历了从单一请求队列、多电梯调度,到检修状态,再到双轿厢改造与回收的迭代。随着功能增加,锁的粒度和同步块的位置也在不断调整。
第一次作业中,我主要围绕请求队列设置同步块。输入线程是生产者,电梯线程是消费者,请求队列既要保证 add、poll 的互斥,也要承担 wait/notifyAll 的通信职责。因此这一阶段锁的选择比较直接:以队列对象本身作为 monitor。同步块内部只处理队列增删、结束标志读写和线程唤醒,电梯移动、开关门等耗时操作不放在锁内,避免一个线程 sleep 时阻塞其他线程获取请求。
第二次作业加入维护请求后,单纯的请求队列锁已经不够。电梯是否还能接客、是否已经进入维修流程、车内乘客是否需要强制下车,这些状态和队列操作必须保持一致。因此我将“RECEIVE 输出、请求入队、特殊请求挂起标志、队列唤醒”等动作放在同一个 requestQueue 同步块中处理。这样做的原因是:这些语句共同维护“一个请求是否真的被某台电梯接收”这一不变式,如果拆开,就可能出现先输出 RECEIVE,随后状态变化导致请求无法被执行的错误。
第三次作业引入主副轿厢和共享 F2 后,我额外设置了井道级锁,也就是 Shaft 实例锁。它保护 state、backupActive、主副轿厢的当前位置、预留位置、移动状态和 F2 冲突标志。这里我没有让每个字段各自使用独立锁,因为这些字段经常需要一起判断:例如某个轿厢能否移动到 F2,取决于对端当前楼层、对端预留楼层、井道状态和是否正在 moving。如果拆成多把锁,临界区之间就会出现不一致快照,也更容易产生死锁。
三次作业下来,我对“锁与同步块中处理语句之间的关系”的理解是:锁不是为了包住一段代码,而是为了保护一个不变式。凡是共同决定某个不变式成立的读写语句,就应该放在同一个同步块中;凡是耗时但不改变共享不变式的操作,就应该尽量移出同步块。比如电梯移动中的 sleep 不需要持有请求队列锁,但第三次作业中 ARRIVE 输出和 moving=false 被放在 shaft.lock 内,是因为它们共同保证“状态切换不能早于当前移动的到达输出”。这类同步块虽然更大,但它保护的是跨线程可观察的输出顺序,不能随意拆开。
为了避免死锁,我最终采用了比较严格的锁序:shaft.lock > requestQueue > centralQueue。实际实现中也尽量避免嵌套持锁,例如调度器只获取某一台电梯的快照,释放锁后再计算 cost;特殊流程中先从本地队列取出要回退的请求,再单独进入中央队列批量加入。这个原则让我在后期定位并发问题时有一个清晰的判断标准:只要没有反向持锁路径,死锁风险就会小很多。
我的调度器始终采用“输入线程 - 中央队列 - 调度器 - 电梯本地队列 - 电梯线程”的两级生产者消费者结构。输入线程只负责读取请求并放入中央队列;调度器线程从中央队列取出乘客请求,根据各电梯状态选择目标电梯;电梯线程只消费自己的本地队列。这样的好处是职责比较清楚:输入不关心调度,调度不直接执行电梯动作,电梯也不需要争抢全局请求。
调度器与其他线程的交互主要通过两个对象完成。第一个是 centralQueue,它连接输入线程和调度线程,同时在所有电梯暂时不可接收请求时让调度器等待。第二个是各个 carriage.requestQueue,调度器向其中写入请求并输出 RECEIVE,电梯线程从中取出请求并在容量释放、状态变化时唤醒调度器。第三次作业中,Shaft.finishUpdate 和 Shaft.finishRecycle 也会唤醒中央队列,因为井道状态变化可能让原本不可调度的请求重新变得可分配。
调度策略上,单台电梯内部使用 LOOK 思想:优先沿当前方向处理顺路请求,减少无意义折返。调度器层面则使用基于快照的启发式打分。每台电梯在自己的队列锁内生成 CarriageSnapshot,包含当前位置、方向、载重、队列长度、可达范围、井道状态等信息。调度器拿到快照后不再持锁,而是在无锁状态下计算 cost,这样既降低了锁竞争,也避免调度器长时间阻塞电梯线程。
我的 cost 大致由距离、拥塞程度、方向惩罚和可达范围修正组成。距离项让离乘客近的电梯优先响应,降低平均等待时间;拥塞项避免所有请求都堆到同一台“看起来最近”的电梯上;方向惩罚鼓励顺路捎带,减少折返;在双轿厢阶段,SINGLE_TRIP_BONUS 鼓励一次送达,CROSS_RANGE_PENALTY 则允许必要时通过 F2 接力完成跨段请求。
这个策略对时间和电量指标的权衡是比较朴素但有效的。为了降低时间,我不能只让一台电梯沿最优路径慢慢捎带,而要适度唤醒空闲电梯分担请求;为了降低电量,又不能让所有空闲电梯频繁被很小的距离优势唤醒。因此我给空闲响应设置了较小但存在的惩罚,让顺路电梯优先级略高于空闲电梯。第三次作业中,我还通过一次送达奖励减少 F2 中转次数,因为中转不仅增加乘客时间,也会带来额外开关门和移动耗电。实测中这个策略的运行时间和平均时间表现较好,耗电处于中等水平,说明它更偏向时间性能,能耗还有继续调参空间。
双轿厢阶段最特殊的是 F2 共享问题。我的做法不是让调度器提前完全规避冲突,而是在 Shaft.beginMove 中进行井道级仲裁:如果目标楼层和对端当前位置或预留位置冲突,就等待;如果对端空闲停在 F2,则通过 peerBlocked 唤醒对端做单层让行。这使调度器仍然可以只关注“请求应该给谁”,而把空间冲突交给井道协调者解决。后来我又限制 RECYCLE 阶段禁止主轿厢为了让行从 F2 主动移动,并在 RECYCLE-END 前等待主轿厢结束当前移动,避免出现状态已经回到 NORMAL 但主轿厢还在输出 ARRIVE 的问题。
第二单元中最让我印象深刻的 bug 都和“输出顺序”以及“状态变化时机”有关。普通单线程程序出错时,通常能沿着调用栈找到原因;但多线程电梯的错误经常只在某个特定时序下出现,而且日志上只表现为某一行输出不合法。
我遇到过的一类问题是状态切换过早。例如第三次作业中,副轿厢回收完成后会让井道回到 NORMAL。如果此时主轿厢仍在一次让行移动的 sleep 中,就可能出现主轿厢醒来后在 NORMAL 状态下输出一次没有对应 RECEIVE 语义支撑的 ARRIVE。这个问题的根源不是某一行判断写错,而是“RECYCLE-END、状态切换、主轿厢 ARRIVE”之间缺少顺序约束。最后我的修复是两层:一是在 RECYCLE 阶段禁止新的让行移动,二是在 finishRecycle 中等待 main.isMoving() 为 false 后再输出 RECYCLE-END 并切回 NORMAL。
另一类问题是漏唤醒。调度器在所有候选电梯都不可接收时会等待中央队列,如果某个特殊流程结束后没有唤醒它,程序就可能在还有请求的情况下卡住。这类问题的 debug 方法是反向枚举等待条件:线程在哪里 wait,它等待的条件是什么,哪些状态变化能让条件变真,每一种状态变化后是否都有 notify。按这个方法检查后,我给 finishUpdate、finishRecycle 等状态切换路径补上了对中央队列的唤醒。
对多线程程序来说,随机测试很重要,但更重要的是根据设计中的危险点构造针对性测试。
这三次作业让我意识到,线程安全不是简单地给所有方法加 synchronized。如果锁加得太粗,程序虽然可能更安全,但性能差、死锁风险高,而且很难说明每个锁到底保护什么;如果锁加得太细,又会出现状态读写不一致的问题。比较好的方式是先划分层次,再根据每一层的不变式选择锁。
在我的设计中,RequestQueue 负责请求队列层面的线程安全,Carriage 负责单个轿厢的生命周期和动作执行,Shaft 负责井道级共享状态和主副轿厢冲突,Dispatcher 只通过快照做调度决策,Strategy 只做无副作用的动作选择。层次清楚之后,很多同步问题会自然收敛:调度器不需要知道轿厢开门细节,策略类不直接修改共享状态,输入线程不直接操作电梯内部字段。
层次化设计也降低了迭代成本。第二次加入维护流程时,我可以把强制下车、请求回退和状态切换集中到特殊流程类中;第三次加入双轿厢时,我主要扩展 ShaftState、Shaft 仲裁和调度打分,而不是重写整套电梯主循环。也就是说,好的层次划分不仅让代码更好看,更重要的是让新增复杂功能时不破坏原有正确性。
本单元中我在 Cursor 中使用大模型辅助开发和总结,主要模型是Claude Opus4.7 和 GPT-5.4。使用时,整体架构和最终代码取舍由我自己决定;大模型更多承担代码阅读、边界场景枚举、文档整理和局部实现建议的工作。特别是文档整理,在大模型辅助开发的过程中我会要求大模型根据代码设计和变动,始终维护一份DESIGN.md 设计文档,详细记录架构设计、并发同步控制、调度策略等内容,能够很大程度上帮助我的思路跟大模型完成进度进行对齐。
面对多线程电梯这样的复杂任务,大模型的优势是信息整理能力强。它可以在较短时间内从多个类中总结出线程模型、锁资源和状态机路径,也适合帮我生成测试思路,例如“回收时主轿厢仍在移动”“F2 集中客流冲突”等场景。它还比较适合做代码重构建议,例如把门操作抽成 DoorService,把特殊流程抽成 CarriageTransition,这些建议能帮助我控制类的规模。
但大模型也有明显困难。多线程正确性依赖非常具体的时序和输出规则,模型有时会给出看似合理但实际破坏锁序的方案,或者忽略某个 notify 的必要性。尤其在第三次作业的 F2 冲突和 RECYCLE-END 问题上,如果只看单个方法,很容易觉得逻辑没问题;必须结合完整线程交互和输出约束才能定位风险。
我的感受是,大模型适合作为“高效率的讨论对象”和“文档整理助手”,但不适合作为多线程程序的最终裁判。使用它时最重要的是自己要先有判断标准,例如锁保护什么不变式、等待条件如何被唤醒、状态切换是否和输出保持原子性。只有带着这些问题去问模型,它的回答才会真正有价值。
第二单元是我目前为止压力最大、也最有收获的一个单元。一开始我以为主要难点是调度算法,后面才发现,调度只是其中一部分,更难的是在多线程、实时输出和复杂状态机之间保持一致。尤其第三次作业的双轿厢、改造、回收、F2 共享和跨段接力叠在一起后,程序已经很难靠“跑几组样例”获得安全感,必须回到设计层面分析不变式。
这个单元让我第一次比较认真地理解了生产者消费者、监视器、保护性挂起这些概念,而体验上比较痛苦的地方是指导书规则多、状态边界多,而错误反馈往往只是一段输出不合法或超时。很多 bug 不是稳定复现的,导致 debug 成本很高。