OO第二单元博客
第二单元 多线程电梯 学习总结报告
一、三次作业同步块设置、锁的选择及锁与代码逻辑的关系
回顾本单元三次电梯迭代作业,我对于锁的概念与使用、临界区保护、线程共享和资源竞争有了完整的实践理解。
在整体代码结构中,我主要采用对象锁的方式进行并发控制。
电梯
Elevator类内部全部使用synchronized (this)作为锁。电梯自身的乘客列表、等待请求队列、楼层信息、运行方向、载重数值、电梯运行状态枚举等,都是多个线程会并发访问的共享数据。无论是添加外部乘客请求addRequest、接收维修/更新/回收指令,还是电梯内部上下客、楼层移动、状态切换,全部包裹在同步代码块中。这样设计的原因是,每一台电梯都是独立运行的子线程,只需要锁住自身实例,就能做到细粒度加锁,不会出现全局大锁导致线程阻塞等待的问题。这里值得留意的一点是,在对于电梯状态进行快照时的读方法不能进行加锁,否则会严重影响性能。调度器
Dispatcher中针对静态共享集合加锁。全局电梯列表elevators、等待请求缓冲队列waitQueue属于全局共享资源,会被输入线程、多个电梯线程同时访问修改。因此在请求分发、等待队列转移、电梯遍历唤醒等操作时,都使用集合对象作为锁,保证多线程修改队列时不会出现数据覆盖、请求丢失、集合并发报错等问题。当然,这里直接使用 Java 中自带的线程安全容器也是可行的。锁与同步块内语句的关联:同步块中只存放必须保证原子性的操作,例如:状态修改、队列增删、数值读写、条件判断+修改组合逻辑。而像耗时的
Thread.sleep、单纯的逻辑判断、循环遍历(不进行写操作)等内容,尽量放在同步块外面。一方面可以减少锁的持有时间,提高整体并发效率;另一方面可以避免长时间独占锁,造成其他线程大范围阻塞。同时本次作业大量使用wait()和notifyAll()等待唤醒机制,而这两个方法必须写在同步块内部。电梯在无任务时会在同步块内循环等待,当调度器分配新请求、或者电梯由Normal状态转变为Double状态从而要在换乘层进行避让时,就必须唤醒所有等待线程,让电梯重新检测任务并继续运行,这样既避免空循环浪费资源,也保证多线程之间能够正常地协同工作。
二、三次作业调度器设计、线程交互与调度策略分析
1. 调度器整体设计思路
三次作业我全程采用中心化调度器模式,由Dispatcher统一管控全部电梯与全部请求。在程序启动时,就静态初始化 6 台主电梯与 6 台备用电梯,逐个创建独立线程并启动。对于所有外部请求,包括普通乘客请求、电梯维修请求、参数更新请求、电梯回收请求,全部先交给调度器统一分发处理,电梯本身只负责执行运行逻辑,不参与请求选择,做到调度与执行分离,结构更加清晰。
2. 调度器与各个线程的交互方式
- 输入线程
InputThread:持续读取控制台输入,识别不同类型请求,调用调度器静态分发方法,将请求上交调度器处理。输入结束后,修改全局标记位并唤醒所有电梯,以便电梯线程能够正常结束。 - 调度器
Dispatcher:作为中间管理层,接收输入线程的请求,根据每台电梯当前状态、载重、位置、运行方向筛选可用电梯,进而选出最优电梯并添加请求;当电梯因超载、维修等原因退回请求时,调度器负责二次缓存与重新分发;同时,调度器也参与全局维护剩余请求计数,用来判断是否所有请求已全部处理,从而判断电梯线程是否应当结束。 - 电梯
Elevators:持续循环运行,被调度器分配任务后执行接人、送客、移动楼层;遇到维修、更新、回收指令时主动切换状态,清空当前任务并回退请求;完成单次任务后主动等待,被唤醒后再次判断是否存在新任务。
3. 调度策略设计与多性能指标适配
本次作业中按照多个指标进行调度分配,具有一定启发式性质,由于具体权重参数难以确定(可能可以通过大量数据对参数进行机器学习,但太麻烦了),就没有设计具体的代价函数。
筛选优先级依次为:首先过滤处于不可接收状态的电梯,例如正在维修、单双梯模式限制楼层区间的电梯;其次优先排除会直接超载的电梯,避免了无效的载客替换;优先选择当前静止无任务的空闲电梯,减少乘客等待时间;优先选择运行方向与乘客出行方向一致的顺路电梯,减少电梯绕路;最后结合电梯当前总负载、距离乘客发起楼层的远近进行综合排序。
这套策略可以同时适配多项要求,无论在性能还是拓展性方面的表现都较好,而且不算复杂,我对此较为满意。
三、程序运行出现的Bug 及多线程调试方法总结
1. 开发过程中遇到的典型bug
(1)多线程数据竞争问题
初期设计时因为疏忽没有给个别任务和状态变量加锁,多个电梯同时读取修改共享集合,偶尔出现乘客请求莫名消失、重复加载同一乘客、电梯列表遍历异常的问题。后期给所有共享资源增加同步保护后,该问题完全解决。
(2)线程无法正常结束
曾经出现所有任务执行完毕,但电梯线程一直卡在wait()无法退出或在请求返回重新分配前就过早结束的问题。原因是对于结束的判断不完善,后通过对请求计数(待处理和已处理)的方法完善了全局结束条件、在其置 0 后统一唤醒全部电梯,修复了上述问题。
(3)双电梯换乘楼层死锁
在换乘楼层电梯卡死。初始的逻辑是让电梯在Double状态时,若在换乘层无任务停靠,便自动向临层避让。后来发现其若在Normal状态抵达换乘层停靠,则后续切换至Double状态时,必须将其唤醒,从而解决了死锁问题。
(4)电梯超载问题
未在同步块内统计实时载重,上下客并发修改重量,导致超重判断失效,出现超重依旧运行的问题。
2. 针对多线程程序的debug方法
- 大量打印关键日志
利用题目提供的TimableOutput,在电梯到达、开关门、接收请求、状态切换、等待唤醒等关键位置输出信息,直观观察每个线程的运行时序和可能出现bug的节点。 - 缩小问题范围
遇到并发错误时,针对某一特定的特殊场景设计测试用例,逐一检查各功能是否存在问题,从而减小每次排查的范围。 - 检查所有共享变量
养成习惯,只要是多个线程都会访问的变量,全部检查是否加锁保护,从根源避免线程不安全。
四、结合三次作业谈谈对线程安全与层次化设计的理解
1. 对线程安全的理解
经过三次迭代开发,我意识到多线程开发最难的地方在于资源竞争与执行时序不可控。线程安全的核心就是:多个线程同时操作共享资源时,必须通过加锁、限制访问顺序、保证操作原子性,来避免数据错乱。所有临界资源都需要主动保护(并且想清楚其中的逻辑),否则程序就会在不经意之间随机出现各种bug,这在现实的工程中是不可接受的(且很难调试)。
同时,也要合理控制锁的范围,一味地使用大锁虽然安全,但是会严重降低并发效率,细粒度锁设计十分重要。
另外,灵活使用多种类型的锁以及线程协作的工具,是多线程高效运行的关键,能够让多线程按需高效无误地协同运作。
2. 对层次化、模块化设计的理解
本次代码的分层较为明确:
输入层InputThread只负责读入数据,不处理业务;
调度层Dispatcher专注请求分配、全局管理;
执行层Elevator只负责单台电梯运动逻辑;
工具层FloorUtil、Reclaim提供通用静态方法,和业务线程解耦。
而在每个层次中(尤其是Elevator),进一步将不同的执行策略设计成单独的模块。
分层设计的优势非常明显:
每个类职责单一,代码可读性高;后期迭代新功能时,比如电梯的检修、更新和回收,只需要在对应层增加代码(例如为状态机增加新状态和相应的运行策略),不会大范围改动原有逻辑;并且,在保证原有部分正确的前提下,出现bug时可以快速定位模块,极大降低多线程程序的调试难度。
因此,良好的层次化和模块化设计,是复杂多线程项目能够稳定迭代的关键。
五、大模型使用心得
整体项目架构和核心调度策略全部由我自己设计确定,主要使用大模型查阅多线程语法并学习锁的使用方法。对我而言,依赖大模型范围地生成代码显然是不理智且不太可行的行为,我们还是应当时刻保持自己的思考,并借助大模型来提升自己学习新知识的效率,再在自己的实践中切实巩固所学。
六、第二单元学习真实体验与课程建议
1. 单元学习体验
相比第一单元,第二单元对于多线程的学习其实在程序逻辑的复杂度上并没有提高,但主要难点在于接触了全新的知识,并且更加强调代码编写的严谨性。我从最开始只会简单创建线程、不理解锁的意义,到一步步学习临界区、等待唤醒、死锁避免、分层调度,三次作业迭代下来收获很大。同时,因为运行结果不确定、bug难以复现,多线程代码的调试难度远高于单线程,整个开发过程需要耐心和严谨的逻辑思维。但相对应的,我也在其中学到了很多,成长了不少。无论如何,还是恭喜自己顺利熬过了这艰难的一个月!
2. 课程建议
首先是编写代码的时间略显不足,希望可以适当延长时间期限;其次是希望弱测+中测能够公开全部样例(或者至少公布典型样例),为防止特判骗分,可以让每个数据点执行特定的测试功能,然后生成大量的测试用例在测试时随机选取,这样避免了debug时没有头绪浪费大量时间。
