269
社区成员




目标是要熟悉 Java 多线程的基本操作,包括线程对象与共享对象、线程的创建、线程通信、互斥与锁机制,熟悉产销模型。需求是搭建一个包含6个纵向电梯的调度系统。和往届的不同是每名乘客新增了优先级属性,表示该乘客到达终点楼层的急迫程度。
产销模型。为了保证良好的工程架构,一开始就参考实验情景构造产销模型。输入线程作为生产者,电梯作为消费者。初步分析得到至少需要以下几个独立类:
电梯类。方法有:开门-上客-下客-关门、楼层间移动、停留等待、调头、终止。
候乘表类。每个电梯都需要有自己独立的候乘表。该候乘表管理自己对应电梯上的乘客请求,并根据当前候乘表提供一些方法,例如添加乘客请求、判断是否需要上下客或移动等待调头终止等。
策略类。为了实现单一职责原则、单例模式和监视器模式,形成良好的架构,必须要把电梯类和策略类相分离。策略类的核心作用是根据电梯和候乘表双方提供的状态信息,综合判断电梯的下一步行为。实现电梯类和策略类相分离的好处在于,方便封装不同的策略(继承一个策略接口),在后续作业不同情况下选取不同的策略。
分配器类。作用是承接输入接口,把从输入接口获得的乘客请求分配到不同的电梯候乘表中。
其中调度器类、电梯类是线程对象,做到互不干扰。我专门设置了一个枚举类用于存放电梯的所有状态。
捎带策略。参考指导书提供的 ALS 策略和往届广泛使用的 LOOK 策略,我最终采用 PRI-LOOK 算法(模仿LOOK的核心思想并增加优先级权重)。该算法的具体实现如下:
主请求选择规则:
被捎带请求选择规则:
电梯当前未满(未达到最大承载量);
只捎带与电梯当前方向一致的请求;
在当前楼层,按前述优先级策略逐个筛选进电梯,不考虑同一楼层请求之间的时间差,仅在方向一致 + 优先级 + 距离近这三个维度上依次进行筛选。
线程通信。 锁机制和频繁的唤醒能有效保障线程安全,但会降低程序性能。不要让一个线程对象访问另一个线程对象,线程对象只能访问共享对象。 电梯、调度器都是线程对象,相互之间不能访问。他们呢均访问共享对象:候乘表。
Elevator
类中显然不需要 synchronized
方法,因为内部状态全是私有的、只被自己线程访问,不会被其他线程共享或并发访问。RequestTable
不是线程类,但方法必须要加 synchronized
。这是典型的“监视器模式”:不是线程类的类,反而是线程之间共享的数据类,需要用 synchronized 来保护它的状态。因为**多个线程会同时访问它 **(例如 Dispatcher
类调用 addPersonRequest()
、Elevator
类调用 choosePerson()
),所以它需要通过 synchronized
来加锁 ,保证每次只有一个线程能访问其内部数据结构。notifyAll()
也是广泛使用于RequestTable
中,为了减少不必要的唤醒,只在真正会改变线程状态时使用(例如选到人时、boolean
方法为 true
时)。优化策略。除了捎带策略的优化,主要做了 “抢跑”(也可以认为是量子电梯)。第一个数据通常是0.4秒之后(互测要求1秒之后),假如第一条乘客请求不在 F1 的化,等到第一条乘客请求发出电梯再启动取接人 ,太慢了。因此可以通过动态修改电梯 move
方法的 waitingTime
(而不是卡死在400ms)来实现以下行为:假如第一个乘客请求时间戳为 [1.0] ,出发楼层为 B1,则电梯在 1s 时刻可以直接 ARRIVE
在 B1,相当于在0.6s 时就 “预判” 了该请求。本质上就是:电梯的等待和线程的等待是两码事,线程需要取决于输入进行等待,但电梯的行为可以灵活处理。然而,量子电梯不是绝对优化,在某些路线上也会产生严重弊端:路线繁杂多绕路。
本单元 hack 的唯一策略是大规模评测机。在A屋中,经过一天一夜超过10000个顶格数据的轰炸,找出了天璇星的一个 bug。天璇星还是我的同学。查看代码发现是线程安全问题,触发率很低。
乘客请求中不再指定想要乘坐的电梯;新增电梯的临时调度功能,电梯拿到临时调度任务时,应以新的速度抵达目标楼层,开门清空,等待1s后关门;修改了 OUT 输出格式。
RECEIVE
的设计。主要修改的是 Dispatcher
类中的分配策略。实现了平均分配(模6加1)和影子电梯两种分配策略,广泛测试后发现在某一时刻积压大量请求时,影子电梯表现明显劣于平均分配。故采用平均分配策略。
SCHE
方法的设计。把临时调度看作电梯的一个行为(类似于开关门、上下行)。一旦检测到电梯处于 SCHE
状态(SCHE-ACCEPT之后,SCHE-BEGIN之前),则立即依次进行下列操作:
这里有几个关键点需要说明:
该方法最终目的是把一个乘客请求最优分配出去。方法是为每个不在 SCHE
中的电梯创建一个副本。当一个乘客请求来临时,我们假设6个电梯都不在 SCHE
中,那么理论上这个请求可以分配给6个电梯中的每一个。故有6种分配方式。下面选取最优方式如下:
sleep
以耽误整个真实系统运行,而是为每个电梯建立一个总运行时间属性,然后例如移动就每次加 400ms 即可。这样,该批次每个电梯都拥有了自己的模拟运行时间。经过实验,发现这整个过程系统运行时间在几十毫秒级别。这是忍不了的,因此影子电梯一定要配合量子电梯使用。
然而,开头说过,影子电梯在某一时刻积压大量请求时,表现明显劣于平均分配。为什么会这样呢?原因是影子电梯是局部优先策略,是对每一个请求都根据当前的情况分配性能最优的电梯。然而,假如某时刻积压大量乘客请求,此时有5个电梯全在临时调度,那么显而易见影子电梯会将该时刻所有积压的乘客请求直接分配给剩下的最后一个电梯。这比均分明显差很多,因为等临时调度的电梯结束之后,它们显然仍然可以参与调度。
由于使用量子电梯过度,我在所有的 sleep
中并不固定时间(如400ms),而是固定时间加上当前时刻减去上次电梯启动时刻,想抢占一些程序运行时间。结果导致强测中每个点的 move time
都少一点点,没有进入互测。
新增双轿厢改造功能。
双轿厢改造功能从行为描述上和临时调度十分相似,因此仍然可以当作电梯的一个行为(指 UPDATE
是一个行为)。从宏观角度,本次作业有两种大思路:
maxfloor
、minfloor
、transFloor
属性,通过限制电梯的楼层范围来隐式实现”双轿厢“。思路一的优势在于代码扩展量较小,但是电梯线程类会混合两种功能,不利于单一职责原则。经过权衡考虑,采用思路二。
本次作业主题是”线程交互“,因此线之间的联系与交互是实现的难点。具体而言,要巧妙实现:
UPDATE-BEGIN
,且只输出一遍UPDATE-BEGIN
后,别的电梯才能 RECEIVE
他们的乘客等待请求,而两个电梯释放候乘表是在 UPDATE-BEGIN
之前还是之后,没有规定。于是,我采用了一个机器精妙的架构:借鉴OS中进程同步(PV操作)的知识,构建了如下 crossNotify()
的小结构:
public void crossNotifyOut(Object lock) {
synchronized (lock) {
lock.notifyAll();
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (lock) {
lock.notifyAll();
}
}
public void crossNotifyClear(UpdateRequest updateRequest, String abType) {
synchronized (updateRequest) {
// 更新双轿厢开始
if (abType.equals("B")) {
TimableOutput.println("UPDATE-BEGIN-" + updateRequest.getElevatorAId()
+ "-" + updateRequest.getElevatorBId());
}
updateRequest.notifyAll();
try {
updateRequest.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
synchronized (updateRequest) {
updateRequest.notifyAll();
}
}
在 update
方法中进行如下设计,即可进行同步:
everybodyOut();
crossNotifyOut(requestTable.getLock()); // 同步清空电梯
crossNotifyClear(updateRequest, abType); // 两个电梯均清空后由一个电梯输出 BEGIN
synchronized (globalRequestTable) {
// 清空当前候乘表
}
if (abType.equals("B")) {
// 新建2个双轿厢电梯线程
}
要注意的是,两次同步行为必须要用不同的锁,把两个配对电梯共享的某个对象当作锁就可以。这样就实现了不会在一个电梯输出 UPDATE-BEGIN
后另一个电梯还在下人,也不会出现还没输出 UPDATE-BEGIN
之前就有其他电梯 RECEIVE
到这两个电梯候乘表中的人。
同样的思路可以用到两个电梯如何在换乘楼层不相撞,而是相互避让上。可以设置一个专门的换乘楼层类(并创建对象,单例模式),在双轿厢电梯类中的 move
方法时,判断下一楼层是不是换乘楼层。如果是,就加锁,使电梯井中同一时刻最多仅有一个电梯持有该锁。这就实现了相互避让(互斥访问)。
第二单元是难度最大的一单元,是第一次接触并行程序设计和多线程设计思想,也是我认为这门课的精华所在。从第一次作业初识多线程设计,设计独立的6个电梯线程互不影响独立工作,到第二次作业对一些线程进行特殊扩展,再到第三次作业涉及到线程之间的同步和互斥,我对线程的独立并行与通信协同有了实践的认识。
三次作业中我始终采用 LOOK 调度策略和平均分配原则,尝试了局部最优(影子电梯)和全局平衡(均分),最终我更推荐相对均衡普适的原则,因为任何局部最优策略都有风险。
synchronized
关键字,但在实验中对读写锁有了基本的了解。这使得一定程度上减少了程序的并发能力,但是提高了可读性和简洁性。第二单元非常精彩,第三单元加油 ~