本单元的题目为设计电梯,通过这单元的学习,我初步了解了关于java多线程编程及线程之间并发安全性设计等方面的内容。以下为对这三次作业的分析与总结。
作业分析
序号 |
楼层 |
电梯数量 |
可停靠楼层 |
调度策略 |
1 |
1-15 |
1 |
1-15 |
任意 |
2 |
-3--1,1-16 |
1 |
-3--1,1-16 |
捎带 |
3 |
-3--1,1-20 |
3 |
A:-3--1,1,15-20 |
任意 |
B:-2,-1,1,2,4-15 |
||||
C:1,3,5,7,9,11,13,15 |
表1:三次电梯设计要求
图1.电梯设计——类与结构
一、
第一次的电梯比较简单,楼层均为正数,一部电梯,且不限制调度策略,更重要的是不计性能分数。因此这次只需构造出读写线程,调度的时候来一个请求就执行一个请求,不需要什么算法即可实现。分析以下电梯问题不难发现这其实是一个经典的消费者——生产者模式。电梯的“按钮”(Producer)负责发出(produce)请求——调度器(Tray)收到了请求——电梯(Consumer)负责执行(consume)请求。套用该模型即可完成此次实验。
二、
第二次的电梯稍稍麻烦了一些。首先,这次电梯引入了负层,乍一看和正层没什么区别,但在日常生活习惯上没有0层的存在,因此在地上下层切换的时候不能带入0层。其次,第二次电梯对调度策略有了要求,至少效率要达到“捎带”。我认为捎带有三层含义:第一,不能一个一个执行请求,因为捎带的时候电梯中可能有多个人;第二,要实现“捎带”。第三,要有实时的策略判断。此次的电梯仍然沿用第一次作业的生产者和消费者模式。
负层的引入容易解决,只需要特判1<-->-1的情况,改变输出层数即可。就“捎带”算法而言,对于第一点,电梯中有多个人,显然要存储电梯里面的人的请求信息,因此我设计了一个链表来存储这些数据。
对于第二点的“捎带”策略,捎带条件很容易理解并实现,关键在于我们需要判断“捎带”。“捎带”的判断即捎带条件,我们已经理解;那么需要在什么时候判断需要“捎带”呢?首先我想到的是来一个请求就判断符不符合判断,即“先验性”判断,但回头一想,根据电梯的实时性,在判断后可能出现其他的请求,过程难以预料且复杂多变,管理这些“先验”判断想想都让人头皮发麻,因此此路不通。而后一想,“捎带”这个动作并不是在任意的时间内发生的,只有在电梯正好到达楼层的情况下才可能出现,因此我们只需要在到达楼层的时机,判断有没有可以稍带的就行了。那么我设计了在电梯到达楼层时进行“捎带”判断,解决了这个问题。
至于第三点,这里的实时问题的具体表现为如何一次读入所有的同时请求。此次的接口是阻塞性单个读取,显然不能一次读取多个请求,但我在这个问题的解决上花了不少时间。最后才明白,解决是不可能解决的,这辈子都不能解决,接口使然。后来捋清了思路,大概是以下的思考过程。
图2.“捎带失败”
也就是说,当送入了一个新请求后,电梯便开始判断,若发现不能捎带的话,便直接离开该楼层,导致可能会错过“捎带”。因此为了避免这种情况,当电梯有开门需求时,在中间的空闲时间内调度器可以接受指令,到了关门的时刻电梯再次询问一遍,可以保证不会漏掉“捎带”请求。但对于电梯不开门的情况,我到现在还没想出一种更为优雅的方法解决,只能在取指令的时候不断wait(),才能笨拙地解决,同时带来了时间的延迟和负载的增加。
三、
这一次作业相当惭愧,只过了弱测,没有进入互测环节。这次失败的主要原因是对线程安全理解不到位,交上去之后先是读不到最后一条指令,之后又是CPU超时,结果就是绊倒在了中测路上。
对于读不到最后一条指令的问题,我分析了我的代码。为了解决如二所述的漏掉“捎带”问题,我设置了一个请求缓冲类,所以说我的发送请求流程变为以下模式。
图3.输入处理
如图所示,这样处理就会出现读不到和null用时出现的最后一条指令,因为此时缓冲类可能正在检查是否结束,发现有了end,便结束了,但是此时end前的一条指令还未送到buffer中。为了修复该问题,当时只得采用“缓兵之计”,让Button读取后sleep一段时间。在bug修复阶段,我决定不弄这些花里胡哨的东西了,所以我选择删掉这段代码(嗯,一劳永逸)。
对于CPU超时问题,一开始是死活想不出来,检查了好几遍代码,并没有发现暴力轮询的迹象,改了几个地方,最后还是没有过。后来在BUG修复阶段,冷静下来之后,才发现自己的设计中可能存在隐形的轮询的情况——不同于易发现的循环内的暴力轮询,而是线程不断竞争形成的轮询。以下是我的作业中中发生轮询的代码。
1 synchronized boolean getSignal(Elevator e) throws InterruptedException { 2 while (firstTrip.isEmpty() && e.isEmpty() && !isOver()) { 3 wait(); 4 } 5 transfer(e); 6 notifyAll(); 7 return dispatch(e); 8 }
当某个电梯获得同步后运行到此处,若不满足等待条件,就会直接运行下面的三个函数;如果该电梯不断获得同步,那么就会不断进入函数进行运算,这就增加了大量的CPU时间,导致出现了CPU超时的情况。分析自己的设计后,发现我并不需要让电梯一直请求调配,在判断是否应该阻塞时,就可以分析出电梯此时有没有任务需求。因此只需在判断条件中加入是否有该电梯需要的任务,没有的话就阻塞,防止函数“空转”,以下为修改后的函数。
1 synchronized boolean getSignal(Elevator e) throws InterruptedException { 2 while (!adjustDir(e)) { 3 wait(); 4 } 5 transfer(e); 6 notifyAll(); 7 return dispatch(e); 8 }
将判断条件改为有无任务需求,将判断条件精确化,防止判断条件太过于泛化导致即便没有任务需求,也调用了函数,消耗大量CPU时间。
心得体会
这单元主要训练了对多线程编程的理解。第一次作业目的在于让我们对多线程有个初步的了解(线程的创建、并发情况以及基础、经典的P-C模式),第二次作业在第一次的基础上对多线程的性能做了要求,第三次作业在第一和第二次作业的基础上又增加了同类线程之间的协作和通信的要求。对于多线程的心得与体会,可从线程安全和设计模式两个方面来考虑。
1. 线程安全
线程安全问题主要针对于临界(共享)资源,即临界资源在一个时间只能由一个线程访问,这很好理解,多个线程访问的话如果进行了不同的操作,那么线程可能得到了错误的数据。对于简单的多线程模式,只需要在访问临界资源时获得同步就能保证线程安全,在这单元里,我的临界资源是调度器,电梯获得调度器的同步后,才能够调用调度器的函数。
值得一提的是,我在bug修复阶段,电梯访问的一个调度器的函数没有加锁,如下面代码所示:
1 Elevator: 2 public void run() { 3 ... 4 while (!noTask() || ...) { 5 ... 6 } 7 ... 8 } 9 10 Dispatcher: 11 boolean noTask() { 12 ... 13 for (Resource r: rList) { 14 ... 15 } 16 ... 17 }
rList数据的修改只能够由获得了同步的电梯进行修改,想到方法的同步是整个对象,因此后加的noTask函数就没同步,反正访问时肯定是没有其它线程占用,也是独享资源的。结果交上去出现了Java ConcurrentModificationException,for-each发生了异常。查询了关于该异常的部分知识,我发现了问题所在:虽然进入访问的时刻没有其他线程,但在访问的for-each时间内,其他线程可能进入了Dispatcher,修改了rList,从而造成异常。因此,对于多线程的临界资源,一定要进行全面的线程安全考虑。
2. 设计模式
这个单元作业我采用了单例模式,所有的优点围绕只会实例化一个对象展开,比如这次作业的调度器,只需要一个,可以采用单例模式设计。但是由于个人的水平处于新手阶段,对编程理解并不到位,还是不太理解单例模式的优点,感觉就是实例化一个对象而已,设计工程之前,肯定会提前知道需要几个这样的对象,单例模式就显得很鸡肋。不过用了之后感觉比较省心倒是真的。