304
社区成员
发帖
与我相关
我的任务
分享本单元三次作业的主题都是多线程电梯调度,但三次迭代的复杂度递增非常明显。第一次作业中,请求已经指定电梯编号,程序主要解决单部电梯线程与请求队列之间的协作问题;第二次作业中,请求不再指定电梯,同时引入临时检修,程序需要增加全局调度器和请求重分配机制;第三次作业中,又进一步引入双轿厢改造与回收,请求调度的对象从“单部电梯”上升为“井道”,并且需要处理主轿厢、备用轿厢、换乘层以及井道状态机之间的关系。
虽然每次迭代都引入了新的需求,但我整体上一直试图保留几个核心设计:用请求池和队列隔离线程交互,用 Condition/SnapShot 封装状态观察,用 Strategy 表达调度决策,用 Action 表达具体执行过程。
第一次作业中,请求已经给定了目标电梯编号,因此调度问题相对简单。我采用了比较直接的生产者-消费者模型:输入/分发线程读取请求后,根据请求中的 elevatorId 将乘客请求放入对应电梯的 RequestQueue;每部电梯对应一个 ElevatorThread,只消费自己的请求队列。Main 中创建了 6 个 RequestQueue 和 6 个电梯线程,并为每部电梯绑定一个 ElevatorCondition 和同一个 LookStrategy。
这一版中,RequestQueue 是最主要的共享对象。它内部使用 ArrayList 保存请求,并通过 synchronized 方法保护 put、take、remove、checkAll、setEnd 等操作。队列为空时电梯线程会等待,请求到来或结束标志改变时通过 notifyAll() 唤醒等待线程。
电梯内部状态由 ElevatorCondition 保存,包括当前楼层、方向、载重、车内乘客和等待队列等信息。策略层并不直接修改这些字段,而是通过 ElevatorSnapShot 获取只读快照。这样可以减少策略对象对电梯内部状态的直接破坏,也让“观察状态”和“修改状态”之间形成了比较清晰的边界。
在电梯运行逻辑上,我将“决策”和“执行”拆开:ElevatorStrategy 负责根据当前状态规划下一步动作,ElevatorAction 负责真正修改电梯状态并产生输出。第一次作业中的主要动作包括 MoveAction、OpenAction 和 SetDirAction。其中 MoveAction 负责移动一层并输出 ARRIVE,OpenAction 负责开门、上下客、超载处理和关门。
单梯调度策略采用 LOOK。电梯会尽量保持当前方向运行,只要当前方向上仍然存在车内乘客目的地或等待乘客出发楼层,就继续向该方向移动;如果当前方向没有请求,则重置方向并重新选择上行或下行。
这一版的架构优点是简单、清晰,线程共享范围较小。但它也留下了一个隐患:ElevatorCondition 表面上封装了电梯状态,但 RequestQueue 仍然被电梯线程、分发线程和 Condition 同时持有。这意味着请求队列并没有真正成为 Condition 的私有状态,后续当全局调度、维护回流和结束判断变复杂后,这种状态所有权不够单一的问题被放大了。
第二次作业中,请求不再指定电梯,程序需要自行派单;同时加入临时检修请求,某部电梯在运行过程中可能进入维护流程。为此,我将第一次作业中“输入即分发”的结构拆成了三层:InputThread 负责读输入,PendingPool 作为全局等待池,DispatchThread 负责从全局池取请求并派给具体电梯。InputThread 将普通乘客请求按照当前等待楼层放入 PendingPool,将维护请求放入维护请求池,输入结束后标记 inputEnd。
DispatchThread 从 PendingPool 中取请求,并借助 DispatchStrategy 选择目标电梯。为了避免策略直接访问大量共享对象,我延续了第一次作业中的快照思想,设计了 DispatchSnapShot。它包含当前请求、请求当前等待楼层、所有电梯快照以及各电梯的预分配请求。
第二次作业中还引入了 preAssignQueue。它的语义是:请求已经经过调度器决策,被预分配给某部电梯,但尚未正式输出 RECEIVE,因此还没有真正进入该电梯的服务范围。DispatchThread 会在确认目标电梯没有维护请求、且当前可以接单时,才将预分配请求正式 flush 到该电梯的 RequestQueue,并输出 RECEIVE。
维护流程则通过 MaintState + MaintStrategy + ElevatorAction 建模。MaintState 将一次维护拆成 NORMAL、REP_ACCEPT_BEGIN、REP_ACCEPT_MOVE、REP_ACCEPT_END、REPAIR、TEST_GOING、TEST_RETURNING、TEST_END 等阶段。StdMaintStrategy 根据当前维护状态生成动作序列,例如接收维修人员、移动到 F1、清空乘客、维修等待、测试运行和维护结束。
相比第一次作业,第二次作业还重构了开门逻辑。第一次作业中的 OpenAction 同时负责开门、下客、上客和超载处理,职责较重;第二次作业中,我将开门决策抽象为 OpenStrategy,由 GreedyOpenStrategy 判断是否开门,并生成 OpenAdvice,其中包括上车乘客、正常下车乘客和失败下车乘客。OpenAction 则只负责根据 advice 执行输出和状态修改。
这一版的总体设计方向是清晰的:输入、派单、执行三层解耦;普通请求和维护请求统一进入全局调度系统;调度策略基于快照决策;维护流程通过状态机拆分。但是,这一版也暴露了多线程架构的难点:请求可能在 PendingPool、preAssignQueue、RequestQueue 和轿厢内部之间迁移,结束判断也不再能只看某一个队列是否为空。如果请求迁移和结束协议没有被严格定义,就容易出现提前结束、晚到请求、重复 RECEIVE 或无法退出等问题。

