OO第二单元总结:电梯
作业迭代
HW5
第五次作业仅需要实现单部电梯的运行,无需考虑不同电梯之间的交互调度问题,旨在让我们初步理解多线程的概念和实现。在课程组给出实现思路后,整体的实现难度并不是很大,重点在于关于死锁等线程安全问题的理解。
UML类图
第五次作业的UML类图如下:
本次作业实现了三个线程类:InputThread
输入线程,用于不断地从控制台接收发出的请求;Schedule
调度线程,负责管理电梯线程的终止,以及电梯请求的分发;Elevator
电梯线程,负责电梯的实际运行。
RequestTable
是一个“托盘类”,采用了生产者-消费者模式将请求的接收和分发同步起来。Strategy
是电梯的运行策略类,接收电梯的一系列状态参量,分析之后给出决策,电梯只负责机械地服从其给出的Option
,实现运行与分析逻辑的分离。
UML协作图
各个线程终止的标志:
Main
主线程在创建完毕各个线程后即终止;
InputThread
在控制台输入完毕后即结束,并对总表进行setEnd
操作,告知调度器已经没有新的请求读入(但可能有换乘请求重新回到总表:下一次作业);
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
allRequest.setEnd();
break;
}
Schedule
在得知总表isEnd
并且isEmpty
后,对各个分表进行setEnd
操作,告知各个电梯该线程终止,该线程结束;
for (int i = 1; i <= 6; i++) {
requestTableMap.get(i).setEnd();
}
Elevator
在没有新请求会到来requestTable.isEnd()
、请求列表中的请求已经相应 requestTable.isEmpty()
且轿厢内没有乘客dstTable.isEmpty()
时,线程终止。
if (requestTable.isEnd() && requestTable.isEmpty() && dstTable.isEmpty()) {
return;
}
数据结构的选择
本次作业中使用了若干种容器,在此后的两次作业中也基本沿用:
对于总表中的请求,由于总表只是用于向外分发,而不与具体电梯的运行挂钩,并且需要保证请求的先来后到,因此我使用了ArrayList<Request>
这一容器。先后到来的请求按照顺序存储在该容器中,在分发时候只需使用ArrayList.remove(0)
即可;
对于分表中的请求,此时需要考虑如何让电梯更方便地运行(电梯的运行和掉头需要基于请求所在的楼层决策),因此设计了HashMap<Integer,HashSet<Request>>
容器(出发请求表),表示fromFloor
的所有的请求,在获取运行策略时,只需要按照楼层进行遍历即可,无需全部遍历;
以上两种结构均为RequestTable
类,其内部请求的含义均可视为:尚未被处理的请求。此外,我在电梯类内设置了一个HashMap<Integer, HashSet<Request>>
用于表示已进入电梯的所有乘客的toFloor
请求,其设计用意与上面类似。该处结构在双轿厢电梯出现后有一定的改动,后面会提及。
生产者-消费者模式
生产者是生成数据(请求)的线程,即InputThread
,消费者是使用数据(请求)的线程,即Elevator
,为了保证生产者能够安全地将数据交付给消费者,需要一个桥梁角色RequestTable
用于消除线程间处理速度的差异。
程序运行的过程中,输入线程将生产的请求发往“托盘”——请求总表中,当电梯线程还在运行的过程中,请求总表不断地向目标电梯线程分发请求,这个请求被加入电梯的分表中,进行下一步的处理。
在我实现的过程中为了实现相似方法的重用,将总表和各个电梯的分表都使用了RequestTable
这一个类,实际上这样产生了相当高的耦合度,在我编写代码的过程中需要市场思考某个方法是针对总表还是分表的,这是我在实现上的不足之处。这里给出一个可能的改进方案:
class WaitQueue{ // 总表
private ArrayList<Request> requestQueue;
public synchronized void addReq(){...}
public synchronized void removeReq(){...}
public synchronized void setEnd(){...}
...
}
class RequestTable{ // 分表
private HashMap<Integer,HashSet<Request>> requestQueue;
public synchronized void addReq(){...}
public synchronized void removeReq(){...}
public synchronized void setEnd(){...}
}
调度器设计
本次作业以及此后作业的电梯运行策略使用的均为LOOK策略,具体解释如下:
- 在每一层首先判断有没有人要进出:
- 如果当前电梯内有乘客到达目的地,电梯开门
WAIT
- 如果当前楼层有和电梯运行方向一致的乘客请求,电梯开门
WAIT
- 如果当前电梯内有乘客到达目的地,电梯开门
- 如果没人进出,继续判断电梯内是否有人:
- 如果有人,则直接运行
MOVE
,再重复上面的过程。 - 如果此时电梯人数为0:
- 如果电梯的请求队列中没有任何请求,返回
STOP
,电梯线程进入等待。 - 否则在请求队列中寻找,是否有必要反向(如此时电梯向上运行,从当前楼层向上遍历请求队列,检查是否有请求,电梯向下运行时同理)。如果有请求则继续
MOVE
,如果没有则REVERSE
。
- 如果电梯的请求队列中没有任何请求,返回
- 如果有人,则直接运行
由于此次作业指定了乘客乘坐的电梯,因此不涉及调度分配的问题。本人设计的调度器类Schedule
的也只有两个作用:请求终止时为所有电梯请求列表setEnd
;根据请求的目标电梯进行对应请求表的直接分配。
// 电梯行为枚举:
public enum Option {
MOVE, // 电梯在目前方向上移动一层
STOP, // 电梯停止运行,等待请求到来
WAIT, // 电梯在楼层停靠,等待人员进出
REVERSE // 电梯方向更改
}
同步块与锁
本次作业是我第一次接触锁这一概念,在使用的时候理解并不到位,只是在RequestTable
这一类中将所有方法上锁,也没有使用同步块,这也就导致了我下面会提到的bug。
实际上在Strategy
中对requestTable
进行遍历得到策略、在Elevator
中对requestTable
遍历接收乘客时,都需要保证分发端此时不会对requestTable
新增请求,由于我此时并不理解同步块,我采用了拙劣的方法:将遍历过程挪到RequestTable
这一类中的一个上锁的方法中,也是相当的丑陋了。
由于此次作业基本没啥共享资源(除了电梯请求表),因此我的这种偷懒的方法起了一定的作用,但需要明确的是:锁和同步块都是用于保证对共享资源进行安全访问的措施,锁是粗暴地对一个方法进行维护,同步块是在更细致的层面直接对共享资源的保护。而同步块的使用更加灵活,使用时需要充分考虑谁是必须保护起来的共享资源,在进行一个操作时,另一个线程的操作会不会对其造成影响,充分考虑线程间的重合部分,才能做好线程安全。
// 两种方式的等价,锁失去了灵活性
public void synchronized method() {
// code
}
public void method() {
synchronized(this) {
// code
}
}
bug分析
在本次作业的强测和互测中,均没有出现bug,但在课下完成作业时,经历了较为漫长的debug的过程。
本次作业中遇到的bug主要是关于线程安全,具体的说,主要解决的问题是ConcurrentModificationException
这一报错,究其原因便是:在对电梯的出发请求表遍历的过程中没有对其进行上锁,导致有新增请求时容器的size
发生改变,出现错误。在遍历的代码段使用同步块synchronized (requestTable)
便解决了这一问题。
(实际上一开始并不理解这一问题,是在第六次作业中解决的)
HW6
第六次作业新增了RESET
请求,并且乘客请求不再指定电梯,也就是需要我们自己选择方案进行调度,由于第五次作业中Schedule
这一设计的存在,我可以在其中选择将请求分配到哪个电梯,因此我选择首先实现RESET
的相关行为,之后再实现调度策略。并且基于前一次作业的架构,此次作业没有大幅度修改的地方,做的基本是新增与微调工作。
UML类图
由于图像大小原因,仅保留了关键部分。
UML协作图
线程终止的标志放到下一部分阐述~
RESET的实现
与第五次作业不同,我们此时的输入有两种请求——乘客请求与重置请求,为了将两者统一起来进行分配,我设置了父类AllRequest
,两类请求RstRequest
与EleRequest
继承此父类。在分表中引入新的数据结构ArrayList<RstRequest> resetTable
用于存放重置请求。
起初我的设计是通过Schedule
将重置请求分往对应的电梯,但这样会出现Move Too Much
的错误,此部分会在bug分析中提及。最后我选择在InputThread
中直接将重置请求发往对应的电梯,不再经过调度器的分发。
在课程组的提示下,我们可以将RESET
作为电梯的一个行为,只需要接收这个行为并模拟这个行为即可。
乘客去往哪
首先RESET
时需要将电梯内所有的乘客“赶出去“,此时如果电梯内仍有未到达目的地的乘客,我们应该把他们塞到哪里呢?最自然的方法是让他们在原地等待当前电梯完成重置,继续乘坐该电梯到达目的地,这样只需要:
- 遍历目的地请求表
dstTable
,将未到达目的地的乘客的fromFloor
更改为当前楼层,重新加入电梯的分请求表中 - 将目的地请求表
dstTable
清空(模拟清空电梯内的乘客)
由于这种处理没有对总表进行修改,因此在实现过程中只需要对分表进行维护,并且无需更改电梯线程终止的标志。但是乘客原地等待1.2秒的是否值得,是否会造成电梯资源的浪费我们不得而知(起初我想做出类似影子电梯、调参等方式的调度策略,从这点出发),将所有被”驱逐“的乘客重新塞到总表中似乎是更合理的方式。
为了实现这个目的,原本作为消费者的电梯,此时又被赋予了生产者的身份,会和InputThread
一样向总表中派发请求,此时电梯线程也需要将allRequest
总表作为一个私有属性,一定程度上增加了代码的耦合度。
线程的终止
由于目前有两个线程都会向总表中分发请求,线程的终止逻辑也要发生较大的改变。
InputThread
仍然和之前一样正常结束,但这时的allRequest.setEnd()
的意义发生了改变——表示来自InputThread
的请求结束,来自Elevator
的重新派发的请求是否结束呢?我们不得而知。因此我们对于Schedule
线程的结束,应该满足以下两个条件:
- 不再有来自
InputThread
的请求:读入ctrl D
- 不再有来自电梯
RESET
后的重新派发的请求
为了实现第二点,我们在RequestTable
中引入int resetCnt
这一变量,用于统计尚未处理完毕的重置请求。当读入RESET
时,该变量自增1,当输出RESET_END
后,该变量自减1,这样便可以保证总表为空、输入线程终止但仍有电梯正在重置时,调度线程不会终止,仍在等待可能从电梯线程返回的请求。
// Schedule线程结束的标志
if (allRequest.isEnd() && allRequest.isEmpty() && allRequest.getCnt() == 0)
尽可能地移动
其次,为了让电梯在RESET_ACCEPT
后让电梯在满足题目要求下(不多于两次MOVE
)尽可能多的进行移动,我在Strategy
中设置了变量int resetMove = 0
:
- 当电梯无人时,直接开始重置;
- 当电梯有人且有人到达时,开门等待;
- 当电梯有人且无人到达,电梯移动一层,
resetMove++
,说明已经移动了一层 - 下一次如果还想要移动,则不被允许,直接开始重置
if (!resetTable.isEmpty()) { // 有重置请求
if (personNum == 0) { // 电梯无人
resetMove = 0;
return Option.RESET;
} else if (dstTable.get(floorNow) != null) { // 有人要出去
return Option.WAIT;
} else if (resetMove == 0) {
resetMove++;
return Option.MOVE;
}
resetMove = 0;
return Option.RESET;
}
调度策略
理想很丰满,现实很骨感,能力很有限,结果很惨淡。。。
当我想使用影子电梯策略时,已经因为debug时间太久来到了周六,于是便采用了最为简单的均分策略。
对于每一轮的分配(6次为一轮),我会首先对当前轮次未分配的电梯计算运送距离,选取运送距离最短的电梯去接收请求:
- 如果请求方向与该电梯运动方向一致且在该电梯的移动路径上,他们之间的距离便是:楼层间隔数目*速度
- 否则,距离计算变为:(电梯当前楼层与最远目的楼层的间隔楼层数 + 折返的楼层间隔数)*速度
其实最后来看,这看似有所改动的均分策略所起的作用微乎其微,看起来更像是对自己的自我欺骗和安慰。均分的固有局限决定了在此基础上的打分所起的效果增益是递减的,比如第一座电梯接收到的请求可能是最合适的,但到了最后两座或一座电梯,基本上是在瘸子里面拔将军,沦为一种强制的分配方式。并且这种极其粗暴简略的打分方式没有考虑客容量等因素,也带来了特别大的随机性。
鉴于个人的时间和精力有限,此后也没有对该策略进行改动,后来尝试自创的打分策略也因各种bug宣告失败。不过这种代码量极小的策略,相比于影子电梯等复杂调度,出错的概率极大程度地降低了,代价便是极其辣眼的性能分(苦笑)。
锁与同步块
经过上一次作业的历练,此次作业对锁的认识加深了不少,不仅de出了第一次作业没被发现的bug(bushi),在共享资源变多,线程交织程度剧增的情况下能保证同步的正确性。
此次作业我基本上使用的是同步块来保证线程安全,比如在电梯RESET
过程中,会进行分表的遍历,此处只需要对分表进行同步块修饰即可;对于向总表的请求的重新分配,只需使用上一次作业中的方法锁即可。
正在重置的电梯的请求分发
对于正处于RESET
过程中的电梯(已输出RESET_BEGIN
),为了防止”围城必阙“情况的发生(所有请求被塞到一个不在RESET
过程中的电梯),我们需要保证仍向该电梯分发请求,但是相应的RECEIVE
语句必须在RESET_END
之后输出,为了解决这个问题,必须对锁与同步块有清除的认知:对RESET
全过程进行同步块修饰,这样保证一旦RESET_BEGIN
,requestTable.addReq()
操作便不可进入,但调度器仍然可以正常分配,直至RESET_END
才会将RECEIVE
信息输出。
synchronized (requestTable) {
TimableOutput.println("RESET_BEGIN-" + id);
// 请求重新分发至总表
sleep(1200);
TimableOutput.println("RESET_END-" + id);
// code...
}
此外,对于notifyAll
的使用,我的理解也加深了,过多的notifyAll
可能导致线程不断被唤醒,出现轮询而导致CTLE
的情况,因此需要明确谁在wait
,因为什么原因而wait
,需要什么条件来notify
,有没有多余的notify
导致没必要的唤醒等等,此问题会在bug分析中提及。
bug分析
本次作业的强测与互测均没有出现问题,但此次作业的debug是整个单元里最折磨的一集~~(也可能是清明假期的原因)~~。
过多的notifyAll
首先是因为notifyAll
太多出现的轮询问题,我采用了助教给出的printf
大法(此方法在其他地方的debug过程中也发挥了很大的作用),在每个while
条件块中进行打印测试,找到输出文件大小最离谱的那个,即确定了轮询的位置。
经过反复定位,最终找到了轮询的位置——电梯线程的STOP
处:
case STOP: // 等待新的请求
try {
requestTable.Wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
这和直觉是相符的,因为整个程序中只有电梯线程会陷入等待,而isEmpty()
方法中的notifyAll
会在在while(true)
里不停地被执行,等待队列中的电梯一直被无效唤醒,出现了轮询的情况。
// Elevator.java
while(true) {
if (requestTable.isEnd() && requestTable.isEmpty() && dstTable.isEmpty())
return;
// code....
}
// RequestTable.java
public synchronized boolean isEmpty() {
notifyAll(); // 罪魁祸首
return requests.isEmpty() && resetRequest.isEmpty();
}
过迟的RESET分发
起初在我的实现中,不论是乘客请求还是重置请求,我都会将其交给Schedule
进行分发,示意如下(箭头上的数字表示分发的顺序)。这样的方式会导致优先级很高的重置请求被延后,而我们想要的是对重置请求的瞬时响应,否则会出现错过RESET
导致MOVE too much
的情况。
为了解决这一问题,我们在InputThread
收到重置请求的瞬间,立马发往对应的电梯,而不是经过调度器分发(不经过总表),最终的效果变为如下,可以保证RESET
的及时响应。
注:图中省略了输入线程向总表添加请求的过程,箭头上的顺序表示分表接收的相对前后顺序。
HW7
本次作业引入了一种新的电梯类型——双轿厢电梯。它诞生于单轿厢电梯的”毁灭“,自然地想到在结束原电梯线程的同时,开启两个新的DoubleElevator
线程,运行逻辑与原有电梯类似,只需做好在换乘层的交接和电梯线程终止,总体难度不大。
UML类图
省略了RequestTable
等未发生变动的模块。
UML协作图
双轿厢的防撞策略
我们对于“碰撞”的定义如下:同一号电梯中的A、B电梯Arrive
换乘层的信息相邻地输出。因此只需保证当一部电梯到达换乘层后,对换乘层上锁,当此部电梯离开换乘层后将锁释放,此时另一部电梯可以获得锁并且立马输出Arrive
,避免出现在等待时间上的浪费。
为了避免双轿厢电梯在换乘层发生碰撞,我在Strategy
中保证:在双轿厢电梯到达换乘层并执行完对应操作后会立马离开换乘层;此外,我单独设置了一个类Occupation
,其实例为“分裂”后两个电梯的公共资源,通过上锁来保证每次只有A电梯或B电梯能够获得Arrive
换乘层的权限,具体实现如下:
// Occupation.java
class Occupation{
private int flag; // 1为占有
public Occupation() {this.flag = 0;}
public synchronized void setLock() {
waitLock();
this.flag = 1;
notify();
}
public synchronized void releaseLock() {
this.flag = 0;
notify();
}
public synchronized void waitLocK() {
if (flag == 1) {
wait();
}
}
}
// DoubleElevator.java
public void doMove() {
floor = up ? (floor + 1) : (floor - 1);
try {
sleep(new Double(speed * 1000).longValue());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (floor == transferFloor) {
flag.setLock();
}
TimableOutput.println("ARRIVE-" + floor + "-" + id + "-" + type);
if ((floor == transferFloor + 1 && up) || (floor == transferFloor - 1 && !up)) {
flag.releaseLock();
}
}
每次双轿厢电梯到达某一楼层,判断是否到达换乘层,如果到达换乘层,就尝试去获得flag
的锁,如果此时换乘层没有被占用,则此电梯成功抢占换乘层,直至下次移动时释放锁;如果此时换乘层被另一电梯占用,则此电梯线程会因Occupation.flag == 1
进入Occupation
的等待队列,直至锁被释放。
双轿厢电梯线程的终止
当原有电梯线程被终止后,姊妹电梯仍公用一个请求表,并且保证换乘的乘客登上的一定是同一电梯井中的另一个轿厢。因此只有两个姊妹电梯轿厢中均没有乘客时,才能考虑让进程终止。在我的实现中,姊妹电梯互相拥有对方的目的地请求表,用于判断是否达到进程结束的标志。
if (requestTable.isEmpty() && requestTable.isEnd() && dstTable.isEmpty() && anotherDst.isEmpty()) {
return;
}
锁与同步块
本次的锁与同步块主要体现在两个部分:Occupation
中的楼层抢占(上面已经提及),以及DstTable
的各个操作的原子性保障。
因为DstTable
成为了姊妹电梯的共享资源,因此需要对使用它的地方进行上锁:
public class DstTable {
private HashMap<Integer, HashSet<EleRequest>> dstTable;
public synchronized boolean isEmpty();
public synchronized void add(int i, EleRequest r);
public synchronized HashSet<EleRequest> get(int i);
public synchronized void remove(int i);
public synchronized void put(int i, HashSet<EleRequest> set);
public synchronized boolean containsKey(int i);
}
bug分析
本次强测没有出现bug,但在双轿厢相撞上花费了较多时间进行debug,在这里简述一下我的debug方法。
由于我本人采取了均分策略,每次都可以进行微小的修改将所有请求分配到同一部电梯,让其跑出共性问题。调试过程很可能破坏时间片,但是只要能够复现,便可以锁定线程,进行成员变量、运行过程的跟踪,此外我也学会了使用条件断点,这对于多线程的debug是相当有用的,尤其在我遇到双轿厢相撞时,能够迅速定位到问题发生的位置,快速做出修改。
尽管printf
大法有时候也会干扰线程的运行,但在解决线程无法终止等死锁问题时候往往有奇效。
新增与修改
第六次作业在第五次作业的基础上扩展了Schedule
部分,增加了调度策略;新增了请求类型RstRequest
;电梯线程作为生产者可以产生请求;电梯运行策略Strategy
引入了重置RESET
行为,其余为细节上的变动。
第七次作业在第六次作业的基础上新添了线程DoubleElevator
;增加了运行策略中的换乘TRANSFER
行为;新增了”防撞公共类“Occupation
;新增了请求类型DoubleReset
,其余为细节上的变动。
总的来看,三次作业的迭代过程基本是一个增量过程,并没有涉及很大的改动,每次作业都在上一次的基础上做新增。除去输入线程等给定的代码框架,RequestTable
,Schedule
等为较为稳定的内容,Elevator
,Strategy
等为每次修改较多的内容。
由于每次作业新增的部分基本都是电梯行为层面的改动,因此与电梯具体运行相关的内容容易发生变动,而请求分配、生产者-消费者的设计模式等由于一开始有着较好的设计,后续改动并没有特别大。
心得体会
线程安全:
本单元自始至终最令我头疼的,便是随时都要考虑的线程安全问题。以往只接触单线程,在书写多线程代码时总是容易遗忘对线程的保护,而这正是多线程的高效率的基础,如果连基本的线程安全都无法保障,再高的效率也只是一纸空谈。
无论是在测试个人bug还是在互测过程中,总是遇到无法复现或者复现概率相当低的情景。在很多次测试中,千分之一的出错率,究竟是笃定它不会被测出而不去修改、不去重新审视代码,还是去追求严谨、努力扼杀每一处的线程不安全?我想这个答案是确定的,但在我身上并没有很好的做到。此后面临的问题,不再是OO的课下作业,而是大型的工程项目,与现实接轨的实际问题,所承担的风险不再是挂掉的一两个点,而是整个项目的质量,和无法估计的损失。千万分之一的错误概率,放在实际工程中,也是不可原谅、不可接受的。
层次化设计:
不管是课程组推荐的设计模式,还是反复提及的SOLID
原则,好的设计所带来的收益绝对是巨大的。本单元的作业,虽说呈现出了一定的层次化,但回头看还是感到臃肿、难以入眼。不过谁又能保证自己的设计是绝对完美的呢?在这个过程中看到哪怕些许的进步,也是值得欣慰的。
结语
是容易遗忘对线程的保护,而这正是多线程的高效率的基础,如果连基本的线程安全都无法保障,再高的效率也只是一纸空谈。
无论是在测试个人bug还是在互测过程中,总是遇到无法复现或者复现概率相当低的情景。在很多次测试中,千分之一的出错率,究竟是笃定它不会被测出而不去修改、不去重新审视代码,还是去追求严谨、努力扼杀每一处的线程不安全?我想这个答案是确定的,但在我身上并没有很好的做到。此后面临的问题,不再是OO的课下作业,而是大型的工程项目,与现实接轨的实际问题,所承担的风险不再是挂掉的一两个点,而是整个项目的质量,和无法估计的损失。千万分之一的错误概率,放在实际工程中,也是不可原谅、不可接受的。
层次化设计:
不管是课程组推荐的设计模式,还是反复提及的SOLID
原则,好的设计所带来的收益绝对是巨大的。本单元的作业,虽说呈现出了一定的层次化,但回头看还是感到臃肿、难以入眼。不过谁又能保证自己的设计是绝对完美的呢?在这个过程中看到哪怕些许的进步,也是值得欣慰的。
结语
受尽折磨的第二单元结束了,看着没有出错的强测略感欣慰,看着惨不忍睹的性能分,又对自己有些许失望。正如第一单元经历的那样,在一次次竭尽所能中,接纳普通的自己。