更新 2023.4.20
在复习操作系统期中考时,我终于明白了电梯设计时,与其说是“唤醒指定线程”,不如换个说法“决定线程调度顺序”。 ————线程同步!
在我的架构中,需要先同步各个空闲的电梯,再由调度器分配一个线程运行,指定线程调度顺序。
LockSupport 的实现和 信号量 Semaphore 是类似的,都是线程同步工具。包括 Lock 的 Condition,都可以解决此处的线程同步调度顺序问题。
OO 课程的教学中只介绍了互斥
(synchronized 关键字),却没有介绍同步
。尽管在实验课中,使用了 Semaphores 信号量实现有限缓冲区的生产者消费者模式,但是没有明确指出其同步作用。理论课甚至没有提到 Lock 接口的 Conditon。
synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
实在感叹,在学习了操作系统之后,面向对象课程对于线程的疑惑和误解才消除。
引言
在本单元,需要实现一个可以实时交互的电梯以运送乘客,并实现电梯的增加、维护功能,最后实现电梯的可达性限制(乘客多次换乘电梯)、楼层电梯数量控制。
我学习了java多线程的调度,了解了部分锁工具的使用,对线程控制有了更深的理解。
架构设计
首先展示最终 hw7 UML 类图。我使用了生产者-消费者模式, Input 输入线程为生产者, Elevator 电梯线程为消费者,设置有 Scheduler 调度器线程,以及 PersonRequest 为乘客请求的缓冲区。
输入线程将乘客请求放入 PersonRequest 缓冲区,并向 Scheduler 发出实时添加/维护电梯的请求。
Scheduler 将乘客分配给空闲的电梯作为主请求,并处理电梯的添加/维护。
Elevator 的实现使用了状态模式(使用状态模式描述更加清晰,代码的可读性、可维护性更好),另设接口 ElevatorState,描述电梯状态 空闲/终止/接主请求/送主请求。电梯在 Reset 为空闲状态时,由 Scheduler 分配新的主请求。
同步块和锁
在谈及我的设计之前,先回顾 synchronized 关键字。synchronized 是JVM内置实现的,本质上是对指定 monitor 信号量为1的锁。这是 java 最简单的互斥实现,但是功能太弱,不够灵活。
在我的架构中,PersonRequest 是临界资源,需要在访问、修改 PersonRequest 时,都使用synchronized 关键字实现互斥。各个电梯的 monitor 都为同一个 PersonRequest,如果需要指定某个电梯去接指定的乘客(hw7可达性调度),则必须要实现唤醒指定的线程
。
唤醒指定的线程
这一需求是在hw7中加入的,但是在hw56中,我都是简单地使用了synchronized实现互斥。在查找了大量资料后,我决定采用 LockSupport(线程阻塞工具类),使用 Scheduler 控制各个 Elevator 阻塞/继续运行,以实现将乘客分配给指定某个电梯。
LockSupport 支持在 A 线程中,将 B 线程阻塞取消。Scheduler 获取 Elevator 的信息和乘客的信息,阻塞所有空闲的电梯,每次只取消阻塞单个指定电梯,单个电梯再从 PersonRequests 使用 synchronized 互斥地选择首个乘客座位主请求,实现可达性调度。
1
2
3
4
5
6
7
//假设已有Elevator e1, Elevator e2
LockSupport.park();//此句代码在e1线程内 e1空闲,等待调度
LockSupport.park();//此句代码在e2线程内 e2空闲,等待调度
......//调度器完成判断,决定将指定乘客分配给 e1
LockSupport.unpark(e1); //此句代码在 Scheduler 线程内
调度器和调度策略
调度器如何与程序中的线程进行交互;总结分析三次作业中的调度策略,并分析自己的调度策略是如何适应时间、电量等多个性能指标的
在 hw5 和 hw6 中,我没有设计调度器,所有电梯共用同一个请求队列 PersonRequests ,由JVM调度自由竞争决定哪一个电梯接收主请求。
自由竞争和均匀分配是近似一致的,在数据量较大时,两者表现几乎没有差异。
hw7 添加了可达性要求。我在电梯新增/维护时,建立图,再在 PersonRequest 中接收乘客请求时,BFS广度优先搜索动态决定下一次要抵达的楼层。符合乘客可达性要求的电梯将其作为 主请求/捎带。
我的做法实际使用时间较小,但是耗电量以及最长等待时间性能较差。
-
耗电量
假设只有2台空闲电梯都在1层,2个乘客FROM-10-TO-11,我的2台电梯都会去10L接受各自的主请求。
该问题实质上是由于主请求
和捎带乘客
这两个概念引起的。事实上,完全没有必要区分主请求
和捎带请求
,电梯运行方向应当取决于电梯内的乘客集体而不是主请求
。这个问题于 hw7 发现,但重构已晚。 -
最长等待时间
假设乘客请求密集,电梯可能不会出现空闲状态(空闲电梯会从PersonRequests中选接受较早的乘客),电梯一直在处理运行途中的捎带乘客,则可能导致部分早到的乘客一直等待,造成最长等待时间过长。
在不重构的条件下,我目前还没有得到较好的解决方案。
Sequence Diagram 时序图。下图为 hw7 的时序图,可以发现 PersonRequests 乘客请求分配给电梯需要经过 Scheduler 调度。
但是在 hw56 中,是直接由 PersonRequests 自由竞争分配给 Elevator。
迭代情况分析
我们无法预知未来,在 hw5 的时候无法得知 hw6 和 hw7 的需求变化。况且每一年课程组都在修改要求,我只能在开发时尽可能地保证代码的可拓展性。
在我的代码中,使用了状态模式建模电梯的状态,方便修改,逻辑更加清晰。这部分修改较小。
在阅读了往届学长的 blog 后(他们的性能分很高,加强了我的信心),我决定采用自由竞争的做法,但是今年要求变动,课程组希望我们设定调度器,增多了不利于“自由竞争”的数据点,新增了“可达性”要求,这迫使我在第三次作业时重构。
这次重构的过程十分痛苦,我为了尽可能保留原有代码,避免产生新 bug ,最终选择使用 LockSupport 再加一重阻塞。期间求助了google、Stack Overflow、ChatGpt(吐槽ChatGpt,给的代码全是错的),花了1整天的才解决这个问题。
BUG与DEBUG
本单元,我了解了如何 Debug 多线程程序,可以使用线程分析软件(idea内置,没啥用),print调试(实属下策),调整线程数量稳定复现 bug。
在第一次作业中,我的电梯可能发生状态转换CPU空转(while循环内空转),导致强测CPU超时。最终参照讨论区,使用了 print 调试法发现了此问题,修改了状态转换逻辑。
第二次作业强测中,我的电梯可能出现在人满而无法接受主请求的问题,导致该电梯始终停留在主请求的楼层。我通过手撕强测数据,查找故障电梯的输出信息发现了此问题,最终通过手工构造数据并修改源码(调整只有1个电梯),稳定复现了此问题。
此外有一个BUG未被发现,若在输入快结束时,维护一个电梯,从此电梯出去的乘客可能无法抵达目的地。由于公测数据不包含临界数据(奇怪的设定),互测无人发现。该问题由我在使用讨论区评测机时发现,同样手工稳定复现了此问题。
心得体会
本单元第一次和第二次作业都较为顺利,由于我采用的自由竞争做法(无调度器),代码量仅仅 350->450。但是均出现了bug,最终得分一般。
第三次作业时,我纠结于如何实现“可达性”的线程控制,同时纠结如何规划路线(可能由于数据结构的图论掌握不足)。
第三次作业时还发生一次严重失误,当时昏昏沉沉终于解决了唤醒指定线程
问题,由于 git 历史提交过多,想尝试合并历史,结果手误 git reset --hard
强制回退,然而又没有 commit 工作内容,导致200行迭代丢失。心态炸裂。最终完成效果不理想,本次作业成为了最差成绩。
我在完成作业时,几乎没有和其他同学沟通,信息渠道只有讨论区分享和往届blog。
第三次作业的失败,让我意识到了信息交流的重要性,如果我向其他同学求助路线规划问题,或许这次作业完成度会高很多。 同时,也让我意识到 git 版本控制部分操作的严肃性,在作出危险操作前,应当手动本地备份仓库文件。
如果按照生产者-消费者模式完成本单元作业,线程安全是容易保证的(简单使用synchronized互斥足以完成作业),但由于我的架构特殊,额外使用了 LockSupport。另外出现了线程空转问题(逻辑瑕疵),强测发现的难以复现 bug。我没有出现线程死锁问题。
本单元层次化设计架构也是十分清晰的,输入 -> 请求缓冲区 -> 调度器 -> 电梯,电梯自身可以使用状态模式,修改运行状态。调度器是核心。
总之,本单元收获很大,学习了多线程控制。在本单元尤其是第三次作业迭代时,由于个人原因,作息时间不规律,状态很差,第三次作业完成效果不理想。
提前规划好时间,调整状态,希望能在之后的几个单元做得更好!