BUAA_2022_OO第二单元总结
BUAA OO第二单元总结
O.写在前面
再次恭喜自己又度过了一个OO单元。虽然由于各种各样的失误,本单元的作业成绩不是很理想,但毕竟没有出现无效作业的情况,还是守住了底线,表明自己是有用心去做的。对成绩的影响不可避免,不过认真分析一下自己的开发经历,总归是能够有所收获的。
本文将从并发架构、调度方法、Bug分析与自动化测试、心得体会这四个方面来对第二单元的作业进行总结。
一、并发架构
为何要把并发方式、架构和调度方法分开来讲?它们难道不是都属于电梯架构的一部分吗?尽管如此,并发是本次作业的一个重要的主题,程序并发的安全性是程序运行正确的前提,体会并发的设计思想是本单元的核心内容;并且,调度方法属于电梯自身的"运行属性",是完全可以独立于并发架构之外且容易更换的。
训练的代码和实验课的代码给了我很大的启发,根据已有的并发模式进行设计的确能够省下不少力气。其中,在我的并发架构,线程协同的方法主要参考了生产者-消费者模式。
生产者-消费者模式简析
每当我想到生产者-消费者这几个字时,我会想起一个经典的小学数学题:游泳池里有水、水龙头和排水口,给出水龙头放水的速度和排水口排水的速度,问多久能将游泳池中的水排完?在这个例子中,水是需要排干的东西,相当于消费品;水龙头则相当于生产消费品的生产者,排水口则相当于消费消费品的消费者。这一个模型(可以)有以下的特点:
- 可以有多个水龙头(实际上,我们的作业中没有多个"水龙头")
- 可以有多个排水口
- 每一份水的"微元"能且仅能进入一个排水口
这个小例子里面的行为主体和我们的生产者-消费者线程协作模式中的行为主体够一一对应起来。就构成了一个简单的模型。这里的Producer和Consumer都是一个Thread即线程。实际上,我们可以复杂化这个模型,比如说,增加Producer、Consumer,即有多个线程一起访问共享资源。我们甚至可以让Producer和Consumer产生反馈,把一些东西送回Shared资源再处理(其实这已经有流水线模式的雏形了)。
协作模式大致如此,接下来我们来考虑一下锁的使用。我们知道,产生线程不安全问题的原因是多个线程同时对资源进行了读写操作,他们的并发性和非原子性导致了代码执行结果的不确定性。我们还要知道我们在本次作业中频繁使用的锁synchronized的特性,在本次作业中,主要使用它的两个特性:
- 构造方法同步块,这会给拥有该方法的对象"上锁"
- 直接对某个对象"上锁",在同步块中这个对象是被同步访问的
那么对于生产者-消费者协作模式来说,保证作为共享资源的实例化对象的同步访问是实现线程安全的关键步骤。既然我们知道线程不安全问题的根源所在,也有同步共享资源的方法,那么对共享类中的方法进行大规模的上锁会是一个不错的选择,这会避免在其他类中零散地使用同步块的现象的出现,不仅能使得代码更加美观,还可以有效预防死锁现象的发生。
当然,我们不可能把所有的方法都写在共享资源类中,有些方法写在外部的线程类中还是更加符合面向对象和层次化设计的思维方法。这些方法中可能需要用到第二种上锁的方法,在使用这种上锁的方法时,我们千万要明确谁是需要被锁的对象。要知道,总会有一些考虑不周的地方,比如对ArrayList上锁其内容仍可能线程不安全、类中的静态属性需要使用类级别的锁。不清楚上锁对象和上锁对象的特性,就很容易产生误操作。
生产者-消费者模式大致是建立在上面所述的这一模式上的。不过,接下来我们来考虑这么一件事:倘若泳池没水了该怎么办?在现实生活中,我们可以把排水口开着;但是在计算机的环境下,CPU会在"排水口"处不断地询问是否发生请求。这是一种非常浪费性能的行为,我们一般称之为轮询。CPU完全可以把这些空转的时间用于计算别的内容。为了避免CPU做无意义的轮询,JML提供了一些手段。具体到作业中代码实现的环节,有几个比较重要的方法需要搭配起来使用:
run()
,用于处理资源,是进程运行的主方法wait()
,为一个对象所拥有,在某一个进程中调用该方法,可以使某一个进程进入等待状态notifyAll()
,也为一个对象所拥有,可以由于这个对象产生等待的所有进程
在run()
方法中,"排水口"不断地拿取"水微元"进行处理;一旦"水池"中没有水了,"排水口"就陷入等待状态(通过调用"水池"的wait()
方法);一旦"水龙头"往水池中注水,"水龙头"变通过"水池"唤醒排水口,即调用notifyAll()
方法,使它重新开始处理数据。这是理想的协作模式;同时我们也发现,作为共享资源的"排水口",也起到了某种通信的作用。
下面结合作业架构来深度体会线程协作模式的特点和锁的使用。
Homework5
本次作业要求在五个楼座实现五个纵向电梯,模拟电梯的运行和乘客的接送。
把每个类和每个方法都列出有一些不必要,故而在类图中仅列出一些重要的属性和方法。
上图中类与类之间的关系比较明确。很明显,在本次作业中,LookElevator
是消费者类,我也把它叫做电梯类,模拟了一个电梯,它可以模拟电梯竖直移动、模拟接送乘客;RequestAccept
就是生产者类,也叫读入类,用于分析分派乘客请求。它们共同拥有一个或多个PassengerList
的实例对象(电梯目前只有一个对象,RequestAccept
类有多个),每一个对象都装有其对应的楼座中,处于等待队列的乘客,还有一系列的同步方法,供电梯线程和读入线程安全地访问共享资源。这次的代码架构中,有一些逻辑比较重要:
- 消费者-生产者模式的应用。电梯拿取请求时使用
PassengerList
类提供的线程安全的方法,读入线程分派请求的时候也使用该类提供的线程安全方法,不擅自使用别的方法修改该类的实例化对象。 LookElevator
类对Elevator
类的继承。写作业之前,参考过往届学长的博客,看到有的学长使用电梯类实现了接口。我本来想借鉴地使用,经过一些思考,还是选择了"电梯模版,策略继承"的方法。展开来说,就是将电梯的普遍属性和方法写入Elevator
类,而将策略及电梯运动方法写入其继承类中——即我们可以用一个简单的电梯模版,构造许多种不同的电梯。这不仅提高了代码的复用率,还便于我们构造不同策略的电梯。Elevator
类中的关键信息包括:电梯能到达的楼层、电梯所处楼层和楼座、电梯载客量、梯内乘客信息、开关门方法、乘客进出方法等。LookElevator
类则包含了电梯的调度策略,并在run()
方法中使用其本身和父类的方法实现电梯的模拟。- 线程结束的条件。根据要求,我们的程序需要在将所有乘客都送达目的地后才能结束。读入线程结束的条件很简单,即读文件请求值EOF。但电梯线程不能同步结束(有的乘客尚未到达目的地),需要设置额外的结束条件。在此处我采用了"内部判断+协作线程标记"的方法,两个条件同时打成才会跳出循环。不过,这个方法以后还需要升级。
- 输出线程安全。本次作业要求使用官方包进行时间戳的输出,但是官方包是线程不安全的,故而需要新建一个
Output
类来确保线程输出安全。这个类只有一个静态的加锁的println()
方法,不再列出。
本次作业中,对于每一座楼的请求,只存在一个电梯和一个读入类对它进行操作,不会产生额外的数据竞争,线程安全的问题不难处理。
Homework6
第六次作业相比第五次作业,增加了横向移动的电梯,还增加了三类新的请求:增加横向电梯的请求和增加纵向电梯的请求,横向移动的乘客请求。本次作业有两种新的情景:一部或多部纵向电梯在同一楼座处理纵向请求、一部或多部横向电梯在同一楼层处理横向请求。由于电梯数量的增加,除了为单部电梯设计调度策略外,还要考虑多部电梯之间的策略分配。不过,在本部分我们不关心策略,主要还是关心类的架构。很明显,沿用上一次的架构,增加电梯种类并不是难事;为了应对横向的请求,额外增加了存储该类请求的类,让横向电梯线程和读入线程共同管理之。
另外,官方提供的PersonRequest
类功能有些单一,我在本次作业中使用Passenger
对这个类进行了继承,继承类有更加丰富的标记和更加便于操作的方法。
可见,即便类变得更复杂、电梯的种类变得更多样,生产者-消费者的基本设计思路依然没有改变。我们需要考虑的就是线程安全的问题。多个电梯争抢同一份共享资源会发生什么事情?线程能否正确结束?有了上一次作业的基础,在本次作业应该不会大规模出现这类问题。
Homework7
第七次作业和第六次作业相比,新增了"电梯可定制"的功能和"斜向乘客"的请求。前两次作业的架构能够轻松应对第一个迭代请求——我们在Elevator
类中存放了一大堆电梯的属性,不论是横向电梯还是纵向电梯都要按照这些属性的限制进行运动。对于定制电梯的请求,我们只需要解析请求后调用各个属性的set
方法就可以进行修改。
第二个请求比较有意思。在前两次作业中,我们的请求只有两类:横向和纵向。为了应对它们,我们相应地构造了横向和纵向的电梯。所谓的"斜向请求",无非就是横向和纵向请求的叠加,故而我们判断出并不需要通过增加新种类的电梯来应付这类请求。那么,如何更好的地使用先用的电梯来应对新的请求呢?在介绍架构前,我们先来介绍一下之前提到过的"流水线模式"。
流水线模式
我认为这一模式并不是一类独特的线程协作模式,而是"生产者-消费者"模式的派生出的模式。基于已有的生产者-消费者框架,我们可以画出如下的流程图。
W代表了一系列的工序。根据共享资源的特征,一系列的工序线程会进入共享资源区寻找可以处理的工件,将其"加工"后返回到共享资源区中,由其它的线程继续处理。这是一种很贴近现实并且很具有启发性的线程协作模式,结合我们已经熟练使用的生产者消费者模型,我们很容易一步一步建立起流水线的乘客接送模式。
每一道工序处理的过程都是一次"生产者-消费者"模式行为的复现过程,只要运用好之前准备的线程安全的方法,基本不会产生线程安全的问题。本次作业的线程协作模式颇有通信的味道。这与操作系统、网络通信的一些内容不谋而合。我们只能感叹,优秀的知识,在被抽象以后都是如此的相似。
现在我们回到Homework7的架构分析。我们刚才提到过,用已有的类和架构就可以完成运送"斜向请求"的乘客,只需要将当前的模型改进为"流水线模式"即可。
当然,在理论上请求的确可以送达目的地,但既然发生了模式的切换,我们就应当考虑一下线程安全的问题。流水线模式有一处和生产者-消费者模式区别很大的地方:一个流水线上的"工人"需要完成的工序可能不仅仅来自"老板",还可能来自别的"工人"。换言之,考虑这么一种情况:一个乘客到达目的地,需要先乘坐A电梯,再乘坐B电梯。然而,他仍在A电梯内时,读入线程结束了,并且B电梯也已经把他可以直接拿取到的请求都处理完了。根据之前的线程结束方法,此时B电梯的线程就应该结束了,乘客从A电梯出门的时候,就会发现没有电梯可以乘坐。
为了解决这个问题,在本次作业中新增了一个Counter
类,用于记录未到达目的地的乘客数量。它有一个counter
方法用于模拟乘客栈,同时提供了用于修改其值的线程安全的方法。ReadRequest
读到文件末尾时,该线程并不会立即结束,而是会进入一个while
循环,不断地访问counter
,倘若counter
为0,就跳出循环,执行一个用于结束全部线程的函数;若counter
不为0,则使用Counter
类提供的counterWait()
方法进入等待状态,防止CPU轮询。电梯线程每次送达乘客并且修改counter
的值的时候,都会调用Counter
类的notifyAll()
方法。这样一来,就可以使得程序按照正确的逻辑执行,并且顺利结束了。经过这一次修改,我们可以体会到,线程之间的协作关系更加紧密了,而且线程之间的"通信"的行为变得更加双向且频繁。
小结
本部分对并发的架构做了介绍。虽然描述起来并不复杂,但是在实际实现的过程中还是好谨慎考虑的——当然,有方法论的指导,总归来讲还是能够建立正确的架构的。我们可以感受到,每更新一次作业,并发的线程数都变得更多、线程合作关系都变得更复杂、线程协作模式都变得更先进、线程通信都变得更加频繁。这应该也是课程组设计的训练模式,希望我们能够从由易到难、由简到繁的过程中更深刻地体会并发与多线程的设计。
二、调度策略
前面提到过,电梯策略和并发架构实际上是本次作业的两个不同的范畴。同学们在完成作业时,可以展开针对策略的"大讨论"。在网上搜寻策略的同时,我也发现电梯的策略和我们的一些专业课知识非常相似——许多策略可以搬用磁盘调度的策略。在第七次作业你甚至可是采用图算法对乘客调度进行动态规划。不过,调度策略总的来讲只能是"当下的较佳选择",而不能是"预测未来的工具"。我个人认为,在随机数据的轰炸下,针对"某某情况"专门修改策略是无效的(当然,在现实生活中可以根据统计规律修改调度策略);对于某些情况,可能任何调度策略都不突出,这也是会发生的。故而在本部分,我只客观地介绍调度策略;至于策略的选择,我希望留给以后可能看到这篇博客的同学一些思考的空间。
由于三次作业中电梯的策略相似度很高,在此就不按照作业次序分开介绍了。
1.纵向电梯策略
在三次作业中,我的单部纵向电梯采用了LOOK策略;多部电梯采用了"自由竞争"策略。本小节的电梯统一指的是纵向电梯。
单部策略解读
LOOK策略源自一种磁盘调度算法。磁头初始状态下朝着某个方向移动,倘若这个方向上还有请求,还是不断移动直至当前方向上不再存在请求,并且转向。
策略的描述很简单,但是在电梯调度中,并不能照搬该策略。显然,一个乘客在电梯内外都会产生请求,并不是将乘客接上电梯乘客的请求就终止了。在这里,我对请求进行了优先级的划分:
- 电梯内有乘客,则电梯将梯内乘客的目的地楼层作为请求队列,并按照LOOK策略移动
- 电梯内没有乘客,则按照电梯所处楼座的请求队列为主请求队列,并按照LOOK策略移动
同时,我也设计了两种捎带策略,在满足基本条件(电梯不满、电梯与乘客在同一位置):
- 乘客的请求方向和电梯当前运动方向相同才能可被捎带
- 只要遇到乘客,就捎带
- 电梯运行时,只要有乘客达到目的地便开门
我在第五次作业中采用了前一种捎带策略(这和我们日常生活习惯相同);在第六次和第七次作业中采用了第二种捎带策略。前一种捎带策略性能尚可,总体上看中等偏上;第六次作业因为强测失足,故而没能观察出策略的优劣;第七次作业,居然超时了三个测试点,改成前一种策略才能通过,但其他的测试点表现也还可以。总体上来讲,单纯的LOOK策略属于一种中规中矩的策略,毕竟大家都采用这个策略。从数学的角度分析,前一种册类的优点在于"被捎带者都可达",后一种策略的优点在于"电梯载客率高"。
并发电梯策略解读
第六次作业和第七次作业都存在电梯并发的情况,即同一楼座可能有多部电梯在运行。在第六次作业中,我采用了"自由竞争"的策略,即各个电梯线程单纯依靠访问速度来接送乘客,谁"抢"到请求就归谁。这是一种懒人策略,因为什么都不用写。但其实在本次作业中,分派调度器才是一个比较好的选择——因为在乘客较少的时候,若采用自由竞争的策略,那么相当于只有一部电梯在工作,这是很糟糕的。调度器可以解决这个问题。
采用何种调度策略为佳?经过讨论,我认为以下这一方案有很大的优点:
- 继承官方包的
PersonRequest
类,添加一个应答该请求电梯id
的int
属性 - 乘客请求加入请求队列。在每次电梯移动前,都对乘客队列进行遍历。对于每一位乘客,将其
id
属性标记为距离该乘客楼层最少的电梯;如果几部电梯距离该乘客距离相同,则标记为其中载客最少的电梯(多个满足条件的取第一个) - 电梯在按照LOOK策略运动时,只能"看到"
id
标记和自己相同的乘客请求
这种调度策略在静态策略中应该是表好的。它有效地释放了电梯的并发资源,使得不同的电梯可以处理不同的工作,而不会产生无效的移动。当然,在这种策略下,电梯的线程结束条件、遍历方法还需要再进行微调。
第七次作业中,产生了运行速度不同的电梯。这个时候,调度器的收益就没有那么明显了。尽管调度可以提升性能,但由于快的电梯实在是太快了,使得自由竞争的情况变得复杂了起来,慢速电梯的无效移动可能被弥补了。在如此复杂的情况下,调度器不仅不会带来明显的性能提升,还可能会拖慢速度、甚至产生bug,收益可能很小甚至为负,故而在本次作业中采用自由竞争策略是一种收益较大的策略。
2.横向电梯策略
第六次作业和第七次作业中,出现了横向电梯。我在这两次作业中都采用了同一种横向电梯策略。
- 电梯内有乘客,则取第一个乘客的请求为主请求。由于横向电梯的运动轨道是一个五边形,运动方向直接取最短路径所在的方向(方向分为顺时针和逆时针)
- 电梯内无乘客,则取外部队列的第一个乘客为主请求。运动方向的选择同上。
- 捎带策略则是"全部带走",即只要电梯可以容纳乘客,乘客就可被捎带
- 只要有乘客到达目的地,就开门
这一捎带策略和基准策略比较类似,但由于捎带条件比较宽松,电梯载客率更高,故而其性会更好。
3.其他优化
- 在研讨课上,有的同学提出可以按照一定的顺序对乘客列表进行排序(出发楼层、目的楼层等),这样能够提高电梯载客率。经过讨论,很多同学也认为提高载客率是提升性能的好方法。
- 先下后上是共识。和日常生活常识一样,作业中的电梯在先下后上时载客率可以得到提高
- 利用开关门的空闲时间。输出开门信息后,先令乘客下电梯,然后等待0.4秒的开关门完成后,再接乘客上电梯,这样可以争取到0.4秒的输入请求的时间,或许能够提高性能。
4.小结
性能在作业中占的比例不大,但是还是要确保基本的性能要求。由于没有达到性能要求,我在第七次作业中超时了三个测试点,故而性能部分也不能"摆烂"。不过,我认为"极致的优化"也是没有必要的。在大量的随机数据的轰炸下,只要策略符合普遍共识(即策略是公认的合理方法),那么就可以放心提交。
三、Bug分析与自动化测试
在本部分,将介绍我作业中的Bug、分析Hack思路和介绍自动化测试工具。
1.作业中的Bug分析
第五次作业
第五次作业总体安全,但出现了输出线程不安全的Bug,导致在互测中被Hack了很多次。
第六次作业
第六次作业没有进入强测寄!,原因是为了进行上述优化中的第三点优化,开关门时进行了标记,但标记的方法出错,导致有的乘客没有进入电梯。取消这个标记后程序并没有发生问题。
第七次作业
第七次作业没有被发现Bug,但是在强测中有三个测试点超时。修改捎带策略后(删除了一个方法),即可解决此Bug。
在互测中没有被发现Bug。
2.互测
本单元的互测中,我在第七次作业成功Hack两次。互测的基本思路是在同一时间投放大量的数据,同时新增电梯。
3.自动化测试
在第六次作业中,由于强测失足,故而在闲余的时间内用python完成了一个自动评测机。该评测机结合命令行使用,从输入输出文件读入内容,并判断正确性。在分析输入输出文本后,我们会发现文本非常好解析,乘客的序号、出发终点楼座,电梯的载客量、开关门信息等。将这些文本加以比对,结合python中字典、序列等灵活的数据结构,就可以方便地进行自动化测试。本人的自动化测试包括以下的功能:
- 检查时间戳是否递增
- 检查电梯是否超载
- 检查乘客出入电梯逻辑是否正确,如乘客是否入梯后再出梯、是否在开关门时间内进出
- 检查所有乘客是否成功到达目的地
结合史泽宇同学提供的数据生成器和官方投喂包,可以对自己的电梯程序进行长期的、大量的随机测试,保证程序的正确性。缺点是无法测试程序的性能是否在课程组要求之内。
四、心得体会
总体来讲,电梯单元的难度比上一单元难度下降了一些;也有可能是在经历了第一单元的"折磨"后,我在开发时对程序的可迭代性加以关注,使得开发变得轻松了一些。尽管难度有所下降,但是得分情况反而比较差。反思了一下,应当早一点尝试搭建自动评测机,就算不能避免性能问题,至少可以保证正确性。还是应了那句话,"有事没事,多做测试"。在做完本单元的作业后,我个人感觉,自己对多线程和并发有了更好地理解,也能够将相关的基础知识运用到别的课程当中(如操作系统),对相关内容的理解更上层楼。
另外,我也有关注这几年的电梯作业。基准的训练内容没发生太大的变化,不过复杂度的提升稍微有点快。在感谢各位助教的辛勤付出的同时。希望课程组可以把握好作业的复杂度,让同学们能够将注意力更多的集中在"体会多线程与并发"上。