BUAA_2022_OO_Unit2_Summary
BUAA_2022_OO_Unit2_Summary
大梦谁先觉,平生我自知。
第一章 基本架构
第一次作业架构分析
总体设计
我的第一次作业架构主要包括输入类(InputThread),总调度器(Schedule),电梯类(Elevator),候乘类(RequestQueue),输出类(MyOutput)。大致过程如下:
- 输入类将乘客请求打包为乘客类后放入总调度器的等待队列中;
- 总调度器从自己的等待队列中取出请求,根据请求分配到合适的电梯等待队列中;
- 电梯在合适的楼层从自己的等待队列中取出请求,放入自己的运行队列中。当电梯发出一定的行为时,电梯调用输出类输出;
- 输入线程结束时,给予其他线程结束标志,其他线程完成当前的所有任务后,结束,程序运行结束。
在我的设计中,输入类,调度器,电梯类均为线程,输出类不是线程。具体来说采用生产者消费者模型:
- 第一级:InputThread →waitQueue→Sehedule
- 用于输入线程和调度器线程之间的交互
- 第二级: Sehedule→requestQueue→Elevator
- 用于调度器线程和电梯线程之间的交互
电梯类分析
由于电梯类是我们的核心线程,我们可以仔细分析一下:
@Override
public void run() {
while (true) {
//线程结束判断
if (processingQueue.isEmpty() && processingQueue.isEnd() && peopleInEv.isEmpty()) {
return;
}
//电梯等待判断
synchronized (processingQueue) {
if (processingQueue.isEmpty() && peopleInEv.isEmpty()) {
try {
processingQueue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
}
//电梯开门判断
if (!isOpened && needOpenDoor()) {
openDoor();
}
//出、进、掉头检测、再进
if (isOpened) {
personLeaveElevator();
personComeElevator();
setTowards();
personComeElevator();
}
//关门判断
if (isOpened && needCloseDoor()) {
closeDoor();
}
//运行判断,运行需要判断掉头之类的问题
if (!isOpened && needGo()) {
setTowards();
go();
}
}
}
提炼以下几点:
- 电梯的run()方法中只保留电梯应该具有的行为逻辑,而判断是否执行该行为的方法可以下发给其策略子类;
- 运行过程中动作时完备的,即判断开门→开门→出人→进人→设置方向→进人→判断关门→关门→判断运动→设置方向→运动,这一套行为是一次粒子操作,即需要全部完成。
- 电梯的运行遵循look策略:
- 运行条件:电梯内有乘客,等待队列中有乘客,
- 捎带条件:请求方向和电梯方向相同,
- 转向条件:当前运行方向上没有请求(电梯内人在更远处下,更远处有人),
- 线程等待条件:整个楼座均没有请求时,电梯主动 wait,让出资源。
- 当电梯没有可以响应的请求时,应该立即wait,防止轮训;
- 结束判断不仅仅是依据输入线程发出的终止标志,还要将等待队列中的所有请求完成才可结束。
一些节约时间的歪门邪道(由于不符合现实逻辑,不在正文赘述):
-------START
- 可以将开门时间设置为400ms,这样可以快速响应关门200ms内的请求,
- 量子电梯,当电梯等待时间过长,可以瞬间响应下一请求,可反映的时间取决于等待开始到下一请求到达的时间差,
- 放弃固定的关门、开门时间间隔,利用系统行为决定开关门、移动所需时间(只需符合约束即可)。
-------END
同步块的设置和锁的选择
- RequestQueue设置为线程安全类,利用 synchronized 同步addPersonRequest、getOnePersonRequest、getCurFloorRq、hasHigherRequest、getRequestQueue、setEnd等方法
- 利用 static synchronized 包装了线程不安全的官方输出方法
public static synchronized void println(String string) {
TimableOutput.println(string);
}
- 未考虑使用其他类型的锁机制。
UML 类关系图
第二次作业分析
架构分析
-
沿用了第五次作业的二级生产者 - 消费者模式,纵向LOOK策略;
-
横向电梯在运动逻辑上与纵向保持一致,稍微修改纵向电梯的策略类即可用于横向电梯。横向电梯也采用变形的LOOK策略,大致算法如下:
-
运动条件:电梯内有乘客,请求队列有请求;
-
接人条件:请求方向和电梯方向相同,请求方向为使距离更短所走的方向
-
转向条件:电梯内无人且最先请求距电梯运动方向更远或者电梯内所有请求到达目的地距电梯运动方向更远;
-
线程等待条件:整个楼层均没有请求;
-
结束条件:输入线程发出结束标志并且已经处理完所有请求。
-
-
调度策略:自由竞争
- A座所有纵向电梯共享同一个等待队列,B~E同理,1层所有横向电梯共享一个请求队列,2~10层同理,即初始化生成15个请求队列,在同类型电梯和输入线程类之间共享;
- 请求进入等待队列后,所有同类型电梯自由争抢,由于队列是安全的,可以保证同时最多有一个电梯接到了请求,其余电梯无法获取;
-
优化方案:
-
优先接和电梯内乘客相同目的地的乘客,可以减少开门的次数
-
优先接距离远的请求
-
无请求时让多部电梯均匀分散在各个楼层,这样无论来了哪个楼层的请求,都可以快速响应
-
继续采用作业一指出的部分外门邪道
-
同步块的设置和锁的选择
此部分继承作业一,无异。但是由于作业一过分notifyAll()导致作业二一种非常隐蔽的轮询:
- 现有A座纵向三个同类型的电梯EV1、EV2、EV3,等待队列为空,假定EV1、EV2都在wait;
- EV3开始使用processingQueue.isEmpty()判断等待队列是否为空,而isEmpty()会调用notifyAll(),唤醒EV1、EV2;
- 此时EV3在wait,而EV1、EV2被唤醒,又会调用processingQueue.isEmpty()唤醒EV3,导致假wait,即轮询。
解决方法也很简单,删除多余的notifyAll()即可,实际上过分的加锁和notify不利于多线程的编程工作,我们需要有选择的进行同步控制,才能写出清晰的代码。
UML 类关系图
第三次作业分析
总体设计
我的第三次作业架构主要包括新增了UnFinish类来计数未完成的请求数,大致过程如下:
-
启动初始线程;
-
输入类依据请求类型划分,若为电梯请求则创建并运行,若为乘客请求则将其打包为乘客类后放入总调度器的等待队列中,未完成请求数加一;
-
总调度器从自己的等待队列中取出请求,若乘客当前位置等于终点位置,未完成请求数减一;否则拆分请求为三段式,仅对第一段进行规划,并放入合适的请求队列中;
-
电梯在合适的楼层从自己的等待队列中取出请求,放入自己的运行队列中。当电梯发出一定的行为时,电梯调用输出类输出;完成某一请求后,从运行队列删除该请求,并放回总调度器的等待队列,以供调度器的后续安排;
-
输入线程结束时,给予其他线程结束标志;其他线程完成当前的所有任务后,等待;当未完成数为0是所有线程结束运行,程序结束。
架构分析
-
沿用了第二次作业的二级生产者 - 消费者模式,纵向电梯、横向电梯均采用LOOK策略;
-
多生产者消费者模型:
- 输入线程类可以创建电梯
- 电梯完成某请求,需重新放回总调度器的等待队列
-
调度策略:自由竞争
-
路线规划:
- 响应时间成为一个衡量因素,因此考虑优先完成由电梯锁生产的请求;
- 遵循少换乘原则,保证一个请求最多换乘两次;
- 在换乘次数相同的情况下,寻找最短的路径,根据运行速度赋权值1/speed;
- 依据基准策略和可达性安排当前请求。
同步块的设置和锁的选择
-
本次作业主要由以下共享对象
-
waitQueue为一级托盘,电梯、输入线程作为生产者,电梯作为消费者,需要对get、add等方法上锁,采用synchronized;
-
requestQueue作为二级托盘,调度器为生产者,电梯为消费者,需要对get、add等方法上锁,采用synchronized;
-
UnFinish作为记录未完成请求数量,调度器、输入线程、电梯都需要访问修改,采用synchronized进行上锁;
-
MyOutput作为输出类,由所有的电梯共享,也需要使用synchronized对输出方法上锁。
-
-
锁只使用了synchronized锁,仍没有使用 ReentrantLock、ReentrantReadWriteLock 等锁。
调度器设计
Schedule作为独立的线程,有以下职责:
- 管理所有的电梯等待队列和纵向电梯列表;
- 接受输入线程的请求,依据基准算法拆分请求,分发至合适的请求队列;
- 接受电梯生产的请求,依据请求的状态,如果请求已完成则删除,否则依据基准算法分发至不同的请求队列;
- 所有请求完成并且输入线程结束,通知电梯结束运行。
UML 类关系图
UML 类协作图
第二章 BUG和HACK好兄弟的日常
第一次作业
自己的作业bug
-
公测、强测未发现bug,得分98.8;
-
互测存在输出线程不安全的情况,将输出用MyOutput安全类封装即可。
HACK
- 也是输出时间戳不递增问题,由于第一次作业逻辑较为简单,没有发现其他方面的bug。
第二次作业
自己作业的bug
- 公测、强测、互测均未发现bug,强测得分98.9;
HACK
-
Saber存在轮询情况,使用下述数据可以命中:
[1.0]ADD-building-6-A [1.1]ADD-building-7-A [1.2]4-FROM-A-4-TO-A-10 [28.0]7-FROM-A-10-TO-A-5 [48.0]8-FROM-A-10-TO-A-5 [60.5]9-FROM-A-10-TO-A-5
第三次作业
-
公测、强测、互测均未发现bug,强测得分99.2;
-
大家可能都无心hack,这是一个久违的平安夜,大家相安无事。
-
事实上我发现很多BUG,但是由于评测机的原因,无法复现,例如Lancer、Caster存在无法结束线程的情况,本地刀刀中,“沙城”却无用。
附:手动多线程,卑微
第三章 可拓展性分析
层次化设计&设计原则
SRP
SRP: 职责应该单一,不要承担过多的职责,体现在每个类只负责自己的行为:
- 电梯只有开关门、上下人、 转向、 移动等逻辑行为,判断逻辑和策略选择交给策略类完成;
- RequestQueue仅仅负责增加、查找、删除请求,根据请求分派交给Schedule;
- InputThread仅仅负责解析数据,具体对请求的操作也是交给Schedule处理。
OCP
OCP:修改软件功能的时候,使得他不能修改我们原有代码,只能新增代码实现软件功能修改的目的:
- 三次代码的结构是迭代的, 仅仅新增了部分代码,原有的代码我们都极大的保存了下来。
ISP
ISP: 接口的内容一定要尽可能地小,能有多小就多小:
- 我将电梯工厂的电梯接口保留了run()方法,因为这是所有电梯的统一行为。
第四章 心得体会
线程安全
线程安全是多线程相较于单线程而言难度较大的地方。其存在三个问题:读写、死锁以及轮询。
-
读写问题出现主要为写读、写写不为原子操作导致获取数据紊乱。其有些类似于上学期计组课设流水线CPU设计。通过本单元的学习,我掌握了基本的确保线程安全的方式,明白了如何有效的对共享对象进行加锁、释放锁的时机。
-
对于死锁,应该采用正确的wait-notifyAll方式编程,但是不要使锁结构过于复杂。
-
对于轮询,需要给定合理的等待条件,使得电梯以及调度器线程可以在合适的时候进入休眠,避免占用过多的CPU资源,与此同时,需要注意不要为每个方法都加上notifyAll,有选择有针对的notify才能正确的避免轮询。
层次化设计
层次化设计在应对较大规模的工程问题时十分重要,我们需要将较大的问题分解为几部分,再针对各个部分内部的任务进行完成,最后在各部分间的接口上完成连接,构成层次化结构。对于电梯作业,有“输入—分配—运行—完成”三个阶段,以乘客类为媒介,以若干等待队列为共享对象,涉及输入类、调度器类、电梯类、输出类,并让他们通过共享队列进行交互。这样有利于迭代开发与扩展,也有利于在出现bug时有针对性地维护。
感想与收获
相较于第一单元的狼狈,第二单元无论在代码上,还是在测试中都获取到了令我满意的结果。但是由于实验代码二的失败,导致我在第七次作业中心理上避免了流水线架构、单例模式、枚举类等设计。事实上它们都是非常好的设计。由此可见克服心理障碍永远是成功的先决条件。
艰难的电梯月就这样度过了,我似乎已经习惯于以代码为核心的计算机学院生活了,仰望第三单元的光景,大抵也是这样吧。