BUAA OO 第二单元总结
概要:第二单元是电梯的单元,同时也是多线程的单元。
本文不会谈到的方面:
- 运行策略:LOOK 策略是绝大部分人的选择,这里就不占用篇幅了。
本文将从如下方面总结:
- 第一部分:架构设计与 线程协作
- 调度器的交互、调度策略、性能指标的评价、架构的变和不变与可扩展性
- 第二部分:锁、同步块与 线程安全
- 物理锁和逻辑锁的配合、双轿厢电梯如何不相撞
- 第三部分:BUG分析—调度策略的漏洞和 线程安全 的回响
- 调度策略造成的 bug、线程安全造成的 bug、互测 hack 策略
- 第四部分:心得体会
- 线程安全方面、层次化设计方面
第一部分:架构设计与线程协作
调度器的交互:生产者-消费者模式
虽然第一次作业 HW5 并不需要调度,但我从 HW5 开始便设计了调度器(Schedule)从而方便迭代。
刚进入第二单元时,我发现多线程十分困难,sleep、wait 这些不明所以的指令好像理解了,但到了具体的代码又发现自己的脑子根本想象不出多个线程运行的过程,从而陷入深深的焦虑——这时,设计模式 的作用就变得格外重要了。
面对一个随便的多线程程序,线程怎么开始运行、怎么互斥、怎么同步、又是怎么结束,光靠脑子这当然没办法想象了。但是在设计模式的范式统一下,一切就都清晰明了了……
经过对 图解Java多线程设计模式([日]结城浩,2017) 的阅读和第三次实验的训练,我们发现 生产者-消费者 模式以其清晰易懂的协作优势完美适配于第二单元的电梯情境。不仅是调度器的交互依靠生产者-消费者模式,可以说整个单元就是一张生产者-消费者模式的网络。
第二单元的千行代码汇聚在一起就是下面这张图。其中,蓝色表示线程类(nputThread,Shedule,Supervisor 和 Elevator),黄色表示共享对象类(RequestQueue,其中又包含总表和分表;ResetRequestQueue),紫色箭头表示生产关系,绿色箭头表示消费关系。
- InputThread 是 “InputThread-总表-Schedule” 关系和 “InputThread-重置表-Supervisor” 关系的 生产者;
- Schedule 是 “InputThread-总表-Schedule”关系与“Elevator-总表-Shedule”关系的 消费者,同时又是“Schedule-分表-Elevator”关系的生产者;
- Elevator 是 “Schedule-分表-Elevator”关系的 消费者,同时又是“Elevator-总表-Shedule”关系的 生产者。
- Supervisor 是 “InputThread-重置表-Supervisor” 关系的 消费者。
调度策略
从形式上厘清了调度器如何与上下游进行交互之后,我们深入调度器内部进一步分析调度策略。
为了便于迭代,我将调度策略设置为了接口,各种策略实现这个接口[[具体见后面的架构的变与不变和可扩展性-作为行为接口的策略小节](#### 作为行为接口的策略)]。这样做的好处是符合 开闭原则,我在业务代码中只需要更改一种具体实现的策略即可,而不会因为更改一次策略而破坏原有代码。
/// Main.java
public class Main {
public static void main(String[] args) {
......
/* Timeline:
HW5的调度策略: 直接指定 (DirectElev)
HW6的调度策略: (1) 均摊 (RandomElev)
(2) 调参 (ScoredElev)
(3) 模拟 (SimulateElev)
HW7的调度策略: 均摊 (RandomElev)
*/
ScheduleStrategy scheduleStrategy = new SimulateElev(elevators, processingQueues);// ScheduleStrategy - 接口; Stimulata - 接口的实现
Schedule schedule = new Schedule(waitQueue, processingQueues, scheduleStrategy);// 接口作为 Schedule 的属性
schedule.start();
......
}
}
在 HW6 中我尝试了各种调度策略:
- 均分/随机:随机数均分,模 N 均分,考虑电梯和分表容量的均分。其中“模 N 均分”在实现简单的同时表现最好。
- 调参/打分:参数调整困难,表现一般甚至较差,费力而不讨好。
- 模拟/影子电梯:实现有一定的复杂度,但表现极好。
最终我选定了模拟/影子电梯,其中在 HW6 和 HW7 中又有所不同。
HW6 的模拟:Simulate-T
影子电梯,作为学长学姐们凝练出的智慧经验,不同的人在具体实现上有所不同,比如全模拟还是部分模拟、粗略模拟还是精准模拟。在此,对我所模拟的实现上作出几点声明:
- 进行模拟的 必要条件 是电梯不处于 RESET 状态:作为一种保守估计,这里舍弃了对正在重置电梯的考虑。[这里在 HW6 中存在 BUG,具体见后面的 BUG 分析小节]
- 模拟为 粗糙模拟。众所周知,多线程程序中电梯的属性也在随时变化,如果要精确获取其属性,那么就不得不为其设置同步区;而前面我们又提到了电梯是线程类。为了划清线程类和共享对象类的角色界限,我就没有为电梯设置同步区。
- 模拟为 瞬时模拟。在上一点基础上,当模拟时,会获取这个 瞬间 电梯的所有属性 快照(包含深克隆),然后交由影子电梯类模拟并给出结果。[这里在 HW7 中存在 BUG,具体见后面的 BUG 分析小节] 影子电梯类在模拟时就和多线程没什么关系了,作为一种“静态”模拟,在一瞬间即可得到结果。
- 模拟的 评价标准为时间。此刻时间耗费最少的成为局部最优解。伴随着时间的局部最优,耗电量在经验上也呈现较优表现。
t i m e min = min { t i m e i ∣ e l e v a t o r i 满足必要条件 } time_{\min}=\min\left\{time_i\mid elevator_i满足必要条件\right\} timemin=min{timei∣elevatori满足必要条件}
是为基于时间的 Simulate-T。
/// SimulateElev.java
ShadowElevator shadowElevator = new ShadowElevator(elevator);
Request shadowRequest = request.dclone();
double curTime = shadowElevator.simulate(shadowRequest);
if (curTime < minVelocity) {
res = i;
minTime = curVelocity;
}
HW7 的模拟:Simulate-V
在 HW7 中,由于换乘层的存在,我们的模拟似乎不那么容易了,至少全模拟的确不那么容易了。
那我们就不能模拟了吗?当然可以!继续从 局部最优 的思路考虑:
假如有一个乘客需要从 2 楼去往 11 楼
- 一个 Normal Elevator 可以将其直接送到 11 楼
:)
,但可能要很久:(
(当然也可能很快:)
); - 一个 Double-Car Elevator 只能将其送到 5 楼
:(
,但可能很快:)
(当然也可能很慢:(
)。
怎么取舍?从局部最优的角度考虑,折衷一下路程和时间,即为速度。我们不 care 乘客在换乘层下还是在目标层下,只要电梯能够让当前乘客以 最快的速度动起来,我们有底气保守地认为这个决策是可以接受的。
基于此,仍然有几点声明:
- 进行模拟的 必要条件 是:① 电梯不处于 RESET 状态;② 电梯能让当前乘客朝目标楼层 靠近至少一层 (“动起来”)。
- Double-Car Elevator 只会在换乘层或乘客的目标层停下来下客(无论是模拟还是实际运行)。比如,电梯
1-A
携带乘客3-FROM-2-TO-11
,只会在换乘层下客,而不会提前将其“丢下”,“扔给”其他电梯。(减少复杂度) - 模拟的 评价标准速度。此刻速度最快的成为局部最优解。伴随着速度的局部最优,耗电量在经验上也呈现较优表现。
v e l o c i t y max = max { d i s t a n c e i t i m e i ∣ e l e v a t o r i 满足必要条件 } velocity_{\max}=\max\left\{\dfrac{distance_i}{time_i}\Bigg | elevator_i满足必要条件\right\} velocitymax=max{timeidistancei elevatori满足必要条件}
这样做的好处是回报-投入比高,牺牲一定的准确性作出保守估计,获得较为可观的性能分数。而不必采用 DFS 等等其他方法既增加代码复杂度又给 CPU 暴增压力。
是为基于速度的 Simulate-V。
/// SimulateElev.java
ShadowElevator shadowElevator = new ShadowElevator(elevator);
Request shadowRequest = request.dclone();
double curVelocity = shadowElevator.simulate(shadowRequest);
if (curVelocity > maxVelocity) {
res = i;
maxVelocity = curVelocity;
}
调度策略性能分析
我统计了均分(Random)和模拟(Simulate-T)在 HW6 中测的性能表现,以及均分(Random)和模拟(Simulate-V)在 HW7 强测的性能表现。
(当然,试验数据可能存在几秒内的波动)
可以发现,在少部分情况下,模拟策略的局部最优并不必然使得全局最优。但基本上,模拟策略(在耗时上)全面优胜于均分策略。这也帮助我在强测中获得了 99 以上的分数。
架构设计
UML 类图
UML 图如下所示。各个类各司其职,相互协作。
但是显而易见的是, Elevator
类已经过于臃肿。但是反过来又觉得只能这样,这么多的职责与功能也只能放在 Elevator
自己内部。这与前面提到的,电梯本身既有“线程”角色又有“共享对象”角色相关。
UML 时序图与线程协作
一幅非常简陋、极不专业的时序图如下所示。
Schedule 的线程协作
Schedule 负责分发 Normal Request,按照调度策略将这些请求分发出现即可。
Supervisor 的线程协作
Supervisor 负责分发 Reset Request。
-
HW6 中的第一类重置还比较简单,将 RESET 信号分给电梯即可,剩下的都是电梯自己的事了;
-
HW7 中的第二类重置就涉及到了新电梯的电梯
Note 1: 第二类重置能像第一类重置一样完全由电梯自己完成吗?
理论可以,但是“创建新电梯”这个动作由一个 自己即将作废的电梯 来完成实在不合时宜。
因此,必须交由一个新的实体——Supervisor。
Note 2: 那
sleep(1200);
这个动作也移交 Supervisor 完成吗?答案是否定的,若并发出现多个 RESET 请求,则不能 按时 响应所有 RESET 请求。
因此,这里涉及到了一种“握手机制”或者说“线程间通信”
(请原谅我乱用术语)。Supervisor 和 Elevator 不再是单边通信,而是有来有往。Supervisor 发送 RESET 信号后,RESET 的具体操作由 Elevator 自己完成,Supervisor 则继续执行自己的任务,Elevator 重置结束后再 通知 Supervisor 创建新电梯。这样便实现了职责的分离。
线程结束条件
电梯的结束也是一个让人头疼的问题。第二单元中,电梯一会儿提前结束,一会永不结束,实在是让人抓狂 :(
。
在 HW5 中,结束是一个比较简单的线性过程,从输入线程开始依次 setEnd
:
I
n
p
u
t
T
h
r
e
a
d
结束
→
S
c
h
e
d
u
l
e
结束
→
E
l
e
v
a
t
o
r
结束
\rm InputThread结束\rightarrow Schedule结束\rightarrow Elevator结束
InputThread结束→Schedule结束→Elevator结束
但从 HW6 开始,Elevator 等着 Schedule 结束,但 Schedule 又等着 Elevator 打回的乘客。互相依赖,怎么办?
设计一个“计数栈” unfinishedNum
,表征未完成的请求数量。在 InputThread 输入请求时“压栈”(unfinishedNum++
);在完成相应请求(完成 RESET / 乘客到达目标层)时“弹栈”(unfinishedNum--
)。栈空是线程结束的 必要不充分条件。
InputThread 结束
计数栈为空
}
→
Schedule 和 Supervisor 结束
→
Elevator 结束
\begin{rcases} \text{InputThread } 结束 \\计数栈为空 \end{rcases}\rightarrow \text{Schedule 和 Supervisor }结束 \rightarrow \text{Elevator } 结束
InputThread 结束计数栈为空}→Schedule 和 Supervisor 结束→Elevator 结束
架构的变与不变和可扩展性
变与不变
我认为,架构中不变的就好像“浮在水面上”的那些基本动作。InputThread 就负责输入,Schedule 就负责按照调度策略调度,Elevator 就负责按照运行策略运行。
而变的部分就是“水面之下”的具体支持,比如,增加 RESET 请求,那运行策略内部就要新增这个动作,然后电梯按照“面向对象”的方式为策略进一步提供信息。
解耦与分离
在架构中,运行策略与调度策略实现了分离,RESET 类请求和正常请求的分发实现了分离,多线程电梯的动态运行和影子电梯的静态模拟实现了分离。
这样做利于扩展迭代,即便一部分代码迫不得已需要拆解,而其他部分不至于被破坏。
作为行为接口的策略
面向对象来进行设计与构造,我将策略封装为了接口,不同的策略实现了接口。
这样的设计降低了调度器和电梯的臃肿程度,更重要的是符合开闭原则,天然支持扩展。比如我在 HW6 尝试各种调度策略时,如果我尝试的策略表现比较好,那我就将其接入业务代码;如果表现比较差,那我也可以毫无顾虑的舍弃。
第二部分:锁、同步块与线程安全
在多线程程序设计中,锁是一个非常重要的概念,锁的存在保证了同步区的代码每次至多只有一个线程在执行。这又可以分为物理锁和逻辑锁。
物理锁
Java 多线程中,物理锁指 JVM 虚拟机提供的锁:
synchronized
非读写锁:基于对象的 / 基于方法的ReadWriteLock
读写锁- 等等
我主要用到了基于方法的 synchronized
锁,在共享对象类内部设置同步区,然后再配合 wait-notify
实现同步互斥。这样做的原因还是划清线程类和共享对象类的角色界限,另一个原因是基于对象的不熟。
/// ResetQueue.java
public synchronized Request getOneRequestAndRemove(int floor, boolean isUp) {
......
return null;
}
...... // 等等
逻辑锁
就像操作系统课程讲的同步互斥有硬件方法也有软件方法一样,我们也可以有“软件方法”的逻辑锁。
在防止双轿厢电梯相撞的处理中,我主要用到了 JHZ 同学在讨论区分享的方法。
A 电梯和 B 电梯共用一个换乘层,即为临界区(Critical Region)
/// CriticalRegion.java
public synchronized void setOccupied() {
while (state == State.OCCUPIED) {
try {
wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
state = State.OCCUPIED;
notify();
}
public synchronized void setRelease() {
this.state = State.UNOCCUPIED;
notify();
}
这些 巧妙、优美、简洁 的代码就构成了一种逻辑锁。
AB 电梯到达换乘层 / 临界区时需要抢占锁,离开换乘层 / 临界区则立即释放锁。
/// Elevator.java
/* 抢占临界区 */
if ((type == Type.A || type == Type.B) && curFloor == transferFloor) {
criticalRegion.setOccupied();
}
/* 从临界区避让
注意顺序: 先输出, 再将临界区 release. 防止还没 ARRIVE 到隔壁就被占位.
*/
TimableOutput.println("ARRIVE-" + ... );
if ((type == Type.A || type == Type.B) && (curFloor - step == transferFloor)) {
criticalRegion.setRelease();
}
。
第三部分:BUG分析—调度策略的漏洞和线程安全的回响
调度策略的漏洞
前面提到了
进行模拟的 必要条件 是电梯不处于 RESET 状态:作为一种保守估计,这里舍弃了对正在重置电梯的考虑。[这里在 HW6 中存在 BUG,具体见后面的 BUG 分析小节]
这就导致了一个问题
在 HW6 互测前一天,我惊恐地发现,当 5 个电梯都处于 RESET 而一个电梯 ALIVE 时,高并发的请求会全部分给仅存电梯。
在 HW6 中,包括我在内的很多人都只顾着去考虑 “分给谁” 的问题,而忽视了 “分不分” 的问题。从而我在互测中被刀中了这个 BUG,也从而我在互测中也刀中了其他人的 BUG。
解决方式也很多样。我的方法是为每个分表设置阈值,当队列人数达到阈值时,则不再分配。换言之,前面提到的 “必要条件” 还要再加上第 ③ 点:
①电梯不处于 RESET 状态
;
②电梯能让当前乘客朝目标楼层靠近至少一层
(
动起来
)
;
③分表人数未达阈值
.
① 电梯不处于 \text{ RESET } 状态; \\② 电梯能让当前乘客朝目标楼层靠近至少一层(动起来); \\③ 分表人数未达阈值.
①电梯不处于 RESET 状态;②电梯能让当前乘客朝目标楼层靠近至少一层(动起来);③分表人数未达阈值.
线程安全的回响
前面提到了
模拟为 瞬时模拟。在上一点基础上,当模拟时,会获取这个 瞬间 电梯的所有属性 快照(包含深克隆),然后交由影子电梯类模拟并给出结果。[这里在 HW7 中存在 BUG,具体见后面的 BUG 分析小节]
我会获得一个电梯信息的瞬时快照。但万万没想到这个瞬时快照的“拍照”过程不够“瞬时”,能够被“横插一脚”。
在影子电梯的构造方法中:
/// ShadowElevator.java
public ShadowElevator(Elevator elevator) {
this.maxNum = elevator.getMaxNum();
this.curNum = elevator.getCurNum();
this.curFloor = elevator.getCurFloor();
this.transferFloor = elevator.getTransferFloor();
this.isUp = elevator.isUp();
this.moveTime = elevator.getMoveTime();
this.openTime = elevator.getOpenTime();
this.closeTime = elevator.getCloseTime();
this.queue = elevator.getProcessingQueue().copyContext();
this.boardedRequests = elevator.copyContext();
this.type = elevator.getType();
this.time = 0;
this.distance = 0;
this.endFlag = false;
this.startFlag = false;
this.request = null;
}
第 3 行获取了电梯人数,第 13 行深克隆了电梯里的乘客,这个异步间隔极可能被“横插一脚”,就导致了深克隆乘客的 size
和前面的 curNum
是不等的。前者
≥
\geq
≥ 后者倒也还好,也就是估计得粗糙了点;但前者
<
<
< 后者时,我便触发了异常
Exception in thread "Thread-6" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
解决方式是在正式模拟时使用 curNum
为乘客的 size
即可。
Hack 策略
- 投放高并发的数据:在同一时刻投放大量正常请求和重置请求;
- 针对结束条件进行 hack:比如在末尾投放大量重置请求。
总之,高并发的数据投放在 Hack 中都取得了不错的效果。
第四部分:心得体会
线程安全方面
由于对生产者-消费者模式的忠实“照猫画虎”,我并没有出现死锁问题;再加上我对基于 wait-notify 的线程协作的充分设计,也没有出现 CTLE 问题。
在课下出现了线程提前结束和永不结束的问题,通过 print 大法 可以很好的锁定问题。
现在想来,关于线程安全的最大体会就是在第二单元刚开始时对多线程深深的恐惧,从而使我没有产生死锁等问题。
这是由于这份恐惧和不安使得我在 coding 的时候对多线程时刻充满了敬畏,处处都小心提防、充分考量。如果我当时是无比“自信”、信手拈来地写的话,说不定强测后会有非常痛苦的 debug 经历。
总之,无论是多线程还是其他程序,在写之前,一定要充分 设计,避免“只见树木,不见森林”;在开始写的时候,也一定要 构造 好每一颗“树木”。这正是 面向对象设计与构造。
层次化设计方面
在本单元中,层次化设计也是很重要的,虽然涉及的层次的确不是很深。
主要就是体现在前面所说的“浮在水面上”的“不变”与“水面之下”的“变”的层次关系。
在电梯月,每次看到宿舍的电梯心里总是骂骂咧咧。
以后,在看到电梯时,又会有不一样的感受罢。