多线程编程,千万别死锁
第一次作业
作业思路和心得
第一次作业,是单电梯捎带策略。在电梯调度策略上,本次作业难度不大,主要是刚开始接触多线程编程,对编程方式和语法还不熟练。我在第一次作业期间,阅读了很多多线程相关书籍,其中受益最大的是《图解JAVA多线程设计模式》,内容十分适合初学入手。这本书对我的作业起到了很大的帮助(帮助我理解多线程的原理和编程方式)。
本次作业思路是一种另类的ALS策略。原本的ALS策略是同方向捎带,我采用的策略是不管方向如何,当前楼层有人就捎带上(进入电梯)。由于第一次作业电梯没有人数限制,所以经过我自己的分析,在大部分情况下通过减少开关门次数是可以提高效率的(减少总运行时间)。
而关于电梯线程的设计,我没有设置调度器,我的思路是让电梯自己选择,电梯线程自由运行。电梯类里封装运行过程和上下人过程。
本次作业,让我向多线程编程迈出了第一步。这是我编写出的第一个多线程程序。通过本次作业,我对多线程编程有了初步的认知和编写方法的了解。
多线程同步控制分析
本次作业,我采用的是通过一个AllReqQuene类的一个实例作为输入线程(主线程)与电梯线程之间的共享对象。我对该共享对象的每一个公用方法加了synchronized关键字来进行同步控制,所以电梯线程不需要对共享对象的访问进行同步控制。不过由于AllReqQuene类包含了每个楼层一个的RequestQuene类,所以电梯线程对AllReqQuene类进行操作时也加上了syn关键字,以保证每一个楼层队列的操作是同步的。
经过这样的同步控制后,在公测和互测中均没有出现线程安全的问题。
程序结构分析
- UML分析
本次作业,Main类充当输入线程。由Main类创建每个请求、总请求队列、电梯和电梯运行线程。总请求队列(AllReqQuene)包含每个楼层一个的请求队列(RequestQuene)。电梯实例保有一个总请求队列,进行请求的处理。电梯线程保有一个电梯实例,同时管理电梯运行结束的方法。
- 复杂度分析
第一次作业电梯运行过程并不复杂,我的设计中,getOff方法设计复杂度和圈复杂度最高。因为在多个方法中都调用了getOff方法,导致了设计复杂度较高。同时getOff方法由于需要判断当前楼层是否下人、是否需要开关门等操作,所以判断条件很多,导致了圈复杂度较高。
通过这个复杂度分析,我觉得本次作业在复杂度方面,我可以改进的地方是,应该将getOff方法中的一些判断开关门的操作提取出去,单独形成一个方法,这样既可以提高复用率,又可以降低一个方法的圈复杂度。
- 时序图
本次作业,一共有两个线程,第一个是输入线程(Main线程),另一个是ElevatorThread线程。其中ElevatorThread线程在main线程运行中被启动,最后也是Elevator线程先结束后,Main线程最后结束。这两个线程之间通过一个共享对象——AllReqQuene的实例来进行交流。
hack与被hack
本次作业一共成功hack5次,不过都是hack到的同质bug。
由于我自己在本地测试时,并没有找到自己bug的数据,所以本次互测采用随机数据的方式进行hack。
通过python自动生成随机数据,进行hack。
第二次作业
作业思路和心得
第二次作业相较于第一次作业,新增的限制有每个电梯限载7人,一共有5个电梯,增加了负楼层。
本次作业是从开始上OO课以来,我自己认为迭代最成功的一次作业。
本次作业我仅在上一次的作业基础上增加了一个ReqQueueInele类,该类为电梯内部乘客的队列,设置限制人数为7人。该类采用生产者-消费者设计模式中的托盘(tray)的设计方式,当人满时,再要进入就要wait;当人为空时,再要出去就要wait。这样就完成了限载的要求。
其中关于人满之后,有一个关键的操作来防止死锁的产生:
while (!temp.isEmpty() && !full) { //满员了就走,不用wait,否则会死锁。
Request req = temp.get(0);
TimableOutput.println("IN-" + req.getId() + "-" + req.getFrom() + "-"
+ Thread.currentThread().getName());
temp.remove(0);
full = inQuene.put(req);
hasPeoIn = true;
}
这部分代码的关键在于while的循环条件里的&& !full,如果没有这句话,那么电梯装满人后,会等待下电梯的操作来唤醒电梯线程。可是如果循环条件少了后半句,那么即使满了,也可能会在有人的一层停下来等待,可是这一层却没有人下电梯,电梯不动了,也就不会有人下电梯。这样就陷入死锁的局面。所以这个!full的判断条件是十分重要的来避免死锁的方法。
多线程同步控制分析
本次作业在多线程同步控制方面与第一次作业的不同之处是:电梯内部的乘客队列,需要用一个新的类的实例来保存。而这个类的编写采用了经典的生产者-消费者模式的思路。get和put方法各有wait和notifyall方法,同时put方法会返回一个布尔值(是否已满)来帮助电梯线程避免死锁。其他的syn关键字的用法和使用位置与第一次作业相差不大。
通过这样的同步控制,在公测和互测中均没有出现线程安全的问题。
程序结构分析
- UML分析
本次作业Main类依旧充当输入线程,管理输入和程序的结束。Main类也会创建Elevator类的实例(共5个),并分别创建启动五个电梯线程。五个电梯每个电梯保存一个自己的内部乘客队列,同时五个电梯共享一个AllReqQuene类的实例作为共享对象。
本次作业与第一次作业总体结构相差不大,仅多一个电梯内部的队列类。所以,我对本次作业的迭代开发较为满意,本次作业很好地继承了上一次作业的代码和结构。
- 复杂度分析
本次作业较之上一次作业,电梯数量由一个增加为5个,所以有可能出现多个电梯去接一个乘客的情况。而我为了提高程序性能,避免重复的电梯运行,我便在noPeoplerun方法中加入了一个全体队列的syn块,来判断如果有电梯接走了乘客,别的电梯就不用再去接了。但是这样的设计却导致了该方法的非结构程度较高,即维护难度提高了,这是我在优化这一项时没有提前想到的。
而与之对应,加了上述的设计后,要在电梯的work方法中添加对应的判断主请求是否为空的代码段,这又进一步增加了work方法的非结构程度。这些关联在一个设计改动的复杂度的巨大变动是我在进行设计改动之前没有考虑到的,是我要在之后的作业和学习中改进的地方(提高全局性思考的能力)
- 时序图
本次作业,依旧是两个线程,主线程(Main类)和电梯线程(ElevatorThread类)。这两个线程之间的交互依旧是通过AllReqQuene类的实例实现的。所以本次作业时序图与上次作业类似,只不过由于电梯数量增加为5个,所以在创建并启动电梯线程的部分,多了很多步骤。
hack与被hack
第二次作业,我在互测中依旧采用随机数据的策略。效果一般,没有有效的hack用例。不过由于我在本地测试自己的程序的时候也没有发现自己的bug的用例,所以很难有目的地手动构造测试用例。
我在第二次作业的互测中没有被hack到,不过在强测中WA了一个点。这个点是由于我自己的程序在处理一个电梯被中途停下后(要去接的乘客被别的电梯接走了),再次运行时,起始楼层的问题,属于功能性错误。这是我在写程序的时候遗漏的一个细节。不过没有线程安全的问题,说明我对线程的同步控制做的还不错,是我比较满意的一个点。
第三次作业
作业思路和心得
第三次作业较上两次作业难度有明显的提升。与前两次的功能上最大的区别就是,这次作业电梯停靠楼层不相同,所以涉及到换乘问题。
我对换乘问题的处理是动态处理,并不是打表处理(感觉一次换乘可以打表,如果要多次换乘的情况,可能打表有点麻烦)。动态处理即我先将最初的请求按一定规律分配给各个电梯,但并不保证分配给一个电梯的请求的目标楼层是该电梯可达楼层(但是一定保证该请求的起始楼层是该电梯可达的,否则会产生死锁)。之后电梯接到乘客后,判断寻找一个距离该乘客目标楼层最近的该电梯的可达楼层将该乘客放下,同时改变该乘客请求的起始楼层为当前乘客下电梯的楼层,并且将该请求推送给可以在该请求的目标楼层停靠的电梯的请求队列。这样,乘客的换乘任务,就完全由电梯线程自己掌控,避免了很多拆分请求和打表等“人工换乘”的问题。
多线程同步控制分析
关于线程安全的问题,本次作业由于我将三类电梯的请求队列分开处理,每种电梯一个请求队列。所以在线程同步处理这部分,我在前两次的作业上进行了改进。主要是我将电梯线程内的给整个方法加的syn关键字全部替换为方法内部的语句级同步控制块,这样既能提高程序性能,又能对三类电梯的三个共享对象的同步控制进行更精准地控制。所以在公测和互测中,线程安全同样没有出现问题。
不过,做完作业反思一下,我发现我的三类电梯中的每一类电梯都保有所有三类电梯的请求队列(共享对象),那么这样会导致同步控制的复杂性的提高并且可能会引入一些未知的bug。而我也确实对我的程序的每一处对这三个共享对象的操作的时候都费尽心思进行同步控制,来保证线程安全性。所以,建议大家不要学我(我也要改进),共享对象在各个线程间的耦合性越小越好(省点脑子。
SOLID原则自我评价
- SRP:单一职责方面,我要自我批判我的Elevator类,这个类承担了全部的与电梯开始运行后有关的事情和功能:电梯运动、乘客上下、捎带与否都是Elevator类所管理的职责,这让Elevator类有些臃肿,同时逻辑上有些复杂。其他类在单一职责方面表现还不错,电梯线程类仅关注运行电梯和结束电梯,输入线程类仅关注获取输入和向请求队列推送请求。
- OCP:这方面,依旧是要批评一下我的胖乎乎的Elevator类。由于其内部封装了全部和电梯运行和乘客请求的操作,所以每当有新增功能或是电梯本身属性变化时,就要对Elevator类内部进行改写,没有很好地遵循OCP准则。不过,也许任何事物都有两面性,在我的Elevator类实现了大部分功能后(
终究还是它一个类扛下了所有,其他类和方法几乎不需要任何改变,只需使用Elevator类内部封装好的各个功能即可完成任务,所以其他类的OCP准则实现的很好。 - LSP:本次作业我并没有使用继承关系来解决问题,所以这个准则在本次作业中没有什么体现。
- ISP:本次作业在这方面我觉得完成的还不错,我使用一个Elevator接口来统一管理三类电梯的类的实例。而且由于每种电梯的功能完全相同(指实现的方法种类,内容可以不同),所以我的Elevator接口很好地抽象了三个电梯类的功能。这也让我可以用一个ELevatorThread线程类来实现三种电梯线程的创建。通过多态性,极大地化简了我的代码(可是足足少了两个类啊)
- DIP:本次作业没有涉及到一些顶层模块与底层模块的一些依赖关系的问题,所以DIP准则在本次作业中也没有什么体现,暂不分析。
总体来看,在OO设计原则方面,由于我对一个类的实现过于繁杂,导致SRP和OCP两个准则遵守的都不够好。而且我发现,SRP和OCP这两个准则应该是有很大关联的,即一个原则遵守不好,另一个也很难遵守好。这两个准则是我在今后的学习中需要继续深入理解并运用到代码中。
程序结构分析
- UML分析
本次作业与前两次最大的不同是有三种类型的电梯,每种电梯的工作方式都不太相同,所以设计了三个Elevator类(A、B、C),同时这三个类都实现了Elevator接口,这样便于利用多态性简化代码。每类电梯内部保有一个自己的相对应的内部乘客队列(ReqQueueInEleA、B、C),同时为了进一步化简代码,我将每个电梯的外部请求队列都使用AllReqQuene类的实例。为了方便结束电梯,我本次将输入线程从Main类提取出来成为单独的一个线程。
- 复杂度分析
本次功能难度的提升较大,所以为了实现电梯自己处理换乘问题,我对三个电梯的下人(getOff方法)、上人(isPiggy方法)和去接乘客的方法(noPeopleRun方法)都进行了很大的改动,也增加了很多判断条件来实现换乘,所以这三个方法的设计复杂度和圈复杂度较上一次又有所提升。不过这种复杂度的提升是由于我将过多功能封装在电梯类内部的必然情况,不解决SRP原则的问题,复杂度的提升也很难解决。
所以在今后的作业中,我要首先遵守SRP原则,这样我相信设计复杂度和圈复杂度自然会被有所控制。
- 时序图
本次时序图,与第二次作业类似,只不过本次将输入线程提取出来为一个单独的类。
输入线程读取输入,同时建立并启动三个固有电梯ABC。之后将请求推送进请求队列。
hack与被hack
本次作业在强测中功能性错误有很多,不过总结起来都是关于换乘问题的,即改变乘客的起始楼层后,没有对其他电梯及时更新告知,导致运行楼层错误。
在中测过程中,我也经历了一次CTLE,不过很快找到了bug。总结来说,当出现CTLE时,99.99%的问题都在循环。我的bug是判断结束时不小心写出了轮询,导致了CTLE。
在互测中,我尝试了随机数据和本地测试数据相结合的手段。最终hack到5个人。
心得体会
说实话,这个单元的作业是让我了解了一个全新的领域。之前从来没有接触过多线程编程,甚至对线程是什么都不甚了解。这个单元的作业,让我从了解多线程到成功实现多线程编程,收获颇丰。
同时,这个单元的作业让我充分地体会了迭代开发的快(轻)乐(松)。我在第一单元几乎每一次作业都要重构一次(至少是部分),而这个单元,我写的电梯类和电梯线程类每次只需要根据需求改动一下,就可以继续完成任务。减少了我很多重新编写代码的功夫。(原来成功的迭代开发这么爽
经过第二个单元的学习,我觉得我自己对OO的思想和原则有了进一步的理解,在编码中也对迭代开发有了进一步的体会。
不过这个单元的代码中依旧存在一些问题,比如Elevator类过于臃肿,没有遵守SRP和OCP原则。这些我都要在之后的学习中努力改进。
总体来说,第二单元体验良好,三次作业层层深入,很好地帮助我掌握了多线程相关的知识。