269
社区成员




首先想谈谈对线程的理解。我觉得应该把"线程类" (Thread) 和"普通类"区分开来,普通类是相对静态的,其内部存在改变状态的方法,但是需要线程类进行调度 (run),以真正改变状态。就像 Thysrael博客中的图 这样:
线程的设计 by Thysrael:
对于每个线程,我们都需要思考有没有必要开启这个线程,思考的标准就是仅用现有的线程有没有办法完成任务,对于每个类,我们需要确定他的方法会被哪些线程调用,数据会被哪些线程读写。
每一个对象一定属于一个或者多个线程,不然就就没人实例化,调用它,它就没有作用。
对于电梯调度,可以发现同时进行的任务有:
所以对应地,我们开启 InputThread/DispatchThread/ElevatorThread 三个线程来执行这些任务。
于是有了这次作业的架构:
PersonQueue
是等电梯的总队ProcessingQueue
是每个电梯对应的等待队列可以看到,personQueue
和 processingQueue
实例会被多个线程共享,如何避免实例被两个线程同时操作?每个实例拥有一个独立的锁,synchronized
方法可以使其每次只由一个线程运行。
如果执行
obj.wait();
那么,当前线程便会暂停运行,并进入实例 obj
的等待队列,进入等待队列后,便会释放其实例的锁。
obj.notify();
obj
的等待队列中的一个线程便会被选中和唤醒,然后就会退出等待队列,重新竞争锁,抢到后从 wait
的下一句开始执行。
当没有乘客坐电梯当时候而且进程未结束时,电梯应该处于"等待"状态,当来人了再进行唤醒,避免无效的 while (true)
循环。
PersonQueue:
// 被DispatchThread调用
public synchronized Person poll() {
if (persons.isEmpty() && !isEnd) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
...
}
// 由InputThread唤醒
public synchronized void addPerson(Person request) {
persons.add(request);
notifyAll();
}
ProcessingQueue:
// 被ElevatorThread调用
public synchronized void waiting() {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 由DispatchThread唤醒
public synchronized void addRequest(Person request) {
PriorityQueue<Person> queue;
...
queue.offer(request);
notifyAll();
}
特别注意,notify
可能造成死锁:
四个消费者线程 (C1、C2、C3、C4) 开始工作,但由于队列为空,它们都无法从队列中取出数据进行消费,因此它们进入阻塞状态,进入等待池中,等待被唤醒
接下来,四个生产者线程 (P1、P2、P3、P4) 开始执行。P1线程首先抢到锁,将一个元素放入队列中,并调用 notify()
方法唤醒等待池中的线程。由于等待池中只有消费者线程,notify()
一定会唤醒一个消费者线程,这里假设唤醒的是C4。C4线程从等待池中被唤醒,进入锁池与其他线程争抢锁。假设P4线程也成功抢到锁,将元素放入队列,并再次调用 notify()
唤醒等待池中的线程,这次假设唤醒的是C1
此时,队列中有两个元素,等待池中的线程是: 两个生产者线程P2、P3和两个消费者线程C2、C3。P2和P3作为生产者线程,尝试将数据放入队列,但发现队列已满,因此它们无法继续工作,只能进入等待池中,等待队列有空间
锁池中的消费者线程C1和C4争抢锁。假设C4先获得锁,从队列中取出一个元素进行消费。消费完后,C4调用 notify()
唤醒等待池中的线程,这次假设唤醒的是C2线程。接着,C1线程获得锁,从队列中取出另一个元素进行消费,并在消费完后调用 notify()
唤醒等待池中的线程,这次唤醒的是C3线程
此时,锁池中的两个消费者线程 (C2、C3) 开始竞争锁。假设C2先获得锁,但发现队列为空,因此它无法继续消费,只能重新进入等待池。接着,C3线程也获得锁,发现队列为空,同样进入等待池
现在,等待池中有两个生产者线程 (P2、P3) 和两个消费者线程 (C2、C3) 。它们都在等待被唤醒,但没有线程能够唤醒它们。生产者无法向队列中放入元素,因为队列已满,消费者也无法消费,因为队列为空。导致死锁的发生
参考《图解Java多线程设计模式》的 Two-Phase Termination 一章
在要停止线程时,我们会发出“终止请求”。这样,线程就不会突然终止,而是会先开始进行“打扫工作”。状态的变化是: 操作中 $\rightarrow$ 终止处理中 $\rightarrow$ 终止线程,一个模板:
public class CountupThread extends Thread {
private long counter = 0;
private volatile boolean shutdownRequested = false;
public void shutdownRequest () {
shutdownRequested = true;
interrupt(); // 线程正在wait的时候也能中断wait,处理shutdown
}
public boolean isShutdownRequested () {
return shutdownRequested;
}
public final void run () {
try{
while (!isShutdownRequested()) {
doWork();
}
} catch (InterruptedException e) {
} finally {
doShutdown();
}
}
private void doWork() throws InterruptedException {
counter++;
System.out.println ("doWork: counter = " + counter) ;
Thread.sleep(500);
}
private void doShutdown() {
System.out.println("doShutdown: counter = " + counter);
}
}
public class Main {
public static void main(String[] args) {
System.out.println("main: BEGIN");
try {
CountupThread t = new CountupThread();
t.start();
Thread.sleep(10000);
System.out.println("main: shutdownRequest");
t.shutdownRequest();
System.out.println("main: join")
t.join(); // 等待线程终止
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main: END");
}
}
From hyggge's blog:
- 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。
- 到达某楼层时,首先判断是否需要开门
- 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
- 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。
- 接下来,进一步判断电梯里是否有人
- 如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯)——
- 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
- 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。
- 如果请求队列为空,且输入线程已经结束,则电梯线程结束。
- 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)
电梯会在停在一层楼并且关门的状态下向 Strategy
获取下一步的运行策略。
ProcessingQueue
中使用 PriorityQueue
记录,在同时同层有很多人上一个电梯时会按优先级进入电梯。
public class ProcessingQueue {
private HashMap<Integer, PriorityQueue> queueMapUp = new HashMap<>();
private HashMap<Integer, PriorityQueue> queueMapDown = new HashMap<>();
public ProcessingQueue() {
for (int i = Constants.LOWEST_FLOOR; i <= Constants.HIGHEST_FLOOR; i++) {
PriorityQueue<Person> queueUp = new PriorityQueue<>(new PersonComparator());
queueMapUp.put(i, queueUp);
PriorityQueue<Person> queueDown = new PriorityQueue<>(new PersonComparator());
queueMapDown.put(i, queueDown);
}
}
...
}
public class PersonComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
if (p2.getPriority() > p1.getPriority()) {
return 1; // p2在前
} else {
return -1;
}
}
}
offer()
表示提供一个元素到队列而非添加,也就是说,可以不必须成功。
ps. 第二次作业涉及克隆,所以我弃用了优先队列。所以复杂的数据结构还是要谨慎使用。
中测
当判断电梯前进方向是否有等候的乘客时,我的判断条件是 乘客在电梯的前进方向 && 乘客的目的地方向和电梯方向一致
,这个判断逻辑有问题,不应该考虑乘客的目的地方向的。这个bug导致CTLE,定位它的方法是在 while(true)
中加上打印语句
public void run() {
while (true) {
Advice advice = elevator.getStrategy();
System.out.println(advice.toString()); // debug,发现不断输出TURN
...
}
}
互测
互测的时候发现在输入请求的时间相近的情况下,一位同学会产生这样的输出:
[ 0.8290]CLOSE-F2-4
[ 46.5500]ARRIVE-F3-6
可能是锁的问题,但比较遗憾,本地无法再次复现
在第一次作业的基础上,需要:
RECEIVE
表示分配结果SCHE
请求,尽快到达目标楼层,并且不能进行任何上下乘客操作选择什么样的分配策略?
影子电梯就是把当前电梯的状态 (包括在电梯里的乘客,和电梯即将处理的请求) 克隆一份,然后模拟运行,算出运行时间,对比各个电梯的时间,选择最优的那个来运送乘客。本质上是贪心算法,可以找到局部最优解 (无法得到全局最优)
影子电梯应该“运行”到什么时刻?
该不该把正在处理 SCHE
的电梯纳入考虑?
SCHE
电梯的运行速度不慢,完全有可能比普通电梯更早地完成乘客请求SCHE
期间无法 RECEIVE 乘客?——设立缓冲区 buffer,如果乘客分给了 SCHE
电梯接送,那么乘客会先到 ProcessingQueue
的 buffer
队列中,等到临时调度结束再转移到正式的处理队列中第一次作业相比,调度器需要监视电梯的状态,所以要为电梯加锁。
对于ElevatorThread中费时但并不影响状态的sleep操作,是不需要加锁的,只有改变状态的核心语句需要加锁。尽量使用同步块而非同步方法,例如:
public void move() {
try {
Thread.sleep(speed);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (this) {
currentFloor += direction;
}
TimableOutput.println("ARRIVE-" + Constants.FLOOR_R.get(currentFloor) + "-" + id);
}
另外影子电梯克隆的时候,需要获取电梯的候乘队列和电梯本身的锁,再开始克隆。
for (int i = 1; i <= Constants.ELEV_SUM; i++) {
synchronized (elevatorMap.get(i)) {
synchronized (queueMap.get(i)) {
...
}
}
}
在模拟的时候需要取2把锁,前面等待的时间会累积,所以遍历到第6个电梯的时候,实际上已经不是我们刚开始进行分配的时刻了。第6个电梯可能会悄悄进行一些操作,在时间上占有一定优势。但因为在Elevator里加的锁比较细了,我觉得延后的时间也不会太久,所以还是保持这个设计。
考虑这样的情况,输入已经结束,我们向电梯发出 END
指示。如果此时电梯正在进行临时调度,过了一段时间后,临时调度结束,又有乘客被迫下电梯,但是电梯觉得已经 END
了,线程便停止运行,这些乘客的请求得不到处理。可见,输入结束并不能让线程停止,要等处理完所有请求才能停止。为了解决这个问题,我在 PersonQueue
里新增了 todoNum
和 scheNum
属性,用来记录未完成的乘客请求和调度请求数。
public class PersonQueue {
private int scheNum = 0;
private int todoNum = 0;
}
相应地,增加结束条件:
public synchronized boolean isRealEnd() {
notifyAll();
return isInputEnd && todoNum == 0 && scheNum == 0;
}
DispathThread只有在 isRealEnd
的时候才会停止,并且告知电梯线程停止
public void run() {
while (true) {
if (personQueue.isRealEnd()) {
for (ProcessingQueue queue : queueMap.values()) {
queue.setEnd();
}
break;
}
...
}
}
强测和互测均未出现bug,记录一些中测的bug:
乘客下电梯后的参数改变
乘客下电梯但是没有到达目的地时,修改了 startFloor
但是没改 direction
,导致电梯在错误的方向越走越远,无法停止……
双轿厢改造就是将轿厢安排到其他可用的电梯井中,此时在同一电梯井道内同时拥有两个独立的电梯轿厢。
"UPDATE-ACCEPT-A电梯ID-B电梯ID-目标楼层"
清空电梯并关门
"UPDATE-BEGIN-A电梯ID-B电梯ID" # 开始改造
RECEIVE取消
期间不能参与调度
"UPDATE-END-A电梯ID-B电梯ID" # 改造完成
电梯闪现到特定位置
在InputThread接收到Update请求后,把这个请求放入电梯A和B的处理队列中,生成 Coordinator
对象实例并传入电梯A和电梯B。电梯在处理请求时再据此修改运行参数。
由A电梯输出 UPDATE-BEGIN
,使用 CountDownLatch 来记录准备就绪的电梯数
public class Coordinator {
private CountDownLatch readyLatch = new CountDownLatch(2);
public void notifyEmptyComplete() {
readyLatch.countDown();
}
public void awaitReady() throws InterruptedException {
readyLatch.await();
}
}
都准备好后,A电梯输出,B电梯睡觉,这样看起来公平一点 (bushi)。
try {
coordinator.awaitReady();
if (isA == 1) {
TimableOutput.println("UPDATE-BEGIN-" +
coordinator.getElevatorAId() + "-" + coordinator.getElevatorBId());
} else {
Thread.sleep(5);
}
} catch {...}
需要注意的是,结束也要注意同步!不然容易出现B电梯处理完UPDATE后,接到乘客输出 RECEIVE
,但此时A还没处理完UPDATE,也还没输出 UPDATE-END
!仿照 readyLatch 加上 sleepLatch 即可。
两个电梯都能到达换乘层,但同时只能有一个电梯在换乘层。仿照学长学姐和讨论区大佬的做法,设置换乘层锁
public class Coordinator {
private boolean isOccupied = false; // 换乘层的锁
public synchronized void competeTransferFloor() {
if (isOccupied) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
isOccupied = true;
}
public synchronized void releaseTransferFloor() {
isOccupied = false;
notifyAll();
}
}
电梯必须拿到锁才能移到换乘层,离开换乘层就必须释放锁。
public void move() {
synchronized (this) {
currentFloor += direction;
}
if (coordinator != null) {
if (currentFloor == transferFloor) {
coordinator.competeTransferFloor();
}
}
TimableOutput.println("ARRIVE-" + Constants.FLOOR_R.get(currentFloor) + "-" + id);
if (coordinator != null) {
if (currentFloor == transferFloor + direction) { // 是从换乘层离开的
coordinator.releaseTransferFloor();
}
}
}
何时离开换乘层?Look策略会保证如果有未处理的请求,则向着请求方向移动,自然就离开换乘层了。除此之外,当没有未处理的请求并且电梯内没人时,我也会让电梯离开换乘层。
buffer的转移
更新前准备的时候 (清空电梯到时候) 如果有请求,我会将其加到buffer内,准备好后只把 Process 移回了总队,buffer也应该放回总队的
轿厢的同步
就是前面说的双轿厢改造结束需要同步的问题,测出bug的数据是:
[1.0]UPDATE-1-2-B2
[1.0]UPDATE-3-4-B1
[1.0]UPDATE-5-6-B2
[1.3]1-PRI-29-FROM-F4-TO-F2
[1.4]2-PRI-12-FROM-B4-TO-F7
我的错误输出: ㄟ( ▔, ▔ )ㄏ
[ 1.3940]UPDATE-BEGIN-1-2
[ 1.3950]UPDATE-BEGIN-5-6
[ 1.3950]UPDATE-BEGIN-3-4
[ 2.3960]RECEIVE-2-2
首先最最最重要的就是学习了多线程hhh!
线程安全方面,我在开始写代码前,会仔细考虑有哪些对象、有哪些线程会访问这些对象,对共享对象加锁,保证线程安全。另外注意线程获取对象锁的顺序,避免产生死锁。层次化设计方面,我感觉自己做得并不好,电梯类包含了运行策略的选择,非常臃肿。然后Shadow类又大量地复制电梯类的代码,感觉这样不是很好。
相比于上个单元的懒散,我在电梯上投入了更多,收获也是更多。希望在下半学期能更用心、认真地对待身边的一切 (๑˃̵ᴗ˂̵)و ヨシ!