BUAAOO_第二单元总结与反思
作者:互联网
BUAAOO_第二单元总结与反思
题目简要回顾:电梯
第一次作业(单部多线程电梯模拟运行)
第二次作业(多部同型号多线程电梯模拟运行、可动态增加电梯)
第三次作业(多部不同型号多线程电梯模拟运行、可动态增加电梯)
一、架构介绍
1. 第一次作业
第一次接触多线程,第一次作业由于一头雾水,未完成作业无效,因此仅作简要介绍。
包含以下6类:
MainClass
:主类,用于生成并启动其他线程。InputThread
:输入进程,将输入的电梯使用请求输入等待队列waitingQueue
。Allocator
:调度器进程,将等待队列waitingQueue
中的请求按一定算法分配给特定电梯的处理队列processingQueue
(为程序可拓展性而写,在本次作业种仅一部电梯)。Elevator
:电梯进程,处理请求的主要进程,内部有电梯的运送算法。
WaitingQueue
:等待队列类,其实例化对象由MainClass
进程、InputThread
进程、Allocator
进程共享。负责在分配给电梯前作暂存起缓冲作用,降低类间耦合度。ProcessingQueue
:处理队列类,每个Elevator
类有一个该实例化对象,由MainClass
进程、Allocator
进程、Elevator
进程共享。
2. 第二次作业
设计策略
采用默认生产者消费者模式。
生产者:输入线程
消费者:电梯线程
托盘:调度器线程
架构介绍
第二次作业架构理应延续第一次作业的架构,主要对Allocator
中的调度算法进行了修改,另外Allocator
中增加了对于增加电梯请求的处理。
包含以下6类:
MainClass
:主类,用于生成并启动其他线程。InputThread
:输入进程,将输入的电梯使用请求输入等待队列waitingQueue
。Allocator
:调度器进程,将等待队列waitingQueue
中的请求按一定算法分配给特定电梯的处理队列processingQueue
。Elevator
:电梯进程,+处理请求的主要进程,内部有电梯的运送算法。
WaitingQueue
:等待队列类,其实例化对象由MainClass
进程、InputThread
进程、Allocator
进程共享。负责在分配给电梯前作暂存起缓冲作用,降低类间耦合度。ProcessingQueue
:处理队列类,每个Elevator
类有一个该实例化对象,由MainClass
进程、Allocator
进程、Elevator
进程共享。
UML图如下图所示:
![Top-Level Package](http://m.qpic.cn/psc?/
线程设计
线程协作关系
UML协作图如下:
线程启动与结束:
- 由
MainClass
生成并启动其他线程。 InputThread
循环判断需求输入是否结束,是则将waitingQueue
状态置为终止(即发送向其他线程发送结束信号)、并自行终止;否则继续读入需求至等待队列中。Allocator
循环判断等待队列是否为空且终止,是则将每一个处理队列notify(即向每个电梯线程发送终止信号),并自行终止;否则判断是否满足等待队列未终止但队列为空的条件,是则wait等待,直至其他线程通知已写入等待队列;否则即代表等待队列非空,处理请求,按调度算法进行分配。Elevator
循环判断是否等待队列终止、为空,且属于自己的处理队列为空,是则线程结束;否则判断是否等待队列仍有可能输送请求,而处理队列为空,是则wait等待,直至其他线程通知已写入等待队列或通知线程结束(证明经过此过程,处理队列有可能为空。即未分配请求未分配给自己,线程结束),开始对处理队列中请求进行处理。
线程安全问题
共享对象包含以下三个:(由于MainClass
仅起到初始化对象、并启动线程的作用,启动后不对共享对象进行读写,因而不存在与其共享产生的线程安全问题,下忽略)
-
elevatorInput
:由输入线程InputThread
与MainClass
共享,不存在线程安全问题,不需要锁(理由如上)。 -
waitingQueue
:由输入线程InputThread
与电梯线程Elevator
、调度器线程Allocator
共享,前者对其进行输入,后者对其进行读写,可能存在线程安全问题。因而:在输入线程的
run()
方法中对其状态与内容进行更改的部分(判断输入是否终止从而决定是否更改waitingQueue
状态为终止、将输入需求写入waitingQueue
中)加锁;在调度器线程的
run()
方法中对waitingQueue
状态与内容进行读写的部分(判断waitingQueue
是否终止、从waitingQueue
中将需求转移至对应processingQueue
中)枷锁;在电梯线程的
run()
方法中对waitingQueue
状态进行读取的部分(判断waitingQueue
是否终止)加锁。 -
processingQueue
:由调度器线程Allocator
与电梯线程Elevator
共享,前者对其进行读写、后者对其进行读写,可能存在线程安全问题。因而:在调度器线程的
run()
中对其状态与内容进行更改的部分(判断队列是否为空以决定是否等待、将waitingQueue
中需求通过一定算法转移到特定处理队列中)、reallocate(long id)
中对其内容进行更改的部分(将特定processingQueue
中元素进行取出)加锁;在电梯线程的
run()
中对其状态与内容进行读写的部分(判断队列是否为空、将队列中需求通过一定算法转移到特定电梯内需求队列inElevatorRequest
中)。注:隐藏冗余:原程序中在使用
processingQueues
部分也加了锁,但实际上该二维队列并非共享对象,根本无需加锁,仅其中的元素(每个processingQueue
)需要加锁。
核心算法
调度器设计
采用分布式调度器,在等待队列不为空时启用。取等待队列中最早入队的一项需求,
-
若为
PersonRequest
,则获取laziestQueue
,即当前待处理队列项最少的“最空闲队列”,将需求加入该队列,使各电梯带处理队列项数尽可能平均、提升性能。LaziestQueue
的获得由方法getLaziestQueue()
实现。注:此处遍历每个处理队列时由于进行了内部元素的读取,需要对读取部分加锁。
-
若为
ElevatorRequest
,则调用方法reallocate()
,删除每个处理队列中超过平均待处理需求数的部分,并将其合并生成新的处理队列赋给新的电梯进程;该算法优点在于重新分配,可以避免加入新电梯后老电梯仍忙碌而新电梯闲置,以提升性能。reallocate()
方法中调用了方法getAverageNum()
以获得每个电梯的平均待处理需求数目,据此转移特定需求至新电梯。注:此处遍历每个处理队列时由于进行了内部元素的修改,需要对修改部分加锁。
电梯处理算法
电梯内部的处理算法根据arrivingPattern
的不同,分为了以下三种算法。
注:此部分每个处理部分都需要对处理队列进行复杂操作,均全程对对应电梯线程的处理队列加锁。
-
Morning
由于Morning模式中所有需求均由1层出发到其他层,因此采取电梯在一楼等待足够长时间、捎带足够多人后再出发;出发后每层只可能放人、不会收人,到需求中的最高层即可停止。
- 捎带部分:先抵达一层,若处理队列不为空、电梯内未满员,则处理队列中第一项转移到电梯中;若此时处理队列为空。则等待固定时间,继续循环以上过程,直至电梯满员或等待队列依旧为空(没等来)。
- 运行部分:电梯向上运行,每到一层遍历电梯内需求,若有满足项则放人,循环此过程直至电梯内处理队列为空,若电梯现在不在一层(即本次deal开始时处理队列非空,电梯真的运行了),则调转方向。
-
Night
由于Night模式中所有需求同时出现,且均由其他层出发至1层,因此采取电梯直接到目前等待队列中的需求所在的最高层,向下开始运行,每层只可能收人、不会放人,到1层后全部放出。
- 实现:先调用
getHighestRequest()
方法获得当前处理队列中位于最高层的需求并抵达该层,调转方向,每到达一层若有满足项则放人,直至电梯到达一层或满载,则直接抵达一层并放人。
- 实现:先调用
-
Random
Random模式可出现任意类型需求,采用ALS调度算法。
- 实现:若处理队列与电梯内处理队列任一非空,则开始循环:若电梯内处理队列为空,则获得处理队列的第一个需求作为主请求,并抵达该主请求所在楼层受人;否则将电梯内处理队列的第一项作为主请求。接着运送主请求,其途中可以捎带与之方向相同的请求,因而每抵达一层都要检查是否有满足项收人、放人。直至到达主请求目标楼层,重复此过程。
3. 第三次作业
设计策略、架构介绍、线程设计
本次作业继续采用第二次作业的设计与架构,没有较大的变化。
UML图如下:
UML协作图如下:
核心算法
调度算法
相比第二次,在调度器调度过程中,增加了判别某请求是否可加入某电梯的过程(调用ableToReach()
方法),并将请求加入可到达相应楼层的、等待人数最少的相应处理队列,此基础上,型号优先顺序:C、B、A。
电梯处理算法
对dealRandom()
进行了优化:在电梯内处理队列为空,而处理队列非空时,增加在去往主请求所在楼层的过程中,也可捎带的功能。
其他改动
- 本次作业增加了电梯型号的输入,增加
ArrayList<Long> types
域,存储每个电梯的型号,在Allocator
中可单独访问types
获得某电梯型号,减少Allocator与Elevator的关联性,降低耦合度。(电梯中不存储型号,在初始化时即根据型号决定各参数指标常量) - 另外,对三个模式中功能类似的“在每层检查电梯内请求并收人/放人”的代码部分进行整合,形成
carryPersonFloor()
方法与kickOutPersonFloor()
方法,增加代码简洁性易读性。
可扩展性
采用分布式设计,调度器与各电梯间耦合度很低,功能可分离增加,可扩展性较好。
graph LR InputThread --> waitingQueue waitingQueue --> Allocator Allocator --> processingQueue_1 --> elevator_1 Allocator --> processingQueue_2 --> elevator_2 Allocator --> processingQueue_3 --> elevator_3二、bug分析
本地测试发现
1. 死锁问题
程序写完的首要任务是将程序成功运行起来,于是遇到了第一个问题即程序死锁问题。笔者第一次接触多线程编程,并无此意识,直至后来查阅了许多网上资料,才终于发现了bug的原因,即死锁。
-
导致死锁的第一个原因是对于两个对象,synchronized添加位置互相颠倒,以下代码为例:
// In Thread A: synchronized(processingQueue) { synchronized(waitingQueue) { // code } } // In Thread B: synchronized(waitingingQueue) { synchronized(processingQueue) { // code } }
这是最典型的一种导死锁的样例。当线程A已拥有
processingQueue
的执行权限、进行到等待获得waitingQueue
锁一步,若同时线程B已拥有waitingQueue
的执行权限、进行到等待获得processingQueue
锁一步,则会发现当前两线程所需要的锁都在对方手中,导致两线程均无法继续进行,即死锁。解决办法:将其中一个线程此处两个锁的顺序进行调换,即可避免。
-
导致死锁的第二个原因是对于存在调用
wait()
的共享对象,在其他线程中使用锁的部分最后没有notifyAll()
导致该对象无法获得锁、wait()
不结束。解决办法:在每个对
processingQueue
加锁的位置末尾加上processingQueue.notifyAll()
。(最好不要加notify()
,可能会导致死锁)
2. ArrayList
类对象加锁、notify的问题
调试过程中,又出现了线程安全相关的数据不安全问题,经检查出现在这样一段代码中:processingQueue.wait()
,程序无法继续进行,原因大概率是某个用到processingQueue
锁的位置在结束时并没有notify的原因。但回过头检查,唯一可能会与这里产生线程安全问题的位置好像加锁了、也通知了?
synchronized(processingQueues) {
processingQueues.notifyAll();
}
Allocator类中的对象processingQueues
是一个ArrayList<ProcessingQueue>
类型的对象。原来笔者天真地认为,对processingQueues
加锁会递归地对其所有子元素同样加锁,然而事实却是只会对其本身,即proessingQueues
加锁,其每一个元素processingQueue
是不会被加锁的,因此产生了线程安全问题。
解决办法:将以上代码更改为:
for (ProcessingQueue processingQueue : processingQueues) {
synchronized(processingQueue) {
processingQueue.notifyAll();
}
}
才能达到实际的效果(每个processingQueue
加锁)。
事实上如此看来,procesingQueues
根本不需要加锁,因为它并非实际的共享对象,相当于Allocator
类内部的本地变量,不存在任何线程安全问题。
3. 忘记加锁
程序可以运行后,会报各种奇怪的错误,如访问越界异常,可能就是由线程安全问题造成的,即某处忘记加锁了。以processingQueue
为例,当Allocator
类中正在对其中的某个请求做访问,然而Elevator
类中却对该需求做了处理并退出队列,导致对该需求的操作访问越界。
另外,忘记加锁导致的线程安全问题也可能造成如下情况:在Allocator
类中已做暂存取出的需求,在Elevator
类中却已经处理完毕,后又在Allocator
类中推入其他Elevator
的处理队列,导致一个需求被处理两次,结果错误。
解决办法:仔细分析线程运行依赖关系,对该对共享对象加锁的位置加锁,保证线程安全。
强测发现
第二次作业强测有7个点没有通过,其中三个点rtle为150s开始输入数据,需要在剩余60s内运行完毕。经验证为算法问题,需要进行优化才可能通过。
剩余四个点bug:Elevator
类中,曾经我在编写经条件判断进入的dealMorning()
、dealNight()
、dealRandom()
时,默认能够进行到此步时,其处理队列非空,在此前提下进行编程。然而事实上,我们观察以下代码,这种情况是可能发生的:
while (true) {
synchronized (waitingQueue) {
synchronized (processingQueue) { // 等待队列结束,且无未处理请求,电梯线程结束
if (noPersonAwait() && processingQueue.isEmpty()) {
return;
}
}
}
synchronized (processingQueue) {
if (processingQueue.isEmpty()) { // 等待队列仍有未处理请求,但处理队列为空。等待调度器转移请求并给锁;
try {
processingQueue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 等到锁了,可以开始处理处理队列中的请求
switch (arrivePattern) {
case "Morning":
dealMorning();
break;
case "Night":
dealNight();
break;
default:
dealRandom();
break;
}
}
第一次作业中只有一部电梯,不存在”调度算法“可言,因而等待队列若未终止或等待队列非空、而当前处理队列又为空,那么将来本处理队列中一定会等到需求排入、继续进行,我们可以默认进入deal()
阶段,处理队列一定非空。然而自从第二次作业加入调度算法后,即使当前等待队列未终止或等待队列非空、且当前处理队列又为空,wait()
结束获取锁的那一刻,可能等待队列中的需求并不是排到了本电梯的处理队列中、而是排到了其他电梯,wait()
依然可以退出;那么此时显然,处理队列依然为空,却要执行deal()
阶段。
因此在编写deal Morning()时,未考虑到在处理队列为空时,电梯不运行,依然停留在一楼的情况,在结尾处changeDirection()
调转了方向,导致再次进入该方法时,运行方向错误,电梯从一楼“向下运行”,因永远到达不了目标层而没有尽头,最终导致rtle。
解决办法:在dealMorning()
中加入条件判断,若依然在一楼(未运行)则不调转方向。
第三次作业强测中未发现bug。
三、发现别人程序bug所采用的策略
本单元可能出现bug的核心位置包括以下三个方面:调度算法、电梯运行算法、线程安全问题。
所采取的策略:本次debug采用了讨论区董翰元同学的时间戳输入法,可按时间输入需求,以在更大限度上操纵可输入数据的灵活性。利用此工具、结合分析他人代码的核心算法部分,试图找出可能存在的bug。
线程安全问题:从分析代码出发,确定共享对象与对其进行更改、读取的部分,据此构造数据,并结合时间戳工具多次运行。
与第一单元的差异:第一单元为单线程,当输入数据固定时,所得到的结果是确定的,即是否有bug也是确定的;然而本单元多线程则并非如此,由于线程执行的不确定性,输入固定数据每次所产生的结果是不确定的,因而bug是否产生也是不能确定的。因此测试时要注意构造数据的微妙之处。
四、心得体会
本单元第一次接触多线程编程,可以发现多线程的构造设计上与单线程问题是有很多不同之处的。多线程编程除了要考虑架构设计相关问题,还需要考虑线程设计的问题。本单元提示了采用生产者消费者模型,实际上已经大大降低了设计的难度,生产者-托盘-消费者三个线程已然明确,可见将一个实际情况利用建造模式抽象的过程是多么的重要。确定线程的数量与逻辑层次、与共享对象的分配,是多线程编程设计过程中的首要任务之一。
设计完成后,程序中产生的线程安全问题通常也是足以致命的,并且亲身经历告诉我这类bug由于其不可复现性真的非常难de。我总结出的debug经验是,首先利用好时间戳工具,然后观察错误输出的数据特征,在设置断点时要设置Thread类型的条件断点,这样保证之前程序还是大概率按照原来情况运行的;切勿单步运行到特定位置,因为这样大概率是与原来情况不同的运行情况,不能复现。另外,注意锁的添加:不必要的锁不要加、必要的地方一定要加、注意加锁的对象与顺序(避免死锁);有wait的一定要notify。
最后,我还注意到了本次作业主要区分为采用分布式和集中式两种设计成策略的同学。查看往届相关博客后,我了解到了集中式的优势,即性能较高,电梯利用率较高,但经过思考我认为“抢人”的机制拓展性不是很好、且在与ALS算法结合时有较多困难。综合第一单元的失败经验,我决定求稳从而选择了较为稳妥的分布式策略,虽然性能上略输一筹,但拓展性良好、架构清晰,都是不可多得的优良编程体验。
标签:加锁,队列,处理,电梯,线程,BUAAOO,反思,processingQueue,单元 来源: https://www.cnblogs.com/peppermint1113/p/14697736.html