304
社区成员
发帖
与我相关
我的任务
分享三次作业的同步设计随着架构复杂度的增加而逐步精细化。
HW5:锁粒度较粗,WaitQueue 与各 ElevatorQueue 均以 this 作为锁对象,同步方法覆盖整个方法体。各对象独立加锁,不存在跨对象的嵌套锁链,死锁风险极低。
HW6:引入 Scheduler 后,出现了跨对象的锁调用链:
Scheduler.dispatch()(持 Scheduler.this 锁)
└─→ ElevatorQueue.offer()(持 ElevatorQueue.this 锁)
所有线程的加锁顺序均为"先 Scheduler 后 ElevatorQueue",方向一致,不存在死锁风险。dispatch() 内的 wait() 是关键设计——调用 wait() 的瞬间线程释放 Scheduler 锁并挂起,其他线程得以进入同步区执行 notifyAll() 唤醒它:
// dispatch() 中等待可用电梯(hw6 Scheduler.java:103-114)
private synchronized void dispatch(PassengerTask task) {
int bestId;
while ((bestId = chooseBestElevator(task)) == -1) {
wait(); // 释放锁,等待 elevatorAvailable / onPassengerBoarded 唤醒
}
TimableOutput.println("RECEIVE-" + task.getPersonId() + "-" + bestId);
elevatorQueues.get(bestId).offer(task);
}
HW7:新增 ElevatorShaft 作为第三个锁对象,形成三层嵌套链:
ElevatorShaft.acquireF2()(ElevatorShaft 锁)
└─→ mainQueue.setBackupNeedsF2(true)(ElevatorQueue 锁)
固定的锁顺序(ElevatorShaft → ElevatorQueue)避免了死锁。同时,backupNeedsF2 字段被声明为 volatile,允许 ElevatorThread 无锁读取,避免为了一次读操作进入同步块带来的开销:
// ElevatorQueue.java(hw7:13)
private volatile boolean backupNeedsF2 = false;
// 无 synchronized,volatile 保证可见性
public boolean isBackupNeedsF2() { return backupNeedsF2; }
同步块的范围取决于需要保持原子性的最小操作集合。以 dispatch() 为例,RECEIVE 输出与 offer() 必须在同一锁保护下:
// 两步必须原子——中间不能被其他线程插入
TimableOutput.println("RECEIVE-" + task.getPersonId() + "-" + bestId);
elevatorQueues.get(bestId).offer(task);
若两步之间被打断,可能出现 RECEIVE 已输出但乘客未进入队列的窗口,导致电梯判断空闲并退出,乘客永久丢失。
相反,Thread.sleep() 不能放在同步块内——电梯移动的 400ms 若持锁,会阻塞所有试图访问队列的线程,严重降低吞吐量。因此电梯线程的主循环本身不加锁,仅在操作共享队列时进入各对象的 synchronized 方法。

HW5 的 DispatchThread 是纯粹的路由管道,无调度逻辑,按请求携带的电梯 ID 分配。
HW6 用 Scheduler 合并了原来的 WaitQueue + DispatchThread,同时实现 SchedulerCallback 接口,承担正向分配与反向事件处理两个职责。所有对 Scheduler 状态的访问均通过 synchronized 方法序列化,逻辑清晰。
HW7 将 Map<Integer, ElevatorQueue> 升级为 ElevatorShaft[],管理单元从"单部电梯"变为"井道"。新增 backupCarActivated / backupCarDeactivated 回调,用于在备用轿厢状态变化时唤醒正在 wait() 的 dispatch() 重新尝试分配。

