BUAA_OO_2022 Unit2 总结
单元总览
本单元的主题是多线程,基于真实的电梯调度场景,学习了基于线程、共享、交互的面向并发和协同抽象的层次设计结构,重点关注并发行为的安全和效率。
同步块与锁
锁的机制
通过课程的学习和课下的阅读,我了解到synchronized为JVM实现的较为简单的锁,在线程数量不多时有较好的性能,实现简单,不易出错。条件锁和读写锁需要自己实现,有更高的灵活性,但也意味着更容易出错。
我在课下尝试了读写锁,发现性能较synchronized并没有提升,于是最终提交的版本换成了出错率更低的synchronized锁。
加锁的方式与细粒度
synchronized可以对方法或语句块加锁产生同步块。对方法加锁可以保证任意时刻只允许一个线程调用该方法,代码实现简单,但缺点是加锁的范围容易较为粗糙;相较而言,对语句段加锁可以精准控制加锁的范围,避免同步块过于臃肿。
在实践中,我发现加锁的粒度也可以影响调度策略。在让乘客进入电梯的方法requestIn中,我一开始是对“获取一个符合要求的乘客”这个方法加锁,电梯每进一个人就需要再去竞争这个锁,从而导致多部电梯自由竞争时不同的电梯交错进人,具有随机性。
后来我尝试了对整个进人的语句段加锁,保证一部电梯在尽可能同向捎带后再把锁让给下一部电梯,使下一部电梯更有可能去另一个方向载客,提高调度效率。但是,后一种加锁的方法不符合临界区最小化的原则,将一些不需要访问monitor中的不可变数据的代码也加入了临界区,导致在更多的测试数据中会略慢于第一种方法。最终我选择了第一种加锁方法。
调度器与线程交互
在我的设计中,调度器的功能是将输入的请求分派给对应的楼座或楼层的请求队列。
输入线程与调度器线程通过一个共享的请求队列进行交互,调度器线程与电梯线程通过楼座或楼层的请求队列作为共享对象进行交互,构成了两级生产者消费者模型。
在线程结束时,结束信号也是通过输入线程经过调度器线程传递到电梯线程的。
在第七次作业中,由于可能出现换乘,电梯线程也能作为生产者,通过请求队列与调度器线程交互,此时调度器线程的角色转变为消费者。
架构模式分析
第5次作业
共有InputThread、Controller、Elevator三个Thread类,InputThread与Controller、Controller与Elevator之间均通过RequestQueue类进行交互。
为了避免输出的时间戳乱序,我将官方输出包包装成一个加锁的对象,由所有Elevator线程共享。
在线程协作方面,分为创建线程、处理请求、结束线程三个阶段。所有线程均由主线程创建,请求的数据流由输入线程经过调度器线程单向流向电梯线程,结束信号也是如此。
第6次作业
新增了横向电梯类。由于我纵向电梯是由目标楼层targetFloor驱动运行的,但横向电梯是由内置的方向属性dir驱动运行的,二者实现逻辑有一定差异,故将横向电梯单独建了一个类。
但写完后发现,二者确实也有很多共同的方法和属性,或许更好的方式是将原本的继承Thread类改为实现Runnable接口,然后在电梯类中只实现基本的电梯方法,在横向和纵向电梯中各自实现具体的run方法。
此外,在实验中我学习了单例模式,于是我将上一次作业的OutputQueue修改为单例模式,避免了 OutputQueue 作为参数传递到各个电梯中,减少了类之间的耦合,提高了代码的鲁棒性。
第6次作业新增了创建电梯请求,相比第5次作业,输入线程也能创建电梯线程。
第7次作业
第7次作业有3个关键点,分别是线程结束条件、换乘策略与请求拆分方式。
线程结束条件
由于每个电梯都可能作为输入线程,我仿照实验的代码,采用了黑板模式,用一个RequestCounter来维护当前有多少个完成了的请求,每当电梯线程将一个请求送达最终目的地后,就将将计数器加1,只有当输入线程结束且所有请求都已经完成时,才会将调度器标志为End。
换乘策略
换乘策略我采用了静态拆分方式,在请求输入时就进行拆分。拆分的策略在基准策略上做了一定优化,即优先将换乘楼层设定为出发楼层或达到楼层,从而尽可能地减少换乘的段数。
请求拆分方式
我在Person类里新增了一个ArrayList链表,链表中存储了这个请求被拆分后的状态。当乘客出电梯时,会判断这个链表是否为空,若为空则表明已到达目的地,将RequestCounter加1;否则表明这个请求仍需换乘,将链表头的请求通过WaitQueue插回到调度器中。
与第6次作业相比,第7次作业增加了流水线架构,电梯线程也可以反过来作为调度器线程的输入。新增了电梯线程和输入线程的共享对象RequestCounter用于判断所有请求是否都被满足,只有当输入线程结束且所有请求都被满足后才开始传递结束信号。
架构总结
由于在最开始就考虑到了后续可能的横向电梯、自定义电梯即换乘需求,我在最初设计时就留下了足够的接口,便于后续的迭代开发,避免了重构。
bug分析
自己的bug
在三次公测和互测中均未出现bug;
别人的bug
第5次作业中,有同学输出未加锁,导致输出时间戳可能乱序;有同学的电梯可能会超载;
第6次作业中,有同学的调度策略有问题,导致电梯在某种特殊情况下会在两个请求之间来回打转却一个也接不上,最终死循环;
第7次作业中,有同学横向电梯判断wait的条件仍然是请求队列为空,没考虑电梯可达楼座,导致轮询。
测试策略
基本思路是采用随机数据生成脚本+评测机+自动测试脚本
数据生成支持多种模式,便于不同的测试需求。可以选择完全随机的数据用于正确性检验,也可以选择请求集中的数据用于策略比较。为了分别研究横向和纵向电梯的调度策略,可以限制仅生成一个方向的请求。
评测机检查电梯行为是否符合逻辑,请求是否正确地被满足。
自动测试脚本支持自动大批量测试和检查,并将运行结果输出成表格,便于统计分析。
此外,为了检查是否轮询,在自动测试时可以打卡电脑的任务管理器,检查CPU利用率是否在正常范围内。
总结下来,与一单元相比,本单元数据生成更加简单,评测机难度略有上升,最值得注意的是多线程的线程安全问题。轮询的问题最容易被测评机忽略,因此需要格外注意。
心得体会
线程安全
线程安全的目标是避免线程之间执行顺序的不确定性,即无论多个线程以什么次序访问,都不影响该对象的行为结果。
线程的直接交互容易产生线程安全问题,为了避免线程直接交互,我通过共享数据访问控制来隔离线程的依赖关系。
为了解决多个线程对共享对象的读写次序不确定导致的数据竞争,我通过对共享对象加锁产生临界区,保证对共享对象操作的原子性。
关于线程安全的另一个问题是死锁与轮询。
为了避免死锁,我有意识地回避多个线程对共享对象的循环引用,防止两个循环依赖的monitor产生死锁。
为了避免轮询,我合理设置wait的条件,且只在必要时notifyAll。
层次化设计
层次化的设计就像一个树形结构,最顶层由电梯、调度器和共享队列等类组成,每个线程类中又由run方法作为顶层调用者,往下分成开关门、上下乘客等具体的事件方法,最后再细化到查询电梯是否满员等末梢方法。
层次化设计的好处是提高了架构的灵活性和扩展能力。例如将调度策略单独抽象成一个类之后,电梯想更换调度策略仅需更换其树形结构中的策略类节点即可,其他方法和属性完全不变。
SOLID原则