整体架构

一、三次作业中的同步块设置和锁的选择

第二单元三次作业的共同核心是“多个线程围绕共享请求和共享电梯状态协作”。我的同步设计也基本沿着这个方向逐步演进:HW5 主要保护请求队列,HW6 开始保护电梯状态快照和维修状态,HW7 则进一步把锁划分到调度器、轿厢和井道三个层面。

1. HW5:以队列锁为核心

HW5 的结构相对简单,主要线程包括 InputThreadSchedulerThread 和 6 个 ElevatorThread。共享对象主要有两类:

  • DispatchTable:输入线程向其中加入乘客请求,调度线程从中取请求。

  • RequestTable:每台电梯对应一个请求表,调度线程向其中加入请求,电梯线程从中读取和删除请求。

因此 HW5 的同步块集中在 DispatchTableRequestTable 上。DispatchTable.take() 使用 while (queue.isEmpty() && !closed) wait(),避免空队列时忙等,也避免虚假唤醒导致错误取空。RequestTable.awaitWork() 同样使用条件等待,表示电梯在线程没有任务时挂起。

这一版中,电梯自身的楼层、方向、载客列表主要由对应电梯线程独占访问,所以没有给电梯内部状态单独加锁。

2. HW6:队列锁和电梯状态锁并存

HW6 加入维修请求后,状态复杂度明显上升。调度器不仅要分配普通乘客,还要判断电梯是否处于维修接收、维修中、测试中等状态。此时仅保护请求队列已经不够,电梯自身状态也会被调度线程读取。

这一版主要有三类锁:

  • ScheduleQueue 锁:保护全局待分配队列、未完成乘客数、输入结束状态和版本号。

  • ProcessQueue 锁:保护某台电梯的等待队列、楼层桶、维修请求标记。

  • ElevatorThread 对象锁:保护电梯当前楼层、方向、状态、车内乘客、目标楼层等运行状态。

为了让调度器安全读取电梯状态,我设计了 ElevatorSnapshotElevatorThread.getSnapshot() 在电梯锁内复制必要字段,然后调度线程基于快照计算 cost。这样调度线程不会直接持有电梯内部集合,也不会在计算过程中阻塞电梯线程太久。

HW6 中一个重要经验是:锁内语句应尽量只完成“状态采样”和“状态提交”。例如电梯移动和开关门会 Thread.sleep(),不应该长时间持有共享队列锁。

3. HW7:调度器锁、轿厢锁、井道锁分层

HW7 引入双轿厢改造、回收和跨 F2 换乘后,锁的粒度必须继续拆细(不是这真的能想明白吗?)。目前主要有三层同步:

  • Scheduler 锁:保护全局等待队列、乘客完成数、特殊请求完成数和 dispatchVersion

  • ElevatorCar 锁:保护单个轿厢的 waitingonboard、当前位置、方向、可服务区间、是否可接客、特殊命令。

  • ShaftController 锁:保护同一井道主副轿厢的位置、是否双轿厢模式、F2 预约状态和回收状态。

这一版的锁分层很关键。调度线程从 Scheduler 取出乘客后,会释放 Scheduler 锁,再通过 ElevatorSystem 采集电梯快照并尝试提交。提交时通过 ElevatorCar.tryAcceptTrip() 在轿厢锁内二次检查状态,只有当前仍可接客才打印 RECEIVE 并加入电梯队列。

井道锁单独放在 ShaftController,用于解决同井道主副轿厢争用 F2 的问题。仅判断“对方当前不在 F2”是不够的,因为 F1 到 F2 和 F3 到 F2 可能同时出发。因此我加入 reservedF2By,谁要进入 F2 必须先预约,另一个轿厢即使当前看到 F2 为空,也会因为预约被阻塞。

这一版也暴露出锁和同步块设计的几个教训。第一,Thread.sleep() 不释放监视器锁,开关门时如果持有 ElevatorCar 锁,会让调度线程在 snapshot() 上阻塞。后来我把门时间和部分特殊流程等待移到锁外,只在开门、上下客、关门这些状态切换点短暂加锁。第二,wait() 必须配合条件循环。之前电梯线程在循环末尾无条件 wait(),可能在 notifyAll() 发生后才睡下,导致已经收到 UPDATE 却迟迟不执行。最终改成只有“没有特殊命令、没有等待乘客、没有车内乘客、也不该退出”时才等待。第三,井道等待也要考虑可取消性。副轿厢等待进入 F2 时,如果收到 RECYCLE,必须能退出普通移动等待,回到主循环执行回收,否则会形成活锁。

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

1. HW5:输入队列到电梯队列的直接分派

