0. 作业简介
实现六部电梯系统,设计调度策略,并模拟重置、双轿厢等高级特性。
1. 同步块的设置和锁的选择
1.1 锁的选择
Java为多线程提供了多种工具,主要包括同步块(synchronized
), 锁(Lock
)与读写锁(ReadWriteLock
),强制透写(volatile
),原子类(Atomic
)以及一些线程安全的数据结构(如CopyOnWriteArrayList
)。
我认为,选择的主要依据是同步的目的。如果是无序访问(互斥),只需保证原子性和可见性(即写入共享对象时不被打断且保证其他线程能立即看到),使用原子类完全能够达到目的(volatile
不保证原子性,我没有使用)。而需要更高级的同步特性,例如阻塞、唤醒等需求,那么可以使用synchronized
块。
例如,我使用计数器来统计是否还有电梯会像分配器打回乘客(对应每个resetRequest,revokeCount++; 对应每次打回,revokeCount–)。那么在输入线程增加计数器时,只需保证自增操作不被干扰;在电梯线程减少计数器时,也只是到减小到0时才去通知可能在等待的分配器线程——已经不会再打回乘客了,这时需要在同步块中notify。
//statement
private final AtomicInteger revokeCount = new AtomicInteger(0);
//increment
public void increaseTRevokeCount() {
revokeCount.incrementAndGet();
}
//decrement
public void decreaseRevokeCount(int delta) {
int count = revokeCount.addAndGet(-delta);
if (count == 0) {
synchronized (issueQueue) {
issueQueue.notifyAll();
}
}
}
这种选择方式有以下优势:
- 减少不必要的
synchronized
,避免持有锁的嵌套,即如下情况,提高代码的可读性和维护性,并且从源头上避免死锁(两个进程各持有一个锁,却互相等待对方的锁)。
synchronized(A) {
//codes
synchronized(B) {
//codes
}
}
- 同步方式的一致性,同步全部使用
synchronized
,互斥全部使用Atomic
,避免不同类型的同步机制混用(例如混用synchronized和Lock),使代码清晰,减少bug。
1.2 同步块的布置
synchronized
有两种部署方法:在方法声明中和在块中。前者的优势是设计简单、同步入口点一致,比较适合“无脑同步”;而后者能够缩小同步粒度,并且能灵活地部署wait
和notify
,但带来的挑战是编程者必须清楚的知道是想使用哪个对象的监视器。笔者的程序中全部运用后者,在编程时也通过画图对两类同步对象和三类线程的关系保持清醒的认知:
/*
* +-----------------+ +-------------------+
* | Input | PersonRequest | Dispatcher |
* | Thread |--------------> | Thread |<-----------------------
* | | LOCK"issueQueue"| | |
* +-----------------+ +-------------------+ |
* | LOCK"issueQueue" ^ |LOCK"requestList" |(same thread)
* | ResetRequest Passenger | | Passenger |
* v LOCK"requestList" (revoke) | v (dispatch) v
* +---------------------------------------------+ +---------------+
* | Elevator | LOCK"stateCopy" | Emulated |
* | 6 Threads |------------------> | Elevator |
* | | update each cycle | |
* +---------------------------------------------+ +---------------+
*
*/
图中,issueQueue
对象联系输入、分配、电梯三线程,分别对应写入、分配和打回三项操作;而六个电梯的requestList
对象联系电梯和分配线程,分别对应接收和分配。(详情请见“3.架构设计”中的“协作图”)
2. 调度器的设计
2.1 调度器和其他线程的交互
homework 5
在hw5中,我将调度器类Dispatcher
直接继承于Thread
,在run
方法中实现了读入和分配。我没有设计发射队列,而是读取一个分配一个。另一方面,调用相应电梯的addToWaitingList
方法,这个方法实现了同步与唤醒。当输入结束时,分配器唤醒电梯并通知可以结束。
homework 6
进入hw6,由于涉及电梯在reset时需要打回已接受但没送达的乘客,这种“单托盘双线程”的模型已不再适用,于是改成了“双托盘三线程”的方式:输入、分配、电梯三个线程,用issueQueue
(发射队列)沟通同步事宜。
上述设计引发了另一个问题:输入结束后仍可能有电梯像发射队列打回乘客,分配器必须在确认电梯不会回写后再通知电梯可以结束。解决方案是在Dispatcher
中增加一个计数器revokeCount
,每当读入reset请求便自增,每当电梯响应一次请求便自减(使用原子类即可)。
三线程关于issueQueue
的工作流程如下:
homework 7
增加revokeCount
计数器的规则:分配到的电梯会检测当前乘客是否需要换乘,若是,则自增。每当乘客换乘,则自减。
2.2 调度与运行策略
运行策略
由于前期功课没做够,未研究各算法优劣 ,在hw5选择了ALS算法,结果强测性能很差。由于底层的数据结构是适应ALS的,所以后面没有改。
具体到编程,实现了一个Moore型FSM(有限状态机),状态转移图如下(RESETTING为hw6新增部分):
hw6调度策略
为了追回ALS的不足,在hw6选择了“影子电梯”,即对于当前乘客,需要分别模拟所有电梯接受这名乘客并按相同策略运行,比较哪部电梯最早进入空闲状态(不是最早送达这名乘客,因为乘客上电梯是乱序的,先让后到的乘客上电梯可能影响先到的乘客等待时间),这样可以保证对于每个当前的乘客,系统都能以最快的速度结束。在实现中,引发了以下问题:
- 电梯要能够自我复制。类似于
fork
复制进程——在拷贝内存时还要拷贝PC,电梯要复制属性和当前程序执行的位置。还要考虑同步和避免死锁; - 影子电梯和真实电梯要重用代码,但底层行为不同(不输出、不sleep而是增加累计时间)。不能直接复制代码,否则在迭代和debug需要同时改两份;
- 某一电梯在reset重置期间也要模拟和接受分配(但是不能输出RECEIVE),否则会在互测时对于如下数据,会全部分配给不在reset的电梯:
[49.9]RESET-DCElevator-1-3-3-0.6
[49.9]RESET-DCElevator-2-4-3-0.6
[49.9]RESET-DCElevator-3-5-3-0.6
[49.9]RESET-DCElevator-4-6-3-0.6
[49.9]RESET-DCElevator-5-7-3-0.6
[49.9]1-FROM-11-TO-1
...(64 more passengers)
自我复制
对于问题1,我在每一次状态转移时都会拷贝一份电梯属性的副本(类似于硬件的“逐拍采样”),然后模拟时利用这份副本生成影子电梯,影子电梯直接将相应的状态处理函数作为入口即可。这样相比于即时复制,可以减轻同步负担并保证电梯状态和程序执行同步。
我在hw6时没处理好复制导致bug:如果同时出现多条请求,前一条已接受的请求对于后一条的预测是不可见的(状态没更新,不会采样),所以每条请求都会进入同一部“最优电梯”(预测时不知道前一条请求的存在),导致互测被hack。解决方案就是打补丁:每次分配时都更新以下状态副本中的requestList
(候乘表)即可。
重用代码
真实电梯Elevator
和影子电梯EmulatedElevator
同时继承于抽象电梯AbstractElevator
。
对于等待与输出,直接利用多态性选择入口。类图和示例代码如下:
//AbstractElevator
protected abstract void pauseForSpecificTime(int time);
//EmulatedElevator
protected void pauseForSpecificTime(int time) {
accumulatedTime += time;
}
//Elevator
protected void pauseForSpecificTime(int time) {
try {
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
reset时接受分配
这一方面需要影子电梯支持(不能reset了就模拟结束了),另一方面需要真实电梯支持(reset期间需要接受分配,但不能输出)。
对于影子电梯,如果发现需要reset,就应先reset再投喂,否则直接投喂。
对于真实电梯,必须将“REVCEIVE”的输出置于电梯线程。我借鉴了缓存回写的设计,在侯乘表类RequestList
中增加缓冲区“buffer”,它对于电梯和分配器均不可见,分配器会调用RequestList::addToWaitingList
时实际写入buffer中,而电梯每次读取RequestList
时都会先将buffer写入真正的候乘表,此时输出RECEIVE。(重置期间电梯是不会读候乘表的)buffer示意图如下:
实现中,还要考虑输出reset begin、打回乘客、模拟与分配的时序问题,否则可能出现打回后尚未reset而又分配等错误。具体的时序图如下:
影子电梯策略对双轿厢电梯的适应
hw7中,乘客在双轿厢电梯的换乘楼层下电梯,不能认为电梯就运行结束了。我大胆采用了DFS算法,如果遇到双轿厢电梯,
结束时间
=
max
(
换乘前轿厢结束时间
,
min
六部电梯,在乘客换乘时间投喂
(
结束时间
)
)
结束时间=\max(换乘前轿厢结束时间, \min_{六部电梯,在乘客换乘时间投喂}(结束时间))
结束时间=max(换乘前轿厢结束时间,六部电梯,在乘客换乘时间投喂min(结束时间))
首先,模拟电梯要支持“定时投喂”功能,即没到相应时间不可见到当前虚拟乘客。仍可以利用上一节的“buffer”方法;如果电梯提前结束,可以把当前时间设置成投喂时间,再次运行即可。
其次,DFS必须要剪枝,即如果发现**累计时间已经超过了当前最小时间,应立即返回。**否则对每个请求,最多6个双轿厢电梯有6个不同的换乘楼层,需要模拟
6
6
=
46656
6^6=46656
66=46656次。如果实际在中途换乘,模拟次数更多。即使剪枝,在互测中一个边缘数据点还是CPU超时了(总体时间不长)。实际CPU时间数量级与限制时间相同,说明可以通过优化达到相关要求(或者直接放弃DFS )。
调度策略对性能指标的适应
hw5中,ALS策略是出于优化单个乘客最长等待时间,牺牲了总体运行时间。强测结果证明弊大于利。
hw6中,影子电梯策略最小化整体运行时间。另一方面,为了防止某个乘客在因reset而被打回后到队尾重新排队,导致最长等待时间过长,因此我将候乘表中的FIFO模型改成了优先队列,即每个乘客在创建时都会被赋予一个顺序编号,编号越小优先级越高,越优先上电梯。(前期被打回的乘客会保持高优先级)
hw7中,双轿厢电梯的耗电量小。但是,由于无法知道耗电量和时间的权重关系(尽管有公式,但是不可能知道他人的相关性能,因此仍是无法计算的),不可能定义代价函数进行多目标优化。所以还是优先时间,只能在同等情况下分配给双轿厢。
3. 架构设计
3.1 三次作业架构设计的逐步变化
hw5
hw5的设计相对简单,有几个特点:
MultiFifioMap
是一种适应按楼层查找,每个楼层对应多个人(队列)的数据结构(泛型)在候乘表、电梯内部均有使用。- 已经猜到了后续会自己调度,所以预留了
Dispatcher
调度器类,管理6部电梯; OutputAdapter
和Passenger
是对课程组TimableOutput
和Request
类的适配器,是因为我担心课程组的接口会改变导致代码大面积修改。(事实证明这样做是对的)
hw6
这里省略了ds.MultiFifoMap
。与hw5的主要区别在于:
- 电梯被分成抽象电梯、真实电梯和虚拟电梯,三者关系已在“影子电梯”部分阐明。
Elevator
不再继承Thread
,输入线程也没有被封装成一个类,它们都使用lambda表达式
作为属性,更加轻量化。
//Dispatcher::inputThread
private final Thread inputThread = new Thread(() -> {
ElevatorInput elevatorInput = new ElevatorInput(System.in);
while (true) {
//reading a line
}
//end
}
//Elevator::elevatorThread
private final Thread elevatorThread = new Thread(this::execute);
hw7
hw7的关系明显复杂(略去了和hw6重复的部分),出现了Dispatcher->Elevator->ElevatorShaft->Dispatcher
的循环依赖。轿厢依赖上级井道,是因为需要井道协助双轿厢reset的操作;电梯依赖分配器,是因为模拟影子电梯时用到DFS,需要递归地分配。可见这种优化提高了程序复杂度。
为了避免循环依赖产生的混乱,Dispatcher
实现了TimeEvaluator
接口,影子电梯只能调用这个接口的getMinTime
方法,划清访问权限。
可扩展性
如果对电梯增加更多约束,例如停靠楼层等,只需在电梯类实现,影子电梯的预测自然追随电梯,而分配器等前端架构不用改变。
3.2 线程的协作关系
协作图
这里展示的是各线程之间通信关系,是两托盘——三线程的生产者——消费者模型:
时序图
这里展示对乘客、重置、结束三类请求的时序关系:
3.3 稳定的内容和易变的内容
稳定的内容
- 电梯的状态转移框架。尽管hw6增加了重置状态,但是之前的转移逻辑没有改变;
- 适配后的乘客:引入
Passenger
类而不是直接调用官方包的Request
,在迭代时与上层的交互不用变; - 分配器内部的代码:由于影子电梯的模拟是电梯自己提供的服务,分配器只需调用相应方法获得时间比较即可,无需改动。
易变的内容
- 输入接口,输入线程的处理(request请求的变化);
- 电梯的模拟策略:hw6引入影子电梯,hw7又增加dfs,需要循环调用分配方法,使得影子电梯策略频繁改变;
- 电梯类内部的组织:由于hw6没考虑代码风格约束,在电梯类内定义了很多内部类,hw7时超过500行,需要把这些内部类移出,造成一些可见性问题,又配置访问方法,改动较大。
总之,hw6几乎是大改;hw7主要是配置一些对象的交互方式,但是框架没有变。
4. 双轿厢的实现
4.1 基本设计
第7次作业,增加ElevatorShaft
作为分配器和电梯之间的桥梁,由井道选择合适的电梯,使得Dispatcher对井道内部情况不可见。
4.2 保证双轿厢不碰撞
在电梯井道ElevatorShaft
类中,有一个Object
对象transferLock
作为两个轿厢共享的监视器。
如果轿厢希望进出换乘楼层,轿厢会拿到transferLock
的锁,然后调用touchTransferFloor()
方法,进入换乘楼层,交换乘客并立即回到当前楼层。
如果transferLock
已经被占用,表示另一个轿厢在换乘楼层,该轿厢线程会被阻塞,让权等待直到另一个轿厢释放锁。这里不需要使用wait和notify,因为被阻塞的线程不会占用CPU,使用wait和notify反而可能造成bug。
pauseForSpecificTime(parameters.getMovingTime());
if (shaft != null) {
synchronized (shaft.getTransferLock()) {
touchTransferFloor();
}
} else {
touchTransferFloor();
}
这里有个细节,先让电梯等待走一层楼的时间,再去竞争锁(量子电梯),这样可以使一个轿厢走出换乘楼层时,另一个立即进入。
5. 多线程debug
可能遇到的bug包括常规bug(单线程)、双线程同步问题(访问互相干扰、死锁等)。
5.1 线程快照和idea中debug的打断功能
idea线程快照(📷)可看到是不同线程的状态,已经在竞争哪个锁等信息,
可以初步排除无法结束的问题。
第6次作业遇到无法结束问题,本以为是死锁,但是风扇转速很快,于是使用idea的线程快照功能,得到如下结果:
"Thread-0" #20 prio=5 os_prio=0 tid=0x00000221635e1000 nid=0x277c runnable [0x000000bc7feff000]
java.lang.Thread.State: RUNNABLE
at java.util.TreeMap.getEntry(TreeMap.java:359)
......
"Thread-7" #27 prio=5 os_prio=0 tid=0x00000221635df800 nid=0x44c waiting for monitor entry [0x000000bc7fcfe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at Dispatcher.decreaseResetCount(Dispatcher.java:141)
- waiting to lock <0x00000006c4419610> (a java.util.PriorityQueue)
......(6 more threads)
发现有一个线程(分配器——影子电梯)是RUNNABLE(在运行),其他在BLOCKED,不是死锁。于是使用idea的debug,在卡死的时候进行打断(暂停)并选择RUNNABLE的线程,从当前指令单步运行,发现是影子电梯陷入状态SEEKING_DESTINATION
死循环(影子电梯不会wait),所以加一个特判即可解决。
5.2 print方法
在关键部分直接将相应属性输出。比如在hw7中我发现有时会少处理一个乘客,于是在分配器和电梯井道分别设置打印,跟踪这个乘客去了哪个井道的哪部电梯。
//ElevatorShaft
public void addToWaitingList(Passenger passenger) {
Elevator properElevator = getProperElevator(passenger);
properElevator.addToWaitingList(passenger);
//System.out.printf("passenger: %d, go to elevator: %d-%s", passenger.getId(), properElevator.getId(), properElevator.getCarType());
}
发现该乘客在双轿厢重置期间仍然进入了单轿厢电梯,但单轿厢电梯已经清空乘客准备结束了。这是getProperElevator
没有实现同步,用synchronized包裹即可。
5.3 多次测试
如遇bug难以复现,可以多进程同时运行一条错误数据点,并且打印输出相关信息,这样比盲目生成大量数据效率要高。
6. 心得体会
6.1 线程安全
体会包含在了第一章“锁的选择”中。主要总结为以下几个方面:
- 先想好哪个变量需要互斥访问,哪些线程直接需要等待和唤醒,然后按需选择同步方式(尽可能轻量,尽可能缩小范围)。解决同步问题不是盲目的
synchronized
,不是无脑复制参考代码,否则debug成本远高于前期思考的时间; - 不能只考虑当前指令流,心中要有多线程同时运行的场景,明白缺省情况每一行代码中间都可能被打断。可以画出时序图分析各线程对于锁的竞争关系;
- 同步策略的一致性。不要多种锁混用,不要将
synchronized
声明的方法和synchronized
块混用,减少锁的套用。良好的代码风格会从根源上避免很多问题。
6.2 层次化设计
最终的架构被分为了“前端”和“后端”,前端的分配利用后端电梯提供的预测服务进行分配,不关心双轿厢具体的实现,认为只有六个电梯;后端的电梯也不知道分配器的存在,认为只有一部电梯。这种解耦极大简化复杂度。
但是hw7中一些循环的依赖还有改进的空间,可以尝试解耦。
6.3 其他
我发现,本单元和硬件和系统设计有着很多共同点,我在设计时也参考了相关思想,包括缓冲、采样、时序分析(运行与调度策略-hw6调度策略-reset时接受分配)以及状态机等。
7. 建议
建议课程组引入“停靠”约束,防止所谓“量子电梯”这种没有实际意义的“优化”。即:先输出“停靠”才能开门,关门后先输出“离开”才能移动(模拟真实电梯校准过程)。