第三次作业进一步引入双轿厢改造和回收。此时系统不再只是 6 部独立电梯,而是 6 口井道,每口井道初始有一个主轿厢,并可能通过改造引入一个备用轿厢。因此,我将系统扩展为 12 个轿厢线程:1–6 为主轿厢,7–12 为备用轿厢。Main 中创建 6 个 ElevatorShaft、12 个 RequestQueue、12 个 ElevatorCondition 和 12 个 ElevatorThread,同一井道的主轿厢和备用轿厢共享同一个 ElevatorShaft。
ElevatorShaft 是第三次迭代中的关键新增抽象。它保存井道级共享状态,包括主轿厢 id、备用轿厢 id、维护状态、双轿厢状态、当前活跃特殊流程,以及 F2 换乘层的占用者。F2 在双轿厢模式下是主轿厢和备用轿厢共享的临界资源,因此 ElevatorShaft 提供了 acquireF2OrWait() 和 releaseF2IfHeldBy(),用于保证两个轿厢不能同时占用换乘层。
第三次作业中,调度策略的返回值也从具体电梯变为井道。DispatchStrategy 的注释中明确说明,plan() 返回 1-based 井道 id,而不是具体轿厢 id。也就是说,全局调度器先决定请求应进入哪口井道,再由 DispatchThread 在 flush 阶段根据井道当前状态、双轿厢模式和请求楼层决定它最终进入主轿厢还是备用轿厢。
为此,第三次作业中的 preAssignQueue 也变成了井道级预分配队列。请求先被预分配到某口井道;如果该井道当前不能正式接收请求,例如正在维护、更新、回收,或者主副轿厢状态不稳定,请求会继续停留在 preAssign 中;只有当某个具体轿厢满足 flush 条件时,才输出 RECEIVE 并放入对应轿厢的 RequestQueue。
双轿厢改造和回收通过 DoubleState 建模。DoubleState 包括改造阶段 UP_ACCEPT_BEGIN / MOVE / END、UPDATE_SILENT、双轿厢运行阶段 DOUBLE,以及回收阶段 REC_ACCEPT_BEGIN / MOVE / END、RECYCLE_SILENT 等状态。 这延续了第二次作业中用状态机处理维护流程的思路,只是状态机的作用域从单部电梯扩展到了整个井道。
双轿厢模式下,普通 LOOK 策略也需要变化。主轿厢服务 F2–F7,备用轿厢服务 B4–F2;对于目标楼层超出当前轿厢服务范围的乘客,其有效目的地应当被视为 F2 换乘层。为此,我设计了 DoubleFloorRules 封装有效目的楼层、候梯楼层过滤和换乘层强制下客规则;MainDoubleStrategy、SubDoubleStrategy 和对应的开门策略则基于这些规则运行。
第三次迭代中,普通乘客、维护请求、更新请求和回收请求仍然统一进入 PendingPool。PendingPool 同时保存四类请求,并记录输入结束、各电梯结束、全局结束以及请求迁移中的 inFlightTransitions。其中 inFlightTransitions 是针对第二次迭代中“请求迁移过程中短暂不可见”问题加入的标记:只要仍有请求处于生命周期迁移过程中,系统就不能认为自己已经完全空闲。
因此,第三次迭代的架构可以概括为:在第二次统一 PendingPool 的基础上,引入井道级 ElevatorShaft,将调度目标从电梯扩展到井道;用 DoubleState 管理改造和回收流程;用 F2 holder 处理换乘层互斥;用井道级 preAssign 表达“已决定井道但尚未正式 RECEIVE”的稳定中间态。
从三次作业整体来看,我的架构设计主线大致有四条。
第一,用队列和池划分线程交互边界。第一次是每部电梯独立请求队列;第二次开始加入全局 PendingPool 和分派线程;第三次继续强化统一等待源,并将预分配提升为井道级中间层。
第二,用快照隔离策略和状态修改。无论是单梯策略还是全局派单策略,我都尽量让策略基于 SnapShot 做判断,而不是直接修改共享状态。
第三,用状态机处理复杂时序流程。第二次的维护流程和第三次的双轿厢改造/回收流程,本质上都是由多个阶段组成的协议。按照指导书建议将它们拆成 MaintState、DoubleState 和对应策略,可以降低单个线程主循环的复杂度。
第四,用动作对象统一执行层。移动、开门、设置方向、推进状态、维护开始、更新静默、回收静默等行为都通过 ElevatorAction 表示。这样电梯线程只负责选择当前策略并执行动作序列,而不需要直接包含大量具体业务分支。
不过,这一单元也让我意识到,多线程架构的正确性并不只取决于类的分层是否清楚。即使整体架构看起来合理,如果请求生命周期、输出时序、锁顺序、结束协议和特殊状态边界没有被严格约束,仍然会在测试中暴露出很隐蔽的问题。
本单元中,我逐渐意识到调度器不仅承担调度功能,还需要负责线程生命周期管理,因此,清晰的工作链和单一等待源需要作为一个重要的架构设计点被考虑,而我最开始对其重要性的认识显然不够
整体上,我将请求流划分为三层,作为调度器线程中持有的属性:PendingPool 作为全局待调度池,统一承接输入线程读入的新请求以及电梯线程因 OUT-F、维护、改造、回收产生的重分配请求;preAssignQueue 作为预分配层,保存“已经由调度策略决定目标、但由于目标电梯或井道暂时不能正式 RECEIVE 而尚未进入电梯队列”的请求;RequestQueue 则表示某部电梯已经正式 RECEIVE 后的私有接收队列。这样的设计使 InputThread、DispatchThread 和 ElevatorThread 的交互边界相对清晰:输入线程只向 PendingPool 写入请求,调度线程围绕 PendingPool 取请求并根据策略放入 preAssignQueue,在目标电梯或井道状态允许时再 flush 到对应 RequestQueue,电梯线程则消费自己的 RequestQueue,并在乘客失败下车或特殊流程清客时将请求回灌到 PendingPool。其中 preAssignQueue 的设计很特殊,我会在后面的调度策略中讲到它。总体来说,这一设计的核心目标是保证请求在 PendingPool → preAssignQueue → RequestQueue → 轿厢 的生命周期中始终有相对稳定的归属,同时尽量让调度线程只围绕统一等待源工作,减少多等待源和多容器迁移带来的并发风险
在调度策略上,我采用的是一种冷热状态切换的混合思路:低负载时优先响应,高负载时优先负载均衡,并在高负载或特殊状态较多时启用预分配队列。这样设计的原因在于,本单元的调度目标并不是单一的“最快”或“最省电”,而是在时间、电量、特殊请求响应、线程结束稳定性之间做折中。
首先,在系统处于冷状态,也就是请求较少、电梯压力不高时,我倾向于使用响应优先策略。此时系统瓶颈通常不是总吞吐,而是单个乘客的等待时间。如果为了负载均衡而强行把请求分散给不同电梯,反而可能造成负优化。例如当前只有一个请求,而最近的一部电梯可以很快接到它,此时如果调度器为了“让各电梯负载均匀”而选择一部更远的电梯,就会同时增加乘客等待时间和电梯移动距离。因此,冷状态下更合理的策略是优先选择当前代价较低、响应更快的电梯或井道,而不是追求形式上的平均分配。
其次,在系统处于热状态,也就是请求数量较多、队列逐渐堆积时,单纯响应优先就会出现问题。最近的电梯可能连续收到大量请求,然而可能根本没有能力全部处理;而其他电梯虽然单次响应距离略远,但如果让它们参与服务,整体完成时间反而会下降。因此,在热状态下我会切换到负载均衡思路,综合考虑电梯车内乘客、等待队列、预分配请求以及特殊流程带来的忙碌程度,把请求分散到整体压力较小的电梯或井道上。这个策略牺牲了一部分单个请求的最短响应,但换来了更好的系统吞吐,避免某一两部电梯成为瓶颈。
这里也涉及时间和电量之间的矛盾。如果只从省电角度考虑,某些情况下最理想的策略可能是尽量少启动电梯,甚至把请求集中交给一部电梯,让其他电梯保持静止。这样可以减少电梯总移动次数,也能让单部电梯内部的 LOOK 策略更充分地整合路线。但是,这种做法显然不利于时间指标:当请求数量稍多时,乘客会集中等待同一部电梯,整体完成时间会迅速变差。相反,如果过早让所有电梯都参与运行,时间可能变短,但电量消耗和无效移动也会增加。因此,我的混合策略实际上是在冷状态下偏向“少动、快响应”,在热状态下偏向“多梯并行、保证吞吐”。
preAssignQueue 的作用主要体现在热状态和特殊状态较多的场景中。最典型的情况是,多部电梯同时处于维护、改造、回收等非正常运行状态,只剩下少数电梯可以立即 RECEIVE。如果调度器只允许把请求正式分配给当前正常运行的电梯,那么大量请求会全部压到这一两部电梯上;而等其他电梯恢复正常时,这些已经 RECEIVE 的请求又不能自然重新分配,最终可能导致严重超时。因此,在高负载下,我希望调度器可以把一部分请求预先分配给暂时不能接单但未来会恢复的电梯或井道,只是暂时不输出 RECEIVE,等其状态恢复后再 flush。这样既避免了把全部压力压到当前正常电梯上,也保留了后续重新利用恢复电梯的机会。
另一个和 preAssignQueue 有关的场景是:某部电梯刚刚结束维护或改造,如果此时没有新的输入请求到来,调度线程可能不会自然被新的请求唤醒;但系统中其实还有旧请求等待完成。如果没有预分配层,这部恢复正常的电梯可能会继续空闲,直到下一个新请求触发调度。而有了 preAssignQueue 后,调度器可以在状态恢复后重新尝试 flush 已经积压的预分配请求,使刚恢复的电梯立即重新参与工作。这一点对输入已经结束但系统尚未清空的阶段尤其重要。
在第三次作业中,引入双轿厢后,我进一步将调度策略从“直接选择具体电梯”调整为“先选择井道,再在 flush 阶段选择主轿厢或备用轿厢”。这样做也是为了避免过早绑定目标。双轿厢回收、改造、F2 换乘层占用等状态会让某个具体轿厢在短时间内从可接单变为不可接单。如果调度策略在 plan 阶段直接把请求绑定到某个具体轿厢,那么这个目标一旦因为状态变化失效,请求就可能卡在错误的预分配位置。相比之下,先选择井道,把主副轿厢的选择推迟到 flush 阶段,可以让调度器根据最新井道状态决定请求到底应该进入主轿厢、副轿厢,还是继续等待。
我的开门策略暴露了两个方法,返回布尔型的 needOpen() 和返回一个上下乘客建议的 plan()。开门本身有明确代价,会消耗时间和电量,因此 needOpen 的判断应当相对谨慎,只有在当前楼层确实存在下客需求,或者存在方向兼容且载重允许的上客机会时,才决定开门。但一旦已经决定开门,上下客选择本身是没有任何额外代价的,因此我在 plan 阶段采用尽可能激进的优化思路来调整乘客分布:使用类似背包问题的 DP(动态规划)算法,在载重限制内尽可能选择更合适的乘客组合,最大化载重利用率。
电梯运行策略上,我最后的认识是:LOOK 是最简单、稳定、有效的基础策略。LOOK 的语义很清楚:有当前方向时尽量沿当前方向继续服务,当前方向没有请求后再切换方向。它可以自然减少频繁折返,也便于和开门策略、候梯队列、车内乘客状态配合。相比设计复杂的局部优化策略,我更倾向于使用 LOOK 保证单梯运行逻辑稳定,把主要优化空间留给 dispatch 层面的冷热调度和预分配机制。
在维护、改造、回收等特殊状态中,指导书要求不需要立刻清空轿厢内的全部乘客,但是却规定了时间上限。因此我对于尽可能少的开门次数下尽可能优化清人流程采用了“两段清人”的思路:第一段发生在电梯前往特殊流程目标楼层的途中,让“在目标楼层下车会需要走回头路”的乘客提前 OUT-F,而其余的顺路乘客则尽量继续服务;第二段发生在特殊流程真正开始前的边界楼层,例如维护到达 F1、改造到达 F3、回收到达 F1,此时必须清空轿厢,目的地正好是当前楼层的乘客 OUT-S,其余乘客 OUT-F。这样既能减少不必要的失败下车,又能保证 MAINT1-BEGIN、UPDATE-BEGIN、RECYCLE-BEGIN 等关键状态开始前,轿厢满足指导书要求。
我曾经出现过输入结束后电梯过早退出的问题:INPUT_END 只表示不会再有新的外部输入,但系统内部仍可能因为 OUT-F、维护清客、改造或回收产生新的重分配请求。如果调度器在输入结束后立刻 setEnd,就可能导致请求被重新分配到已经退出的电梯。后来我在架构上将输入结束和系统结束区分开,要求 PendingPool 为空、preAssignQueue 为空、所有电梯清空、没有迁移中的请求后,才允许真正结束。
另一个典型问题是请求迁移时短暂“不属于任何地方”。例如 OUT-F 时,如果先从轿厢删除乘客,再放入 PendingPool,dispatch 恰好在中间窗口检查系统状态,就可能误判所有请求都已完成。后来我将“未完成请求必须始终有稳定归属”作为核心约束,并通过统一 PendingPool、减少多容器拆分提交、在迁移期间标记 inFlightTransitions 等方式降低这种风险。
维护、改造、回收这类特殊流程中,我曾经遇到过重复 RECEIVE 问题。原因是电梯刚接受特殊指令时,就过早把原本已经 RECEIVE 到该电梯的候梯请求回流到 PendingPool,但此时 MAINT1-BEGIN、UPDATE-BEGIN 或 RECYCLE-BEGIN 还没有输出,旧的 RECEIVE 生命周期尚未正式结束。除此以外更易错的问题是 outF 时重分配,如果实际的 outF 逻辑在打印 outF 信息之前执行,可能出现因为 outF 而被送回待调度池的请求立刻被分配线程分配并输出 RECEIVE 信息,先于 outF 的信息真正被打印。后来我把这些候梯请求的释放时机推迟到对应输出之后,严格保证输出边界先于请求重新暴露给调度器。
第三次作业中,双轿厢场景曾暴露出 plan 和 flush 不一致的问题:dispatch 策略在 plan 阶段可能认为某个具体轿厢合适,但 flush 阶段又因为维护、回收、井道状态或本地指令阻塞而无法真正 RECEIVE,导致请求卡在错误的 preAssign 中。后来我将策略粒度从“直接选择具体轿厢”调整为“先选择井道”,再在 flush 阶段根据最新井道状态决定主轿厢、副轿厢或继续等待。
第三次作业中,F2 是双轿厢共享的换乘层,如果某个空闲轿厢停在 F2 并持有 F2 资源,而线程主循环因为“本轿厢为空”直接进入等待,就会阻塞另一轿厢进入 F2,最终造成回收或 preAssign flush 停滞。后来我在策略和主循环中加入“空闲但持有 F2 时不能直接等待”的判断,使轿厢先执行一次合法的无 RECEIVE 移动,让出换乘层。
经过这些 bug,我逐渐总结出了一组重要的并发约束:未完成请求必须始终有稳定归属;输出必须先于会影响调度判断的状态暴露;dispatch 应尽量只有一个统一等待源;notifyAll 不能代替持久业务状态;setEnd 后不能无条件接收晚到请求;结束判定不能只靠扫描多个容器是否为空;plan 和 flush 的语义必须一致;车内乘客和候梯乘客的方向判断不能混用。这些约束共同保证请求生命周期、状态机边界、输出顺序和线程等待条件等等并发细节组成的协议的正确性。
在 debug 方法上,我后来不再只依赖正式输出,而是将正式输出和调试输出分离:正式输出统一走 OutputManager 和课程组接口,调试信息走 stderr,避免污染评测结果;同时借助 Codex 加入 TraceLog、watchdog 和 debug summary,用来追踪特定乘客在 PendingPool、preAssignQueue、RequestQueue 和轿厢之间的迁移,并在程序长时间不结束时输出线程状态和各电梯状态。测试时,我会保留 run.out 和 run.err,对偶发点并行跑多组数据,优先通过 ELEVATOR_STALL、DISPATCH_WAIT_PREASSIGN、DISPATCH_WAIT_POOL 等日志关键词定位问题发生在策略、flush、请求池还是退出协议上;修复时则尽量一次只处理一个最小范围 bug,避免多个并发问题互相掩盖。
经过这一单元,我对线程安全最大的认识是:线程安全并不等于把所有共享对象的方法都加上 synchronized。单个对象内部加锁只能保证某一次局部读写是互斥的,但多线程电梯中真正容易出问题的地方,往往是跨对象、跨阶段的组合状态。例如,一个请求是否完成,并不只取决于某一个队列,而可能同时涉及 PendingPool、preAssignQueue、RequestQueue 和轿厢内乘客列表;一部电梯是否可以退出,也不只取决于自己的 RequestQueue 是否为空,还取决于输入是否结束、是否存在重分配请求、特殊流程是否结束,以及是否有请求正处在迁移过程中。因此,线程安全首先是业务不变量的设计问题,其次才是具体锁的使用问题。如果业务状态本身被拆得过散,即使每个类的方法都加锁,也仍然可能在组合判断上出错。
我后来越来越倾向于维持共享对象的单一性。第一次迭代中,我设计了 ElevatorCondition 来封装电梯状态,但 RequestQueue 仍然被 DispatchThread、ElevatorThread 和 ElevatorCondition 同时持有。这个问题在第一次作业中不明显,因为请求只需要投递给指定电梯;但当维护、重分配、双轿厢等需求加入后,请求队列和电梯状态之间的组合判断越来越多,例如“电梯是否清空”需要同时判断轿厢内乘客、私有队列和特殊指令,“系统是否结束”需要同时判断输入状态、全局池、预分配队列和所有电梯状态。此时如果相关状态分散在多个共享对象中,就会带来几个问题:等待和唤醒不知道应该绑定在哪个对象上;结束判断需要同时读取多个锁保护的状态,难以保证一致;不同路径还可能形成不同的加锁顺序,带来潜在死锁风险。
因此,我认为共同构成一个业务不变量的状态,最好由同一个共享对象统一维护。如果无法做到完全统一,也应尽量减少共享对象数量,并明确唯一的状态入口。例如,PendingPool 应当是全局待调度请求的统一入口,ElevatorShaft 应当是井道级共享状态的统一入口。preAssignQueue 虽然额外增加了一层状态,但它必须有稳定语义,即“已经决策但尚未正式 RECEIVE”,否则它也会变成新的复杂度来源。好的封装不是简单地把字段设成 private,而是让一组相关状态拥有明确的所有者和一致的修改入口。
在 wait/notify 的使用上,我认识到,notifyAll() 本身不能承载业务语义。它只是把等待线程叫醒,但如果被唤醒的线程醒来后看不到一个可以反复检查的持久状态,那么这次唤醒就是不可靠的。更麻烦的是,如果 notify 发生时目标线程还没有进入等待,那么这个瞬时事件就可能被彻底错过,这就是 唤醒丢失 问题。
因此,更安全的模型不是“发生某事,然后 notify”,而是“先写入一个持久状态,然后 notifyAll,等待线程醒来后重新检查条件”。例如,电梯结束维护、改造或回收时,仅仅唤醒 dispatch 线程是不够的;dispatch 线程醒来后必须能观察到“井道状态已经恢复”“存在 preAssign 需要重新 flush”或“ PendingPool 中出现了新请求”这样的持久事实。否则,一次唤醒如果被错过,调度器可能就会长期睡眠。换言之,notifyAll 不是消息队列,不能替代状态本身;等待线程等待的应该是条件,而不是某一次瞬时事件。
单一等待源问题和共享对象单一性是紧密相关的。共享对象越分散,等待源就越分散;等待源越分散,线程协作就越难保证正确。对调度线程来说,如果它同时依赖 PendingPool、preAssignQueue、RequestQueue、ElevatorCondition 和 ElevatorShaft 的变化,那么就很难回答几个关键问题:到底哪个对象负责唤醒 dispatch?哪个状态变化代表调度器应该继续工作?如果状态变化发生在 dispatch 进入等待之前,会不会漏唤醒?如果多个对象同时变化,结束判断又如何保持一致?
所以我后来倾向于让 DispatchThread 尽量只围绕 PendingPool 这一类统一等待源工作。其他对象中的关键变化,要么转化为放入 PendingPool 的请求,要么通过 PendingPool 上的持久标志或统一唤醒接口反映给调度线程。这样,dispatch 的主循环就不需要在多个监视器之间反复切换。完全单一等待源在第三次作业中并不容易做到,因为井道状态、F2 占用和 preAssign flush 都会影响调度,但即使无法完全做到,也应尽量让主要等待条件收束到一个对象上,而不是让多个对象都成为独立事件源。
线程退出是本单元最容易被低估的问题之一。最开始,我用“输入结束 + 各队列为空”来判断程序是否结束,但后续发现,“队列为空”并不等价于“业务完成”。请求可能正在从轿厢回流到 PendingPool,可能暂存在 preAssignQueue,也可能处于特殊流程的边界状态。此时扫描多个容器是否为空,很容易受到中间状态影响,出现提前结束或无法结束。
因此,我现在认为,这次电梯作业最理想的退出机制仍然是维护一个全局的未完成请求计数,而不是依赖多个容器的瞬时空状态。具体来说,输入线程读到一个乘客请求时,未完成乘客计数增加;只有乘客真正 OUT-S 到达最终目的地时,计数才减少。维护、改造、回收这类特殊请求也可以类似处理:请求进入系统时计数增加,对应 MAINT-END、UPDATE-END、RECYCLE-END 完成时计数减少。至于 OUT-F、重分配、preAssign、进入 RequestQueue,都只是同一个请求生命周期中的中间迁移,不应改变未完成请求计数。
这样,系统结束条件就可以更接近业务语义:
inputEnd == true
&& unfinishedPersonCount == 0
&& unfinishedSpecialCount == 0
在这个条件满足后,调度器再统一通知各电梯队列结束;电梯线程则在自己的队列清空、轿厢清空并收到结束信号后退出。相比“扫描多个队列是否为空”,这种方式更能避免请求迁移窗口对退出判断的干扰。
本单元中,我对大模型辅助编程的认识发生了很大变化。相比第一单元中主要让大模型辅助理解架构、生成测试、讨论局部优化,第二单元的多线程电梯任务让我更直接地感受到:大模型在复杂并发项目中确实有帮助,但如果使用方式不当,也会迅速放大架构中的隐患。
我主要使用过 DeepSeek、ChatGPT、Cursor 和 Codex。它们在我的工作中承担过不同角色:DeepSeek 和 ChatGPT 更多用于架构讨论和反思总结;Cursor 主要用于代码补全和较大范围的 vibe coding;Codex 更多用于 debug 阶段,帮助阅读代码、分析日志、总结 bug 链路、生成修复文档和测试流程。经过这一单元后,我对于 AI 编程有了一些沉痛的体悟。
第二次作业中,为了赶时间,我实际提交的代码有一部分是在已有手写架构基础上让 AI 接续完成的。事后看,这是一次不太成功的尝试。当时我已经对一些容易出 bug 的点有感觉,比如共享对象边界、请求迁移、结束判断和 wait/notify 的位置,但并没有把这些隐含规则完整交接给模型。于是模型生成的代码可能在局部函数上看起来合理,却没有稳定遵守整个系统的不变量。最终结果既没有达到完全手写代码那种可控性,也没有达到由 AI 从零设计一个小系统时可能具有的局部自洽性,强测也没有通过。
这次经历让我认识到,复杂多线程项目中,vibe coding 绝不能“我写到一半,让 AI 接着写”。如果模型没有先理解线程模型、共享对象所有权、锁顺序、请求生命周期、输出时序和退出协议,它就很容易通过增加中间队列、事件流或局部补丁来解决眼前问题,但这些改动可能破坏更高层的不变量。尤其是电梯作业中,很多错误并不会表现为明显的语法问题,而是表现为偶发 TLE、重复 RECEIVE、请求卡在 preAssign、某个线程提前退出等很难定位的问题。
第三次作业中,我尝试了更彻底的纯 vibe coding。我没有继续基于第二次实际提交的 AI 版本迭代,因为那份代码中存在很多我自己也难以完全掌握的细节,而且没有通过强测;我选择把 hw6 以手写方式完成,将这个版本交给 Cursor,再提供 HW7 的新增需求,要求它维护迭代说明文档和类变更索引。这个过程比第二次更工程化:它确实生成了关于 ElevatorShaft、DoubleState、主副轿厢、F2 互斥、Update/Recycle 状态机等内容的文档,也尝试按照文档推进实现。但最终结果仍然没有通过公测。
这说明,即使给 AI 提供文档和架构基线,也不能保证复杂多线程系统正确。文档可以降低上下文缺失,但不能替代自己对核心并发不变量的掌握。如果我提供的基线架构中本身仍有退出机制、请求迁移时序、特殊流程边界等细节问题,AI 很难自动发现并修正这些深层问题;相反,当 HW7 又叠加双轿厢、改造、回收、F2 互斥等需求时,这些隐患会被进一步放大。
相比之下,Codex 在后期 debug 中给我的帮助更明显。我让它阅读代码和日志,整理出修复参考、Respond 重构基线、特殊请求时序修复记录和本地测试流程。它帮助我把“某个测试点超时”拆解成更具体的问题:请求迁移时是否短暂无归属,输出是否早于状态暴露,plan 和 flush 语义是否一致,双轿厢 LOOK 是否混淆了接人方向和送人方向,dispatch 是否存在多等待源和丢失唤醒风险等。这种分析型、文档型、工具型的辅助,比直接让模型大段修改核心并发逻辑更可靠。
我最终形成的使用经验是:对于多线程电梯这样的复杂任务,大模型的优势在于快速阅读代码、发现可疑路径、整理状态机、生成 debug 日志工具、归纳修复原则和固定测试流程;它的困难在于难以自动维护开发者心中的隐含架构约束。尤其当代码已经半手写、半 AI,且接口语义没有被明确文档化时,大模型很容易误解变量含义或状态边界。
因此,后续如果继续使用大模型辅助类似项目,我会调整分工方式。首先,在让模型写代码前,我会先要求它复述当前架构,包括线程模型、共享对象、请求流向、锁顺序、退出条件和输出边界;只有当它的理解基本正确时,才让它生成局部实现。其次,我会尽量把任务拆小,例如只让它实现某个策略函数、某个 Action、某个测试脚本或某个日志工具,而不是让它一次性接管调度器主循环。最后,对于涉及并发协议的核心部分,我会优先让模型做审查和反例分析,而不是直接替我修改。
总的来说,大模型是需要深入沟通的协作伙伴。它能显著提高信息整理、代码阅读和 debug 效率,但前提是我自己必须掌握系统的核心不变量。对于多线程程序而言,真正决定正确性的不是某一段代码写得是否流畅,而是请求生命周期、状态机边界、等待源和退出协议是否一致;这些部分不能完全交给大模型代替我思考。
总体来说,第二单元的难度提升非常明显。相比第一单元中偏确定性的表达式解析,多线程电梯更考验架构设计、线程协作和边界条件处理能力。尤其是在维护、改造、回收等特殊请求加入后,程序正确性不再只取决于某个策略是否合理,而取决于请求生命周期、输出时序、等待唤醒和退出机制是否形成完整闭环。这一部分虽然很有训练价值,但也确实是我在本单元中最翻跟头的地方。
我比较希望课程组能在同步实现和线程退出机制上给出更细致的提示。比如可以在指导书或研讨材料中补充一些典型风险场景,不一定需要给出具体代码实现,但如果能以反例或设计原则的形式提醒,会帮助同学们更早意识到多线程程序的难点不只是“哪里加锁”。
另外,我也建议在公众号文章中推送一些更侧重思维建立和工程场景的并发编程原则,帮助学生更好掌握真实可用的并发编程经验。