考虑到后面的迭代,HW5有必要解耦输入线程和调度线程。HW5 的调度器是 SchedulerThread,很直接。输入线程读取请求后放入 DispatchTable,调度线程从 DispatchTable 中取出请求,根据输入中的电梯编号直接分给对应 RequestTable,再由电梯线程执行。

InputThread -> DispatchTable -> SchedulerThread -> RequestTable -> ElevatorThread

电梯内部使用常用的 LOOK 思路:优先下客,再捎带当前方向乘客;如果当前方向还有请求则继续移动,否则反向;没有任务则等待。

2. HW6:全局待分配池和基于快照的 cost 调度

HW6 中乘客不再固定电梯,同时出现维修请求。我的调度器变成 DispatchThread + ScheduleQueue + ProcessQueue[] 的结构。ScheduleQueue 保存还未分派或被踢下后重派的乘客,ProcessQueue 是各电梯的等待队列。

调度器维护一个 pending 池,而不是只处理队首请求。每次输入、乘客完成、电梯移动或队列变化后,通过版本号唤醒调度线程,调度器批量重试 pending 中的乘客。选梯时读取每台电梯的 ElevatorSnapshot,只考虑 NORMAL 且没有维修挂起的电梯。

HW6 的 cost 函数主要考虑(很明显不是我想出来的):

  • 电梯到乘客出发楼层还要走多少层;

  • 到接人前可能停多少站;

  • 电梯当前车内人数和等待队列规模;

  • 是否顺路、是否同楼层同方向聚集;

  • 电梯空闲时是否适合去接主请求。

这个策略试图兼顾时间和电量。时间方面,减少乘客等待和绕路;电量方面,鼓励顺路捎带和同楼层聚合,避免远处空电梯频繁被唤醒。但如果惩罚参数过强,可能让系统过度分散请求,能耗反而上升;如果过弱,则可能把请求堆给单台电梯,造成长尾。

3. HW7:扫描式调度、换乘评估和双轿厢状态协作

HW7 的调度器进一步拆成 SchedulerDispatchThreadElevatorSystem。其中 Scheduler 管全局乘客状态和结束条件,DispatchThread 扫描等待队列,ElevatorSystem 负责选车和处理特殊请求。

一开始调度线程按 FIFO 处理队首乘客。后来被高并发测试点叉爆,新增加入每台电梯最多接收 8 个请求的硬上限,队首乘客如果暂时找不到电梯,可能阻塞后面本来可以分派的乘客。因此我把调度改成扫描式:每轮扫描当前等待队列长度,能分派就提交,不能分派就放回队尾;如果一整轮都没有进展,才等待 dispatchVersion 变化。

HW7 的调度策略是当前三次作业中最复杂的一版。对每个乘客同时评估两个方案:

  • 直达方案:某台电梯直接从当前楼层送到目标楼层;

  • F2 换乘方案:第一段送到 F2,乘客 OUT-F 后重新进入调度,再由另一台电梯完成第二段。

评估时会采集 12 台轿厢的快照,用影子模拟估算接人时间、送达时间、开门次数、移动次数和能耗近似值。最终 cost 中加入了换乘惩罚负载惩罚硬上限过滤

HW7 中调度器与线程的交互更像状态机协作:

  • 输入线程把乘客加入 Scheduler,或把特殊请求交给 ElevatorSystem

  • 调度线程从 Scheduler 取乘客,通过 ElevatorSystem.tryAssignPassenger() 尝试选车。

  • 电梯线程执行上下客、移动、特殊流程,并在状态变化后调用 system.signalAvailability() 唤醒调度器。

  • 井道控制器负责 F2 互斥,保证同井道主副轿厢不会同时抢占 F2。

这套结构能适应多性能指标:时间指标通过模拟完成时间和扫描式调度降低长尾;电量指标通过负载惩罚、同向捎带和限制过度换乘减少无效移动;正确性指标则通过 tryAcceptTrip 的原子二次检查、F2 预约和特殊状态屏蔽来保证。

三、出现过的 bug 和多线程 debug 方法

第二单元中我遇到的 bug 大致可以分为三类:状态机错误锁语义错误调度策略副作用

第一类是特殊请求状态机错误,说白了就是题目意思理解有偏差。例如维修、改造、回收过程中,被踢下电梯的乘客不应在 BEGIN 之前重新 RECEIVE。最初我的实现是在 OUT-F 时立即 requeue,这会导致评测机看到特殊流程尚未进入正式阶段,乘客却已经被其他电梯接收。后来改成先放入 delayedRequeue,等 MAINT1-BEGINUPDATE-BEGINRECYCLE-BEGIN 输出后再统一放回调度队列。

