BUAA-OO-Unit2总结:电梯的进化
回首三次作业,有种我竟然做了这些事的感慨。为解决自由市场无序竞争的弊端,建立了预分配制度,由总调度器统一规划人员请求。统一规划大处落笔,对于细节的把控有限,可能存在一定程度的分配僵化,为此引入抢夺机制,允许先送完人员的电梯主动帮助未完成的电梯完成任务。设计了流水线上、主从模式的线程管理,使用带路径压力的最短路决策,建立了较为合理的规划迭代方案,尽可能地兼顾了成本和速度。重要的是对线程的并发有了更多的理解,分不分儿的反倒不重要了。
同步块与锁
总的原则:能不加锁就不加锁,不加不行才加锁。
-
hw5
-
候乘表类存储各座及各楼层等待队列,Controller分配请求和Lift取请求时均对其操作 => synchronized锁waitTable
-
当电梯空且等待队列空且InputThread未结束,电梯转为STAY状态,原地待命;Controller负责唤醒处于因进入STAY状态而wait的Lift => Lift对象是主体,锁Lift对象
-
RequestQueue使用阻塞队列实现,put/take方法不需要加锁/同步。
-
-
hw6
-
public void run() {
Dispatcher.RUNNING_LIFT.getAndIncrement(); // i++
while (true) {
/* 打印到达信息 */
synchronized (controller) {
if (move == Move.STAY) {
break;
}
}
/*
电梯运行逻辑
*/
}
synchronized (Dispatcher.class) {
Dispatcher.RUNNING_LIFT.getAndDecrement(); // i--
setMove(Move.SHUTDOWN);
Dispatcher.class.notifyAll(); // 通知总调度器有电梯已死亡
}
} -
重构了电梯状态逻辑,新增加锁代码如上所示。电梯压力系统认为处于STAY态的电梯线程恰处于内外人员皆空的中间态,不需要重启,直接将请求塞给其等待队列并初始化运行方向;处于SHUTDOWN态的线程已经死亡,需要重启。而实际上,STAY态的电梯可能即将死亡(第五行的break条件),如果恰好在这时向其等待队列塞请求A,则只有等到该电梯被新请求B选中并重启时才能运送请求A,如果没有请求B了,那么请求A被困在了大楼内。所以需要在break条件处防止多个线程同时读写move,这里选择了锁Lift唯一对应的Controller对象,实际上锁move对象更精密。
-
第二个锁涉及总调度器终结问题。总调度器需要等所有电梯均死亡方可死亡。为避免轮询,需要wait-notify,这里选择了锁Dispatcher类。
-
// 总调度器终结逻辑(部分)
while (true) {
synchronized (Dispatcher.class) {
if (!hasRunningLift()) {
break;
}
try {
Dispatcher.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
-
-
hw7
-
将Dispatcher类中的handlePersonRequest方法改为synchronized方法,因为其可能被多个线程调用,引发重大bug,详见调度器设计。涉及到图的读取或修改的地方,用了ReadWriteLock。
-
架构与调度
hw5-嵌入式调度器与朴素的look
任务描述:五座楼,每座十层,每座一部纵向电梯,实现调度。保证人员请求的出发地和目的地不相等,且保证在同一座上。初始为五部纵向电梯。
调度器
-
电梯与调度器绑定,每个调度器控制一部电梯的行为,由Main直接启动5部电梯。由于本次作业只有5部电梯,不设置总调度器来管理请求分配也问题不大。
架构
生产者-消费者模式,InputThread往waitQueue加人员请求,Controller从中取请求。look写炸了,有概率退化成scan。
这一阶段一边敲代码一边疯狂看书补知识点,看过的有《JAVA并发编程的艺术》、《JAVA并发编程实战》、《图解java多线程设计模式》以及各种博客。<em>属于是生产者-消费者了。</em>
hw6-二级调度、可重启的强盗电梯
任务描述:在hw5基础上,新增横向环形电梯和横向人员请求。可以在某一层上新增一部横向电梯,可在五座之间环形移动。人员请求保证为单纯的纵向移动或横向移动。初始仍为五部纵向电梯。
调度器
-
总调度器——电梯控制器二级调度。采用了预分配模式,每部电梯保存一张独立的候乘表,不需要加锁。期望将人员请求分配给最合适的电梯,实际实现方式为一种很基础的策略:清点电梯内外人数,选择分给人数最少的电梯(模拟太难辣)。
模块 | 交互 |
---|---|
Dispatcher | 处理电梯请求 为人员请求挑选合适电梯 将人员请求分配给相应电梯等待队列 启动电梯 |
Controller | 控制电梯移动 控制人员进出 |
这里偷偷放一张look策略。不难发现,STAY是一个用于改变接人方向的中间态;若本楼层上下人流程走完了,电梯还处于STAY这个中间态,那就意味着无人可接送,电梯线程可以死亡啦。由于本次作业中,电梯线程和电梯实体没有做到分离,所以需要在“重启电梯”时new一个新电梯(clone是一个形象化的说法,并不是指Object.clone,目的是把原电梯的上下文替换进去)。如果做到了实体与线程分离,那就可以只new一个新线程,把实体换绑到这个新线程上,省却了clone操作。
架构
- 生产者-消费者模式,InputThread往waitQueue加人员请求,Dispatcher从中取请求。
- 主从模式,Dispatcher作为主线程,Lift作为从线程。Lift全部由Dispatcher启动,并且由Dispatcher监听是否终结。当且仅当所有Lift从线程均终结,主线程方可终结。
因为这次作业新增了横向电梯,猜测下一步可能会横向电梯与纵向电梯联动,即出现分段请求;所以在写本次作业前,首先要思考如何向下一次作业过渡。
在分段请求存在的情况下,难以判断程序何时应该终止运行。各个电梯对彼此“正在运载”和“将要运载”的请求毫不知情,没有哪个电梯有把握告诉其他电梯:你们可以下班了——因为每个电梯的工作量不一样;必须综合考虑所有的电梯,确保电梯内外均无人,且不再有输入才可结束。
如何确保电梯内外无人?最先想到的可能是遍历所有电梯和候乘表,查看是否还有乘客。研讨课上有些同学提出了用二级候乘表管理所有乘客请求,顶级候乘表保存所有输入的人员请求,次级候乘表保存每层/每座的人员请求,当且仅当顶级候乘表空,程序方可结束——分析其心路历程,基本思路应当源于此。优点在于直观易懂,缺点在于对候乘表(特别是总候乘表)的操作频繁,容易阻塞电梯线程。
我想到的是另一种思路:简单观察,容易发现,若从输入结束后的某一刻起,再没有电梯处于活跃状态,则表明所有人员都已经到达了自己的目的地,总调度器可以终结。关键在于如何实现。
hw5中,每个线程相对独立,没有从属关系(不把主线程纳入考虑)。为了让程序终结,必须先等待所有自定义线程终结,在hw5里就是Controller和所有的Lift;顺着这个思路推理,如果我将某个线程终结的条件设置为某一类线程全部终结呢?或者说,它的下属线程全部终结?Dispatcher已经监听了InputThread,在此基础上,令其多监听所有的Lift不就好了?结合输入请求中混有人员请求和电梯请求,由Dispatcher来启动并管理电梯线程,再方便不过。于是,本次作业我改将Lift设为Dispatcher的次级线程,Dispatcher线程会等到所有Lift(另一个原因是我觉得这样会比较省电)只不过,当时的我还没有意识到,这实际上就是主从模式。
众所周知,线程一旦死亡是不能再次start的,那么我是如何实现“可重启”电梯的?
电梯干完自己的活儿之后不休眠阻塞,而是保存上下文后直接死亡;当有活儿派给这个电梯时,新建一个电梯线程,恢复上下文,start。
小优化:预分配模式在大量人员涌入时理论性能比自由竞争要好得多,但难以利用到新增的电梯,最坏情况下(50Person后加20电梯)只能利用原有的5部电梯,效率远逊于自由竞争。为了解决这个问题,我的电梯可以化身强盗,但凡需要启动/重启的电梯,都可以把邻居电梯的等待乘客从它的等待队列抢到自己的等待队列里。这样既有了预分配的长处,也避免了其局限性。
hw7-流水线 + 主从模式 + 动态的静态拆分
任务描述:横向电梯有可达性约束,随电梯请求给出。人员请求不保证为单纯的纵向或横向。初始为五部纵向+一部1F全楼座可达的横向。
调度器
-
依然是二级调度,不同点在于将电梯实体与电梯线程分离,并对次级调度器部分开放处理人员请求的权限:可以将下车且未到达终点的人送给某个电梯的等待队列。曾想过将人员请求塞回总调度器的等待队列,由总调度器统一处理,实际操作起来效率很低,中转请求等在所有其他请求均处理完成后才能被处理(将等待队列改成Deque,然后中转请求从头部插入效率会提高一些),无法利用多线程的性能优势,且需要修改Dispatcher的终结条件,容易出锅。因此,本次作业令每个Lift的Controller都可以调用Dispatcher的人员处理方法,不慎出了更大的锅,详见bug分析。
架构
- 生产者-消费者模式,InputThread往waitQueue加人员请求,Dispatcher从中取请求。
- 主从模式,Dispatcher作为主线程,LiftThread作为从线程。LiftThread全部由Dispatcher启动,并且由Dispatcher监听是否终结。当且仅当所有LiftThread从线程均终结,主线程方可终结。
- 流水线模式,handlePersonRequest方法根据人员出发地和目的地规划路线,设置本阶段目的地,递交给相应电梯的等待队列;人员下车后,检测是否到达最终目的地,若是,移除请求,若否,回到调用handlePersonRequest。多个电梯线程接力完成一份人员请求。
这次作业新增了出发地与目的地楼座、楼层均不相同的请求,势必要对请求分段,而分段听上去就很流水线。在hw6时的设想是Dispatcher将请求拆分为若干段,用ArrayList保存各段目的地,再交给电梯线程去完成,实际上是一种静态拆分。
考虑一种情况:加入70条从A-10到E-10的人员请求,这时路线仅有一条,即A-10 -> A-1 -> E-1 -> E-10 。 1.0s后在每一层新增1部全楼座停靠的横向电梯,现在,明面上的最短路线为A-10 -> E-10 。容易发现,静态分配不能应对新增电梯导致出现更优路线的情况。讨论课上交流得知,同组同学多采用的还是静态拆分,解决不掉这种情况。
动态拆分的思路并不明确,不同的人有不同的写法。严格的说,我的写法应该属于动态的静态拆分。显然,路线的优化主要依靠新增电梯带来的可达性及运载能力的优化。那么我们在新增电梯请求后,重新为所有人规划路径就好了!实践中,我仅在新增横向电梯时将所有等待队列的人拉出来重新规划。这个方法并不优秀:一方面,等待队列是与电梯控制器共享的,需要加锁,而当等待队列被控制器拿到时,Dispatcher被阻塞住,无法处理其他请求;另一方面,人员路径需要响应式规划,频繁的新增电梯,例如同一时刻新增12部电梯,需要重新规划12次,等候者频繁的进出候乘表,时间消耗非常大,而实际上只有最后一次有用——这与著名的响应式页面渲染十分相像。对于这个问题,Vue采用的方法是缓冲队列,简单来说就是将所有数据更新操作缓存起来,在下一个tick到来时,先去重后渲染。大佬或许可以手搓一个类似的东西出来?或许需要实现一个异步队列?又或者搞一个总调度器Dispatcher的从线程专门负责周期性更新?因为不知道如何拦截掉不同的计算,我没有使用这个方案。
最终想到了一个版本控制的思路:每个人员请求持有一个路线图版本号,代表当前拿到的路线对应的路线图的版本,人员进入所等的电梯之前(不是人员线程嗷,仍由电梯控制器线程来操作),先检查是否有版本更新,有则重新规划,安排TA去等某个电梯,无则进入。
至于如何跑出一个最合适的路线,那就是最短路的问题了,手搓一个边权建图就行,粗略计算了下dijkstra复杂度足以满足要求。细节点在于路径回溯(考虑在何处换乘哪个电梯)和额外时间消耗(换乘及路径压力)。我的做法比较简单的取了平均等待时间作换乘时间,以及仿照xx地图堵车图设计了路径压力系统。使用Pair<楼座, 楼层>建图、PreNode保存换乘节点Pair和要换乘的电梯ID(如有)、ArrayList<PreNode>保存换乘节点序列。后来悲惨的发现早在java11就已经移除了javafx,不过我们的作业是基于java8的所以勉强能接受
bug分析
自己的神秘bug
hw7出了大锅,强测互测反馈五花八门,隐约感到自己快要集齐了各种bug
- 纯粹的RTLE
- 总调度器死等的RTLE
- 电梯从1F开门2F关门,然后打印Arrive2F信息的WA
- 有人员未被送到终点的WA
- 电梯从1F飞到3F,然后在3F打印了两遍Arrive信息的WA
打印调试,最终确定错误出在电梯关门处。看了很久也没de,怒而将关门时间改为0,交上去过了。。。当时还跟舍友说用奇怪的方式混了过去,妹想到style分98不能确认修复。改好style又交了一次,结果有个点没过。。。这一瞬深刻体会到多线程的缘分之美。
继续de了很久发现是电梯线程重启出了锅,在某种情况下,会clone出两个线程同时对同一部lift操作,示意图如下:
原因在于hw6中只允许Dispatcher一个线程调用涉及电梯线程clone的handlePersonRequest方法,因此该方法没有加锁;而hw7中为了效率,允许所有电梯线程参与人员请求分配,结果忘记了该方法的底层实现,未给该方法加锁。修复方法为在方法名前加synchronized修饰。
一切bug的源头竟然是忘记添加一个关键字,很奇妙,非常可惜。这个bug的价值在于让我对线程安全的理解更深了,称得上得失平衡。
他人bug探测
测试他人的bug,主要采取大力并发的样例。以及神奇的《1.0Person-70.0Lift》样例,可以卡掉一些死等的代码。
[1.0]1-FROM-A-4-TO-C-3
[70.0]ADD-floor-7-3-6-0.4-5
判断可停靠楼座可以用lowbit不断取最低位1。
按UP、DOWN、RIGHT、LEFT、STAY、SHUTDOWN顺序设置电梯enum状态量,这样只需对下标异或1,即可反转电梯运行状态。
public enum Move {
UP(400),
DOWN(400),
RIGHT(200),
LEFT(200),
STAY(0),
SHUTDOWN(0);
private final int movingTime;
Move(int movingTime) {
this.movingTime = movingTime;
}
public int getMovingTime(double vel) {
if (vel < 0.3) {
return 200;
}
if (vel < 0.5) {
return 400;
}
return 600;
}
}
private void invertMove() {
lift.setMove(Move.values()[lift.getMove().ordinal() ^ 1]);
}
回首三次作业,有种我竟然做了这些事的感慨。为解决自由市场无序竞争的弊端,建立了预分配制度,由总调度器统一规划人员请求。统一规划大处落笔,对于细节的把控有限,可能存在一定程度的分配僵化,为此引入抢夺机制,允许先送完人员的电梯主动帮助未完成的电梯完成任务。设计了流水线上、主从模式的线程管理,使用带路径压力的最短路决策,建立了较为合理的规划迭代方案,尽可能地兼顾了成本和速度。重要的是对线程的并发有了更多的理解,分不分儿的反倒不重要了。
多线程编程涉及到线程安全的问题,某个地方上一次作业没有线程安全问题,下次迭代,就可能出现安全问题。迭代中尤其要关注:凡是涉及到操作者线程由1变n的迭代,一定要仔细过一遍底层逻辑,该加锁的地方加上锁,确保万无一失。
第一单元用递归下降,短暂跟大家结成了统一战线,第二单元从选择预分配开始就渐渐跟大部队分道扬镳......不再有现成的思路可以参考,一切都需要自己构建,听不懂别人的bug、让别人听不懂自己的bug也是家常便饭,独立debug能力++。
现在看hw5的代码已经看不下去了,写的很逆天,hw7也存在一些优化的空间。时效性强的任务很难写的很漂亮,乘闲时,品杯香茶,剖析丑陋的代码也是极好的。🐕