文章目录
如果要让我对这充满坎坷的“电梯月”做一个总结,我认为贝克特的这一句名言无疑是最贴切的:
Ever tried,Ever failed,No matter.Try again,Fail again,Fail better. --塞缪尔·贝克特
事实上,如果你碰巧是一名文学爱好者,那么你可能很难相信以荒诞文学闻名的贝克特会说出这样励志的名言。正如在真正完成这一单元之前,我也不会想到自己会在面对多线程层出不穷的Bug时,依然坚持"Fail again, fail better"。
前言
第二单元我们要实现的是基于共享数据的,多线程电梯调度问题,在这一过程中,要迭代出电梯重置,双轿电梯等功能。相比第一单元,在第二单元的设计工作中,我深刻感受到了设计架构的重要性,以及多线程并行计算的优势。
整体架构图
UML图
由于是迭代开发,在这张UML图上我们可以看到这一路的过程。
白色的部分代表第一次作业。
绿色的部分代表第二次作业的增添。
紫色的部分代表第三次作业的增添。
时序图
设计架构开发历程
[!CAUTION]
第二单元的架构开发,我想多介绍一些, 对比第一单元作业,性能不在是实实在在的需求,而是相对虚无缥缈的东西。在保证正确性的前提下,不进行任何性能优化,也能有不错的性能分,因此架构占据了绝对的主导作用。
关于方法的复杂度就放在最后一起分析了。
作业要求:
第二单元的任务为模拟群控电梯调度,重点在于多线程并发的设计。以下是任务的迭代:
- hw5模拟了6部电梯的调度,乘客的请求会指定特定电梯
- hw6的乘客可以自由分配给任意一部电梯,电梯需要输出已接收信号。同时增加了电梯参数的重置,重置时会暂停并处于静默状态
- hw7增加了分裂为双轿厢的重置模式,需要在换乘层进行协调
一图概之:
hw5
同步块和锁
第一次作业,同时也是首次进行多线程编程,完成作业的大部分时间都放在了理解什么是多线程,以及什么是同步块上。
那么什么是同步块呢?简单来说就是,随便找点什么,多个类共用(指的是多个线程在同一时刻共同访问),套上synchronized
关键字。同步块的效果是什么?套上synchronized
关键字之后,在每一个时刻只能有一个线程访问。什么是锁?锁是当多个线程竞争同一个共享数据的时候,不让其他线程访问的重要武器。在synchronized
之外,还可以通过重入锁,读写锁等方式实现灵活的上锁。
由于第一次作业要求实在太过简单,六台电梯分别有自己对应的乘客,因此只需将总请求池上锁即可,同时在电梯需要进行sleep()
时给电梯上锁,确保其状态不受改变就可以保证线程安全,具体细节上采用ReentrantLock
即可实现,无需多言。
调度器和调度策略
由于第一次不涉及“电梯抢人”的问题,所以无需设计调度器以及调度策略。
电梯运行算法
采用的是LOOK
运行算法,也是日常生活中的电梯算法,即顺带接人运行策略,略微不同的地方是,现实中,在乘客进入电梯前,我们是不知道乘客的目的地的。但在本单元的作业中,人不是线程而是一个Object
,不仅方便了调度器的分配,而且降低了复杂度。
hw6
同步块和锁
与第一次作业类似,实际上,由于我们的分配操作是将一个个请求添加到每个电梯各自的请求的请求队列中,还是要将请求池,电梯各自的请求队列以及电梯自身设为共享对象,并围绕他们构建同步块,进行上锁。
调度器和调度策略
调度器采用**影子电梯
**策略,即算出局部最优解分配给6个线程。具体做法是对电梯和该电梯的处理队列进行深克隆,调用克隆后的shadowElevator.move()
实现模拟推进时间,遍历6个电梯,找出能够使得新请求加入后整体运行时间结束最早的那一种加入请求算法。
为方便说明,举例如下图所示:绿色方块为电梯原有请求完成需要花费时间,每一列为一种分配策略对应各个电梯用时(共六种,即把新请求分配给六个电梯,分别遍历),找出该策略下用时最久的电梯为该策略对应的全局任务完成时间,再将六个策略的时间找出最短的,就获得了局部最优解。下图的情况即为将新请求加入3号电梯是局部最优解。
注:示例图片来自武彬煦学长的博客
这样,调度器成为了线程,调度器和输入线程之间,调度器和各个电梯之间,通过一个等待队列连接。
这样的策略可以做到局部时间最优,由于本人的懒惰,并未在影子电梯的指标中加入耗电量的考量。
hw7
同步块和锁
与第二次作业类似,实际上,由于我们的分配操作是将一个个请求添加到每个电梯各自的请求的请求队列中,还是要将请求池,电梯各自的请求队列以及电梯自身设为共享对象,并围绕他们构建同步块,进行上锁。事实上,这只是我粗浅的理解,让我在互测中身中15刀
[!CAUTION]
事实上,由于我的设计中,DC Reset要往调度器类中的电梯队列里添加电梯,而在判断调度器线程是否结束时要遍历电梯队列,而我未对电梯队列上锁,这样会报错
ConcurrentModificationException
😭
调度器和调度策略
调度器依旧采用**影子电梯
**策略,由于这次存在双轿厢电梯,而影子电梯无法模拟换乘,因此在计算电梯结束时间时,对于双轿厢电梯中无法直达的请求,采取惩罚补时,来进行粗略模拟,效果还可以。
双轿厢电梯安全控制
在设计这次作业时OS正好在学信号量,事实上,换乘楼层只允许一个轿厢停靠,本质上就是一种信号量操作,而最大值为一的信号量实际上就是一把锁,两轿厢要进入换乘楼层时尝试获得锁,离开换乘楼层时释放锁即可。
具体代码上通过设置Police
类来监管双轿厢:
public class Police {
private boolean occupied = false;
public synchronized void getAccess(Character type) {
tryAccess(type);
occupied = true;
notifyAll();
}
private synchronized void tryAccess(Character type) {
notifyAll();
while (occupied) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
public synchronized void release() {
occupied = false;
notifyAll();
}
}
稳定和易变
稳定:
稳定的内容主要还是电梯的捎带一直采取LOOK
算法,保证了绝大多数情况下电梯平均性能的优越性。同时在二三次作业中都采取了影子电梯模拟的调度策略,改变不大。
易变:
乘客需求,电梯的类型每次作业都在改变,而这些都是通过Reset
指令实现的,如何妥善实现这些指令也正是本单元的设计重点。
结束的条件也随之变化,从每个电梯自己结束,到调度器统一分配结束。
BUG分析
hw5
第五次作业较为简单,在本地编写代码以及线上测试时都未发现Bug
hw6
第六次作业在编写时遇到的最主要的Bug在于线程无法安全结束,原因在于与第五次作业不同,在第六次作业中调度器是作为一个线程存在的,因此要确定它和电梯线程结束的顺序就成为了Bug产生的重灾区,最终也是通过不断通过评测机测试得到错误数据再通过Print
大法来查看线程具体结束情况最终解决Bug。
在互测中产生了一个Bug,原因是在五台电梯Reset
时会将所有请求分给一台可以使用的电梯,最终导致RTLE
。只要在只有一台电梯不在Reset
时让调度器等一等就行了,也不会轮询。
hw7
第七次作业的Bug堪称最搞笑的一集。
强测中没出现Bug,但在互测中,由于我的设计在DC Rese时要往调度器类中的电梯队列里添加电梯,而在判断调度器线程是否结束时要遍历电梯队列,而我未对电梯队列上锁,这样会报错ConcurrentModificationException
,或者造成无法正常Reset
,导致CTLE,最终身中15刀。
解决办法是做ArrayList<Elevator> elevators - >CopyOnWriteArrayList<Elevator> elevators
的修改即可。
总结
毫无疑问,对于多线程编程而言,Debug绝对是最痛苦,最耗时的部分。总结来看,一个好的架构往往能保证较高的Bug复现率。
而对于多线程Bug,除了Print
大法我深感无力,这也导致在强测结果出来前我一直保持着“先质疑,再质疑”的态度,好在最后强测都没出事,也算是万幸。
还是那句老话,对于多线程测试,评测机上十万组正确数据都不如一个能稳定复现的Bug来的金贵和安心。
心得体会
线程安全
从单线程到多线程,性能上的巨大飞跃,必然带来的是设计上的复杂性,数据安全便是这方面的核心,但是更重要的是保证线程安全的同时,提高解决效率,不能长时间占有锁,更不能在sleep
的时候占有锁,要保证原子性,但一定要对同一时间的多条判断语句进行原子锁。
保证线程安全下的提高效率,真正的拉开了与单线程之间的差距,只注重数据的安全的结果往往是死锁,和轮询,这是要极力避免的。
层次化设计
好的设计带来的优势在这一单元体现的淋漓尽致。正如前文所言,好的设计能够极大减少性能优化上的工作,以及提高本地测试时的Bug复现率,能够减少代码编写时的时间耗费。
“两天设计,一日编码”。这单元最好的工作流程就是这样,多线程编程设计细节颇多,如果无法在设计上提前规划这些细节,那么只会徒增Bug,白白浪费时间罢了。