第二类是锁语义错误。一个典型问题是开关门时持有 ElevatorCar 锁并 sleep(0.4s)Thread.sleep() 不释放对象锁,导致调度线程采集快照时卡在某台开门电梯上。如果调度线程又持有系统级锁,就会进一步阻塞输入线程处理特殊请求。后来我把耗时等待移出同步块,锁内只做状态更新和输出相关的原子段。

另一个典型问题是丢失唤醒。电梯线程在循环尾部无条件 wait(),如果 requestUpdate() 刚刚 notifyAll() 时电梯线程还没睡,通知就会丢失;之后电梯线程带着 specialCommand 睡下去,直到其他无关通知才被唤醒。修复方式是标准的 guarded suspension:只有在确实没有任何待处理任务时才等待,并且用 while 反复检查条件。

第三类是调度策略副作用。硬上限 8 能防止单台电梯被灌入太多请求,但也会带来 FIFO 队首阻塞,因此需要扫描式调度配合。又如回收期间禁止 F2 空闲让位本来是为了避免回收结束后出现普通模式无任务移动,但在某些时序下,副轿厢等待进入 F2,同时主轿厢又因为回收状态不愿离开 F2,会造成活锁。最终修复为:普通移动等待 F2 时,如果该轿厢收到特殊命令,可以取消等待返回主循环;特殊流程内部移动则不能被自己的 special 命令取消。

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

三次作业让我对线程安全的理解从“给共享方法加 synchronized”变成了“维护共享对象的不变量”。

在 HW5 中,只要保护好请求队列,系统基本就能正确运行,因为电梯内部状态没有被多个线程同时修改。到 HW6,调度线程需要读取电梯状态,电梯线程又会不断改变状态,所以必须用快照隔离读写。到 HW7,系统进一步拆成乘客生命周期、轿厢状态、井道状态、特殊请求状态几个层次,不同层次应该有不同的锁。

层次化设计的价值在 HW7 中非常明显。如果把所有逻辑都塞进电梯线程,双轿厢改造、回收、F2 互斥、乘客重派和调度 cost 会混在一起,任何修改都容易破坏另一个状态机。现在的分层是:

  • Scheduler 管全局乘客和结束条件;

  • ElevatorSystem 管分派策略和特殊请求入口;

  • ElevatorCar 管单个轿厢的运行;

  • ShaftController 管同井道双轿厢约束;

  • Passenger/Trip/SpecialCommand/ElevatorSnapshot 作为数据对象表达状态。

这个结构仍然有改进空间,例如 cost 模拟可以继续抽成独立策略类,特殊流程也可以进一步封装。

五、大模型使用心得

GPT-5.4 / Codex(主力)

  • codex是个agent,内置了很多文件操作工具能够自动的根据提示词调用工具访问文件。

  • 通常是先把OO指导书导出为MarkDown模式让大模型读取,给出代码的框架,整体的思路;我会根据框架和题目思考出来一些重点的策略和机制,就比如HW6的ACCEPT可以顺路下乘客的设计,HW7的双轿厢防止装车的设计;有了框架和设计我会让大模型生成一份代码,先看看能不能通过中测,能的话就开始考虑性能,不能就开始阅读代码,找出可能的错误。

  • 在Debug方面Codex确实是一把好手,我把错误的输入输出发给Codex基本上它都能定位到错误代码的位置然后修改错误。

Claude-Opus-4.7 / Claude Code

  • Opus-4.7降智让我有点失望,要不然我就可以更高效完成OO作业了(不是)

  • 不过Opus可以用来debug,不同的大模型能够看出来不同的bug。

Gemini / 网页版

  • Gemini是个乐观主义者,动不动就是你目前的代码足够你通过强测了改完这个错误你就没有其他的问题了,它说没错了但实际上有时候中测都跑不过,所以一般我用Gemini来提供一些优化性能的思路。

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

第二单元的真实体验是压力很大。经常遇到“两个线程在某个极小窗口里互相错过了”的问题。第二单元很容易在强测挂掉,正确性比性能要更重要。

我觉得本单元难理解状态机和官方包输出时机。比如特殊请求的 ACCEPT 是官方包自动输出,这会让内部状态更新时间和输出时间之间出现窗口;如果不知道这一点,很容易把错误归因到调度策略上。另一个难点是性能和正确性的冲突:为了性能想让电梯顺路下人、减少开门、积极换乘,但每个优化都可能引入新的状态边界。

对课程的建议:

建议在指导书中增加一些典型并发错误案例,有时候真的不知道自己哪里错了。

建议缩短互测提交和中测提交间隔为15min。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