BUAA 2024 OO Unit 2
🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
文章目录
Unit 2 概览
第二单元的核心内容是 多线程程序设计,以 电梯调度 问题为核心任务。即给定具有特定参数(容量、开关门时间、运行速度等)的六部电梯,不定时输入乘客(纸片人,被动接受电梯调度)请求和电梯重置请求(修改参数,单轿厢变双轿厢),要求在规定时间内将乘客正确送达,并通过输出时间戳的方式检验调度是否合乎逻辑。
Hw 5
第一次作业中,乘客的请求指定了电梯,需要着重处理的是电梯调度的 架构设计 和 运行策略 问题。
架构设计
整体采用了生产者——消费者模型,具体到电梯调度问题,即采用了 “1+1+6” 的设计模式。
生产者——消费者模型
InputThread
:一个输入线程(生产者)。因为输入是不定时的,所以我们需要一个线程来专门处理输入。这个线程从控制台中获取输入,并通过调用官方包将其封装为 Request
请求。同时,将请求放入 requestQueue
中。
Scheduler
:一个调度器线程。当 requestQueue
(相当于盘子)中有请求时,将其取出,并按照一定的策略分配,放入相应电梯的候乘表 waitRequests
中,交给电梯线程处理。因为第一次作业指明了乘客乘坐的电梯,所以 调度策略 实际上被简化为 按照电梯的 ID 进行分配。
ElevatorThread
:六个电梯线程(消费者)。当 waitRequests
中有请求时,按照一定的运行逻辑,依次处理乘客请求,并输出 open、close、in、out、arrive 等信息。
共享对象问题
在确定了整体框架之后,我们可以看到其中的两个 共享对象 需要我们格外注意,通过一定的同步控制手段来避免写后读、写后写等可能带来的访问次序混乱造成的问题。
requestQueue
:对于请求队列,由于内置的 Queue
的线程安全得不到保障,所以我对其进行封装,自己实现了一个线程安全类 RequestQueue
(当然也可以使用 BlockingQueue
这种内置的线程安全类,但是用了这单元就没什么好玩的了……)。这个类本身具有一个 Queue<PersonRequest>
队列,通过对添加、取出元素的方法使用 synchronized
修饰,来保证线程安全。之所以采取这样的设计,是为了让输入线程和调度器线程关注于自身的业务逻辑,而无需考虑线程安全问题。
public class RequestQueue {
private final Queue<PersonRequest> requests;
public synchronized void addRequest(PersonRequest request) {
}
public synchronized PersonRequest getRequest() {
}
}
这里的 锁 实际上是 requestQueue
本身,同步块中的语句操作的也是这个对象的属性,是使用共享对象本身作为锁。
waitRequests
:对于候乘表,我们实际上也可以实现一个线程安全类或者直接把 RequestQueue
拿来用,添加调度器线程和电梯线程可能需要调用的方法即可。但遗憾地是,我一开始并没有意识到这个问题,而是直接使用了一个 HashMap<Integer,Queue<PersonRequest>>
类型的属性,这就导致我每每在使用 waitRequests
时都得使用 synchronized
来控制,难免出现遗漏同步造成的 bug 或者过度同步造成并发性能下降的问题。
public boolean notHasRequests() {
synchronized (this.lock) {
return this.elevator.getNumIn() == 0 && this.numOut == 0;
}
}
public void addPersonRequest(PersonRequest personRequest) {
int fromFloor = personRequest.getFromFloor();
synchronized (this.lock) {
Queue<PersonRequest> requestQueue = this.waitRequests.get(fromFloor);
requestQueue.offer(personRequest);
this.receive(personRequest);
this.numOut++;
this.lock.notifyAll();
}
}
// ...
这里的 锁 并没有使用共享对象 waitRequests
本身,而是在 ElevatorThread
中新建了一个对象作为锁。这样做是因为部分需要同步控制的语句并不直接操作 waitRequests
,但却间接与其相关。比如上面的 this.numOut
是在操作 waitRequests
时可能改变的值,算是其 附属共享对象,故而新建了 this.lock
作为调度器线程与输入线程交互时使用的控制锁。
运行策略
所谓运行策略,即一部电梯在已知其要接送乘客请求的情况下,选择如何接送乘客的问题。这个策略的选择将很大程度上影响我们的性能分,需要我们综合考虑 电梯耗电量(电梯上下楼、开关门次数)、 系统运行时间 (处理完所有请求所需的时间)、 乘客等待时间 等因素。
策略类
按照课程组推荐的写法(也是非常好的一种做法),我们应该把电梯运行的策略抽象出来作为一个类,能够根据电梯运行的各个参数给出运行的建议(开门,关门,移动),从而指挥电梯进行运行。这样做的好处在于 电梯的运行与策略分离 ,能够随时根据需要改变电梯的运行策略,具有很好的可扩展性。这样做电梯线程本身其实相当于一个 有限状态机 ,由策略类指挥电梯线程完成状态转移。
由于我一开始认为策略类读取电梯的各种状态十分麻烦,所以便没有写策略类,而是直接糅合到电梯线程本身。导致电梯线程耦合了很多本不属于 电梯运行 这个业务逻辑的东西,给后续扩展、debug 带来了不小的负担,应该引以为戒!
我的策略
尽管性能要求的点很多,但归根到我们的设计上,就是要使电梯 在一次运行的过程中尽可能载入更多乘客 ,以此提高电梯的吞吐量。因为我们无法预判接下来的输出会是什么情形,所以也就不存在所谓的最优策略。我们应该尽可能地做好 trade-off,使得运行策略能够适应于大多数情况。在这个问题上,我相信 存在即合理,现实中电梯的运行方式一定是作出大量权衡的结果,于是选择了接近于现实的 Look 策略。即:
- 电梯启动时,向最先发出请求的乘客所在的位置进行移动;
- 电梯到达某一层时,若内部有乘客在这一层出去,则开门放客;若外部乘客的请求方向与电梯运行方向一致,则开门接客;
- 当电梯运行方向上不再有内部请求(在接下来的某一层出去)和外部请求(在接下来的某一层进来)时,则更改方向。
一言以蔽之,电梯应当尽可能地朝着当前方向移动, 尽可能地捎带乘客 ,直到该方向上没有请求再更改方向。
电梯线程与电梯类
在这次作业中,我分别实现了 ElevatorThread
和 Elevator
两个类。前者关注过程,负责电梯运行的逻辑;后者关注状态,存储电梯的各种信息。这样做看起来有点多此一举,但实际上有利于我们在进行多线程设计时,把注意力集中到 ElevatorThread
和其他线程的交互上而不必被其他不存在多线程情况的因素所干扰(实际上是怕一个类的长度超过 checkstyle 的限制)。Elevator
中存放了电梯的参数和各种状态量,并配备了 get
和 set
方法供 ElevatorThread
使用,同时所有的输出也通过后者调用前者的方法来实现。这种实现使得 Elevator
看起来像电梯本身,而 ElevatorThread
像电梯外的请求(实际上变相实现了一个策略类)。
// 电梯 ID
private final int id;
// 电梯容量
private final int capacity = 6;
// 电梯所能到达的最高楼层
private final int maxFloor = 11;
// 电梯所能到达的最低楼层
private final int minFloor = 1;
// 电梯移动一层所需的时间(ms)
private final long moveTime = 400;
// 电梯开门所需的时间(ms)
private final int openTime = 200;
// 电梯关门所需的时间(ms)
private final int closeTime = 200;
// 正在电梯内的人(目的楼层到请求的映射)
private final HashMap<Integer,Queue<PersonRequest>> runRequests = new HashMap<>();
// 电梯内的总人数
private int numOfIn;
// 电梯当前所在楼层
private int curFloor;
// 电梯运行方向(默认为下)
private boolean down;
// 电梯是否在运动
private boolean isRunning;
量子电梯
在本单元的作业中,主要通过输出时间戳的方式模拟电梯运行所需的时间。这意味我们的电梯并不用真的花费 0.4s 去移动一层楼,只需保证在合适的有输出即可,这使得我们可以 在忽略电梯移动所需时间的基础上去优化我们的运行策略 。当电梯需要移动一层楼时,我们不必老老实实地等 0.4s 去移动。当这 0.4s 内有新的请求且可以捎带时,我们就可以重新开门让新来的乘客进入;倘若没有可以捎带的请求时,我们等时间到了让电梯瞬移即可,以此来优化局部的吞吐量。
private void arrive() {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start < this.elevator.getMoveTime()) {
synchronized (this.lock) {
try {
this.lock.wait(this.elevator.getMoveTime());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
if (this.isOpen()) {
this.doWhileOpen();
start = System.currentTimeMillis();
}
}
if (this.elevator.getIsRunning()) {
this.elevator.arrive();
}
}
Hw6
第二次作业不再为乘客指定电梯,同时新增了 reset
请求,需要着重考虑电梯的 分配策略 和 重置请求的处理。
分配策略
同运行策略一样,我们在考虑分配策略时也要着眼于 电梯耗电量(电梯上下楼、开关门次数)、 系统运行时间 (处理完所有请求所需的时间)、 乘客等待时间 等因素。同样的,由于我们无法预判未来时间的输入,绝对最优的策略也不存在,我们所能做的只是尽可能地现有条件下最适合当前乘客请求的电梯。
随机分配
随机分配可以说是 一本万利 的策略。在仅有六部电梯的情况下,我们所随机分配的电梯有高达 16.6%
的概率是最优解,而且是从未来的全知视角下考虑的最优解。尽管这有些冒险,但在面对各种类型的数据点时,就会表现出高度的稳定性。虽然不一定是最优解,但一定不是最差解。同时,因为随机分配往往比较均匀,所以我们也能保持高程度的 负载均衡。最主要的是,这种策略只需要一行代码就能实现。
为了提高随机分配策略的下限,我本人采取了 有选择性的随机分配 策略。即先把所有电梯作为初始集合,然后选出满足某一条件的电梯子集,倘若子集不存在,则在该原集合中进行随机分配;不然,则在子集中进一步优选。这样做的可行性在于,我们总可以确定满足某一条件的电梯是更好的选择,比如空位较多、速度较快、运行方向与请求顺路等等。
public void dispatchRequest(PersonRequest request) {
ArrayList<ElevatorThread> judge = new ArrayList<>();
for (ElevatorThread elevatorThread : elevatorThreads) {
if (/* some conditions */) {
judge.add(elevatorThread);
}
}
if (judge.isEmpty()) {
this.elevatorThreads[random.nextInt(6)].addRequest(request);
return;
}
ArrayList<ElevatorThread> temp = new ArrayList<>();
for (ElevatorThread elevatorThread : judge) {
if (/* some conditions */) {
temp.add(elevatorThread);
}
}
if (temp.isEmpty()) {
judge.get(random.nextInt(judge.size())).addRequest(request);
return;
}
// so on...
}
调参大法
这种方法意在建立一套标准去衡量各个电梯对当前请求的适合程度,然后据此选出最优解。我们要像数学建模一样,去评估电梯的各个参数与当前请求的参数的契合程度。但由于我们无法预知未来输入,甚至完全不知道输入数据可能存在的统计特征,所以这种方法是十分吃力不讨好的,效果很容易比随机分配差得多。
影子电梯
尽管我们无法预知未来,但是我们可以模拟未来。因为我们所有的操作都是在模拟电梯运行,这就意味着可以快速计算出当前乘客分配给某一电梯后完成请求所需的时间。我们只需完整克隆电梯的当前状态(所在楼层、请求队列等),把当前请求分配给电梯,同时将原本 wait
、sleep
掉的时间加起来即可计算出来。更过分一点的话还可以把指导书中计算性能分的公式搬过来,计算当前请求分配给某个电梯的最高性能分,择优调度。可以说,这种贪心策略是我们所能实现的,局部意义上的最优解。毕竟 没有什么比让未来“真实”发生一遍更能预知未来了。但是由于一开始对影子电梯有些误解,并没有意识到其巨大威力,所以很遗憾我没有采取影子电梯策略。
重置请求的处理
关于 reset
请求,需要着重考虑的问题是如何在规定时间内完成以及如何重新分配重置电梯已有的请求。
规定时间内响应
规定时间内响应有两重具体要求,一是及时把重置请求发送给电梯线程,二是电梯线程要立刻处理重置请求。
对于前者,我们第一想法可能是把重置请求当作一般请求,放入 requestQueue
中进行处理,但由于队列 先进先出 的特征,重置请求的 优先级 并不能得到保证。同时,由于在线程分配中可能存在调度器线程抢不到线权的情况,所以将重置请求插到队首也不是很好的选择。因此,我选择了将重置请求在输入线程中直接分配给电梯线程,并通过同步块加以控制,保证在接收到重置请求输入后立马进行分配。
public void run() {
ElevatorInput input = new ElevatorInput(System.in);
while (true) {
synchronized (this) {
Request request = input.nextRequest();
if (request == null) {
requestQueue.setEnd(true);
break;
} else {
if (request instanceof ResetRequest) {
ResetRequest resetRequest = (ResetRequest) request;
elevatorThread[resetRequest.getElevatorId() - 1].addRequest(request);
} else {
requestQueue.addRequest(request);
}
}
}
}
try {
input.close();
} catch (Exception e) {
e.printStackTrace();
}
}
对于后者,重置请求到来时有三种情况:
- 电梯到达某一层后,将要决定开不开门
- 电梯开门后,尚未关门的空档
- 电梯准备移动后,但尚未(瞬移)到下一层。
由于我们采用了量子电梯,电梯在移动前也会判断是否要开门并进行相关操作,所以 reset
相关的操作便集中在开门条件和开门期间的放客上,我们只需在 boolean isOpen()
中修改 reset 相关的开门条件,在 doWhileOpen()
中增加重置相关操作即可。
重新分配重置电梯已有的请求
对于尚未进入电梯的乘客,将其请求原封不动重新放回 requestQueue
中;对于从电梯中出来的乘客,若其出来的楼层并非其目的地,再将当前楼层更新为其初始楼层后,再放回 requestQueue
中。
Hw7
第三次作业新增了双轿厢电梯,需要着重考虑 双轿厢的启动 和 双轿厢的协同 问题。
双轿厢的启动
鉴于双轿厢电梯和原有电梯的 id 相同的关系,我新增了一个 ElevatorController
类来处理单轿厢到双轿厢的切换,原有的对 ElevatorThread
的操作都间接地通过这个中间层来实现。elevatorThread
、carA
、carB
都作为其中的属性,当收到切换双轿厢的请求时,ElevatorController
调用 elevatorThread
中的方法来退回已有请求,同时结束线程,然后启动 carA
、carB
两个线程,同时管理这两个线程持有同一把锁来保证不相撞。新加中间层的做法也是为了满足开闭原则,保证第一二次作业写的代码都能原封不动的使用(主要是怕被改崩了)。
双轿厢的协同
为了保证双轿厢的两个轿厢不碰撞的,我们需要一把锁来保证双轿厢无法同时到达换乘楼层。在这里,我是对 Boolean
进行包装,新建了一把锁。
public class BooleanLock {
private boolean bool;
public boolean getBool() {
return this.bool;
}
public void setBool(boolean bool) {
this.bool = bool;
}
}
当电梯即将前往(瞬移到)一个新的楼层时,若其当前楼层和下一楼层为换乘楼层,则对其进行特判,确保其安全地移动,同时修改换乘楼层的占用情况。
if (this.elevatorCar.getNextFloor() == this.transferFloor) {
synchronized (this.transferFloorLock) {
while (this.transferFloorLock.getBool()) {
try {
this.transferFloorLock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
this.transferFloorLock.setBool(true);
this.elevatorCar.arrive();
}
} else if (this.elevatorCar.getCurFloor() == this.transferFloor) {
synchronized (this.transferFloorLock) {
this.transferFloorLock.setBool(false);
this.elevatorCar.arrive();
this.transferFloorLock.notifyAll();
}
} else {
this.elevatorCar.arrive();
}
架构模式
类分析
从类的设计上来看,三次作业采用了统一的框架。第二次作业在 ElevatorThread
中新增了 reset 相关的方法,在 Scheduler
新增了调度策略,在 InputThread
中新增了 Reset
请求的直接分配。第三次作业在前两次作业的基础上新增了 ElevatorController
这一中间层,并修改了相应的方法。
线程分析
从线程的协作关系上看,四类线程贯彻三次作业的全过程,并通过 RequestQueue
和 BooleanLock
两个共享对象类来互相协调,通过 ElevatorController
类来进行管理。
Main
线程主要负责启动其他线程,做好初始化工作InputThread
线程主要负责从标准输入流接收输入,并按需解析、转化为各种请求对象Scheduler
线程主要负责对请求对象进行分配调度,交由电梯线程进行处理ElevatorThread
线程是电梯业务的核心类,按照一定的运行策略处理调度器分配的请求,产生输出
bug 分析
我的bug
(由于本单元采取了谨慎的优化方式,)三次作业在强测中并未发现 bug,不过在最后一次作业的互测中被砍了一刀。主要问题在于判断结束的条件设置不够完备,导致在少数极端数据下(全部在同一时间发出乘客请求,同时对六部电梯进行单轿厢到双轿厢的转换操作),有概率爆出请求尚未处理但程序已经结束的情况。
在判断是否结束上,我原本采取的策略是同时满足以下条件,则认为可以结束:
- 输入结束
- 每个电梯线程的请求均已处理完毕(内外都没人)
- 没有电梯正在重置
我的问题出在第三点上。我在接收到重置请求后,会把 ElevatorController
的 isDoubleCar
标志置位,不管双轿厢有没有切换完毕,随后的请求添加、状态读取都不再分配给原来的单轿厢,而是分配给新的双轿厢。因为双轿厢电梯不会接受重置请求,所以双轿厢电梯的 getIsReset()
方法我会直接返回 false
。这样就会导致一种极端情况,所有单轿厢都完成切换到双轿厢的重置工作,但是那些被返工的请求尚未分配给新的双轿厢电梯,导致上述三个条件被意外满足,造成提前结束的问题。
因此,debug 的方式也很简单,将双轿厢电梯 getIsReset()
的返回值设置为双轿厢是否切换完毕的标准即可。
当然,我这种采取判断电梯状态的方法其实很容易丢三落四、不方便扩展。更好的判断结束的条件是采用一个 计数器 去计数,当接收到新的请求时加一,当乘客到达目的地或者重置完成时时减一。当计数器为零且输入完成时,即可结束。这种方法无疑是抓住了本质,不需关注繁杂的条件和共享对象的使用,可扩展性良好。
Debug 方式
对于多线程编程来说, 比解决问题更难的是发现问题。由于线程调度的随机性,我们程序的输出并没有确定性的结果,即使知道有 bug 也很难复现出来,即使复现出来在调试模式下可能又是另一副样子。对此,我们别无选择,只有经过大量的测试才有可能发现程序的 bug。
更多的时候,我们能做的其实是对整个程序进行复盘,找到不同线程交互的地方,然后发动脑筋去想想有没有可能存在问题。因为我们在设计的时候,其实存在着一种思维偏差,我们会默认某些方法按照我们预想的顺序执行,就比如我上面的 bug 就是默认重置完了总会第一时间去重新分配请求。所以,假想线程按照一种几乎不可能的极端的顺序执行,可能是一条发现 bug 的出路。
总体而言,还是需要我们理解并发的思想,掌握同步互斥的方法,才能实现无懈可击的多线程设计。
心得体会
线程安全
在线程问题上,我们应当明确划分每个线程的职责。只有 Run
方法是线程的执行流,每个线程的执行流都是各司其职、互不干涉。当我们调用一个线程对象的方法时,并不意味着是这个线程在执行,而很有可能是别的线程在试图获取这个线程的某些状态、修改这个线程的某些参数,我们应该对这些方法进行严格的审视以考虑同步控制的使用。
层次化设计
在层次结构上,多线程设计最主要的是明确调用关系,明确生命周期,洞悉线程之间一对一、一对多、多对多的协作关系,做好设计。