OO Unit2 Summary
不知不觉之间,OO第二单元的学习也已经结束了。在这个单元,我们学习了Java多线程相关的知识,见识了常见的线程安全问题以及解决方案,并通过多线程电梯项目实践了如何将线程安全设计和层次化设计分开进行考虑。
同步块与锁的设计分析
第五次作业
在这次作业的架构中,我创建了三个线程:请求模拟器线程,调度器线程以及唯一的电梯线程。请求模拟器和调度器之间共享全局队列,所以这个时候需要对全局队列进行互斥访问。另外,调度器线程和电梯线程之间共享电梯的局部队列,所以还要对该队列进行互斥访问。
请求模拟器线程中的同步控制块与锁
在请求模拟器的run
方法中有两处同步控制块:
while (true) {
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
break;
} else {
// 在此处使用同步控制块对全局队列进行互斥访问
// 使用全局队列本身作为锁
synchronized (waitQueue) {
waitQueue.putRequest(request);
// 唤醒此时可能阻塞中的调度器
waitQueue.notifyAll();
}
}
}
finished = true;
// 由于request为null时消费者可能也在等待,如果此处不唤醒,调度器有可能永远等待下去
synchronized (waitQueue) {
waitQueue.notifyAll();
}
在第一个同步控制块中,语句有投放请求和唤醒其他线程,这两个操作都是很有必要的。此处没有使用线程安全类,导致在编写这部分代码时业务逻辑和线程安全耦合,如果全局队列最初设计成了线程安全类,那么此处只需要putRequest
和takeRequest
两个能保证线程安全的方法就好了。
在第二个同步控制块中,语句只有一个,用来唤醒可能正在等待的调度器。这种为了唤醒其他线程而强行加一个同步控制块的操作看起来十分迷惑,但是对于这个架构来说,我自己没有想到什么好的解决方案。
在第三次作业写完后,我阅读了某些同学作业的代码,发现其设计了调度器,但是没有把它作为一个线程,而是把调度器作为生产者持有的一个对象;此外,他的设计中似乎没有全局队列的概念,而是拿到请求直接分配到电梯的局部队列中,这样的话生产者中的逻辑就是这样的:
初始化调度器; while (true) { 读入请求; if 请求为空 调度器停止工作; else 调度器马上把请求分配给一个电梯; }
可以看到,这样一来再也不用担心调度器停不下来了,也不用想着需要对全局队列做什么访问控制了,生产者部分不需要任何互斥和同步。
经过对比和反思,我发现自己之所以在这里做出很不好的设计,主要原因是没有事先想清楚是否要将调度器设成一个单独的线程。开单独的线程,相当于把本可以用生产者-消费者进行建模的问题搞成了使用食物链进行建模,此时需要考虑的线程之间的关系明显更加复杂了,且在见识到了其他同学的写法之后,我也认为这样的复杂性是没有必要的。并且,从生产者-消费者模式的动机上去想,要建立生产者-Channel-消费者的模型,生产者和消费者之间应该存在速度差,而思考一下之后会发现其实请求模拟器和调度器的速度可以是相等的,所以这两个角色之间可以不需要用容器去做缓冲,也没必要将这两个东西设成两个线程,只放在一个线程,按照顺序工作就好。
如果使用有速度差的调度策略(比如攒够几个人之后经过分析再调度,以及课上老师说的对请求重排序),才有必要在二者之间使用一个队列做缓冲,用生产者-消费者模式建模,将二者设计成两个线程。
调度器线程中的同步控制块与锁
有的人从出生就是一个错误。
调度器这部分就像“多生牙”一样,可以说其“处处都是问题”,或者说“问题深入到骨子里”。
在run
方法中出现了很大的同步控制块,其大致逻辑如下所示:
启动电梯线程们;
synchronized (waitQueue) {
while (true) {
if 满足结束条件
退出;
while (队列为空) {
如果满足结束条件则退出;
否则等待;
}
调度;
唤醒其他线程;
}
}
这部分的真实代码比较长,大约有40行,几乎整个run
方法都被同步控制块包裹。这些逻辑对于“食物链模型”来说是必要的,否则正确性根本保证不了。但如果不把调度器设计成线程,那么此处完全不需要和生产者之间进行同步控制,只需要在调度器和电梯对该电梯的局部队列的访问这件事情上进行互斥即可。
在调度方法dispatch
中,我也使用了同步控制块,其大致逻辑如下:
public void dispatch(接受一个电梯类型的参数e) {
synchronized (全局队列) {
遍历全局队列的请求,看看能把哪些分给e;
}
}
只看描述,就应该能想象出来这样写会有多糟糕了。这部分同步控制块的问题不在于同步控制块本身,而是在于调度方法需要接受的参数设计砸了。这样写的问题如下:
-
调度方法接受的参数是电梯类型真的好吗?如果调度方法接受的是电梯类型的参数,那么在给电梯分配任务时,需要考虑如何才能安全的遍历全局队列,可能会出现嵌套的同步控制块。
-
历史遗留问题,除了调度器和电梯之间的互斥和同步,调度器和生产者之间的互斥和同步此处仍需要考虑。
但如果把调度方法改成接受一个请求参数,然后考虑将其放到哪个电梯中,全局队列就仍然只需要提供put
和take
两类方法,不需要遍历操作,电梯局部队列也只需要提供上述两类方法。当然,如果调度器本身不是一个线程的话,那么就可以完全对此处的同步控制块Say goodbye。
电梯线程中的同步块与锁
电梯中的同步块主要在run
方法中:
public void run() {
while (true) {
状态模式下,当前状态通过多态调用相应工作方法;
synchronized (全局队列) {
synchronized (生产者) {
if 结束了(需要看全局队列是否为空以及生产者是否停止,另外还有自己有没有送完)
唤醒其他线程;
退出;
}
}
}
}
同步块中的内容和使用的锁还是有很密切的联系的,但是如果调度器不是一个线程且马上处理输入的请求的话,此处不需要使用全局队列锁,在此基础上,可以比较容易实现投放有毒请求来杀死电梯线程,则生产者锁可以去掉。综上所述,该方法中所有的同步控制块都可以去掉。
第六次作业
架构沿用了上一次的,做了很少的改变就实现了分配给多个电梯任务。由于历史遗留问题,生产者和电梯本身的同步控制块几乎没有发生变化,对于这两部分的分析不再赘述;调度器发生了少量变化,但是都是在原有的控制块中增加新的逻辑,下面简要分析一下。
调度器线程中的同步控制块与锁
run
方法的几乎没动,主要看一下dispatch
方法的同步控制块:
public void dispatch() {
synchronized (全局队列) {
遍历请求,看看有没有乘坐请求;
如果全是加电梯请求或者没请求,就退出;
遍历请求,把乘坐请求按人数负载均衡地下发下去;
}
}
问题还是原来的问题,只不过更严重了。起初负载均衡分发请求时遍历了持有电梯们的容器,每次想往电梯中加入请求时都要对不同的电梯进行互斥处理,后来使用优先队列自行定义了优先级,不过还是得互斥控制一次。解决方案第五次作业的分析中已经提到。由于写第三次作业后才摸索出解决方案,所以本次作业仍然保留了此问题,很痛苦。
第七次作业
第七次作业的最终提交的版本和第六次作业差不多,没有实现换乘机制。虽然第七次作业其中的一个版本实现了换乘机制,但是由于前面的历史遗留问题太重,互斥与同步控制变得极其容易出错。也正是因为在实现换乘机制时遇到了不小的麻烦,所以才有了上面分析第五次作业时的经验。
下面对换乘版本中新增加的同步控制块进行分析。
在换乘版本中,我的换乘机制的实现方法是:在调度器中增加一个换乘列表Map<Integer, Queue<PersonRequest>>
,其中该映射的键是乘客id
,值是该乘客的请求被拆分之后的分请求按照顺序组成的队列。每当调度器run
方法中的while (true)
执行一遍的时候,遍历换乘列表中存在的键,然后再遍历各个电梯的局部队列和内部队列,如果该键在局部队列和内部队列中都没出现过,那么说明可以把该键对应的请求队列中的第一个拿出来进行运送。这样来实现的话,需要实现一些工具方法,比如拆分请求的方法(需要对全局队列做访问控制,不能边拆请求边拿请求),检查电梯局部队列中是否存在某id
的请求的方法(需要对电梯的局部队列和内部队列的访问进行控制)等,各种各样的遍历操作使得同步块充斥在调度器类的各个方法中。后来经过反思,我认为如果将电梯的局部队列和内部队列设计成线程安全容器,提供安全的访问方法(比如封装好遍历查询某些信息的方法),换乘部分就可以做到业务逻辑与线程安全设计分离。
总的来说,我认为这几次作业中自己对于同步块的使用很多地方是可以避免的,也有很多地方可以用线程安全容器来使得业务逻辑更纯净。虽然作业中的设计不尽如人意,但是能发现自己的问题并想出解决方案还是让人十分开心的。
调度器设计分析
我从第五次作业开始,就设计了调度器(虽然第一次根本用不上),并通过不断修改一直使用到第七次作业完成。
在我看来,调度器的作用是把请求模拟器产生的请求分派给合适的电梯,至于电梯该怎么走,应该是电梯策略要做的事情。关于调度器是否要设计成一个线程,如果只是分派任务的话是不需要的。
第五次作业
这次只有单电梯,所以我的调度器只是机械的把下一个请求从全局队列拿出来,放到唯一的电梯的局部队列中。
调度器线程和请求模拟器线程之间共享了全局队列,当请求模拟器不生产的时候,调度器需要wait
;请求模拟器结束工作之前,要通知调度器。
调度器线程负责把合适的请求投放到电梯线程中,电梯线程在队列全空时会等待调度器投放请求。当调度器完成其使命之后会设置自身的终止标记并唤醒可能正在等待的电梯,电梯线程在查看到调度器的终止标记被设置之后会在运完所有的目标后终止。
第六次作业
在这次作业中,由于存在多部电梯,为了充分利用它们,我在调度器中使用的分配策略是每次取最“轻松”的那个电梯,然后把当前的请求扔给它去完成。我在此处对于“轻松”的定义只是简单的看哪部电梯的人数最少,比较简单粗暴。另外,由于可以动态加电梯,所以在调度器拿到加电梯请求的时候,我会在调度器中开启一个新的电梯线程。
调度器和请求模拟器之间的交互关系几乎没有改变。
由于有多个电梯线程,且需要实现相对负载均衡,所以我的调度器需要了解电梯的一些状态(比如电梯有多少人等信息),电梯需要提供相应的访问方法。为了负载均衡,我的调度器使用优先队列管理电梯,队头始终是最轻松的那个电梯,每次我会取出这个电梯,把请求扔给这个电梯,然后把这个电梯再压回队列中。通知电梯终止的方法没有变化,仍是调度器设置好自己的终止标志之后唤醒所有电梯线程去检查标志。
第七次作业
前两次作业关注点主要在多线程上,最后一次作业似乎才开始强调面向对象。
在这次作业中,电梯的各项参数都不一样了,所以调度策略需要发生变化,且想要换乘的话需要实现换乘机制。
我最终的版本没有换乘,所以就按照贪心的方法调度的电梯,C能运则C运,B能运则B运,都不行则给A,所以这次我按照电梯的类别进行电梯的管理。这里的调度器就没什么变化。
在有换乘的版本中,我把换乘时的拆分策略放到了调度器里面,拆分策略的主要目的是想充分利用B电梯,把偶数起点和偶数终点的请求拆分成可以由B电梯运送的请求。在换乘版本的调度策略中,我通过一个随手捏的函数计算每个电梯的函数值,该函数综合考虑了电梯人数(重要因素),电梯是否可接可达(决定性因素,拆分后楼层不能到达则一票否决),电梯速度等因素,计算的函数值最大的电梯可以接当前请求。
可以看出来,换乘版本的调度器承担了太多的职能,有一些本该抽成独立模块的部分最终只是成为罗列在调度器中的方法,这样需求一变,方法就可能会变,没有抽象的保护,整个调度器都得随之作出修改。按照课上的作业分析,目前我的调度器中的一些功能应该抽出来交给路径规划模块和电梯状态管理模块,比如路径规划模块负责以一定的策略求出换乘请求队列,电梯状态管理模块负责所有电梯的管理,调度器只是把请求按照一定的策略分发给电梯,这样才方便继续扩展下去。
作业可扩展性分析
由于项目迭代过程中没有类的增加,只是在类内部添加或者改变了一些方法的实现,所以三次作业的类图没什么变化,故为了节省空间只贴出最后一次作业的类图。
第五次作业
顺序图:
从顺序图中能看出很有趣的一件事情:请求模拟器和调度器这两个线程的工作和暂停的步调似乎是同步的。这似乎也暗示了调度器可能可以不设计成一个线程。
第六次作业
顺序图:
由于前面设计好了调度器,所以非常容易支持多部电梯,本次作业仅修改了调度器:
- 在其中添加了动态加电梯的功能。
- 使用优先队列管理电梯,方便实现负载均衡。
第七次作业
整个电梯系统包含请求模拟器,调度器,电梯以及驱动模块这四部分组成。
其中电梯模块除了自身基本的属性之外,还拥有状态和策略两个属性,分别负责管理电梯运行时状态以及单部电梯的运行策略。早期我想的是可能自己会设计多种不同调度策略的调度器,所以将调度器做成了抽象类,各种具体调度策略的调度类继承自该抽象类。电梯由电梯工厂根据电梯型号的不同生产电梯。电梯状态采用状态模式建模,状态转移策略是分布式的状态转移,即每个状态知道自己下一步该转移到哪个状态。为了让状态类能够访问到电梯的数据,所以电梯类暴露出了很多public
方法供状态类查看电梯状态,此处如果把状态类作为电梯的内部类,就不会把电梯的内部状态展示给外界了,状态类访问电梯数据也会更方便。策略部分采用了策略模式,抽象策略Strategy
定义接口方法,不同具体的策略分别实现该方法。
第七次作业顺序图与第六次作业完全相同,UML图如下所示:
顺序图如下所示:
- 本单元我使用了策略模式和状态模式,使得电梯类本身不必纠结于到底应该如何运行和状态转移,而是把它们下放到电梯状态类和运行策略类中,这样可以方便增加新的运行策略甚至可以运行时切换策略,此处是不错的设计。
- 本单元吸取了上一单元的教训,上来就建立了抽象层次,约定好每个模块对外暴露出的行为,这使得具体的请求模拟器,调度器和电梯发生变化时,由于抽象的不易变性,使得修改三者其一不会影响另外两个模块调用该模块的方法,没有再出现求导作业中处理错乱的返回值类型的事情。
- 调度器职责较多,其职责已经超出了我们最开始对调度器的定位,尤其是在实现换乘版本的时候,不做层次化设计,只是往里面塞方法的话,即使勉强实现了换乘,如果作业还需要进行迭代开发的话,稍有变化就可能几乎重写调度器。
由前面的分析可以看出来,调度器单独作为一个线程会让支持换乘机制变得困难,而换乘机制又是提升性能的基础,所以我认为自己第三次作业的架构设计比较糟糕,换乘机制等新功能的添加比较复杂,且不能保证各种情况下性能表现得能看(比如极端的只有A电梯能够直达的情况在没有换乘机制时性能完全不可看),这让我想到指导书中经常说的一句话:真正靠谱的架构,一定是可以做到兼顾正确性和性能优化的。重构思路主要是将调度器作为普通的类而不是线程,然后对这个“调度器”做功能拆分,分为电梯状态管理,请求拆分(路径规划),请求分派三个部分,这三部分由一个顶层模块调用,另外电梯状态类设计成内部类,并且从分布式的状态转移改成集中控制状态转移。这样一来支持换乘机制会简单很多,才有工夫去考虑比较影响性能的请求拆分策略以及单部电梯的运行策略。
另外,我注意到有一部分换乘的同学没有拿到很好的性能分,有些看似是优化的优化最后表现出来是负优化,究其原因,可能是换乘要考虑的东西太多,想考虑策略在大部分情况下的有效性比较困难。要想做这种复杂问题的优化,通过大量数据模拟某个策略,观察其性能,然后决定是否使用该策略可能比较可靠。
自身bug查找和消除策略
在这一单元作业中,我的查找bug的策略主要是基于指导书样例和使用自制评测机评测,先测一遍样例,看看有没有问题,没有问题则使用评测机进行随机数据轰炸,查看有没有问题。
在使用指导书样例进行测试的时候,为了模拟数据投放,我们需要编写数据投放程序。在这里我使用了dhy同学介绍的将数据交给发射程序,然后通过管道的方式投放给正在运行的jar
。这里需要注意,如果使用C语言编写数据投放的话,每发射一个数据,要记得fflush
,否则只有当最后一个数据投放完毕之后jar
才能读到数据。
在构造评测机时,我的做法是编写一个模拟电梯模块Simulator
,其拥有和我们的作业项目中电梯相同的属性。我们把作业项目的输出数据作为模拟电梯模块的输入,分析每条输出,按照每条输出所提供给我们的信息进行相应的操作,比如如果是ARRIVE
,就要通过level++
或者level--
进行电梯移动,如果是IN
就要往电梯队列中加入人,如果是OUT
就要从队列中移除人。Simulator
在根据这些输出进行操作时,也会同时进行合法性判断。在我的评测机中对合法性的检查有这么几种情况:
- 最后是否关门
- 最后是否有人没出来
- 是否到了非法楼层(比如0或者21这种)
- 是否跳楼(比如从1直接到3楼)
- 开门时开门的楼层和当前楼层是否相等
- 关门时关门的楼层和当前楼层是否相等
- 是否重复关门(因为自测时出现过重复关门,所以加上了这一条)
- 乘客进出时楼层是否正确
- 是否有某个人重复出入(比如多个电梯可能同时接了一个人)
该评测机实现非常简单,自己使用效果还可以,但很可能漏掉了一些错误情况,也没有支持CTLE
和RTLE
的检查,还有不小的改进空间。
本单元自测出的bug大多是线程安全问题,少量是业务逻辑编写错误。
在确定出现bug之后,我的定位方法主要有两个,一个是输出调试,另一个是多线程断点和快照。在第五次作业和第六次作业早期时,我还不会使用多线程断点,只会使用输出调试法,输出的插入位置主要是在各种wait
和notifyAll
的前后,这样能帮助我定位死锁类问题最后出现在哪里,然后我再读代码去消除该问题。在第六次作业后期,我学会了多线程断点这个强大的工具。如果说普通断点和输出调试法的关键在于有目的地去查看和输出可疑的数据,那么多线程断点的关键就在于构造极端的执行顺序并执行它。在使用多线程断点时,我的定位方法就变成了猜测哪里可能会出现死锁,然后利用多线程断点能够控制执行顺序的特点,尽可能的让其他线程运行,消耗其他线程的notifyAll
,直到其他线程不可走,最后再让自认为可能会被锁死的线程执行,看看会不会卡死。这种策略是非常有效的,相比输出调试,我可以手动调控执行顺序,再也不怕同一组数据造成的bug不能复现。
在第五次作业中,我自测遇到的bug有两个,一个是无法终止调度器线程,另一个是在某个输入null的时间点会导致不关门。对于第一个bug,其出现的原因是电梯终止的时候没有唤醒调度器,继续追究更深的原因,就是将调度器设计成一个单独的线程有点不合理,导致调度器需要和两边交流信息,增加了复杂度;对于第二个bug,其原因是我的电梯在不工作时会在“寻找下一个目标”这个状态阻塞,如果在这种阻塞的状态下停止,在被最终唤醒且没有任务可做的时候,就没法输出关门,个人认为这是分布式状态转移导致的bug,如果改成集中式调度,最后的终结阶段处理起来会更方便,不容易出现此类问题。在强测中稍稍超时了一个点(RTLE
1秒左右),导致被判为WA
,原因一个是我所有模式使用的都是ALS
策略,在评测机波动时就可能会超时(未改代码重交即可卡进时限)。
在第六次作业中,我自测时遇到了一个bug,在debug时发现一处未唤醒,出在调度器中,该bug的出现主要是线程安全部分比较混乱而导致的。在公测和互测中未被找到bug。
在第七次作业中,我自测中出现了一次因为互相notify
导致的CTLE
,但是很快就定位并修复了,观察可以发现这个bug同样也是前期线程安全没设计好导致的。在公测中没有发现明显bug,在互测中由于无换乘意料之中被极端数据hack了1次。
发现别人bug的策略
在第五次作业互测中,我主要的策略是使用评测机去检测别人的bug,成功发现了一个同学的一个"上天堂"和“下地狱”的bug,通过读代码发现该同学可能是因为运送过程中由于线程安全问题目标丢失,导致持续往一个方向走。
在第六次作业互测中,我仍然使用评测机去检测别人bug,但是没有测出问题,于是作罢。本次由于忙于准备OS测试,所以没有阅读其他同学的代码(然而OS lab2-2课上挂了,hh)。
在第七次作业互测中,读了一位同学的代码,学习到了很多东西,但是互测完全没玩。写博客之前我观察了一下互测房间中hack成功的数据,发现成功hack的两组数据都是构造数据卡A电梯。
本单元我寻找别人bug的策略主要是评测机随机数据测试,相比上一单元在互测上摸了很多,这部分确实没什么好写的。
心得体会
在线程安全方面,仅通过OO课我并没有很好地理解互斥和同步,对何时使用多线程也只是很粗浅的认识,对生产者消费者模式的理解也有点刻板,只是照猫画虎会写几行简单的代码,而真正理解上面那些东西还是在读了《现代操作系统》之后。在理解了PV操作,管程等概念,原理及其应用,并深入学习了几个经典问题之后,我才逐渐明白了啥时候该用多线程,如何利用线程安全类(个人认为很像管程)来实现线程安全与业务逻辑尽可能分离,以及生产者-消费者模式的使用动机(解决生产消费速度不匹配的问题)。总的来说,在线程安全设计这方面有了一定的收获。但是,我完全不敢说自己会多线程,因为多线程除了线程安全这种程序还有很多问题(比如如何做到高并发,而这方面在作业中并没有得到训练),线程安全的水也应该远远不止是现在感受到的深度,故多线程的学习还有很长的路要走。
在层次化设计方面,本单元给我带来的最大的体会是:层次化设计的程度似乎决定了程序能够解决多复杂的问题。要做好层次化设计,关键是想清楚我们设计的类到底要做什么事情。要多问问自己这个事情是不是一件事情?有没有必要再细分?可能最开始问题简单时完成该事情的每个步骤都很简单,但是当问题复杂度上来之后,其某些步骤可能会很复杂,甚至都可以作为一个独立的任务了。如果此时做了层次化设计,那么我们解决复杂的问题或者增加新的功能就会容易一些,修改某一层就好;如果没有做层次化设计,完全是平铺展开的,那么同层几乎都会受到影响,改的多了就可能出更多bug,这种情况下恐怕问题就不可解了,即便解决了,其内部也是摇摇欲坠的,问题稍有变动又会因为同层互相牵连推倒重来(其实更可怕的是推倒重来也没层次化,治标不治本)。
这个单元的作业虽然设计砸了,但得分居然比上个单元好看,架构也没有被需求逼到崩溃的地步,这可能是因为测试数据没刻意卡+项目规模不够大。设计糟糕固然令人难过,不过,这次能够慢慢琢磨明白自己为啥搞砸了,并且基本上明确了重构作业的方向,这种知道了如何从不好的设计变到好的设计的感觉还是相当好的,总的来说还是有进步的。OO课程已经过半,希望下面的两个单元能不懈怠,坚持到最后!