BUAA-OO-unit-2-总结
BUAA-OO-unit-2-总结
第二单元主题为实现多线程的实时电梯模拟,研究探索多线程协作、线程安全、设计模式等问题。
电梯单元的主要难点在于,在保证线程安全的前提下,设计出易于扩展的架构,同时保证性能。对此,我采用了单级托盘集中式调度的架构。三次作业迭代增量开发的过程中,我在电梯之间的宏观调度上主要采用自由竞争的策略,在单个电梯微观调度上则探索了不同的算法。实验和测试结果表明,这种设计有相对较好可拓展性和性能。
同步块与锁的设计
第一、二次作业设计
本实现使用了synchronized关键字标记同步块并实现锁,并测试了使用Synchronized和使用ReetrantLock在性能上的差异。经实验,发现两种机制的性能无较大差异。这主要是因为,在资源竞争不是很激烈的情况下,Synchronized的性能略优于ReetrantLock;但是在资源竞争很激烈的情况下,Synchronized则会较差;而本单元作业即使在极端情形也没有出现非常激烈的资源竞争。
由于在架构上选择了生产者-消费者模式、单级托盘的设计,因此单个请求池RequestPool
被设置为唯一的同步对象,该对象的所有public方法均被加锁。请求池不是线程,可以由输入线程写入请求,由电梯线程读出请求。这种方法实现简洁,不容易出现线程安全、死锁等问题。
//RequestPool
public synchronized void addPersonReq(Person personRequest);
public synchronized void setEndSign(boolean endSign);
public synchronized boolean isEnd();
public synchronized ArrayList<Person> getPersonReq(
char building, int floor, int dir, int capacity,
Predicate<Integer> predicate, boolean isVertical, boolean needCheckDir);
public synchronized boolean hasPersonReq(
char building, int floor, int dir,
Predicate<Integer> predicate, boolean isVertical, boolean needCheckDir);
public synchronized boolean hasReqAhead(
char building, int floor, int dir, Predicate<Integer> predicate, boolean isVertical);
请求池之外,电梯线程等待的代码块也进行了加锁。
//Elevator
synchronized (requestPool) {
if (requestPool.isEnd() && passengers.isEmpty()) {
return;
}
try {
requestPool.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
第三次作业设计
第三次作业在架构上新增了楼层类,在托盘中新增了管理楼层电梯情况的数据结构。因此在共享资源类中,设置了为某一楼层增加横向电梯、获取换乘楼层的方法,这些方法也是被加锁的。
//RequestPool
public synchronized void addFloorEle(int floor, ElevatorRequest elevatorRequest);
public synchronized int getTransferFloor(
int fromFloor, char fromBuilding, int toFloor, char toBuilding);
调度器设计
由于第一、第二次作业可看作第三次作业的子集,这里只说明第三次作业调度的设计和实现。
三级调度机制与实现
- 对于第三次作业,若要对人请求采用静态拆分,那么我们需要实现的调度机制如下图所示,是一个三级调度机制:首先,在拿到新请求后应拆分请求至相应的换乘楼层(第一级调度);在换乘楼层楼座需要安排所乘电梯(第二级调度);电梯本身的运行策略可看作第三级调度。
-
对于一级调度,采用静态拆分的方法,在人类中增加子请求队列,每完成一个请求就pop(),取队头作为该乘客新的请求;对于二级调度,采用自由竞争的方法,由JVM确定获得该请求的电梯;对于电梯自身运行层面的调度,设置在电梯类内部,电梯每运行一步就调用调度算法确定电梯当前行为。
graph LR i(personRequest)--break down-->a a(RequestPool)--get transferFloor-->b(floor1)--assign elevator-->c(elevator)-->a c-->c a(RequestPool)--get transferFloor-->bb(floor2)--assign elevator-->c2(elevator)-->a c2-->c2 a(RequestPool)--get transferFloor-->bbb(floor3)--assign elevator-->c3(elevator)-->a c3-->c3
调度算法
-
确定换乘楼层 确定楼层时,除了基准策略的考虑距离,还需要考虑换乘楼层电梯的忙碌程度和状态。
-
这里就需要引入代价函数,对于不同的换乘楼层,考虑维护该楼层的侯乘乘客数量,楼层电梯数量,楼层电梯平均速度等方式,计算代价函数:
\[cost(floor) = \frac {numOfPersonReq(floor)}{\sum_{ele \space \in eles(floor)}{capacity(ele)*speed(ele)}} \] -
在选择换成楼层的时候,选择距离+代价最小的楼层:
\[targetFloor = \arg \min_{floor} [cost(floor) + \lambda \space dist(fromFloor, floor, toFloor)] \]其中
dist(fromFloor, floor, toFloor)
表示纵向从起始楼层=>中专楼层=>目标楼层的距离。 -
对于上述代价函数中涉及的参数,经实验证明,根据经验调整参数结果可能还不及基准策略;可能的改进是,在随机数据测试的过程中应用模拟退火等优化算法,实现参数调优。(策略本身在不同数据下的表现会出现很大差别,因此优化的目标应该是多次随机数据的测试结果的平均)
-
-
单个电梯调度策略
- 对纵向电梯,利用look算法运行。
- 对横向电梯,利用改进的als算法运行:在乘客为空的情况下寻找最近的请求(可以掉头),设置为主请求,然后捎带方向相同或不同的乘客(能带就带)。
调度器与的线程交互
-
对于一级调度,在输入新请求后立即静态拆分。因此不存在与线程的交互。
-
对于二级调度,在电梯线程竞争
RequestPool
资源时,由JVM进行公平调度。 -
对于三级调度,为电梯线程内的方法,决定线程的行为。
对于三次作业,电梯内部行为都是一致的,电梯线程的生命周期如下图所示:
架构设计
第一、二次作业
-
第一,第二次作业的架构设计基本一致,区别仅在于,第二次作业在第一次的基础上增量开发了横向电梯类。
classDiagram class InputHandler { <<Thread>> -RequestPool requestpool +run() void } class RequestPool { -HashMap<Integer, Person> personPool -boolean endSign +RequestPool() +addPersonReq(Person personRequest) void +setEndSign(boolean endSign) void +isEndSign() boolean +getPersonReq() ArrayList<Person> +hasPersonReq() boolean +hasReqAhead() boolean +sameDir(char fromBuilding, char toBuilding, int dir) boolean } class Elevator{ <<Thread>> -RequestPool requestPool -HashMap<Integer, ArrayList<Person>> passengers +Elevator(RequestPool requestPool, char building, int id, int capacity, double speed) +run() void +move() void +checkDoor() void } class CircElevator{ <<Thread>> -RequestPool requestPool -HashMap<Integer, ArrayList<Person>> passengers +Elevator(RequestPool requestPool, char building, int id, int capacity, double speed) +run() void +move() void +checkDoor() void } class SafeOutput { -long startTimestamp +println(String s) void +initStartTimestamp() void +getRelativeTimestamp(long timestamp) long } class MainClass RequestPool<..InputHandler RequestPool<..Elevator RequestPool<..CircElevator InputHandler<..MainClass Elevator<..MainClass Elevator<..InputHandler CircElevator<..MainClass CircElevator<..InputHandler SafeOutput<..Elevator SafeOutput<..CircElevator
第三次作业
第三次作业在第二次的基础上增量实现了电梯换乘。主要的变化为:(1)对共享资源队列修改:线程结束的条件有所变化;(2)考虑对请求进行拆分,建立每个人的请求队列;(3)电梯运行策略改变,在一个乘客到达目的地的后判断是否还要换乘,换乘则需将请求重新加入请求池。线程安全与调度器设计在前面已经说过。
-
采用单个请求池,在请求池内增加数据结构管理一个楼层的电梯(包括其停靠楼座等信息)。这里需要注意,访问这一数据结构是需要加锁的。然后可以在请求池中加getTransferFloor方法,以获取换成楼层。
-
对Person,增设Person类。对拆分后的请求增加每个人的请求队列。换乘的时候,只需进行pop操作即可。
-
对于进程的结束问题,不能沿用之前的方法。否则会出现有乘客还没完成换乘,就已经结束的情况。为此,可以在请求池中增加计数器PersonReqCnt,只有新增一个乘客是增加,在乘客完全到达最终目的地后再减少。再判断是否结束时只需判断计数器是否为零。
类图如下,
时序图如下,
BUG分析
-
自己程序的BUG 三次公测,互测中均未出现bug。
总结发现自测过程中出现过一个较大的问题:第二次作业横向电梯的调度策略设计不当。
-
发现BUG的策略
-
采取的测试策略为大量随机数据自动化评测+少量手造数据的方式
-
对于第一次作业,编写了自动化测试工具,按照如下所示的pipeline完成一次测试流程:
graph LR C(Generator)--generate data-->B(Tester)--get result-->A A(Judger)其中,评测机支持随机生成与手动输入数据,能够定时定点投喂数据(不用依赖转换程序)。
经过测试发现一位同学有电梯超载的问题。
-
后两次作业,因为时间原因,并没有继续维护评测机。选择了和同学share的方法自测,互测主要以手动构造数据为主。
-
-
-
与第一单元的测试机制相比,本单元的评测需要实现定时定点投喂数据,并且在正确性检验上也有所不同。
数据构造方面,不能一味随机,需要考虑某一座/一层压力较大、某一个方向请求较多等情况增加数据强度。
体验与心得
- 线程安全和层次化设计
- 在充分构思好架构的前提下再动手写代码,可以较好的避免出现线程安全问题。有一个简洁的、拓展性强的架构,在后续开发过程中不用经历重构,出现线程安全问题的几率也会下降。
- 在代码结构上,没有第一单元那样层次分明;但是在设计上依旧体现了层次化。例如,在线程协同的架构模式上体现了层次化;在三级调度架构的设计上体现了层次化。
- MISC
- 优化策略上,确定使用一种优化的前提是做好充分扎实的实验,以确保不会出现负优化的情况。
- 电梯调度显然是一个组合优化问题,但是从本单元的测试结果来看,使用简单的调度算法也可以取得不错的分数。这启发我们在架构与性能的权衡上,更应该注重架构的可拓展性、简洁性、和线程安全。