「BUAA OO」第二单元总结
「BUAA OO」第二单元总结
零、任务简介
本单元要求建立一个多线程的电梯运行系统,实现对乘客的接送运载(并随时输出电梯运行信息以供评测机检查)
- 第一次作业,A、B、C、D、E座各有一个纵向电梯,乘客的输入请求被限制在上下行
- 第二次作业,增加了现实中比较少见的横向电梯(如 10L 的横向电梯可以在A、B、C、D、E座的 10L 间自由运动);输入也分为了加电梯/乘客请求两种类型(如A座可增加2个纵向电梯,2L可增加3个横向电梯)。第二次作业里乘客的请求也仅被限制在上下行或左右行
- 第三次作业,一是电梯容量、速度、核载人数、可达楼层/楼座可定制,二是乘客请求不设限制、可能需要换乘(即如果某人要从 A座1L 到 B座10L 的话,其至少需搭乘3个电梯才能到达)
一、架构设计
整体可分为两个板块:
1、调度器,即输入信息处理与整体调度(时序图见“第三次作业”)
2、电梯,即电梯架构与具体运行策略设计
下面对每次作业进行分析——
第一次作业
1、调度器设计
主要参考借鉴了 exp3,输入线程将请求投递给调度线程,调度线程再把请求投递给各个楼座的电梯
具体来说,总队列为 ArrayList<RequestQueue> processingQueues;若乘客输入请求位于B座,则将请求由 inputQueue 转移到 processingQueues.get(1) 中即可
2、电梯设计
主设计
我的设计理念是将电梯的运动与调度割离,将电梯的运动尽可能自动化,而把稍复杂的接送策略、目标计算等交给策略类进行计算。同时考虑到后续作业中可能会有的多电梯的要求,因此产生了这样的设计:将队列分为三层,由外向内分别为:
- outsidePeople:外层队列,形象化来说就是某座楼里的人
- waitPeople:等待/就绪队列,形象化来说就是已经站在电梯门口等着的人
- insidePeople:内部队列,即电梯里面正在运载的人
电梯自身只访问 waitPeople 与 insidePeople,遇上乘客就接、乘客能下就下;而策略类负责将 outsidePeople 中的人调度到 waitPeople 中,供电梯接取。
这样一来,电梯的运行方式就很简单,只需简单定义 up()、open()、in() 等功能即可,而每到新的一层则调用策略类调整队列 + 计算目标楼层,具体来说就是这样的:
public void run() {
while (true) {
if (...) {
close();
return;
}
out();
strategy.dispatch(this);
in();
int target = strategy.calculate(this);
if (target > floor) {
up();
} else if (target < floor) {
down();
}
}
}
调度策略
由于指导书中给出了在我看来宽容度比较低的定义 \(T_{max} = \max\left(T_{ALS}+3, 1.05\cdot T_{ALS}\right)\),所以我在考虑一些极限情况后认为只有 ALS 策略才能稳妥地通过所有的测试点,故写的是纯 ALS 策略。不过我还是建立了 Strategy 接口,便于实现后期可能的策略更换
结果没想到强测数据里请求又多、时间又宽容......早知道,还是该Look (>﹏<)
第二次作业
1、调度器设计
唯二的区别在于:1、对于加电梯的指令,我选择在 InputThread 线程中得到后直接加电梯,不再往后调度;2、除了每座楼的电梯队列 processingQueues.get(0~4) 以外,还多了每层楼的队列——很简单,再添加 processingQueues.get(5~14) 即可
2、电梯设计
横向电梯 Travelator
我单独新开了一个类,大致的属性还是与纵向电梯类似,只需把 floor 的改变转变为 building 的改变,并更换调度策略即可
调度策略
纵向上,虽然经过第一次作业的强测后我知道了 Look 会稍快一点,但一来差别也不是很大(也就差2分的样子),二来我想空出时间设计一下第三次作业,所以稳妥起见我还是沿用了 ALS
横向上,我觉得横向电梯运动速度着实快,性能差距应当基本完全来源于竖向,所以我横向电梯直接采用了顺时针运行(如某人为E->D,则我的电梯运行路线则为A->B->C->D->E->A->B->C->D)
多电梯在同一座/层楼时,我采取了自由竞争的方式。由于前述三级队列的设计,使得自由竞争相对简单且高效
第三次作业
1、调度器设计
由于乘客可能换乘,所以调度器也不再只是单向向后传递请求,而可能会反复处理。所以我部分借鉴了 exp4-2 中的流水线设计,运用单例模式对 Schedule类 进行了设计,实现了调度与换乘,时序图如下:
考虑到电梯可达楼座可定制,我为了尽可能简化电梯自身的策略,所以将原先的 processingQueues.get(0~14) 扩展为了 processingQueues.get(0 ~ (5 + 10 * 31 - 1)):
- 若乘客需要搭乘竖向电梯,则调度器将其分配至processingQueues.get(0~4) 中对应的那一个
- 若乘客需要搭乘某层楼上的某一类横向电梯,则调度器将其分配至 processingQueues.get((floor - 1) * 31 + switchInfo + 4)
(即:(floor - 1) * 31 + switchInfo + 4 为索引值,每一个电梯具有确定的索引值,每一个索引值可能对应多个电梯)
这样一来,电梯运行方式、策略类均完全不必修改(即不必加条件来判断是否可达)
2、电梯设计
虽然UML图看起来复杂了不少,但其实整体设计毫无变化,主要只是在策略设计上又做了进一步的扩展
先前的纵向、横向电梯分别是 ALS、顺时针策略;这次我新增了纵向的 Look 策略、横向的 ALS 策略与逆时针策略
在此基础上设计了 Simulation类,用于模拟计算采用各个策略所需时间,以此灵活动态地自动化切换电梯策略
最后我纵向电梯还是只采取了单一的 ALS 策略,因为我 LOOk 似乎写得不太优,放到hw6的强测数据点里也不大快
二、同步块与锁
hw5
主要参考的是 exp3 的上锁方式,给共享队列的方法加锁,如:
public synchronized void addPerson(Person person) {
persons.add(person);
notifyAll();
}
public synchronized Person getOneRequest() {
if (persons.isEmpty() && !Schedule.getInstance().isEnd()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (persons.isEmpty()) {
return null;
}
Person person = persons.get(0);
persons.remove(0);
notifyAll();
return person;
}
共享队列的实例即 processingQueues.get(0~x);这样无论是某个线程向其中添加还是某个线程从中取出元素,线程始终是安全的。
另外,我认为课程组所给出的这个 getOneRequest() 函数着实是很精巧,尤其是其中 wait()的设计,直接解决了轮询的问题。具体来说,当A座某电梯 getOneRequest() 而队列为空时,其会先陷入等待;调度器使用addPerson() 先A座电梯添加请求后,A座所有电梯会被 notifyAll() 唤醒,而某一个能得到该请求的电梯就会开始运行(其他电梯则由于同步锁,会先进入等待)
hw6
面对多电梯共享队列、自由竞争的问题,只需在 ALS 策略中进行一下如下处理即可:
public void dispatch(Elevator elevator) {
master(elevator);
synchronized (outsidePeople) {
carry(elevator);
}
}
由于主请求函数只调用 getOneRequest() 获取一个请求,所以不必上锁;而捎带函数中涉及到遍历访问与条件获取,所以需要对其上个锁,确保队列的安全性
hw7
在新增的流水线架构中,有两个地方还需要同步块/上锁
1、用 Counter类 来检验每个请求是否已完成时需要,即:
public synchronized void release() {
count += 1;
notifyAll();
}
public synchronized void acquire() {
while (true) {
if (count > 0) {
count -= 1;
break;
} else {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
(这也基本完全就是 exp4-2 中的代码,其中 wait() 的设计实在是相当巧妙)
2、在我的设计架构里,结束请求处理后,需要唤醒一次所有电梯来实现关门(否则电梯不会停止运行)。下列函数我将之置于了调度器 Schedule类 中,其将会在 InputThread类 的最后一步中被调用唯一一次:
private void notifyAllElevators() {
for (RequestQueue processingQueue : processingQueues) {
synchronized (processingQueue) {
processingQueue.notifyAll();
}
}
}
三、bug分析
测试策略
没写评测机(本单元又偷懒了),主要还是手搓数据
对于线程安全问题,尤其是轮询,主要就调用 print() 来看看情况
相比第一单元,第二单元难以进行分功能测试,基本只能进行整体测试;且多线程的某些 bug 较难复现
自身bug
hw5、hw6:强测、互测均无bug
hw7:强测WA了一个点,互测也被hack中了,具体地来说均是 “Elevator xx cannot open at block X because this block is not reachable”;在分析了几分钟以后,我发现原因是这样的:
上文解释了我对于索引值的设计。然而在实操中,我把 (floor - 1) * 31 + switchInfo + 4 误写为了 floor * switchInfo + 4 ...... 那么,该 bug 在满足以下两个条件时会被触发:
- 错误的电梯与正确的电梯具有相等的 floor * switchInfo 值
- 错误的电梯比正确的电梯离乘客发出换乘时的floor更近;或者若距离相等,则也会有一定的概率被触发
在对电梯总数有限制的情况下,触发条件还是蛮苛刻的......
互测中的他人bug
hw5:我在自己房里没hack中人,但是我的数据让我周边某个同学的hack成功率达到了66%,且让另一个同学的hack成功率达到了100%(感觉可以拿来秀上半年了)。数据是60个在69.5s所投入的 A10 ->A1 的请求——我手搓这组数据的灵感是源于我在最初的自测时发现自己的电梯在调头时可能捎带不上,导致在调头时只能接上一个人,所以我搓了这样的数据用以 hack
hw6:有人 WA (具体原因我没研究)
hw7:我在50s投了45个A10->B10的请求,有人rtle,有人ctle
总的看来,我感觉最主要还是要解决轮询的问题与策略上的小bug
四、心得体会
多线程设计与线程安全
第一次接触多线程设计,在hw5前的准备阶段里投入了较多的时间(上网查资料+自己先试着写点简单的多线程程序感受感受)。感觉多线程的难点是在于“共享”,也即数据间的交互:思考哪些数据可以被多线程处理,分清哪些数据需要共享、哪些数据需要注意线程安全问题。(当然,若完全是独立的多个任务而不需要共享,那多线程的优势则会被发挥得更大——但实际情况中这应该还是比较少见的)
在线程安全问题上,我觉得要注意的是:要分清何时该上锁保护、何时又不必要上锁保护,以及轮询的应对措施。为此,我们也学习了各种各样的同步手段与上锁方式;初学时对语法不熟悉而感觉比较懵,但用着用着就逐渐能“指哪打哪”了。
面向对象与层次化设计
与上一单元不同,我在本单元中最注重的是功能的简洁自动、模块间的独立以及整体架构的扩展性;所以比起上个单元中我次次特化的数据结构所带来的低扩展性,我本单元的扩展性强上了很多,每次新的作业只需往原先就设计好的空挡上添加新的功能进行实现即可
写在最后
第一次实现多线程的设计还是相当有成就感的!然而遗憾的点还是莫过于在写评测机这件事上又懈怠了......
喜忧参半,还得再接再厉。