305
社区成员
发帖
与我相关
我的任务
分享在面向对象第二单元中,我们从零开始完成了一个多线程实时电梯调度系统,经历了单电梯固定分配、多电梯竞争调度+维修、双电梯协同+更新/回收三个递进式作业。本文将从同步块与锁的选择、调度器设计、Debug经历、线程安全与层次化设计、大模型使用心得等方面,对整个单元进行系统总结。
wait/notify 模型第一次作业只有 6 部独立电梯,输入直接指定电梯 ID,电梯本身只有 NORMAL 状态。同步方式非常朴素:
对 waitingPeople 的添加使用 synchronized addPerson(),并在方法内 notifyAll()。
电梯主循环中,使用 synchronized(this) 包裹 while(isEmpty() && !isStopped) wait()。
其他共享变量如 currentFloor、passengers、weight 等均使用 volatile 修饰。
锁与处理语句的关系:this 锁保护的临界区是“等待队列为空且未停止”的条件等待。当调度器线程调用 addPerson() 时,会打破空等条件并唤醒电梯线程,保证线程间的协作。但由于所有与电梯状态相关的方法(如 openProcess、moveOneStep)并没有全部置于同一把锁下,只是使用了 volatile 保证了可见性,原子性依赖的事实上是“电梯运行时只有自身线程修改状态”这一隐含假设。
第二次作业加入了维修请求,电梯多了 REP_ACCEPT、REPAIR、TEST 等状态。同步机制升级:
public synchronized void addMaintRequest(Person person, String target) {
this.haveMainTask = true;
status = Status.REP_ACCEPT;
notifyAll();
}
run() 中的 synchronized(this) 块扩大,增加了状态判断(status != NORMAL && haveMainTask 时直接 startMaint())。
调度器增加了 allocLock,用于在无可用电梯时阻塞请求线程。
锁的层次:
this 锁负责电梯内部线程的阻塞/唤醒,所有修改请求队列或状态并需要唤醒电梯的方法均需持有 this。
allocLock 是调度器级别的锁,用于解决“所有电梯都在维修,新请求暂时无法分配”的问题,让请求线程等待直到有电梯恢复正常。
这种设计已经体现出“每个对象一个锁,各管各的状态”的层次化思想。
第三次作业实现了主电梯(F2~F7)+ 副电梯(B4~F2)+ 中间换乘,并引入了更新和回收请求。这带来了更复杂的并发场景:
shaftLock:主副电梯在 F2 共用井道,moveOneStep 中当 currentFloor == 1 || targetFloor == 1 时,必须持有 shaftLock 才能移动,避免碰撞。
双电梯状态联动:startUpdate() 完成后设置 partner.setNeedsUpdate(true) 并 notifyAll(),使伙伴电梯感知到更新完成并切换为 DOUBLE 模式。
分配锁优化:Scheduler.allocElevator() 中加入 canServe() 判断,确保请求只能分配给真正能到达该楼层的电梯。无可用电梯时仍在 allocLock 上等待。
锁与处理语句的关系总结:
| 锁对象 | 保护的数据/条件 | 相关操作 |
|---|---|---|
this(电梯对象) | waitingPeople、status、haveMainTask 等状态变量 | addPerson、addMaintRequest、主循环 wait |
allocLock | 电梯资源池可用性 | 请求分配时的等待/通知 |
shaftLock | 主副电梯在 F2 的物理互斥 | 跨越 F2 的移动 |
所有锁都遵循“谁的数据谁保护”原则,锁内操作尽量简短,避免嵌套锁死锁(shaftLock 只用于一步移动,不跨状态)。
第一次:调度器退化成一个接收器——请求自带电梯 ID,直接 addPerson。无调度算法。
第二次:调度器实现最小负载优先分配策略:
public int allocElevator() {
int minLoad = Integer.MAX_VALUE, num = 0;
for (int i = 1; i < 7; i++) {
if (!elevators[i].inRepair() && elevators[i].getLoad() < minLoad) {
minLoad = elevators[i].getLoad();
num = i;
}
}
return num;
}
当所有电梯维修时,请求线程在 allocLock 上等待。
第三次:引入分区调度 + 负载均衡:
public boolean canServe(Elevator elevator, int from, int to) {
if (elevator.isMain()) {
if (elevator.getStatus() == Status.DOUBLE) return from >= 1 && !(from == 1 && to < 1);
else if (elevator.getStatus() == Status.NORMAL) return true;
} else {
if (elevator.getStatus() == Status.DOUBLE) return from <= 1 && !(from == 1 && to > 1);
}
return false;
}
只有可达的电梯才参与竞争,同时结合负载(getLoad() < 15)和在线状态(inShaft),保证请求总能被正确服务。
线程交互:
InputHandler 线程解析输入,调用 scheduler.addRequest()。
Scheduler 线程(实际是调用者线程,即 InputHandler 或 Elevator)负责分配电梯,可能阻塞在 allocLock。
Elevator 线程执行 RUN 循环,完成移动、开关门、维修/更新/回收。
电梯完成特殊操作后调用 scheduler.notifyAvailable() 唤醒等待分配的线程。
电梯内部采用LOOK 算法(单向扫描到底再折返):
public int getTargetFloor() {
// 找出同方向最近的目标楼层
// 如果同方向无请求,反转方向寻找最近目标
}
对时间的影响:LOOK 算法避免了频繁折返,在请求密集时能明显降低平均等待时间。
对电量的影响:减少无意义的反向空驶,直接降低总移动楼层数,进而节省时间与电量。
第三次作业进一步通过主副电梯分区:原来一部电梯要跑 B4~F7,现在各管一半,长距离跨区请求通过 F2 换乘实现,总行程大幅缩短,电量优化明显。
此外,调度器优先分配给负载最小的电梯,使各电梯任务均衡,从全局角度进一步降低了拥堵和单梯过载导致的长时间等待。
在阅读自己和他人代码时,发现一个高频问题是 ConcurrentModificationException。典型场景如下:
// 在 Elevator.openProcess() 中
Iterator<Person> it = passengers.iterator();
while (it.hasNext()) { ... it.remove(); }
而 addPerson() 或 flushWait() 可能在其他线程中修改 passengers 或 waitingPeople 列表。由于这些列表是 ArrayList 且没有在遍历时加锁,在强测的大规模随机测试中有概率抛出并发修改异常。
修复方案:
将遍历和修改操作统一在 synchronized(this) 块中,保证互斥。
或改用 CopyOnWriteArrayList,但会增加开销,不如规范同步。
另一个隐患是 volatile 不足以保护 ++ / -- 复合操作,但我的 currentFloor 只在电梯自身线程中修改,因此仅用 volatile 保证可见性就足够。
在第三次作业中,主副电梯相互唤醒的逻辑容易导致丢失信号或死等。例如更新完成后,必须 partner.notifyAll(),并且接收方需要 checkChange() 及时处理 needsUpdate 标志。若一方在 wait() 时未检查标志就进入等待,可能永久阻塞。我通过在每次 wait 返回后立即调用 checkChange() 解决。
日志分析法:利用 TimableOutput 输出的时间戳日志,手工或脚本检查事件顺序,比如 RECEIVE -> IN -> OUT 是否匹配,开关门时间是否满足 400ms。
断言与边界测试:在关键位置插入 assert,如 weight >= 0 && weight <= 400,一旦违反立即发现。
多线程测试:编写模拟输入,反复运行,看是否会偶发异常。
这三次作业让我深刻体会到,线程安全不是靠 volatile 和 synchronized 乱加就能保证的。必须:
明确每一份共享数据的所有者线程和访问者线程。
所有访问共享数据的代码路径必须一致地持有同一把锁。
尽量将复合操作(如“检查-执行”)原子化,例如 isEmpty() 判断加任务获取,必须在同步块内完成。
避免在持有锁的情况下调用外部可能获取另一把锁的方法,防止死锁。
从第一次到第三次,程序层次越来越清晰:
输入层:只负责解析,不涉及业务逻辑。
调度层:负责请求分配、全局状态感知(维修/更新/回收),对外提供 notifyAvailable。
执行层:电梯只关心自己的队列、状态机、移动和开关门。
这种分层让每层的变更相对独立。第三次作业加入更新和回收时,调度层增加 canServe 和 deallocate,电梯层增加相应的状态处理和 checkChange,双方通过明确的方法调用交互,没有破坏原有逻辑。
我主要使用 Claude Opus 4.7 和 GitHub Copilot。
分工方式:我主导架构设计、状态机设计和并发模型;大模型负责辅助代码生成、Bug 解释、测试用例编写。
快速生成框架代码:如 FloorMapper、Person 类等重复性工作,几乎可以一键生成。
多线程语法纠正:能够指出 wait() 必须在 synchronized 块中使用等细节。
性能优化建议:提供 LOOK 算法伪代码,减少了我查资料的时间。
并发逻辑理解肤浅:模型给出的多线程代码常常“看起来正确”,但存在隐藏的竞态条件,必须自己逐行分析。
上下文遗忘:在长对话中,模型有时忘记之前定义的锁策略,产生不一致代码。
无法运行验证:大模型只能静态分析,无法实际运行强测,最终的线程安全仍然依赖自己的推理和测试。
大模型像一位 7x24 小时在线的代码助手,能极大加速“写代码”的环节,但“设计对不对”这个核心问题依然需要自己的专业判断。尤其在多线程这类非确定性 BUG 高发领域,人必须掌握底层并发模型,才能驾驭 AI 的输出。
第二单元是多线程电梯,从一个看似简单的“电梯上下”开始,逐步加入维修、多梯竞争、分区协同、状态机……每一步都让我对并发编程的理解更加深刻。
希望提供更完善的本地评测工具:能模拟随机时序,暴露更多并发问题。
研讨课增加“互测 Bug 回顾”环节:让同学分享那些最难发现的线程安全或者死锁问题,集体学习。
作业指导书中可提前强调“集合遍历必须同步”:虽然这应该是基础知识,但在多线程环境中新人极容易忽略。
第二单元结束,我因多线程行为难以理解预测感到痛苦,也对写出一套稳定运行的多线程程序充满成就感。感谢课程组的精心设计!