269
社区成员




目录
一、 第五次作业分析
1.1 架构分析
1.2 类图展示
1.3 电梯优化
1.4 bug分析
二、 第六次作业分析
2.1 架构分析
2.2 类图展示
2.3 bug分析
三、 第七次作业分析
3.1 架构分析
3.2 类图展示
3.3 bug分析
四、双轿厢电梯的协同
4.1 基本架构
4.2 改造协同机制
4.3 运行协同机制
五、 同步块的设置和锁的选择
5.1 同步块的设置
5.2 锁的选择
六、 稳定与易变内容总结
七、 心得体会
第五次作业要求实现一个6部电梯的目的选层电梯系统,根据乘客的需求使用对应的电梯把他们从起始楼层送到目标楼层即可,电梯在运行过程中通过输出一定的带有时间戳的格式化输出来表示出其运行轨迹与状态。
阅读题目之后可以得出一个比较清晰的“生产者—消费者”模式,进而得出了输入—请求池—调度器—(候乘表—)电梯的基本架构,通过输入
获取乘客请求并放入到请求池
当中,调度器
从请求池
中取出乘客请求并且加入到电梯的候乘表
当中,电梯
从候乘表
当中取出乘客请求加入到自己的等待队列当中,然后根据自己的策略类
的建议进行运行。
本次作业当中乘客明确了乘坐哪一部电梯,所以调度器
在本次作业当中所起到的作用仅仅是根据乘客请求指定的电梯ID将其分配给对应电梯即可,无需书写电梯的分配策略。
而在电梯的调度策略中我选用了学长们推荐的look策略,然后在基础的运用上进行了一些优化,相较于朴素look策略能够提高一定的性能。
基于以上的分析,第五次作业的基本功能就能够得以实现了。
在本次作业当中我总共使用了七个类来完成作业:Solver
类用于创建和运行代码中所有的线程;InputHandler
用于从标准输入中读取官方包处理过后的乘客请求,然后作为Scheduler
类的生产者;RequestQueue
类作为一个托盘的作用,在InputHandler
和Scheduler
之间传递乘客请求;Scheduler
类用于分派请求到对应的电梯候乘表中,既作为InputHandler
类的消费者,也作为Elevator
类的生产者;WaitList
类作为Scheduler
类和Elevator
类之间的托盘,在两者之间传递乘客请求,也为Elevator
类维护其候乘表;Elevator
类主要用于进行电梯运行模拟,输出相应的信息,管理自己的待乘队列;Strategy
类来根据电梯信息,提供下一步行为建议,为电梯的运行提供指导。每一个类的作用都比较清晰有效,总共花费614行完成第五次作业的书写。
下面是UML协作图
根据往届大佬们的博客提点以及我个人的一些思考,我在第五次作业中实现了三方面表现出量子性的量子电梯,主要是去卡评测方式的bug。主要包含:1.电梯执行关门操作之后并不输出close,在下一次移动时才输出close,这样电梯就处于关门和开门的量子态,如果很久之后这一楼突然来了一个人,电梯可以少开一次门和更早地关门。2.电梯在移动之后不会马上输出arrive,只有在确定要开门或者同方向移动之后才会输出arrive,这样就可以实现在相邻楼层之间瞬移(回退),可能可以省一次移动。3.电梯根据计数的时间戳来计算sleep时间,这样就可以弥补一些操作带来的时间损耗以及可以在电梯空载的时候抢时间。
look策略主要提供的是电梯在移动过程中如何去决策下一次动作的思路,目的是保证电梯不会向某一个方向空跑的同时避免某些乘客一直不会被接到,但是并没有提供电梯初始时如果上下方均有乘客,向哪一个方向启动的解决方案,同时这种情况也适用于电梯运行到某一层之后电梯内的乘客全部清空之后选择哪一个方向移动的情况。所以我在电梯每一次内部乘客为空且上下方均有乘客请求的时候,分别模拟一遍先向上、先向下的情况,计算乘客优先级和等待时间的乘积之和作为cost来决策往上还是往下,这样就可以尽量优化电梯的调度策略。
为了保证高优先级的人先到达目的地,实现平均等待时间加权成绩更高,我选择当某一层楼想要进入电梯的人溢出之后,将电梯内的人全部请出电梯,然后按照优先级进入直到电梯装满,这样就能够保证先送达优先级较高的人。
第五次作业的结构比较简单,如果只是实现基本功能的话发生的bug比较少,主要的bug都出现在优化的过程中,公测和互测都没有测出自己的bug,也没测出别人的bug,所以分享一些调试过程中的bug。
bug1:可能会出现简谐电梯,由于对于电梯的调度策略(cost计算方式)不恰当,导致了电梯可能在上下的请求之间不断地来回纠结,呈现出简谐运动的行为。
解决1:只需要保证写出合理的调度策略,保证电梯根据调度策略运行之后只会对这一决策做出正收益的改变,这样就可以避免电梯的决策不断做出相反的改变。
bug2:当电梯空着到达最高层之后可能会冲顶,因为最高层的乘客请求和电梯此时运行方向相反。
解决2:只需要给电梯加一个change操作,让电梯在唯一的乘客那一层如果运行方向和乘客请求方向相反的时候调转运行方向即可。
第六次作业主要有两个重点,一个是SCHE指令的执行,一个是乘客的分配策略,这两个迭代功能的实现都不简单,需要进行非常多的改动。
对于临时调度的处理,我选择把电梯相应临时调度之后的操作看作是像move,close,open
这样的原子操作,电梯在接收到临时调度指令之后就会去执行sche
操作,完成电梯临时调度的要求。需要注意的是SCHE指令的时效性,题目中要求“接收到临时调度指令的电梯必须在两次移动楼层操作内输出SCHE-BEGIN-电梯ID
开始临时调度动作”以及“SCHE-ACCEPT
和SCHE-END
之间的时间记为响应时间Tcomplete,Tcomplete不得超过6s。”等要求是为了检验同步块设计得是否合理,是否会过度阻塞,导致这种中断请求不能及时到达。
乘客的分配,即调度器的职能如何实现是这次作业的重中之重,我选择了影子电梯的方法来完成这次作业。具体思路就是当需要分配乘客请求的时候,我就去获取电梯的读锁,进而完全获取电梯此时的状态,彻底模拟电梯从当前状态到送完所有客人的全过程,衡量其中的时间、耗电量等指标,这种方法能够得到一个当时的全局最优解,可以说是性能比较高的一种方法,但是缺点也很明显,由于Scheduler
需要同时获取很多把Elevator
的锁,非常容易导致“长时间”的阻塞或者出现线程安全问题。至于模拟的过程,只需要复制粘贴一份电梯和策略的代码,修改一下便可以成为影子电梯和影子策略。
完成以上两点工作之后就可以完成第六次作业的基本功能了。
本次作业总共用了12个类来完成作业,较上次作业新增了5个类:Floor
类是一个枚举类,用于处理楼层在int和String之间的转换;RequestCount
是一个全局计数器类,统计有多少乘客请求被InputHandler
接收到但是没有完成的,是Scheduler
关闭的计数器;ElevatorControl
是电梯的控制器,专门负责调用电梯类的方法,控制电梯按照指令运行;ShadowElevator
是一个模拟器,用于模拟电梯从当前状态到结束状态中间的cost;ShadowStrategy
是ShadowElevator
对应的策略类,负责给出电梯运行指令。总共用了1550行完成这次作业(其中影子电梯部分基本复制粘贴电梯部分,所以是双倍的量)
下面是UML协作图
这一次作业在调试过程中出现了不少bug,直到开始强测的时候也没能改完所有的bug,挂掉了五个点,下面分享一些我认为比较常见的bug。
bug1:一开始我的影子电梯我选择的是强行同时获取6个电梯的锁,导致可能阻塞过久,SCHE指令输入后不能及时被处理,在ACCEPT和BEGIN之间输出了多个ARRIVE。
解决1:我把这个获取读锁的过程改成了使用**TryLock()**的方法来进行,从而规定最大的请求锁时间,避免在此处阻塞太久。
bug2:调度器和电梯类之间形成了A->B,B->A
型死锁原因在于调度器会再分配过程中会拿着自己的写锁去请求电梯的读锁,这种嵌套的拿锁结构非常的不安全。
解决2:当调度器在进行分配时,可以暂时释放自己的写锁,因为此时不会有线程来改变它的状态。(调度器只是一个线程,其他线程只有电梯能够调用它的一个唤醒方法,所以其他的线程不会在此时改变它的状态。)
第七次作业主要引入了UPDATE指令和双轿厢电梯,可以说是在SCHE指令的基础上又引入了新的电梯模式,难度也比较大。
对于双轿厢电梯来说,之所以他们不能够“自由运行”,就是因为他们“拥有”同一个电梯井,或者说“拥有”同一个共享楼层,也就是说这一个电梯井或者共享楼层可以看做是他们之间的共享资源(后续统一称为共享楼层),所以只需要在电梯需要“访问和修改”(进入、离开)这个共享楼层的时候加锁即可,我的解决方案就是引入了SharedFloor
来解决,具体的架构会在第四部分再说。
这一次关于调度器的分配,我并没有选择继续写双轿厢的影子电梯,因为双轿厢影子电梯大概需要同时获取配套的两个电梯的读锁,同时模拟两个电梯的运行,但是因为我的影子电梯在获取读锁的时候是使用的tryLock()
,所以不一定能匹配得到成套的两个电梯,索性还是把双轿厢电梯看成两个独立的单轿厢电梯,哪里都可以去。但是需要非常小心地判断这个乘客到底可不可以分配给这个电梯,不然很容易死锁!
这一次总共使用了15个类来完成作业,相比于上一次作业新增了3个类:SharedFloor
类是电梯的共享资源,用来进行双轿厢电梯之间改造和移动的通信;InOutControl
类用于管理电梯内部的人员的出入,使我为了更好地达到高内聚低耦合标准而从电梯里面拆分出来的一个类;AbdicateRequest
类是让位请求类,完全类似于SCHE和UPDATE指令,是用来处理双轿厢电梯之间在共享楼层移动的。总共用了2000行代码完成这次作业。
下面是UML协作图
第三次作业在调试阶段出现了非常多的错误,其中主要是各种各样的死锁bug,在强测和互测阶段没有发现bug,也没能找出别人的bug。
bug1:由于一开始直接进行简单粗暴的两个电梯之间的直接交互,所以导致会出现A->B,B->A型的死锁,两部电梯在交换共享楼层使用权的时候直接卡住了。
修复1:通过我后来的设计,避免了他们直接去请求对方的锁,而是共同去请求共享楼层的锁,这样就避免了嵌套索取双方的锁。
bug2:对于人员的分配出现了不合理的情况,把一个人一直往无法到达他目标楼层的双轿厢电梯中送,这样既导致共享楼层被堵死,也会让程序被卡死。
修复2:增加对于这种超出自身楼层的乘客进入电梯的惩罚分,但是这里也要注意一个细节,一开始我直接使用Long.MAX_VALUE,结果与电梯的其他乘客的cost相加后溢出,导致调度器更加倾向于把乘客分给这个电梯。
注:转自我自己的讨论区帖子,并非抄袭。http://oo.buaa.edu.cn/assignment/624/discussion/1693
下面我首先放出共享楼层这一共享资源的大致架构,将会在下面两部分解释
public class SharedFloor {
/*
一些自己想添加的基本数据
*/
/*
改造共享楼层的相关计数变量readyCount和endCount
共享楼层是否已经被电梯占用的标志位haveElevator
*/
/*
共享楼层所持有的WriteLock
用于开始改造共享楼层通信的readyCondition
用于结束改造共享楼层通信的endCondition
用于共享楼层占用问题通信的busyCondition
*/
// 电梯已经准备好可以开始改造
public void isReady(boolean isUp) throws InterruptedException {
通过count和readyCondition来协同开始改造
修改状态、等待、唤醒
在此由其中一个特定电梯线程输出UPDATE-BEGIN
Tips:注意负责输出的电梯先进来和后进来的区别(也可以用输出标志位磨平这个差异)
}
// 电梯已经可以结束改造
public void endChange(boolean isUp) throws InterruptedException {
同理进行输出UPDATE-END的协同
}
// 电梯想要进入共享楼层
public void getIn(boolean isUp) {
如果发现已经被占用,那么发送让位请求
等待与修改状态
}
// 电梯离开共享楼层
public void getOut() {
修改状态与唤醒
}
// 其他方法
}
考虑到两个电梯只输出一个UPDATE-BEGIN与END,并且对于电梯“静默”的要求是以这同一份改造指令的时间戳来要求的,所以两辆电梯的状态改变(是否接受人、上下人、移动)都需要高度同步。而且正如我在上面说的,电梯改造可以看成是两辆电梯共同去改造共享楼层,也就是说此时的共享楼层并非是一个任意时刻只允许一个线程访问的共享资源,而是一个前置条件为两者一起才能访问和修改的共享资源,所以是对共享资源的访问与修改,我们可以通过SharedFloor
中的isReady()
和endChange()
方法来保证输出时间戳和状态变换的高度同步。
这里的运行协同机制并不指要求两辆电梯之间共享侯乘表,以最快的方式完成送客任务,这个难度有点超出蒟蒻的能力范围了,而是单纯指如何避免两者再靠近一点就会爆炸相撞。电梯进入共享楼层可以视为对共享资源的占有,那么他占有了共享资源后,其他电梯想要进入共享楼层自然就需要等待,但是其他电梯也不能就这样干等着吧,所以两个电梯之间必须要有相互的沟通协作才能完成这次作业,不然如果6个电梯都UPDATE了,那么乘客就只能走楼梯了。
在下面我分享两种“换位”机制
最简单暴力的方法——“自觉电梯”。如果一个电梯在共享楼层,并且他不需要开关门接客,那么他将会自动离开这一楼层,给另一个电梯让开位置。这个方法是最简单好用的方法,出锅的概率也非常小,但是可能会带来更多的耗电量(有可能白白开门关门和移动楼层),但是也有可能因为这个移动提前接到人,所以这个方法性价比极高。
我现在使用的方法——“敲门电梯”。按照一般的思路来说,应当是一个电梯A要进入共享楼层的时候,如果另一个电梯B占有共享楼层,那么A电梯应该向B电梯发送请求,希望能够给自己让出位置,也就是我所说的敲门。但是这个方法并没有想上去那么简单,可能会导致非常多的死锁问题(至少我在尝试的过程中就遭遇到了三种不同的死锁),最终经过不懈尝试,似乎找到了可能不那么会死锁的方式:当电梯A希望getIn()共享楼层,却发现已经被电梯B占据的时候,会生成一个针对电梯B的AbdicateRequest请求放到请求池当中,电梯B在接收到这个AbdicateRequest请求之后会离开共享楼层(具体的锁机制在共享楼层之中实现,而这个AbdicateRequest完全按照SCHE和UPDATE请求的方式写就可以了)。
其中有几个需要解释一下的点:1.为什么不直接向B发送请求让位的信号?如果A在占有SharedFloor写锁的情况下去请求B的写锁,此时B刚好在主动地离开共享楼层又会带着自己的锁去调用getOut()
方法,导致形成A->B,B->A
模式的死锁;2.为什么不直接向调度器发送AbdicateRequest?首先这样极其不美观,也不符合调度器该有的功能,其次,调度器和A也很可能形成A->B,B->A
模式死锁;3.你以为这样就万事大吉了吗?其实不然,在调度器,A,B之间还可能存在一个非常有趣的三角死锁,这个死锁的原因就等大家自由探索啦。
同步快顾名思义,就是多个线程可能同时需要进行这个代码块内的操作,而我们需要做得就是保证多个线程访问进行这个代码块之后的结果正确。首先就需要去寻找哪些代码块可能会被多个线程公式请求调用,比如请求池中的putRequest
方法显然会被多个电梯线程、输入处理器线程同时调用,这个时候我们就需要对这个代码块进行同步互斥管理。但是同步块的大小选择也很重要,如果同步块过小,不能保证这个方法或者操作的原子性,那么仍然会导致线程安全问题;如果同步块过大,就会让其他线程阻塞很久,会让整个程序的效率降低。所以我们需要严格地去判断哪一部分是需要保证严格原子性的操作,哪些部分可以让多个线程同时访问也不会出错。
我在作业当中主要选用了两种锁,一种是普通的synchronized
关键字,通过锁住this
来进行同步互斥管理,通常用在比较简单的同步方法中。第二种锁使用的比较多的ReentrantReadWriteLock
,也就是读写锁,我选用它有很多原因:1.读锁和写锁分开,可以让某些情况下读锁同时被多个线程占有,提高了程序的效率。2.读写锁配套的Condition机制能够允许多种await和signal条件的存在,能够让设计更加清晰,避免乱叫醒,晚叫醒,忘叫醒问题的发生。读写锁一般用在比较复杂的方法当中,去进行细化的控制,特别适合于读多写少的场景。
纵观三次作业迭代的架构,其中有不少内容甚至是类几乎从未修改,有些内容却不断地增增删删,大致如下总结。
稳定内容:整个电梯作业的整体主线框架几乎从未改变,一直是“输入—请求池—调度器—电梯”的结构来完成任务;对于输入处理类来说,大致框架从未修改,仅仅只是在加入不同类的请求之后多加一个if
分支语句;整体的线程结束方式几乎没有修改,大致是出入线程根据输入决定是否关闭,调度器线程根据全局乘客计数器决定是否关闭,然后通知电梯线程关闭;电梯当中的原子方法如移动,开门,关门几乎没有进行修改。
易变内容:调度器的分配方案经常改变,从第一次的按照序号分配,到第二次开始决策,第三次又加入了双轿厢电梯的分配决策;电梯的策略类内容经常改变,因为电梯不断增加新的设定,每一次电梯设定改变都会导致电梯在相同情况下可能需要做出不同的决策;电梯进出乘客的方法经常改变,不论是因为电梯的各种调度等指令,还是因为新的优先级优化,都会让电梯进出乘客的标准不断改变。
一开始对于线程安全的认知就是全部加上锁,觉得这样就万事大吉了,后来才发现,这种方式不但会降低程序的运行效率,同时还可能会出现死锁的问题。后来认识到了加锁的目的是为了保护共享资源,于是开始有选择的对那些会操作共享资源的临界区进行加锁,同时还时刻注意拿锁是否嵌套,是否会产生相互拿锁的危险情况。再到后面发现仅仅只是简单的synchronized
并不能应对所有的需要保护共享资源的情况,可能会出现过度保护带来程序效率的损失或者无法达到一定的设计目的,于是开始研究ReentrantReadWriteLock
的使用方法,尝试利用它适用于多读少写、写锁优先等优秀的特性来完成线程安全的保护。最后,还特别推荐直接使用那些本身就线程安全的容器来完成作业,比如Vector
、Stack
、Hashtable
、ConcurrentHashMap
、CopyOnWriteArrayList
、CopyOnWriteArraySet
。