BUAA OO m2博客
作业整体架构
整体描述
我的三次作业架构均类似:
- 一个输入类,负责接受输入并把输入传递给托盘,同时负责发送输入结束信号;
- 一个托盘类,该类承担托盘的职责,同时还负责所有的进程间通信和调度器的功能;
- 电梯类,该类负责实现电梯的具体行为,在第三次作业中我还新增了一个双轿厢电梯类;
- 一个策略类,该类所有方法都是静态的,没有属性,负责接受电梯当前的状态,根据状态返回电梯的行动建议
细节
基础流程
更具体的流程是,Init和Elevator继承了Thread,会在主函数中被创建、运行。Init负责读取输入,把读取到的输入传递给requestTable。同时,Elevator每次循环都会询问Strategy建议,若没有请求则等待,否则执行建议。电梯根据LOOK规则进行。
Init流程:
@Override
public void run() {
ElevatorInput elevatorInput = new ElevatorInput(System.in);
while (true) {
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
break;
} else {
synchronized (requestTable) {
//something
requestTable.notifyAll();
}
}
}
try {
elevatorInput.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
Elevator 流程:
@Override
public void run() {
while (true) {
Strategy.Advice advice;
synchronized (requestTable) {
advice = Strategy.getAdvice(
currentFloor, direction, currNum, elevatorTable,
elevatorID, personDirection);
if (advice == Strategy.Advice.WAIT) {
//something
}
}
switch (advice) {
case MOVE:
//something
break;
case REVERSE:
//something
break;
case OVER:
return;
case OPEN:
//something
break;
default:
break;
}
}
}
锁与同步块
为了避免死锁,我采取了非常简单粗暴的方法:强制设置每个线程只能访问自己的属性/方法或者托盘类的属性/方法。同时,我的托盘类设计为单例模式,这样一来只需要对托盘类的唯一实例加锁就可以避免线程冲突。
调度器与线程的交互
我的调度器实现于托盘类中,通过一些分析,可以发现存在一种方式,使得托盘类无需锁的限制即可得到影子电梯的运行时间。这样一来也就没有线程交互的问题了。详见下文“调度规则/神威·奥义·双轿厢影子电梯/复制与克隆”。
轿厢碰撞的避免
我采取了类似于信号量的机制。将换乘楼层的空置视为一种资源,该资源存放在托盘类中。
- 当一个轿厢试图到换乘时,检查该资源是否已被拿走,若是,则线程等待,直到另一个轿厢释放该资源。之后,线程恢复,标记该资源为被拿走
- 当一个轿厢从换乘楼层离开时,放回该资源。
该过程中,我通过设置锁的顺序,以及规定所有轿厢在完成任务后不得在换乘楼层逗留,必须立刻离开,避免了死锁问题。
调度规则
在第六第七次作业中,我选择了影子电梯的方式进行调度。基本的影子电梯大家已经总结了很多,我想总结一下双轿厢的影子电梯的实现细节。
神威·奥义·双轿厢影子电梯
巅峰产生虚伪的拥护,黄昏见证虔诚的信徒
在本次作业中,实现双轿厢电梯的影子电梯实在是麻烦又危险。接下来我会介绍我设计的双轿厢影子电梯的设计思路与核心创新,但请注意,我实现的完整的双轿厢影子电梯类一共有498 行,非必要不建议在h7还坚持影子电梯——有我一个冤大头已经够了
复制与克隆
wxm巨佬认为,在复制、克隆、模拟影子电梯运行的过程中,必须要保证影子电梯的参数是实时正确的。窃以为没有必要。理由如下:
- wxm提出,如果在模拟影子电梯运行的过程中电梯的属性发生改动,那么会极大影响运行结果。例如:“电梯拿到一个2-TO-8的请求,此时1号电梯与2号电梯分别在2楼、1楼停靠。那么分配器会分配给1号电梯。但此时1号电梯可能正在执行MOVE,刚刚完成等待阶段并移动到了3楼。这种情况对结果的影响不可谓不大。”但是仅对这种情况而言,是不会影响结果的——别忘了量子电梯。当分配器分配给1号电梯后,1号电梯在sleep结束后进行检查时自然会发现这个新增的请求,那么根据量子电梯,1号电梯会停止运动,转而开门接纳请求。因此对于这种情况而言保证影子电梯参数实时正确没有必要。
- 经过分析,可以很自然的发现:因为影子电梯而导致的电梯的请求序列发生修改是不会影响电梯运行效率的。那电梯的参数发生修改是否会影响影子电梯的模拟呢?只要巧妙地设计一下,就不会有任何影响。能够影响电梯参数的只有开门出人和Reset两种动作,对于第一种,很显然电梯能出人那影子电梯也能出人,只需要在初始化影子电梯时额外传递一个参数,根据记录的时间计算出电梯已经执行的开关门时长就能避免。对于RESET,只需要做到电梯参数修改发生在RESET开始sleep之前,这样一来初始化影子电梯时,只需要计算出电梯已经sleep的时间就能做到影子电梯超前、正确地完成Reset。当然,这样一来有可能会有请求分配给正在Reset的电梯,这无所谓,只需要用一个buffer暂存一下所有已分配但未输出“RECEIVE”的请求,然后让电梯在完成Reset后输出一下这个buffer的内容即可。
总而言之,没有必要追求在影子电梯模拟过程中电梯参数是一定正确的,只需要保证初始化影子电梯这一步是个原子操作(过程中电梯的参数不会变化)就可以了。当然,你可能有别的疑虑,担心一些奇奇怪怪的情况会影响性能,无所谓,不用担心,因为最多只会有0.2分不到的损耗,可以接受
以下是我的复制方案: 所有的请求都存储在托盘类里,电梯只存储电梯自己的参数。托盘类会(依靠ArrayList)持有所有电梯的引用,当有新增乘客请求分配一个电梯时,托盘类会遍历电梯ArrayList,调用相应的getTime方法,选取耗时最短的电梯作为新乘客的电梯。而getTime方法如下(事实上整个过程哪怕完全不加锁也根本不会影响性能):
public synchronized double getTime(HashMap<Integer,HashSet<Person>> personRequestTable,
PersonRequest person) {
Person temp = new Person(person);
temp.setElevatorId(elevatorID);
ShadowElevator shadowElevator = new ShadowElevator(elevatorID, elevatorTable,
personRequestTable, currentFloor, currNum, maxPersonNum, (float) moveTime,
direction, personDirection, temp);
double result = shadowElevator.getTime();
return result + resetFlag * 1200 * 0.3;
}
深度克隆或复杂的锁的持有,在实践中可以证实意义不大。
双轿厢影子电梯基本思路
写双轿厢影子电梯的过程真的是一种折磨,各种意义上的折磨。
我的双轿厢影子电梯的思路是这样的:对于影子电梯而言,两个轿厢的运行是不会存在协作的,如果一个乘客想跨越转换层,那么对于影子电梯而言乘客在转换层下去的那一刻就已经和这个电梯无关了——之后是经过重新调度重新给下去的乘客分配一个新的电梯。这意味着一个轿厢的请求处理完就是真的处理完了,不会有新的请求分配给这个轿厢。
这样一来,双轿厢的影子电梯就基本相当于两个影子电梯的拼接。不过还需要考虑两个轿厢对转换层的竞争带来的等待。而我的实现方式是统计每个轿厢的竞争发生时间和发生次数,假设B轿厢占据了转换层,那么当A试图去转换层时会被拒绝,这时记录下B的时间,并记录是第一次发生竞争。当B从转换层出去、A进入转换层时,会根据记录判断发生过竞争,于是让A的时间加上B现在的时间减去记录的时间,同时清空竞争记录。这样一来就能解决两个轿厢的竞争问题了——剩下的和普通影子电梯没有太大的区别。
影子电梯里的调参——适应双轿厢的电量优势
与普通影子电梯有所不同的是,双轿厢影子电梯在电量上有额外的优势。因此本次作业中不能单纯以运行时间决定分配给哪个电梯。我的方案是将运行时间和运行电量做加权,利用加权后得到的结果判定应该分配给哪个电梯。这样在运行时间上可能会落后一些,但是更符合性能分的计算方式。
运行流程
我的选择是,让两个轿厢放在同一个影子电梯里进行模拟,避免复杂的进程通讯。
- 每一个循环,两个轿厢会分别获取自身的建议,然后分别执行建议,分别维护两个轿厢各自的运行时间。
- 当两个轿厢都完成了所有请求处于等待状态时,判断已经处理完毕,返回结果
- 需要额外特化考虑一个轿厢(不是因为转换层冲突而)等待,另一个轿厢运行时的情况,因为假如A轿厢运行完进入等待,B轿厢运行结束后,B轿厢的运行时间要比A轿厢更短(这种情况有可能,因为该条件下只能保证A轿厢获取建议的次数少于B轿厢,别的无法保证),那么如果没有累计A轿厢的等待时间则会导致总时间计算错误。
作业架构分析
架构设计的变化
在第一次作业中,我采用了单例模式和生产者-消费者,输入类承担生产者的角色,电梯承担消费者的角色,同时实现了一个仅包含静态方法的策略类,该类接受电梯参数并给出电梯下一步操作的建议。
在第二次作业中,第一次作业的架构基本不变,托盘类额外承担分配乘客请求的执行电梯的功能(采用影子电梯)。策略类的建议方法会额外读取当前电梯是否有reset请求,如果有则处理该请求——如果有人则开门放人,然后修改参数睡眠1200ms。
在第三次作业中,第二次作业的架构基本不变,新增了双轿厢电梯类,每个对象模拟一个轿厢。电梯如果接收到reset双轿厢请求,就会创建两个轿厢对象,等待1200ms后终止线程。同一个井道内的两个轿厢共享同一个电梯编号,只是在输出的时候才有所区分。托盘类依旧依靠影子电梯给乘客分配电梯,所有对轿厢的访问都会由已经停止的普通电梯对象做中转。
以下是第三次作业的UML类图
线程的协作关系
作业的稳定内容和易变内容
稳定内容:请求的接受和存储、电梯类的运行
易变内容:新增的请求、新增的不同种电梯、乘客分配方法、策略类
请求的接受和存储、电梯类的运行都是植根于题目的大背景下的,因此很难发生什么改变。而新增的请求和不同种电梯,本身是“增量”,对原本的代码影响不大。乘客分配方法和策略类因为请求和电梯的改变容易出现较大的改变,但是因为较好的封装,它们的改变不会给别的部分带来太大影响。
Debug方法
多线程情境下,debug确实非常困难。我采取的策略是输出大法,输出电梯每次行动时的信息,通过分析问题发生前后的日志来判断问题出在哪里。
心得体会
线程安全:锁是福音,也是约束,加锁一定要慎重。这个单元中我没有出现过死锁相关的问题,我认为要归功于自始至终坚持的原则:只允许托盘类的单例对象加锁。当然,在实际开发中这么搞肯定是需要否定的,但是在OO课中这种思路还是有些意思的。
层次化设计:在本单元作业中,我深刻感受到了尽可能用增量代码实现新增需求的重要性。随着运行逻辑的膨胀,每次功能迭代都是在深渊边徘徊,一个不慎就有可能重新引发远古bug。因此,设定好代码的层次,将不同的部分做好抽象和封装就显得尤为重要了。
多线程真的是程序开发中的大难点,乱七八糟的锁实在让人头疼。不过经过这三个周的磨砺,我对于多线程有了一个初步的认识,也掌握了一定的调试多线程的能力。很感谢这份收获,因为操作系统紧跟的期中考试立刻用到了多线程的一些思想。
个人感觉这个单元的难度相较于上个难度要小一点,因为这个单元的逻辑其实是不复杂的,只需要把握好“SOLID”原则,掌握进程的知识点和代码中锁的关系,整体的代码并不复杂。至少比起超长的表达式分析,还是这种类型的题目更适合我(闭目)