前言
IBM的OS/360发布时,带着已知的1000个错误。
——操作系统PPT
就像数十年前的先驱们一样,我的程序也带着数个已知的错误向着强测进发了。虽说我的本意并非摆烂,但经过数天的持续奋战,数百分之一的复现概率和我程序中已乱成一团的线程逻辑终究还是让我向现实低头,选择相信幸运女神会眷顾我的强测。如今面对这份总结博客,我回望过去的一个月,只觉恍若隔世,如释重负的同时又为自己学到了许多感到些许欣喜。或许这就是学习的本质:每天收获一点东西,也就足够了。
谨以这篇博客献给Hyggge学长和Yanna-zy学姐(二位已经在某种程度上成为了我的精神偶像)。
UML图
复杂度分析
UML协作图
第一次作业
现在回想起来,第一次作业相对简单,但对于完全没有接触过多线程的我来说,完成它也并不是一件轻松的事。当时的我十分惧怕线程通信导致的不可预测之bug,因此选择删去了Schedule类,直接在InputHandler中将请求推送给RequestTable。殊不知,这一决定和之后不想重构的懒惰,直接导致了我三次作业都没有调度器类(doge)。
在加锁的问题上,由于第一次作业只有requestTable这一个共享对象,因此我只对该类中修改对象属性以及需要使用wait, notifyAll的方法进行加锁,避免出现并发修改问题。调度策略方面,我选择的是LOOK算法,也是现实电梯中使用最广泛的策略之一。当然,由于没有对LOOK算法进行细节方面的进一步优化,本次强测性能分并不太高。有关LOOK算法,这里需要感谢Hyggge学长博客中清晰的阐释,代码框架如下:
public class Strategy {
public Advice getAdvice(int id, int curNum, int curFloor, int direction, HashMap<Integer,
HashSet<Person>> destMap, RequestTable requestTable) {
HashMap<Integer, HashSet<Person>> startMap = requestTable.getRequestTable();
//首先判断电梯是否能开门
if (canOpenForOut(destMap, curFloor) ||
canOpenForIn(startMap, curFloor, curNum, direction)) {
return Advice.OPEN;
}
//电梯内有人,则按原方向继续移动
if (curNum != 0) {
return Advice.MOVE;
} else {
//在电梯内没人的情况下,检查请求表是否为空。如果请求表为空且结束标志有效,则结束,否则等待
if (requestTable.isEmpty()) {
if (requestTable.isOver()) {
return Advice.OVER;
} else {
return Advice.WAIT;
}
}
//检查是否可以捎带,若可以,则按原方向继续移动;若不行,则掉头
if (hasReqInOriginDirection(requestTable, curFloor, direction)) {
return Advice.MOVE;
} else {
return Advice.REVERSE;
}
}
}
public boolean canOpenForOut(HashMap<Integer, HashSet<Person>> destMap, int curFloor) {
...
}
public boolean canOpenForIn(HashMap<Integer, HashSet<Person>> startMap,
...
}
public boolean hasReqInOriginDirection(RequestTable requestTable, int curFloor, int direction) {
...
}
}
在第一次作业中,我的强测与互测均没有出现bug。那时的我,还对于接下来两周将会发生什么一无所知。
第二次作业
第二次作业主要难点是处理RESET操作的相关问题。由于我没有调度器类,因此自然也没有等待队列,将RESET操作后被迫走出电梯的人重新加回请求表需要直接在输入类里进行。我的第一版程序就是这样写的,且不说这样写显然混淆了各个类的职责,为了保证输入类能够处理完输入请求与RESET后放出来的所有人之后再结束线程,我在requestTable类中设置了多个flag标志以区分各种情况,控制逻辑非常复杂(Hyggge学长博客中提到的RequestCounter类可以解决这一问题,只可惜我写第三次作业才看到)。后来用评测机做测评时,我发现自己对于情况的考虑并不完善,在短时间内发送大量RESET请求时有较大概率出现bug。但是再用flag进行控制的行为实在是太过麻烦与丑陋了,因此我决定直接将RESET放出来的人加回自身的请求表。我认为这种简化处理有一定的道理:首先,RESET操作只需1.2s, 若以电梯0.4秒移动一层的平均速度来计算,只有同方向两层以内的电梯来接乘客才会有性能优势,因此这样处理并不会损失太多性能;另一方面,指导书规定了一次RESET操作有最多5s的处理限制时间,在上一次RESET操作完成之前不会出现下一条RESET操作。因此,除去1.2s的RESET时间外,我有3s以上的时间可以进行自主移动,不会出现一个电梯不停RESET而始终动不了的情况。
在调度器方面,我采用的是多指标评价策略,当时认为这种方法简单,还有很大的自主调整空间,但或许是我在调参方面尚没有太多经验,最终强测性能成绩距离影子电梯有一定差距。我在综合评价时考虑的是距离目标楼层距离,电梯内人数,请求表请求数量三个指标,在互测被刀后又加入了电梯是否被RESET(将在互测部分细致描述)这一指标。首先,我假设这四个指标对于评分的影响权重是相同的,亦即目标楼层减少一层,电梯内减少一人,请求表减少一个请求(RESET一次可近似认为电梯与目标楼层距离多了3层),所导致的评分变化幅度是相同的。然后,再根据评测机跑出来的结果调整各个指标的权重。根据多次试验,加大电梯人数、请求数量的权重可提高电梯运行时间的稳定性,加大目标楼层距离的权重则会让电梯运行时间波动性增大,快的时候甚至超过影子电梯,慢的时候则不如随机分配。由于不敢相信自己的运气,我最终调大了电梯人数、请求数量的权重。代码框架如下:
private int bestElevator(Person person) throws InterruptedException {
double peopleWeight = 18;
double distanceWeight = 5;
double requestWeight = 2.2;
double resetWeight = 3;
double minScore = 1000000;
int bestElevatorId;
ArrayList<Double> scoreList = new ArrayList<>();
for (RequestTable table : requestTable) {
StatusChart statusChart = table.getStatusChart();
double peopleScore = statusChart.getNowNum() / statusChart.getCapacity();
double distanceScore = InTheSameDirection(statusChart, person) ?
Math.abs(statusChart.getNowFloor() -
person.getFromFloor()) * statusChart.getSpeed() :
11 * statusChart.getSpeed();
double requestScore = table.getSize();
double resetScore = table.isLocked() ? 3 : 0;
double score = peopleWeight * peopleScore + distanceWeight * distanceScore +
requestWeight * requestScore + resetWeight * resetScore;
scoreList.add(score);
if (score < minScore) {
minScore = score;
}
}
ArrayList<Integer> bestElevatorList = new ArrayList<>();
for (int i = 0; i < scoreList.size(); i++) {
if (Math.abs(scoreList.get(i) - minScore) < 0.1) {
bestElevatorList.add(i + 1);
}
}
//多个电梯评分相同,随机选一个,避免评分相同时总将请求推给序号最小的电梯
Random random = new Random();
bestElevatorId = bestElevatorList.get(random.nextInt(bestElevatorList.size()));
return bestElevatorId;
}
为了在输入类里获得电梯属性,我新建了StatusChart类,电梯每做一次操作就更新一遍对应的状态表,输入类再从RequestTable类中获取StatusChart来进行电梯调度。因此,StatusChart也是一个共享对象,所有修改其属性和读取其可变属性的方法都要加锁。至于调度策略,我依然采用的是LOOK算法,这里不再赘述。
至于出现的bug,那就数不胜数了。除了一些小细节以外,我印象比较深的是在线程类中遍历requestTable时,由于可能通过addRequest或delRequest操作同时修改了requestTable,因此可能会报ConcurrentModificationException的异常。解决方法是给遍历加对象锁,或者直接将遍历方法写进RequestTable类加方法锁。除此之外,经过数千组的测试,我的程序似乎会以1%左右的概率触发死锁,但我还是没有禁住清明假期狠狠开玩的诱惑,选择放弃debug。幸运的是,强测似乎并没有触发我的死锁bug,在这里狠狠感谢助教与评测机放我一马!
当然,报应很快就来了,我的互测直接被刀成了筛子。总结起来,我在互测中被发现了两个bug,分别是同一时间戳输出RESET_ACCEPT与RECEIVE,以及RESET电梯不参与分配导致的超时(在此狠狠谴责1个bug改了乘客编号连刀3次的做法)。第一个bug我选择直接打补丁:输出RESET_ACCEPT之前睡10ms,第二个bug我选择将RESET的电梯也参与评分,当选到正在RESET的电梯后就先睡1300ms再输出。
第三次作业
第三次作业我认为困难的是架构设计。作业发布后的星期一晚上我一直在思考如何设计架构能够更好地满足第三次作业的要求,最终采用了加Controller切换电梯的架构。至于在电梯运行逻辑方面,有了一二两次作业的铺垫,其实不需大改,只要注意部分细节即可。比较有难度的是如何控制双轿厢电梯两个轿厢不相撞。我新建WaitStation类专门处理这一问题。在Strategy类中实例化WaitStation对象,电梯每想要执行MOVE操作,就要抢到WaitStation的锁,保证一个时刻只能有一个电梯返回MOVE建议。特别地,当电梯MOVE的目标是换乘层时,立即将midFloorBlocked标志置位,让下一个想要去换乘层的电梯等待。当电梯到达换乘层接人,并立刻离开换乘层之后,将midFloorBlocked标志复位并唤醒另一个正在等待的电梯线程,使之前往换乘层。代码框架如下:
synchronized (waitStation) {
if (curFloor + direction == midFloor && midFloorBlocked) {
waitStation.waitForBlocked();
}
if (curFloor + direction == midFloor && !midFloorBlocked) {
midFloorBlocked = true;
return Advice.LEAVE;
} else {
return Advice.MOVE;
}
}
调度器方面,我这次直接开摆,完全复用上次的调度器。但这意味着没有区分单轿厢电梯与双轿厢电梯,而双轿厢电梯的状态表由执行最近一次操作的轿厢决定。显然这并没有什么道理,但我已经没有多余精力去思考优化方案了。果不其然,强测性能分直接寄掉,只比随机分配与顺序分配高一些。至于加锁方面,这次实际上与上次作业没有太大区别在此不再赘述。
至于bug方面,我的程序依旧没有解决死锁的问题。并且经过测试,死锁触发概率由1%提升到了近2%,令我对即将到来的强测十分焦虑。可惜的是,第三次作业的那周要考蓝桥杯,我的空闲时间实在所剩无几,没法专心地研究死锁问题。幸运的是,我的强测依旧没有触发死锁bug,看来OO之神还是爱我的。但是互测被人刀中死锁bug,我只能说该来的总是要来的(乐)。
稳定性分析
从普遍意义上讲,我的代码具有一定的稳定性,尤其是InputHandler, Elevator类的功能不会大变。如果新增需求,主要发生改变的应该是RequestTable类和Strategy类,RequestTable用于容纳新增的请求,可能还涉及简单的请求处理;Strategy类用于容纳应对新增请求而需要的策略。Controller类可以在一定程度上提高代码的稳定性,对于不同类型的电梯,可以继承Controller类,再添加需要的独有方法。
心得体会
第二单元好难啊,有别于第一单元结束时我尚能嘴硬地说一句“不过如此”,书写第二单元博客的我对过去的三周心有余悸。相比上一个单元,我的体会主要是:
-
在编写代码前,要先进行细致的架构设计和可行性分析,不能像以前编写c代码一样,感觉这样写可以,就开始动手。程设、数据结构时代码通常100行内就可解决,现在的程序动辄1000行往上,如果没有清晰的思路,结果通常就是写了100行,感觉不对,又删了50行,反而是事倍功半。
-
关于解题思路,可以与舍友、同学充分讨论。有些任务,即使是一个人的努力可以完成的,通常也并非最佳解法。在这里,我想感谢我的现室友csc, zz, zbr以及前室友gpf, zx。通过与他们的讨论交流,我获得了许多有价值的思路(甚至嫖到了免费的评测机)。学习是一个人的事,却又不只是一个人的事。
-
关于迭代开发。对我来说,每个星期的任务并非熬上一两个晚上就能拿出最终结果的。通常而言,我都是先编写一个大致正确的代码,能够通过样例,在评测机中表现不太差。然后我会先放下OO,做一些其他科目的作业或自己感兴趣的事,当有debug思路、性能优化思路、结构优化思路之后再着手改进代码。我认为这种迭代式的开发即使是在一次作业的完成之中也是十分有益的,这大概也是我在通过中测后还会提交3~4版代码的原因。