一、电梯的调度策略
1.1 状态转移图
1.2 纵向电梯的LOOK算法
LOOK算法实现
- 与真实的电梯大致相同的策略,即维护电梯当前运行方向dir,更远层无请求(各楼层的call请求&电梯中乘客的目的地请求)时才转向。
- 为了在实现捎带策略的同时将电梯和调度器功能解耦,电梯应当①具有“短视性”,即并不知道自己的目的楼层,而是仅根据从调度器获取上行、下行的boolean值。在此条件下电梯实现以层间运行时间为最小单位实时更新可稍带目标层。②屏蔽外部各楼层的call请求:按运行方向运行一层后,询问调度器是否应该开门。
LOOK算法相较ALS的优势
打破了ALS算法可能出现的“局部性原理”。
即ALS算法在将电梯内部乘客全部送达后形成空梯时,会选择最先到达的请求作为主请求。两种策略下对于中间楼层的捎带表现一致,但是ALS策略下低层和高层请求因为难于被捎带而堆积,直到被安排为主请求时才会运行到低层或高层。此类请求容易堆积在末尾才进行集中处理,导致电梯出现因为一个远端请求空转(无捎带,远端请求的vip专梯)的效率问题。
LOOK算法更像是就近远端捎带,在将电梯内部乘客全部送达后形成空梯时会沿此方向去处理远端请求,避免了底层和高层请求因为难于捎带而堆积后的空转问题,效率较好。
1.3 横向电梯
就近策略
不再需要LOOK算法
LOOK算法的优势在于处理纵向梯难于到达的高底层问题,而横向电梯由于下面两个原因不需要LOOK算法。
①横向电梯仅5个座
②横向电梯可环状运行,即打破纵向电梯1层与10层的时间差
就近策略的实现
①判定请求的方向时按最短运行路径②同方向捎带
③空梯时选择距离最近的请求作为主请求
就近策略的电梯利用率比较高,因为总运行路径最短,比较适合横向电梯出现大量请求的情况下使用,比较适合hw7。缺点是开关门的次数比按同方向运行的横向电梯多,顾客等待电梯转到自己方向的时间比较长。不过由于hw6拖后腿的是纵向电梯,所以请求少的时候这种策略也不会很耽误事。
1.4 性能分析
- hw5实现LOOK算法后性能分98,发现的问题是:调度器在是否应该开门时没有考虑已满员的情况,导致出现满员时在请求层开门,电梯发现满员不接客,出现空开关门的情况 :(
- 所以在hw6重构了纵向电梯优化掉了开关门问题,然而性能分只有96 :( 观察输出后发现主要问题不在调度器的调度策略上,而是重构以后的LOOK算法出了问题:虽然捎带要满足同方向才可捎带,但是LOOK算法的远端请求可以为任意方向。有几个强测85的点观察输出后发现是因为远端请求被一刀切成同方向,导致反方向的请求没有被LOOK到不会远端捎带,退化成ALS的主请求造成堆积。
- 个人感觉比较影响hw6性能分的一些85的点的问题并不在调度器策略,对于后两次作业来说,在hw5把LOOK算法实现好就可以保证比较可观的性能分,比起写复杂的调度器算法更符合二八法则 :)
二、调度器
2.1 调度器设计
2.1.1 电梯调度器使用掩码表示楼层请求,取代遍历
- FLOOR[1]为
10'b1
,FLOOR[3]为二进制10'b100
后来Experiment4才知道可以用Bitset
private static final int[] FLOOR = new int[12];
public static void init() {
FLOOR[0] = 0;
for (int h = 1;h < 12;h++) {
FLOOR[h] = 1 << (h - 1);
}
}
- 掩码表示楼层请求,取代遍历
private int inElv;//电梯中所有人的目的地
private int upCall;//上行请求发出层
private int downCall;//下行请求发出层
int status = inElv | upCall | downCall;
//判断是否开门
public synchronized boolean isToOpen(boolean dir,int nowFloor) {
boolean order;
int nowCall = ((dir) ? upCall : downCall);
if ((inElv & FLOOR[nowFloor]) > 0) { order = true; }
else if (((nowCall & FLOOR[nowFloor]) > 0) && (inNum < capacity)) { order = true; }
else { order = false; }
notifyAll();
return order;
}
//判断远端是否有请求 即判断是否换向,无需遍历
public synchronized boolean furtherNoReq(boolean dir,int nowFloor) {
boolean noReq;
int status = inElv | upCall | downCall;
if (dir) {
noReq = (!(status >= FLOOR[nowFloor + 1]));
} else {
noReq = ((status & (FLOOR[nowFloor] - 1)) == 0);
}
notifyAll();
return noReq;
}
2.1.2 座调度器分配纵向电梯的调度策略
①优先分配空梯
②LOOK算法能捎带的电梯:处在电梯运行方向的远端+加入该请求后此次此方向的可稍带请求不会因此请求的加入导致满员而无法捎带
上行时的判断捎带后不满员的逻辑:
//维护当前方向的upNum[i]:i层将要进入电梯的人数
//维护当前方向的outNum[i]:i层将要下电梯的人数
int people = inElvNum;//当前电梯人数
for (int i = floor;i <= 9;i++) {
people = people + upNum[i] - outNum[i];
if (people > capacity) {
notifyAll();
return false;
}
}
notifyAll();
return true;
③无可稍带的电梯,则上行请求分配给当前未处理请求数最少的下行电梯(换向后可处理此请求);下行请求分配给当前未处理请求数最少的上行电梯
2.1.3 层调度器分配横向电梯的调度策略
- 当前未到达请求数最少的电梯调度器
2.1.4 hw7的请求拆分策略
- 分成五类:纵向直达、横向直达、横+纵、纵+横、纵+横+纵。分别在
ProRequest
中标记好下一站目的地,投入对应层/座的调度器。 - 允许在某一层乘坐两次横向电梯扩展横向电梯的可达性:由于横向电梯只有五个座比较容易连通,且为了横向梯的可达性而去一层或其他层中转的纵向时间代价比较大,所以采取
Arraylist<NextBuilding>
存储横向电梯的两次换乘,以达到尽量缩减去其他层中转的纵向电梯的代价。
2.1.5 调度器的性能分析
- hw6强测96,主要是LOOK算法没有优化好。
- LOOK算法修正后hw7强测98,我认为在调度器的调度策略上还是比较好地符合了二八定律的 :)
2.2 UML协作图与三次作业的迭代
输入线程、层\座的调度器线程、电梯线程三类线程之间的协作关系由两类调度器
BuildingScheduler``ElevatorScheduler
负责中转请求、信息交互和计算调度。后续将结合UML类图具体说明。下图为协作方面的迭代设计。
2.3 协作模式与调度器功能
hw5 UML类图
hw5 - 生产者消费者模式
仅有输入线程、电梯线程和每个电梯的调度器。
中转请求:
- 调度器负责接受来自输入线程的请求,把当前楼层的请求发放给电梯。使用生产者消费者模式。
信息交互:
调度器作为电梯线程的决策类,综合当前的各层请求和电梯状态,计算电梯的下一步行为。包括:本层是否开门、是否进入等待请求的阻塞状态、是否换向。
调度器接受输入线程的终止信号,传递给电梯线程,作为线程结束标志。
hw6 UML类图
hw6 - 两级调度器
迭代:增加层/座的调度器
BuildingScheduler
和调度线程SchedulerThread
。中转请求:调度器负责接受来自输入线程的请求,调度线程把请求发放给本层/座特定电梯的调度器。使用生产者消费者模式。
计算调度:调度器综合本层/座的各电梯状态,按调度策略
choose()
选择派遣请求的电梯。信息交互:调度器接受输入线程的终止信号,将其传递给电梯调度器后,作为调度线程结束标志。
hw7 UML类图
hw7 - 流水线模式
迭代:请求分阶段存储;增加
RequestCounter
作为流水是否结束的计数器。中转请求:未完成请求由电梯线程更新下一流水阶段的目的地,并把请求发放给本层/座调度器。
信息交互:完成请求计数器加1.
2.4 调度器的可扩展性
我认为本单元选择调度器是一种结构清晰、扩展性好的办法。
- 层/座的调度器
BuildingScheduler
和调度线程SchedulerThread
- 不论对于楼层和楼座,其基本行为保持一致,都可直接使用,复用性强。
- 如果想更换调度策略,只需重载choose()方法即可。
- 由于电梯的调度器实现了
Sche
接口,本级调度器存储的电梯名单为elvList : ArrayList<Sche>
类型,所以不仅仅可以作为某层或某座的调度器,还可作为包含特定横向电梯+特定纵向电梯的调度器,例如服务于一类二次元主题的横纵电梯,乘客愿意步行爬楼前往此类电梯的可搭乘地进行观光乘坐电梯,即二次元乘客的专属调度器。
- 不同类的电梯调度器实现
Sche
接口,方便与上层调度器交互。开始时由于横向电梯和纵向电梯的策略和实现差异较大,所以选择了接口而非继承。画出UML图以后觉得使用抽象类或许更加简洁。 - 两级调度器模式在hw7并未很大改动,只是增加了电梯线程把未完成请求发给层/座调度器的工序。两级调度器的扩展性较好。
三、同步块的设置和锁的选择
3.1 输出安全
输出类中采取单例模式,实现加锁的静态方法。
采用生产者消费者模式并不能完美地解决电梯作业的输出安全的问题:原因为例如[0.0]在输出队列添加OPEN请求,[0.4]在输出队列添加CLOSE请求,此时并不一定保证输出线程两次take()并println()的时间间隔严格≥0.4s.
public class MainOutput {
public static synchronized void println(String str) {
TimableOutput.println(str);
}
}
3.2 共享对象
- Scheduler类包含的共享对象为本电梯的请求队列
Arraylist<PersonRequest> requests
,所有读写此请求队列的方法均在Scheduler中实现。 - 即把共享对象和需要加锁的方法全部封装进Scheduler.
四、bug分析
分析自己程序的bug分析未通过的公测用例和被互测发现的bug:问题特征和修复办法
- 本单元强测互测没有Bug。
- hw5中测时遇到了轮询cpu超时的问题,后来经过排查是在[电梯线程里关门后]进入[依赖下一步请求实现的换向逻辑]时忘记wait(),导致电梯关门后进入空闲时跳过了此次换向逻辑而进入下一个循环的起始处wait().这导致了空闲后再有新请求进入时,电梯按上次循环时遗留下的方向继续运行,而缺少了电梯换向的逻辑导致从1层运行到0层。解决办法是电梯的每一个步骤后都添加wait()。
- 由于我在电梯每个步骤前都有换向逻辑,所以这种轮询出现情况只会在运行到1层且需要换向时空闲的这种特殊的情况下出现,所以轮询的bug在随机生成样例时比较难复现。手动造边界数据+循环起始处println()对于轮询更为有效。
五、hack策略
分析自己发现别人程序bug所采用的策略
列出自己所采取的测试策略及有效性
分析自己采用了什么策略来发现线程安全相关的问题
分析本单元的测试策略与第一单元测试策略的差异之处
- 主要采用自动评测机的方式。不过这种评测方式有一定随机性,本地评测的bug有时难复现。hw5和hw6的评测机并没有发现bug。hw7的评测机本地运行发现了bug但是没有提交成功。评测机主要用于正确性测试,适合作业前期自测正确性。
- 另一种hack策略时针对特定线程安全的bug手动造数据,比如hw5时的线程输出安全问题,hw7时不可达的横线电梯出现的轮询问题。hw7时hack到一个横向电梯轮询的Bug。手动hack策略覆盖范围不如自动评测,但是效率和命中率较好。
- 本单元相较第一单元的策略差异是,例如超时和轮询的bug比较难通过评测机显现,所以更需要针对特殊情况手动造数据。
六、心得体会
从线程安全和层次化设计两个方面来梳理自己在本单元三次作业中获得的心得体会
从线程安全的角度:
- 死锁的bug比较好排查和避免,出现率并不高;
- 共享对象使用sychronized和notifyAll()保证临界区的访问和修改是一种简单且省心的办法;本次作业稍有遗憾的是受限于时间并没有实现读写锁。
- 线程的结束条件需要依据具体情况判断,比如hw5和hw6的结束条件只需要考虑
输入结束 && 调度器中无请求 && 电梯为空
;由于hw7出现请求中转的情况,所以在输入结束
这个条件上不再只是输入线程读到null
,而是使用类似Experiment4.2代码中的Counter静态实例,当所有请求均运送完毕后才视作输入结束,各级调度器逐级发放isEnd信号。 - 轮询的Bug出现率比较高,且仅在特殊条件下出现而较难排查,需要合理地布局while循环终止地条件和wait()的位置。尤其是在LOOK算法需要频繁依据当前请求而实现转向逻辑的电梯中,需要更加细心地应对各种换向和空闲等情况。
层次化设计:
- 两级调度器更适合完成hw7的多换乘请求,各层级间传递请求易于实现。