架构分析
HW5
总体架构设计及 UML 类图
在第一次作业中所有乘客都指定了自己将要乘坐的电梯,所以在第一次作业中 SCHEDULE 线程的分配可以直接将乘客分配给指定的电梯。总体思路是 INPUT 线程接受请求加入总队列,SCHEDULE 线程对总队列进行定点分配,分配至六个 ELEVATOR 线程(生产者 - 消费者模型),每部电梯根据其运行策略运送相关请求。
协作图
UML 类图
HW6
总体架构设计及 UML 类图
第二次作业中新增了 RESET 请求,以及加入了 REVCEIVE 约束;对于 RESET 请求,接收到重置指令的电梯需要尽快停靠后完成重置动作,再投入电梯系统运行,且在电梯完成 RESET 请求之前不参与电梯调度;对于 RECEIVE 约束,电梯内没有乘客且没有 RECEIVE 到某个乘客请求时的移动(输出ARRIVE)是不合法的移动,这说明了本次作业不能采用动态的自由竞争的电梯调度方法,要设置一个静态的方法选择最合适的电梯然后将请求分配给该部电梯,对于在 SCHEDULE 中具体的调度策略,在后面统一分析。 第二次作业中没有新增线程,难点主要体现在电梯的调度策略设计。
协作图
UML 类图
HW7
总体架构设计及 UML 类图
第三次作业中新增了双轿厢电梯这一设计,通过 DOUBLECARRESET 将某部电梯改造为双轿厢电梯,双轿厢电梯拥有一个 TRANSFERFLOOR,其中一部电梯只能在转接层以下运行,另一部在转接层以上运行,在本次作业中,课程组转门强调了双轿厢电梯的性能优势,其耗电量是普通电梯的 1/4,所以在本次作业中我们也需要调整我们的调度策略来适应这样的设计。 而对于双轿厢电梯,其大体运行逻辑与普通电梯相同,可以将其作为普通电梯的继承,唯一不同的是需要为其设置 MINFLOOR 和 MAXFLOOR 来改进电梯运行策略,以符合题意。 创建双轿厢电梯,可以直接在 Main 中启动十二个电梯线程,也可以在电梯 RESET 的时候关闭原电梯线程,新开两个线程,我代码实现中采用的是后者。
协作图
UML 类图
调度器设计
调度器即 SCEHEDULE 线程,其作用是将等待队列中的请求按照某种策略分配给某部电梯。
@Override public void run() { while (true) { //TimableOutput.println("w"); // 1 -> 6, 6 -> 11 if (waitingList.isEmpty() && waitingList.isEnd() && isFinish()) { for (WaitingList waiting : waitingLists) { waiting.setEnd(true); } for (Elevator elevator: elevators) { elevator.setDcEnd(); } //TimableOutput.println("schedule_over"); return; } WaitingPerson waitingPerson = waitingList.getOnePersonAndRemove(); //TimableOutput.println("get"); if (waitingPerson == null) { waitingList.trywait(); //TimableOutput.println("q"); continue; } // dispatch waiting queue if (!dispatch(waitingPerson)) { waitingList.addPerson(waitingPerson); } } }
在第五次作业中,调度器与 WaitingList(共享对象) 和 Elevators(六个线程) 进行交互,表示将请求分配给请求所要求的具体电梯,此时 SCHEDULE 线程的终止条件是 waitingList.isEmpty() && waitingList.isEnd();在第六次作业和第七次作业中,由于存在两类 RESET,可能导致在 INPUT 线程结束并 SETEND 之后,可能导致仍然有乘客从电梯中“被赶出来”加入原先的总请求中,所以此时判断 SCHEDULE 线程是否结束的条件需要加入 isFinish(),即判断所有的电梯是否已经停止。
调度策略
整体策略
综合各个电梯的运行参数,乘上特定的系数后将其用 VALUE 的方式表示出来,选择最佳 VALUE 将请求分配给对应的电梯。
具体实行
// 从当前楼层到出发楼层时间 t1 = requestFromTime(passenger); // 从当前楼层到目标楼层时间 t2 = requestTargetTime(passenger); // 电梯内部乘客数量 inner = elevator.getPassengerCount(); // 电梯最大载重量 max = elevator.getCapacity(); // 电梯的 WaitingQueue 中人数 waiting = requestSize(); // 超重造成影响 overload = inner + waiting - max > 0 ? inner + waiting - max : 0; // 最终 Value return t1 + a * (t2 - t1) + b * (inner + waiting) + inner * overload;
在实际情况中可以发现超载对运行性能造成了很大影响,比如当某一个请求分配给该部电梯时看似能最快送达,但由于当前电梯内部和外部等待人数过多,导致电梯运送了很多轮个来回来能接到这个分配的请求,导致性能分下降,所以加入了 overload 超载影响。
同步块和锁的设置
对对象上锁
public synchronized boolean isEmpty() { //notifyAll(); return waitingList.isEmpty(); } public synchronized boolean isEnd() { //notifyAll(); return endTag; }
将共享对象看成是我们需要上锁的对象。
对方法上锁
public synchronized void addPerson(WaitingPerson waitingPerson) { int fromFloor = waitingPerson.getFromFloor(); if (!waitingList.containsKey(fromFloor)) { waitingList.put(fromFloor, new ArrayList<>()); } waitingList.get(fromFloor).add(waitingPerson); notifyAll(); }
方法本身成为一个锁,任何时间内只有一个线程能进入并执行这一方法。
这两类方法我主要应用在 REQUEST 和 REQUESTQUEQUE 中,因为这两个对象是所有线程都会访问交互的共享对象。
Lock
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); public void setReset(int capacity, int speed) { lock.writeLock().lock(); try { resetTag = true; this.capacity = capacity; this.speed = speed; } finally { lock.writeLock().unlock(); } } public void setDcReset(int transferFloor, int capacity, int speed) { lock.writeLock().lock(); try { resetTag = true; isDC = true; this.transferFloor = transferFloor; this.capacity = capacity; this.speed = speed; } finally { lock.writeLock().unlock(); } }
采用 Lock 上锁我主要应用在电梯线程和调度线程的交互中,因为无论是调度策略中需要获取所有电梯的状态,最后调度线程判断线程是否终止也需要读取到电梯的具体运行状况,这里我采用 Lock 锁更加直观,也方便好写。
双轿厢冲突
对于双轿厢电梯,由于转换楼层在同一时间只能由一部电梯访问,所以要特别考虑冲突问题。
为 6 对双轿厢电梯设置标志位,代表此时转换楼层是否被占用,我们用一个共享对象来表示这个标志位,每对双轿厢电梯都会对他进行读写,这个标志位可以用一个线程安全的类进行表示。
** 为每对电梯设置标志位 ** Flag flag = new Flag(); DcElevator elevatorCarA = new DcElevator(elevatorId, transferFloor - 1, waitingListCar.get(0), outerWaitingList, moveTime, maxNum, 1, transferFloor, transferFloor, "A", flag, waitingCount); DcElevator elevatorCarB = new DcElevator(elevatorId, transferFloor + 1, waitingListCar.get(1), outerWaitingList, moveTime, maxNum, transferFloor, 11, transferFloor, "B", flag, waitingCount); ** 标志位具体实现 ** public class Flag { enum State { OCCUPIED, UNOCCUPIED } private State state; public Flag() { this.state = State.UNOCCUPIED; } public synchronized void setOccupied() { waitRelease(); state = State.OCCUPIED; notifyAll(); } public synchronized void setRelease() { this.state = State.UNOCCUPIED; notifyAll(); } private synchronized void waitRelease() { notifyAll(); while (state == State.OCCUPIED) { try { wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } } } }
电梯运行策略
采用 LOOK 策略。 并且单独分离出 STRATEGY(策略) 类与电梯类交互。
if (canOpenForOut(curFloor, destMap, transferFloor, maxFloor, minFloor) || canOpenForIn(curFloor, curNum, direction, maxNum)) { return "OPEN"; } if (curNum != 0) { return "MOVE"; } else { if (waitingList.isEmpty()) { if (waitingList.isEnd()) { return "OVER"; } else { return "WAIT"; } } if (hasReqInOriginDirection(curFloor, direction)) { return "MOVE"; } else { return "REVERSE"; } }
BUG 分析
Bug_1
CTLE
造成 CTLE 的原因主要是轮询造成 CPU 空转,从而大量占用 CPU 资源。在 HW6 和 HW7 之间,为了判断电梯线程和调度线程何时停止,总请求队列和 SCHEDULE 线程、ELEVATOR 线程都产生了交互,从而导致加锁不充分、SCHEDULE 线程空转的情况。我通过 waitingList.trywait() 来让 SCHEDULE 线程判断总请求队列是否为空、是否终止从而发送等待(wait)指令,从而避免了 SCHEDULE 线程的重复访问。
public synchronized void trywait() { if (waitingList.isEmpty() && isEnd()) { try { wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }
Bug_2
RESET 缓冲区
在互测中,我被这样一组数据 HACK。在极限时间里将其余五部电梯 RESET,然后此时进入大量的分配请求,在我的调度设计之下,正处于 RESET 状态下的电梯会被排除在可用电梯之外,所以这样的数据导致我的调度策略将所有在同一时间的请求全部塞给了一部电梯,从而产生了超时现象。对于这样的 BUG,设置缓冲区,若某部电梯在 reset,可以先在缓冲区记录所有的 receive 信息,在 reset 之后将所有的 receive 信息输出。
心得体会
在多线程问题的学习中,我明白了多线程问题的迭代需要一开始就定下良好的架构,并且对于线程安全问题有了更深的理解。