BUAA-OO 第二单元总结
BUAA-OO 第二单元总结
一、锁与同步块
(一)多线程安全性
为什么这单元作业需要使用多线程?因为我们有多部电梯,需要允许它们同时运作;同时,在等待需求输入时也要允许它们运作。这种业务上的需求使得我们必须使用多线程来完成这单元作业。
尽管多线程带来了很多好处,但是使用不当,很可能出现线程安全问题。实际上,线程安全问题的本质就是多个线程对共享数据的读写。即对于某个共享数据,不同的线程访问顺序可能导致不同的结果,从而产生与人的逻辑相违背的“多线程灵异事件”。经过理论课的学习,主要有 read-modify-write
和 check-then-act
两种线程安全问题。发生这些线程安全问题的根本原因都是这些操作不是原子性的,即将一个操作分解成多步进行,而在每一步中都可能受到其他线程的干扰。
为了解决这个问题,我们必须使用同步块或锁来保护共享数据,使得对共享数据的相关操作成为原子操作,而同步块和锁使用不当,则又会引发一系列问题(如死锁、死等等)。
(二)锁的选择与同步块的设置
在本单元作业中,我使用 syncronized
同步块把对共享数据的相关操作方法封装成原子操作,使得任何时刻只能有一个线程拥有该对象的锁,从而保证线程的安全。
尽管其他类型的锁(如读写锁等)具有更大的灵活性,但是由于syncronized
同步块已足以实现相关功能,且自己水平不够,对这些锁的了解不够深入,盲目使用这些锁可能产生新的bug,因此没有使用其他类型的锁。(“在出现性能问题前,不要考虑性能”)
syncronized
同步块主要用于同步共享对象的相关操作,以对象为保护目标,使得任何时刻只能有一个线程拥有该对象的锁,保证该共享对象的安全性。只要某一线程获得该对象的锁,就可以调用该对象的其他同步方法,而其他线程则无法调用该对象的所有同步方法。这就使得某一线程获得对象的锁后,变成对该对象的一系列串行的操作,期间可以看做只有这一个线程,因此就解决了多个线程同时操作共享对象的线程安全问题。
在作业中,我将共享对象的所有方法都使用同步块同步整个方法。尽管可以进一步缩小同步块的范围,只同步真正操作共享对象数据的部分,但是由于刚接触多线程,很难准确找出所有操作共享数据的部分,我认为简单粗暴地同步整个方法是一种更为安全的方法。这样,只需要找出所有可能被共享的类(在本次作业中主要是请求队列),将其方法使用同步块保护即可。
而关于同步块的死锁问题,可以通过层次化的架构设计来避免,即把共享资源层次化,每一层的线程只拥有该层的共享资源,而不会跨层次使用共享资源。这就避免了线程在拥有该层的共享资源时,企图尝试获取其他层次的共享资源时导致的死锁问题。
二、调度器设计与交互
(一)调度模式的选择——调度器分配
这单元作业中,调度模式的选择主要有两种:调度器分配模式,电梯自由竞争模式。
1.电梯自由竞争模式
电梯自由竞争,使得整个电梯系统能够动态适应多种情况,即每个电梯竞争最适合自己响应的任务,从而能够在一定程度上提高性能,同时实现的整体结构也比较简洁(免除了一大堆的调度器)。
但是,电梯自由竞争的模式,使得数据流的方向发生了改变。请求信息从上而下传递,但是到达电梯这层时,发生了从下到上的转变——输入从上至下放入等待队列,电梯从下往上夺取请求。这在一定程度上产生了“无序”的现象(正如现实中自由竞争也必然导致无序),包括生产者-消费者之间的无序和多个消费者之间的无序。尤其是多个消费者之间的无序问题,这需要进行更为谨慎的保护。
此外,自由竞争模式也需要考虑扩展性,即如何在产生新的业务需求时扩展各消费者的竞争行为。
考虑到以上因素,我决定采用相对比较规范的调度器分配模式(尽管比较繁琐)。
2.调度器分配模式
在研讨课上,有同学提到,调度器的功能完全可以由生产者实现,调度器作为一个线程有点”多余“。我认为,把调度器单独作为一个线程,能够使得整体的层次化结构更为清晰,同时把生产者和消费者的具体行为解耦。尽管在功能的实现上略显多余,但对于整体的架构设计是有很大帮助的。
调度器分配模式使得我能够对每个数据的具体流向都有一个清晰的把握。无论是在写代码或者debug时,了解程序的具体行为都是至关重要的。
(二)调度器设计——层次化调度
在hw5中,我实现了楼座一支的调度器层次,尽管在互测时明显感受到自己的类的个数多于大部分同学(第一次作业不使用这么多调度器也可以完成),但是这种比较清晰的层次化使得我在后面两次作业的扩展也相对比较方便。
在hw6中,由于环形电梯的存在,增加了楼层一支的调度器层次,只需要根据请求的类型,就可以实现不同类型请求的分流。层次化调度的结构可以很好地适应电梯请求与人员请求的场景。无论是电梯请求还是人员请求,都将其分为横向与纵向两种类型,分流处理,到达最底下一层的调度器时,再根据电梯请求/人员请求分别处理。此外,考虑到hw7可能出现的换乘请求,在hw6中也实现了电梯到总请求队列的数据传输,使得一个请求能够多次被调配。
在hw7中,由于hw6已经考虑到换乘请求的情况,因此依然沿用了hw6的层次化调度结构。
(三)调度器与线程交互——共享队列交互
首先,正如上文所言,我将每个调度器设置为一个单独的线程,因为这能够使生产者和消费者解耦,因此调度器与线程的交互,实际上就是调度器与调度器、调度器与电梯的交互。
对于每个调度器线程而言,其内部数据流动方向都是固定的单一方向(从上往下),从上层请求队列中获取请求,分配给下层请求队列。而这个分配的过程只需要进行简单的逻辑判断(如对应楼层、楼座)即可。因此,调度器与调度器的交互都是通过共享队列的单向交互。
涉及到双向交互的,只有最底下一层的调度器,因为它们需要获取电梯的状态信息,从而动态地将请求分配给合适的电梯以提高系统性能。不过,我在设计时并没有考虑电梯的动态信息,因为过多的优化不仅需要耗费大量时间和精力,最终的成效也是微乎其微。因此,在第二次作业中,我采用均匀分配的基准思路;hw7中,试着稍微做了点优化,赋予不同速度、容量的电梯不同的chances,然后模仿second chance算法进行分配。由于这些信息都是静态的(不会发生改变),因此“双向交互”并不是真正意义上的双向。
三、线程协同架构
(一)UML类图
1.hw5
hw5的功能比较简单,因此整体架构为输入线程-总调度器-楼座调度器-电梯。
InputThread
为输入线程,不断读入新的请求并放置到请求队列中。OutputThread
为安全输出类(叫Thread是因为后来忘记改了...)。
为了实现LOOK的电梯捎带策略,分别实现了RequestQueue
的四个子类:UpQueue
、DownQueue
、ToInQueue
、ToOutQueue
,各自按照一定顺序维护其内部请求的有序性。
WaitTable
类为某一电梯的等待队列,可以根据相应的策略从等待队列中取出相应的请求。Strategy
类则根据电梯的状态从WaitTable
中取出相应请求进行响应。Elevator
类只根据Strategy
类发出的指令运行,并不进行任何的策略判断。通过策略和机制相分离,有效降低了电梯系统的耦合程度,使得Elevator
可以适应多种Strategy
。
这样的架构看起来对于功能简单的hw5似乎比较繁琐,但层次化结构使得我后面作业能够比较容易的扩展,不用推倒整个架构。
2.hw6
hw6中,由于加入环形电梯的类型,因此采用三层调度器的设计。第一层调度器ReqTypeDispatcher
,根据横向或纵向将请求分流。第二层调度器,FloorDispatcher
和BuildingDispatcher
,将横向请求分配到对应楼层,将纵向请求分配到对应楼座。第三层调度器,SubFloorDispatcher
和SubBuildingDispatcher
,将人员请求分配给电梯的WaitTable
或增加电梯。
此外,考虑到hw7的换乘,在TotalReqQueue
中增加属性personReqNum
和finishNum
以确定是否完成全部请求,并实现Elevator
类向TotalReqQueue
的请求发送。
可以看到hw6的架构相比于hw5,仅仅是增加了环形请求的调度分支,而环形请求的调度分支与纵向请求的调度分支差异并不大,在很大程度上保持和利用了原有的整体设计架构,可以说hw6很好地应用和拓展了hw5的架构。而这正是hw5中看似繁琐但清晰且具有拓展性的层次化结构带来的便利。
3. hw7
hw7的主要特点就是流水线架构,即把一个请求的完成拆分成若干阶段。不过,由于每个阶段都是一样的工作(调度器调度——电梯运送),因此只需要把hw6中的调度、运送阶段首尾连接起来即可实现“循环流水线”。
为了解除流水线流程的分析策略和机制的耦合,我实现了一个Settler类以确定某一请求的下一目标位置,然后重新返回TotalReqQueue
再次调度该请求。当请求到达目的地时,则直接移除出电梯系统。
在hw7中,原有的三级调度器的层次化结构没有发生变化,仅仅是增加了Settler
类和电梯的相关可达性信息判断。不过,为了优化请求的动态规划(如横向三级换乘等),主要的时间还是花在了这部分上,但是优化的结果似乎不如基准策略,我认为这可能是局部优化时忽略了整体的性能导致的。
相比于第一单元,第二单元的作业基本上没有重构,我想这主要是因为在hw5中一开始就建立层次化的线程协同结构,不以完成当前功能为目标,而是考虑未来的新需求等因素,尽可能提高架构的拓展性。
(二)线程协作图
线程的协作特点在以流水线架构为基础的hw7中尤其明显,因此这里以hw7的架构绘制线程协作图。
协作图中标注红框的是各线程的start
过程,对于一些单例模式的线程,由主线程Main直接启动。而其他的非单例线程(如各楼座内线程),则由其上一层次的线程负责start
。
协作图中标注蓝框的是各线程协作调度Request
的过程。可以看到,在线程协作中,除了需要换乘的请求从电梯反向流动回总调度器外,其他请求都是按调度层次顺序逐次往下流动,这种资源的单向流动可以很好地避免死锁的线程安全问题。
协作图中标注绿框的是各线程结束的过程。每个线程依次查询上一层次的线程是否结束,从而判断自身是否可以结束。
层次化的调度结构,使得每个线程只负责启动其下一层的线程,只向下传递一次请求,同时结束时只查询上层线程的状态,大大降低了各个线程之间的耦合性。
四、bug分析
在强测和互测中,三次作业都比较幸运地没有被Hack。
在课下的自测中,遇到的线程安全问题主要是hw5中线程最后无法停止。因为我的电梯的请求队列分为向上请求和向下请求,而为了防止轮询,每次都会尝试从向上请求队列中get一个请求(没错,仅仅是为了让电梯wait而已)。但是当向上请求队列空了,而向下请求队列还有请求,同时输入结束时,电梯就会死等,无法结束线程。解决方案是改成当向上队列和向下队列都空时才wait。出现这个bug的原因还是自己的思维固化,只想着套用简单的生产者消费者模式,而忽略了具体情境下的条件(如电梯有两个等待队列)。
此外,在hw5的中测中,遇到的一个bug就是输出类线程不安全(很幸运的是,该bug在中测中就暴露出来,没有拖到后面的强测)。一开始我构建了一个字符串队列,把所有要打印的字符串扔到该队列中,然后由TimeableOutput类从中取出字符串进行打印。但是这样却出现了move时间戳过短的bug。我认为可能有两种原因,一是字符串队列中积累了太多字符串,使得两条move字符串都排在很后面,然后轮到它们时很快地被TimeableOutput输出导致bug(不过这个原因可能性较小,因为TimeableOutput的处理速度应该远大于经常sleep的电梯);二是TimeableOutput的输出操作非原子性,即获得时间戳与打印这两个步骤没有封装成原子操作。在参考了讨论区同学的封装方法后,我封装了一个安全输出类,解决了这个问题。
五、测试与Hack策略
经过两个单元的学习,我深刻感受到测试的重要性。尽管我们都希望在架构设计时就能处理好各种边界情况,但是从设计到代码实现,其中的任何一个环节都可能出现意料之外的错误,只有测试才是打败各种bug的终极武器(个人观点)。
(一)测试——贯穿整个开发过程
这单元的作业使我对测试有了一个更深层次的认识:测试并不仅仅是当完成代码后,用一大堆数据狂轰滥炸自己的代码。测试贯穿着从架构设计开始的整个开发的过程。
在这单元作业中,我们可以自由选择电梯的调度捎带策略。因此,在特定的策略下,我们的电梯会对不同的情况做出不同的响应。关键是,我们怎么确保电梯实现了相应的策略呢?或者说,怎么保证电梯是按照我们期望的策略运行的呢(如在空载时接特定的请求)?这需要我们在设计阶段就开始考虑对程序的一些特定功能进行测试。当然,这并不意味着每完成一个函数就对其进行单元测试,这样将会耗费大量的时间和精力。我们应该在设计某些特定功能时,就构造出该特定功能的测试样例。这些测试样例具有非常强的针对性,将是我们验证特定程序功能的利器。例如,我在设计横向电梯的换乘时,就考虑了一次换乘、二次换乘、三次换乘、四次不换乘的情况,分别构造了非连通、一次换乘、二次换乘、三次换乘、四次换乘(不该实现)的测试样例,等到最终完成全部代码时,再利用这些针对性强的样例验证横向换乘这个特定功能。
(二)测试与面向对象——用面向对象写测评机
这单元的作业,生成测试数据并不难,难的是正确性判断,需要判断每一条标准,漏掉任何一条都可能产生致命的bug。
回顾三次作业自己搭建的测评机,可以说前两次的测评机架构拓展性极差。以至于几乎每次作业都要重构测评机,原因就在于前两次的测评机都是在检查输出字符串的基础上建立的。在搭建第三次的测评机时,我想到,既然学了面向对象,为什么不用面向对象的方法来搭建测评机呢?于是,第三次测评机中,我把输出的字符串解析成指令对象,再由电梯类和人员类去执行这些指令对象以判断正确性,实现真正意义上的模拟电梯运行的测评机(尽管是单线程的)。
在使用了面向对象的方法后,第三次测评机的架构相比前两次都清晰了很多,同时所能覆盖的测试范围也更广了,因为测评机的程序更加贴近于我们实现的电梯系统,而不再仅仅是“字符串检测机”了。
(三)测试策略——不要完全相信测评机
吸取第一单元的教训(完全依赖测评机),第二单元的测试我采用手动构造测试样例和自动测试相结合的测试策略。
手动测试主要包括上文提到的特定功能测试和边界情况测试。特定功能测试样例在设计时就构造好,用于最终验证特定功能是否正确实现。边界情况测试同样在设计过程中构造,用于判断某些边界条件是否正确(如非连通AB、CD则AD不能换乘等)。
自动测试便是利用测评机轰炸自己的代码。
其中,我通过自动测试发现了一个线程安全问题(上文提到的线程无法正常结束)。由于多线程的bug难以复现,这个问题也是跑了近百组数据才发现的,因此,我认为能够产生随机大量数据的自动测试依然是多线程测试中的重要工具(只要测试线程开的够多,就能提高bug的复现率)。
手动测试的样例虽然没有测出bug,但是使我能够清晰地把握每个特定功能的实现情况,依然是十分有价值的。
和第一单元相比,最大的不同在于第一单元我是完全依赖测评机测试,而第二单元则是边设计边构造测试样例,手动测试与自动测试相结合,这样使得手动构造的测试样例具有针对性,能够验证特定功能的正确性和各种边界情况,覆盖面也更广。
(四)Hack策略——黑盒测试
在互测中,当我尝试去读别人的代码时,我对自己的理解能力产生了巨大的怀疑,加上时间较紧,因此就放弃了白盒测试,直接用测评机进行黑盒测试。
hw5的互测中,hack到一个同学的输出线程安全问题。
hw6的互测中,hack到一个同学的横线电梯死循环问题。出现这个问题的关键在于捎带策略的边界条件没有判断好,属于功能性问题。
hw7的互测中,hack到一个同学的容器下标越界问题,极易触发,也许是因为交错代码版本?此外,还hack到一个线程无法正常结束的bug以及横向电梯可达性的bug。线程无法正常结束,可以通过多个测试线程自动测试进行复现,而横向电梯的可达性bug,依然可以通过设计相应功能时构造测试样例解决。(还有一个本地测出进出人员的bug,不过远程没有复现出来。。。)
通过分析几次互测中其他同学出现的bug,可以发现主要是功能性bug和线程安全bug,其中功能性bug占了多数。这再次提醒我,测试贯彻开发的全过程,在设计相应功能时就要构造相应功能的测试样例。
六、心得体会
通过这单元多线程的学习,我开始对多线程有了一个基本的认识,特别是线程安全问题的原因与解决方案。线程安全问题的关键在于多个线程对同一共享资源的操作并非原子性,即一个线程在操作共享资源时,可能被另一个线程干扰。而解决方案就是使用锁和同步块来将相关操作封装成原子操作。而死锁的问题,则可以通过共享资源的层次化调度来解决。
此外,在这单元中,我更深刻地感受到一个清晰的架构的重要性。在这单元的第一次作业中,我采用了看似比较繁琐的层次化结构。但是这种结构使得我后面两次作业只需要针对特定功能进行拓展,而不必修改整个层次架构。这使我认识到,有时候尽管一个简单的架构也可以解决当前问题,但是其可能不具有拓展性;相反,一个看似繁琐的架构,可能正是由于其具有良好的拓展性所以看起来繁琐,而我们在设计架构时,也要考虑未来的需求,力求架构的清晰性(比如清晰的层次结构)和拓展性。
关于测试,这单元中我对测试的认识更进了一步,即测试不仅仅是开发的最后一个阶段,而是贯彻整个开发过程的。从功能设计时,就可以构造对应功能的针对性测试样例和边界样例,以确保预期功能得到正确实现。此外,我们同样可以将面向对象的思想方法用于测评机的搭建中。
最后,是关于优化的一点体会。在第三次作业中,我尝试实现了横向换乘的策略和优化请求分配的策略(第二次作业我使用均匀分配的策略),但是强测的性能并不好。一开始我认为是强测数据偏好于基准思路,但是后来仔细思考,发现是自己在优化时过于关注局部的优化,忽略了整体的性能。这使我明白,在程序开发的过程,不应该纠结于某种特定情况下的最优解,而要放眼全局,在对全局影响最大的主要因素上下功夫。
这单元的电梯惊魂也算是比较幸运地安全度过了。过程中也得到了很多帮助。感谢助教up主战哥整理的资料、debug教程和耐心解答!