269
社区成员




本单元以某公司大楼的目的选层电梯系统为背景。
具体而言,乘客向一个中央控制器输入目的楼层,控制器告诉乘客登上哪一台电梯。
在此基础上,每一个乘客请求被赋予了一个优先级指数,要求优先级高的乘客请求尽可能快地到达目的地。
按照惯例,先把UML类图奉上:
不难发现,本人的Elevator
类出奇臃肿。
在写完三次作业看来,这主要是因为,我将
全部放在了Elevator类中。若能将电梯运行状态信息单独提取出来,相信Elevator
类就不会这么臃肿了。
SubRequest
类是对作业中出现的各类请求的抽象,记录了各类请求中的关键信息。
这里我有一点做得不好:细看不难发现,我将HW6中的临时调度SCHE
请求,与HW7的双轿厢升级UPDATE
请求完全整合进了SubRequest
中。更优美的做法,是将SubRequest
的位置替换为接口,随后各种请求实现这一接口。出于偷懒、拒绝重构的目的,我没有这么做,这个问题当然是需要改进的。
在我的架构中,电梯的运行线程与状态信息相互分离,由中央的Dispatcher
向各电梯分配请求。 整体效果如下图所示:
其中,Scheduler
向请求池RequestPool
(即上文的RequestQueue
)的生产者关系,仅在HW6/HW7中出现。这一点我们在后面分析各次作业时,会详细介绍。
在进程协同模式方面,我的架构整体可以视为两层 “生产者-消费者” 模型:
InputProcessor
作为生产者,Dispatcher
作为消费者,请求池作为交换容器Dispatcher
将请求放入对应Elevator
的请求表内,ElevatorScheduler
根据请求表与当前电梯状态,进行请求处理与电梯运行ElevatorScheduler
作为生产者,将无法处理的请求写回请求池RequestPool
,让消费者Dispatcher
重新分配请求对应的电梯;这在HW6与HW7中出现。此处只展示电梯于单轿厢状态运行时的进程协作图。双轿厢的升级与协同更为复杂,详见下文HW7相关。
说起来你可能不信,从HW5到HW7,除了更改Dispatcher
与Elevator
、ElevatorScheduler
,使之能够支持特殊请求/支持双轿厢以外,我的整体架构几乎没有任何重大改动。
当然,没有改动是不可能的。比如说,在HW6的SCHE
请求与UPDATE
请求中,我们要实现电梯“赶人”的逻辑,这就在整体架构中多加了一层“电梯-请求池”的“生产者-消费者关系”。
对于SCHE
请求与UPDATE
请求,ElevatorScheduler
中的电梯运行逻辑,每次作业都需要更改,同时需要Elevator
类中记录必要的信息。
此外,为了实现双轿厢的协同,以及UPDATE
有关的逻辑,我加了不少进程协作上的逻辑。
总而言之,我本单元相对稳定不变的:
易变的:
我认为,我的整体架构在三次作业中没有明显变化,主要是由于以下两个原因:
在我的整体设计中,我除了同步块外,还使用了ReentrantLock
进行锁的管理。
ReentrantLock
负责对Elevator
类的对象进行锁管理,而同步块主要用于对请求池RequestQueue
进行锁管理。
我的设计中,各线程对Elevator
对象的冲突,显著多于对请求池的冲突。因此,ReentrantLock
占了锁操作的绝大多数情况,我同步块用得反而很少。
ReentrantLock
?其一,为了使锁的管理更加灵活。
在我的实现中,我的调度算法与“影子电梯”颇有相似之处,需要先获取所有待选电梯的锁,等待确定唯一候选电梯后,再将锁全部释放。如果使用synchronized
块实现,我们就会出现高达六层的死亡嵌套:
synchronized(elevator1) {
synchronized(elevator2) {
synchronized(elevator3) {
...
}
}
}
而使用ReentrantLock
进行锁管理的话,这个问题可以得到完美的解决:
for (Elevator elevator : elevators) {
elevator.getLock().lock();
}
// do sth
for (Elevator elevator : elevators) {
elevator.getLock().unlock();
}
不过,ReentrantLock
必须手动、显式地释放锁,使用时因此要多加小心。
其二,配合Condition
使用,可以实现粒度更小的进程交互。
在同步块下,我们使用wait()/notify()
来实现进程交互;这一做法有一显著缺点:**notify()
唤醒的进程完全随机,无法指定唤醒一个特定的进程**。
在使用ReentrantLock
实现时,就完全是另外一种风景了:一个可重入锁可以挂多个Condition
,每一个Condition
都有一套独立的await()/signal()
;这就允许我们将不同的线程挂在不同的Condition
上,并使用不同的Condition.await()/signal()
,做到对指定线程的控制。
这一特性对我的HW7实现尤为重要;没有它,我就没法在HW7中,用纯线程交互的方法解决UPDATE
请求同步,以及双轿厢楼层冲突这两个问题。
同步块相当于获取对象的锁。
以下两种情况可以认为等价:
public class Object1 {
public ReentrantLock lock = new ReentrantLock();
public void method1() {
this.lock.lock();
foo();
this.lock.unlock;
}
public void method2() {
bar();
}
}
testMethod() {
object1.lock.lock();
object1.method1();
object1.method2();
object1.lock.unlock();
}
public class Object1 {
public synchronized void method1() {
foo();
}
public void method2() {
bar();
}
}
testMethod() {
synchronized(object1) {
object1.method1();
object1.method2();
}
}
对请求池RequestQueue
,使用同步块;
同步块中内容起到了如下作用:
ElevatorScheduler
/InputHandler
)Dispatcher
)RequestQueue
上等待(Dispatcher
)RequestQueue
上等待的线程(ElevatorScheduler
/InputHandler
对Dispatcher
)对电梯类Elevator
,使用重入锁;
在以下情况加锁:
ElevatorScheduler
:进行一次电梯运行(爬升/下降一层,停留下客一次)Dispatcher
:对各电梯进行评估,找出最佳选择时前面提到,我们实现了类似“影子电梯”的调度策略;这要求我们先获取所有电梯的锁,找到最佳电梯,完成分配后,再释放所有获得的锁。
整个过程依次如下:
Condition.signal()
,唤醒当前可能正在休眠的电梯运行进程ElevatorScheduler
针对HW6的SCHE
请求与HW7的UPDATE
请求,我都选择交由Dispatcher
实例进行处理。
原因无他,为了减少进程交互的复杂性。 我的电梯运行线程Scheduler
与电梯信息类Elevator
是分离的,我希望Elevator
只有Dispatcher
与Scheduler
两者在竞争。
这两类特殊请求都将直接加入请求池。那我们怎么保证Dispatcher优先处理这两种特殊请求呢?
答案是,将请求池改为用优先队列实现。这是因为,优先队列的比较条件(提供了Comparator接口)可以自行实现,这为我们当前情况的需求提供了极大的便利。
我们需要保证:
示例如下:
import java.util.Comparator;
public class RequestComparator implements Comparator<SubRequest> {
@Override
public int compare(SubRequest o1, SubRequest o2) {
int o1Index = calcIndex(o1);
int o2Index = calcIndex(o2);
return o1Index - o2Index;
}
private int calcIndex(SubRequest subRequest) {
if (subRequest.isSche()) { return 0; }
else if (subRequest.isUpdate()) { return 0; }
else {
// 按你自己的方法,处理普通请求!
}
}
}
在Dispatcher从头遍历优先队列时,我们此时即可保证特殊请求被优先处理。
对应的,我在Dispatcher
中实现了分配这两个特殊请求的逻辑。
这样当然有坏处,就是某种意义上违反了单一职责原则。
我的这个策略在思路上与影子电梯有颇多相似之处,只不过:
进行计算之前,Dispatcher
需要得知当前正在评估的电梯,能否接受当前待加入的请求。
可加入的规则如下:
“电梯满员”视个人实现不同,判断逻辑可能不同;我这里就以我的实现为例,谈谈我的判断逻辑。
正如上文所述架构实现,我们一个电梯的子请求队列不仅包含了当前在电梯内的请求,还包含了将来电梯要捎带的请求;
我们的判断逻辑是,假设请求加入了子请求表,得出各楼层处,最多同时出现的请求数;
这一“同时出现的请求数”,反映了那一运行区间内,电梯内的人数。
若各区间的同时出现请求数的最大值,大于电梯容量,则认为此时加入请求会导致超员。
如图所示,待加入请求在F2-F3
区间会导致超员,故我们不应加入这一待加入请求。
需要注意的是,如果采用了上文RESERVED状态的实现,若预约请求方向与电梯空放方向一致,此时还需将预约请求放进上面的图里进行比较。
在上述复杂的判断后,我们终于可以开始计算了...
我们的计算公式如下:
$$ index = index_{base} + priorityPenalty * elevStopTime $$
我们先需要得知:
stopCount
即中途停靠的次数随后即可算出当前请求的运行时间,作为我们评价指数计算的第一步;
$$ index_{base} = (dis + travel)*elevSpeed + stopCount * elevStopTime $$
优先级惩罚,是所有请求的优先级惩罚之和:
$$ priorityPenalty = \sum_{i=1}^{n}{penalty_{R_i}} $$
对于一个请求 R ,设其优先级为 priority ,楼层跨度为 travel ,则我们规定该请求允许的中途停靠次数 maxAllowedStops 如下:
$$ maxAllowedStops = round(travel * (1 - \frac{priority}{100})) $$
其中, round(x) 取 x 最靠近的整数。
在我们进行评价指数的计算时,我们先假设待加入请求已被加入;随后,对原有的请求 R_i ,算出加入后的总停靠次数 stops 。
由此,我们就可以算出各个(可加入请求的)电梯的评价指数,选择评价指数最低者加入。
我在本单元求稳为先,选择的方法不一定最优。
我的原调度逻辑类似影子电梯,在出现双轿厢的时候因此也犯了难。比起编写新逻辑,我选择最大程度复用原有的调度逻辑。
在原有的调度基础上,我们需要添加是否在运营范围内的判断。
调度第一步,沿用原有逻辑,对所有电梯评估请求是否可分配,选评价指数最低者。
同时,在这一步中,记录下所有的双轿厢电梯。
此处记录的并不是双轿厢电梯对,而是其中的每一个电梯。 例如:2、3是一对双轿厢电梯,我们这里记录的是:电梯2、电梯3
,而不是[电梯2,电梯3]
。
若第一步未能分配,则进行第二步,“拆请求”。在第二步中,我们只对双轿厢电梯进行评估。
“拆请求”如何实现?我们需要修改我们已有的乘客请求类passengerRequest
,在其属性中加一个passengerRequest followingRequest
,存储当前请求的后半段。
在下电梯的时候,需要判断是否包含后续请求,有则OUT-F
,并将后续请求放回请求池,没有则OUT-S
。
判断是否可加入,与计算代价指数的逻辑不变。 不过,我们选取电梯的逻辑发生了变化,选取优先级如下:
选取楼层跨度最大者,是因为,我们无法保证后续换乘的次数;换乘可能会造成额外的等待时间,且额外等待时间难以预知。楼层跨度尽可能大,可以尽可能减少换乘次数,一定程度缓解这一问题。
我在设计调度策略的时候,主要设定了这么两个目标:
“完成当前请求耗时”中,包含了电梯从不同楼层,空放到起点接乘客的时间,这背后反应的是电梯的移动距离,对应了耗电量指标;
“请求耗时最少”本身也是性能指标之一,单个请求耗时最短,绝大多数情况也可使得另一指标:系统整体的耗时减短。
也就是说,我们只关心我们最开始的两个指标,就足以处理绝大部分的性能指标。
至于优先级,我通过构建了一个简单的数学模型,按照优先级设计了“罚时”机制,从而解决了优先级评判指标的问题。
本人实现的亮点在于:UPDATE请求的处理,与双轿厢目标楼层的规避,全部利用线程交互实现。
我们的UPDATE请求放在了Dispatcher处理;此时不难发现一些问题:
这样肯定不对。因此,我们在Dispatcher内引入一个子进程,作为UPDATE请求处理的“总控”。
这个子线程怎么创建呢?使用Lambda表达式,可以快速创建新线程:
Thread th = new Thread(() -> {
// Do something!
});
th.start();
这样,Dispatcher线程就可在处理升级请求的过程中,继续分配其他可分配的请求,而不会发生阻塞。
如果你不放心,还可以用th.join()
,在Dispatcher线程的某处,强制处理UPDATE请求的子线程完成后,再继续执行。
总控与两台待升级的电梯的整体交互逻辑如下:
Scheduler
读取Elevator
中信息,得知电梯需要升级,进行疏散乘客等“预处理”操作UPDATE-BEGIN
,随后修改电梯的运行属性,完成升级具体实现上,我的电梯信息类Elevator
采用了ReentrantLock
实现锁管理,上文中的两个等待,我们通过创建两个Elevator
锁的Condition
实现。
电梯如何指示自己已就绪?我采取给电梯增加一个UPDATE
运行状态;在电梯完成了“预处理”后,电梯就将进入这一运行状态,并使用Condition.signal()
唤醒总控。
下述伪代码仅作示意之用。更进一步的细节,需要各位根据自己的架构实现。
// Elevator
ReentrantLock lock;
Condition schedulerUpdateCond = lock.newCondition()
Condition dispatcherUpdateCond = lock.newCondition()
setUpdateStatus() {
this.status = new Status("UPDATE");
this.dispatcherUpdateCond.signal();
}
updateA() { // updateB()同理
// 修改属性,并将电梯运行状态设为IDLE
this.schedulerUpdateCond.signal()
}
// Scheduler
elevator.lock.lock();
...
if (elevator.shouldUpdate()) {
evacuatePassengers();
elevator.setUpdateStatus;
elevator.schedulerUpdateCond.await();
elevator.respondUpdate; // 解除指示电梯需要升级的flag
// await()结束后,电梯已被更新为新的状态,按照原有的运行逻辑运行即可!
}
// Dispatcher
Thread th = new Thread(() -> {
Elevator elevA, elevB;
// 由两层await(),实现两电梯均就绪后再继续
elevA.lock.lock();
if(elevA.getStatus() != "UPDATE") {
elevA.dispatcherUpdateCond.await();
}
elevB.lock.lock();
if(elevB.getStatus() != "UPDATE") {
elevB.dispatcherUpdateCond.await();
}
// UPDATE-BEGIN
//重新分配被取消RECEIVE的请求
elevA.updateA();
elevB.updateB();
// UPDATE-END
// 放elevA/B的锁
});
th.start();
很遗憾,我们不能通过不到“目标楼层”的方法,规避楼层冲突的问题,否则全部都被改造成双轿厢,且目标楼层均在同一层时,会导致跨越目标楼层的请求全部无法完成。
产生冲突的情况,我们大致可以分为三类:
在我的实现中,与电梯移动有关的逻辑是这样,相信各位的实现与其大同小异:
// Scheduler
while (true) {
elevator.lock.lock;
try {
if (/*一般情况*/) {
normSchedule();
}
} finally {
// 以防中途开锁
if(elevator.lock.isHeldByCurrentThread()) { elevator.lock.unlock(); }
}
}
normSchedule() {
if (elevator.status == RUNNING)
runningSchedule;
}
runningSchedule() {
elevator.lock.unlock();
// 唤醒Dispatcher;开锁是为了允许Dispatcher此时为其分配可加入的请求
sleep(speed);
elevator.lock.lock();
// 更改楼层
// 输出ARRIVE
// 判断下一步状态:是下客的WAITING,还是继续RUNNING?
}
为了防止不必要的判断,这一判断只应在当前电梯即将进入目标楼层时进行。
在判断之前,我们自然要获取对方电梯的锁,以免对方电梯的状态在判断时改变。
观察我们上面实现的锁管理情况,我们不难发现:
runningSchedule()
中恰好有一个开锁的窗口,为一次只拿一把锁提供了可能RUNNING
,只可能为闲置的IDLE
,或正在下客的WAITING
因此,上述三种冲突情况,我们只需要对“相向而行”这一情况特殊考虑;其余情况,都可以通过判断对方当前位置是否为目标楼层判断。
“相向而行”特殊在哪呢?特殊在无法直接确定需要等待的电梯。其他两种情况中,我们可以确定等待的是当前不在目标楼层的电梯,这一情况不行。
对此,我规定:下方的电梯,规避上方的电梯。
为了减少复杂度,我并没有考虑轿厢中乘客的问题,性能可能因此下降;如各位希望争取这一部分性能,可以以这一条件为最低级条件,将轿厢内请求加入判断条件中。
自然,为了避免死锁,我们的判断放在在runningSchedule()
那一开锁的窗口内。
下文中以cooper
称呼对方电梯。(coop-er,能想出的最简洁称号就这个了,笑)
为双轿厢电梯单独写一套运行逻辑自然不大明治。我们将这一判断逻辑融入原有的运行逻辑中,判断只在电梯已被改造为双轿厢(对方电梯cooper !=null
)时进行。
runningSchedule() {
elevator.lock.unlock();
// 唤醒Dispatcher;开锁是为了允许Dispatcher此时为其分配可加入的请求
if (elevator.willEnterTargetFloor && cooper != null) {
cooper.lock.lock();// 获取对方的锁
judge(); // 判断
// 放锁;这样放锁当然有目的,详见下文
if(cooper.lock.isHeldByCurrentThread()) {cooper.lock.unlock(); }
}
sleep(speed);
elevator.lock.lock();
电梯避让的等待/唤醒,使用挂在电梯信息类Elevator
的锁上的Condition实现,命名为conflictCond
。
整体交互如下:
conflictCond.await()
,同时通知对方需要规避conflictCond.signalAll()
,唤醒等待冲突解除的我方。随后,我们就可得出如下判断逻辑:
judge() {
if (elevator.isElevB()) { // 是在下方的B电梯
switch (cooper.getStatus().getStatusType()) {
case RUNNING:
// 相向而行
if (cooper.getFloorBeforeTargetFloor() == cooper.getCurrentFloor()
&& cooper.towardsTargetFloor()) {
cooper.setAvoid(); // 通知对方规避
cooper.lock.unlock(); // 避免同时获取两把锁
elevator.lock.lock(); // 没锁就await(),会发生什么?
elevator.conflictCond.await();
elevator.lock.unlock();
}
break;
default: break;
}
}
// 其他情况
if (cooper.getCurrentFloor() == cooper.getTargetFloor()) {
cooper.setAvoid();
cooper.setUnlock();
elevator.lock.lock();
elevator.conflictHangUp();
elevator.lock.unlock();
}
}
判断写好之后,该通知对方规避了,响应机制当然也得有:
// Elevator
public void setAvoid() {
this.awaitingAvoid = true;
this.idleCall();
}
public void respondAvoid() {
this.awaitingAvoid = false;
}
改完这些还不够,我们需要对WAITING/IDLE两个状态的转移规则稍加修改,使得在一动一静的情况下,两种状态转移后,可以响应规避请求:
waitingSchedule():
... // 原有实现
// 接在原有实现之后
if (entersIdle) {
if(shouldAvoid) {
/*让电梯移动到下一层,类似SCHE,但到了之后不开门*/
respondAvoid()
} else {
setIdle()
}
}
idleSchedule():
if (keepsIdle) {
if (shouldAvoid) {
/*让电梯移动到下一层,类似SCHE,但到了之后不开门*/
respondAvoid()
} else {
// 原逻辑
}
} else {
...
}
对于同向而行的情况,我们还需要继续修改RUNNING下的运行逻辑,使得避让请求被正确响应:
runningSchedule() {
... // sleep()完成
/*更改楼层*/
/*在楼层更改时,若远离了目标楼层,暂存一个flag*/
.... // 状态转移
// normSche原有逻辑结束
if (flag) {
elevator.setUnlock(); // 同样,此处开锁是为了保证一个线程只拿一把锁
respondAvoid()
cooper.lock.lock();
cooper.conflictCond.signal();
cooper.lock.unlock();
}
// 需要Scheduler线程的`while(true)`处能够正确处理中途开锁的锁释放问题,可以看看本文前面的实现
}
做到这里,我们就成功的使用纯线程交互的方法,完成了楼层冲突的处理,不需要引入任何新的类来进行管理。
这应该是我在本单元中犯下的最大的一个错,直接导致我HW5严重失利。
本人在HW5中,我在已经实现了Elevator
类的synchronized
块的锁机制后,才决定换成ReentrantLock
;但在修改代码的过程中,我出于偷懒的目的尝试synchronized
与ReentrantLock
两把锁混用,以期实现与原有代码的兼容性。
然后,Elevator
类中出现了这样的方法:
public synchronized int example2() {
this.setLock();
try {
return 0;
} finally {
this.setUnlock();
}
}
随后就不会出意外地出意外了,程序中出现了多个死锁点,直到作业提交截止前我都没有找完,然后...
最后我还是决定,去掉电梯有关所有的synchronized
。这样改以后,既方便维护,也顺便解决了所有的死锁问题。
最后,再次告诫诸位:
!!谨慎对一个对象加多把锁!!,这是血的教训......
在此之后,我对锁的使用十分谨慎:除Dispatcher情况特殊外,确保每个进程同时只拥有一把锁。
这一锁的使用习惯在后续的两次作业中非常好用,我后两次作业基本没有出现大规模的死锁问题。
唯一值得说的BUG就这个,其它的都是不仔细看指导书要求导致的。
君子生非异也,善假于物也。
IDEA Profiler是一个强大的工具,它可以通过线程转储(Dump threads)功能,获取当前程序各线程的运行状况,并列出线程执行到了源码的哪一位置,为找死锁提供了极大的便利:
我于本单元正式使用评测机,对我写好的程序进行随机测试,以测试程序在多种样例下的表现。
此外,本人的评测机支持多线程运行,这不仅有利于提高测试效率,更能模拟复杂的程序运行环境,一定程度上提高死锁等情况的发现几率。
由于多线程调用次序的随机性,程序能够正常运行一次,并不能证明程序中不存在线程交互上的漏洞。
针对这一问题,我的解决办法就是“一个样例,多次测试”;多跑几次,万一出问题了呢?(笑)
评测机轰炸,也可以覆盖这一情况,让评测机多次测试统一样例即可。
本次作业,让我了解了如何处理边读边写,读后判断等常见的多线程冲突情况。
我在本单元中的锁策略相对保守,要求一个对象最多只有一个线程进行操作(读/写),并没有采用读写锁。这样的确安全,不过可能产生一定的性能损失。
我认为本单元对我让我受益最大的方面,是培养了我如何设计多线程之间交互的能力。通过使用纯线程交互方法实现UPDATE
请求的同步,并解决双轿厢电梯楼层冲突问题,我对多线程交互有了更深刻的认识,在此方面积累了宝贵的经验。
RESERVED
运行状态;这一运行状态可以设置中途是否允许捎带,允许不存在预约者;这为后续实现SCHE
请求的运行过程提供了极大的便利。Elevator
类专用于记录电梯有关的信息,而ElevatorScheduler
线程,则专注于电梯的运行。这有利于进程交互的设计。本单元,我感觉最需要的其实是细心,不然线程间交互很容易出现问题。
本单元亦让我直观地感受到了算法和数学模型,于程序的重要性何在。
本人实现中,判断电梯是否满员的问题,可以抽象为计算一个二维空间内,所有线段的最多重叠次数;没有算法的帮助,计算效率很难提升。
处理优先级问题的方法,其实就是简单的数学建模。
感受到了AI的力量。 使用AI生成checker
+人工修改的效率,远比纯手搓要高。
为了进行正确性检验,本单元的checker
需要构建一整套状态机,其工作量无异于重做一遍本单元作业;但在AI工具的帮助下,本人节省了不少开发checker
的时间,为我对自己的程序进行更充分的测试提供了宝贵的时间窗口。
我的编码能力有待提高。
虽然我的整体设计思路十分清晰简洁,但在具体实现时,我在不少实现细节上的复杂度实在太高,因此带来的BUG与维护的不便不在少数。
上面的UML图也能看到,我的Elevator
类十分臃肿。在我看来,虽然我的程序运行效果还算不错,但它事实上就是一个bloatware。这一感受在我进行互测,观摩同学们的代码时尤为强烈:我实现本单元的所有功能,用了近2000行,而同学们只用数百行即可实现...
设计思路有了,是时候想想,怎么才能让自己写出更优美的源码了...