HW5:无调度策略,按 getElevatorId() 固定分配。
HW6:就近优先,负载次要。核心打分公式:
int score = distance * 10 + load;
系数 10 的含义:距离差 1 层相当于等待队列中多 10 个乘客才被追平,确保就近优先主导,负载项仅在距离相同时起作用。
同时引入队列硬上限(MAX_QUEUE_SIZE = 8),防止大量乘客因 MAINT 重调度全部涌入唯一可用的电梯:
// ElevatorQueue.java(hw6:89-91)
public synchronized boolean canReceive() {
return canReceive && waiting.size() < MAX_QUEUE_SIZE;
}
配合 onPassengerBoarded 回调,当乘客上车释放队列空位时立即唤醒 Scheduler 重新评估,避免不必要的调度延迟。
HW7:在 HW6 基础上增加方向惩罚项:
// Scheduler.java(hw7:138-144)
private int score(ElevatorQueue q, int fromIdx) {
int dist = Math.abs(q.getCurrentFloor() - fromIdx);
int dir = q.getDirection();
boolean movingAway = (dir == 1 && fromIdx < q.getCurrentFloor())
|| (dir == -1 && fromIdx > q.getCurrentFloor());
int penalty = movingAway ? 110 : 0;
return dist * 10 + penalty + q.getLoad();
}
方向惩罚 110 分相当于 11 层距离,有效避免将乘客分配给正在反向运行的电梯。同时分配时考虑主/备轿厢的服务范围(主轿厢 F2-F7,备用轿厢 B4-F2),在 F2 起点时按目的地方向选择轿厢类型。
| 性能指标 | 对应策略 |
|---|---|
| 总运行时间 | 就近分配减少空驶;方向惩罚减少反向接客 |
| 乘客等待时间 | distance * 10 主项,优先接近乘客的电梯 |
| 并发吞吐量 | 队列上限 + onPassengerBoarded 均衡负载,避免单部电梯过载 |
| MAINT/UPDATE 期间 | drainAll + redispatch 确保乘客无缝转移到其他可用电梯 |
Bug 1:主轿厢与备用轿厢在 F2 死锁(HW7)
现象:程序挂起,不退出。
原因:备用轿厢调用 acquireF2() 时发现 f2Occupied=true(主轿厢占用 F2),设置 backupNeedsF2=true 后进入 wait()。但主轿厢的 waitForTask() 循环条件中未包含 backupNeedsF2,即使信号已设置,主轿厢也不会从 wait() 中醒来执行让步动作——双方永久互等。
修复:在 ElevatorQueue.waitForTask() 的 while 条件中增加 !backupNeedsF2(hw7 ElevatorQueue.java:167),使主轿厢在收到信号时返回并执行 F2→F3 避让移动。
Bug 2:MAINT1-BEGIN 输出时序错误(HW6)
现象:checker 报"MAINT1-BEGIN 后仍存在未结束的 RECEIVE"。
原因:最初将 drainAll → redispatch 放在 MAINT1-BEGIN 输出之后。redispatch 触发 Scheduler 为这些乘客输出新 RECEIVE,其时间戳晚于 MAINT1-BEGIN,checker 认为这些 RECEIVE 是在检修开始后发出的,判违规。
修复:调整顺序,先 drainAll → redispatch,待所有重调度 RECEIVE 输出完毕后,再输出 MAINT1-BEGIN。
Bug 3:F2 互斥释放时序过早(HW7)
现象:checker 报两辆车的 ARRIVE-F2 时间戳相同或重叠。
原因:早期实现在 ARRIVE 输出之前调用了 releaseF2(),导致等待中的另一辆车提前获锁,在前者 ARRIVE 输出的同一时刻也完成了 sleep 并输出 ARRIVE。
修复:严格保证 releaseF2() 在 ARRIVE 输出之后调用(hw7 BackupCarThread.java:355-358)。
时间戳重建时序:利用 TimableOutput 的时间戳对 checker 报错的输出序列进行排序,还原事件的实际发生顺序,定位违反了哪条约束。
最小化复现:构造仅 12 部电梯、23 个乘客的最小输入,排除并发随机性,使 bug 必现。
stderr 打印线程状态:在关键路径临时加入 System.err.println() 输出,不影响 checker 对 stdout 的检测,可以观察各线程的状态变化顺序。
手动绘制锁调用图:列出所有持锁路径,检查是否存在两条路径以相反顺序请求同一对锁。
sleep 放大竞争窗口:在怀疑存在竞争的位置临时插入短暂 sleep,将偶发 bug 变为必现,便于稳定复现后再定位根因。
三次作业中线程安全的核心思路是以对象为边界构造 Monitor:每个共享对象(ElevatorQueue、Scheduler、ElevatorShaft)封装自己的状态,对外只暴露 synchronized 方法,外部线程无法绕过同步直接读写内部字段。
volatile 的使用是对"仅需可见性而无需互斥"场景的精细化处理。HW7 中 ElevatorShaft.doubleMode 和 ElevatorQueue.backupNeedsF2 被声明为 volatile,主轿厢可以在不持任何锁的情况下读取,避免了因持锁读取带来的不必要竞争。
终止条件的正确设计同样关键。Scheduler 用 activePassengers 计数器精确追踪"还有多少乘客尚未到达终点",而非简单地等待输入结束。redispatch 不改变计数,passengerArrived(OUT-S)才减一,保证了 MAINT 重调度等场景下计数的正确性。
三次作业的层次结构随迭代逐步清晰:
HW5: InputThread → WaitQueue → DispatchThread → ElevatorQueue → ElevatorThread
(单向管道,无反向通信,无抽象层)
HW6: InputThread → Scheduler ↔ ElevatorQueue ↔ ElevatorThread
(Scheduler 作为双向通信枢纽,引入 SchedulerCallback 接口解耦反向依赖)
HW7: InputThread → Scheduler ↔ ElevatorShaft ↔ ElevatorThread / BackupCarThread
(ElevatorShaft 新增隔离层,主/备轿厢通过 Shaft 协调,不直接感知对方)
层次化最直接的收益是:ElevatorThread 和 BackupCarThread 互不可见,它们各自只依赖 ElevatorShaft(F2 互斥)和 SchedulerCallback(事件上报)。"两辆车不能同时在 F2"这一约束被完整封装在 ElevatorShaft.acquireF2/releaseF2 内,而非散布在两个线程的代码中,修改或 debug 时只需关注一个文件。
SchedulerCallback 接口同样是层次化的体现——ElevatorThread 依赖的是接口而非 Scheduler 具体类,彻底切断了电梯线程对调度器内部状态的直接访问,杜绝了绕过锁直接修改 Scheduler 字段的可能。
使用的模型:Claude(claude-sonnet-4-6)
分工方式
整体上以自己写为主,大模型更多扮演"方案参谋"和"文档助手"的角色。
具体来说:在开始每次作业前,会用大模型讨论架构方案——列出几种可能的类设计,分析各自的优劣,最终由自己拍板选择哪一种。确定方向后,有时会让大模型生成初始的代码框架(类的骨架、字段声明、方法签名),作为脚手架再自己填充逻辑。核心逻辑(LOOK 调度算法、MAINT 状态机的各阶段流程、F2 互斥协议)基本都是自己写的,因为这些部分需要对题目约束有精确的理解,照搬生成的代码很容易出问题。
此外,大模型在整理设计文档(design.md)方面帮了不少忙——把讨论中的结论整理成表格、状态机图和边界情况清单,省去了很多文字组织的工作。
大模型在多线程任务上的优势
volatile 和 synchronized 的语义区别、wait() 释放锁的时机、Java 内存模型的可见性保证,这些查文档很麻烦的问题问大模型很快能得到清晰解释。会遇到的困难
MaintRequest 时自动输出 MAINT-ACCEPT)、checker 的具体检测规则,大模型并不清楚,这些必须自己读题目和手册。使用感受
大模型在这类工程任务里更像一个可以快速讨论的人,而不是能替你写完所有代码的工具。用它来理清思路、查概念、生成框架代码,效率确实比自己从头查资料快;但核心逻辑的正确性还是要靠自己把关,直接把生成的代码复制进去用往往会埋坑。找到这个平衡点之后,配合使用的体验还是比较顺畅的。
真实感受
二单元的难度曲线比较陡。HW5 感觉只是"用多线程写了个程序",HW6 开始才真正进入"设计并发系统"的状态——SchedulerCallback 接口的引入、activePassengers 计数器的正确维护、wait/notifyAll 的时序关系,每一个细节都需要仔细推敲,随便漏掉一个就可能挂机或者 WA。
HW7 的双轿厢是整个单元最难的部分。F2 互斥协议不是很长的代码,但需要同时在脑子里追踪主轿厢、备用轿厢、Scheduler 三个线程的状态,稍微想偏一步就会引入死锁或竞争条件。这个过程中对"为什么要这样设计"的理解比"代码怎么写"更重要。
总体来说,这个单元让我对多线程从"知道有锁"变成了"知道什么时候该用锁、用什么粒度的锁、怎么避免死锁",收获确实比较大,但强度也确实比较高。
建议
增加典型并发错误的分析练习:课程可以提供几个"看上去正确但有隐藏竞争条件"的代码片段,让学生分析问题所在。这类练习比单纯写作业更能建立并发直觉。
优化 checker 报错信息:目前 checker 报错有时只显示违规输出,不说明违反了哪条约束(比如"RECEIVE 未结束时出现了 MAINT1-BEGIN")。更详细的报错信息能大幅降低 debug 成本,特别是对 HW7 这种约束复杂的作业。
HW7 指导书可以补充 F2 互斥的实现提示:"两辆车不能同时在 F2"的要求表述简短,但实现细节(acquireF2 的调用时机、releaseF2 的调用时机、避让移动的合规前提)相当复杂。哪怕在指导书里加一段"需要注意的实现要点",也能帮助学生少走很多弯路。