《2021面向对象设计与构造》第二单元电梯系统总结
一、概述
本次作业的需求是模拟一个多线程实时交互的电梯系统。相较于第一单元的求导系统而言,这次任务更具有实际场景的特征,需要考虑更多物理现实,不能随心所欲地造一些奇怪的东西。
第一单元三次重构,可谓痛不欲生,直到完成递归下降的设计才算有了可靠的迭代基础。这次电梯系统的设计笔者充分考虑可迭代空间,三次作业基本层层递进,真正做到了无重构增量开发,非常amazing啊。
但本单元作业的强测也因一些个人因素没有拿到全部90+,甚至整体得分不如第一次作业。主要在于没有很用心地作代码分析,一些完全不应存活的小bug在最后提交阶段都没有被修复。痛定思痛,一方面可以托罪于OS压力暴增,另一方面还是要怪自己摸起来了,有点浪过头了。
二、同步块与锁的设计
本次作业的核心知识点在于多线程编程,那么如何做到线程安全就是关键问题了。同步块和锁就是为了解决这个问题而被使用的。但要讨论同步块和锁,核心是共享对象。因为同步块是要取得锁的,而锁是加之于对象之上的。所以我们要先讨论共享对象的选取与设计。
-
生产者消费者模式
本单元的作业设计统一使用了生产者消费者模式。请求读取线程(main)作为生产者抛出需求,而电梯线程作为消费者接受需求并处理。但我们知道乘客的到来是不定时的,即需求的产生具有不确定性,而电梯也不能时时刻刻停下来去检查、接受抛来的请求。那么中间的缓冲区就应运而生。
生产者线程要写buffer(添加请求),而消费者既要读也要写buffer(接受请求并将其从buffer中移除,同时根据buffer的情况进行下一步动作)。数据竞争、线程安全问题也由此产生。
-
共享对象-第一次作业
第一次作业仅实现一部电梯。显然,我们需要设计一个Buffer来连接请求读取线程和那个呆呆的电梯线程。笔者选择了Hash Map。key为20个integer,对应着20个楼层,每一个key对应的value为一个能够存储Person Request 的List。
线程安全是如何实现的呢?笔者在第一次作业中并没有将Request Table做成线程安全类,而是在Request Receive 和Elevator中对Request Table具有写操作的几行代码设计为临界区,锁的是整个Hash map。其实不论是请求接收线程还是电梯线程,单独一次写的仅仅可能是某一楼层的队列,即某个integer key对应的List<Person Request>,那么我们实际需要的只是将正在写的这个List锁起来即可(但从另一个角度来思考,由于Hash map被封装在一个Request Table对象中,而锁是针对对象的概念,也就无法在这种设计下实现这一想法)。
-
共享对象-第二、三次作业
第二、三次作业要求实现多部电梯且可动态增加。后面调度器的设计一节会提到,笔者的电梯行为由其内嵌的决策系统根据目前电梯内部容器情况和Request Table情况,作出本电梯下一步行为的决策。如果仍然延续第一次作业使用一个Request Table对象的设计,多部电梯必然很大程度“共进退”,表现出混乱的无序竞争。这显然不是我们想要看到的局面(虽然性能可能在某种程度上有所提升,但这种行为并不符合现实逻辑)。
由此笔者改进了整体架构设计,新增了调度器、wait queue和每部电梯独有的Local Request Table。
从共享对象的角度分析,此时存在两组共享对象。一组是单个的Wait Queue,从请求读取线程接收请求并入队,同时Dispatch可以从队列中取请求;另一组为每个电梯的Local Request Table,虽说这个Hash map为电梯Local所有且读写,但实际上Dispatch会根据调度算法向不同电梯的Local Request Table派发请求,即进行写操作,那么这时候Local Request Tables就作为第二组共享对象存在。
线程安全是如何实现的呢?这次共享对象的读写调用很多,且分散在各个类中。如果像第一次作业那样为每一次调用都手动添加临界区,稍不留神就会丢掉一些,造成线程不安全。所以笔者将Wait Queue和Local Request Table均设计为线程安全类,在调用读写方法时自动加锁,保证了线程安全。
三、调度器的设计
调度算法是电梯系统性能的核心。笔者的调度系统分为两个部分:Elevator类的内嵌决策系统和调度器的请求派遣算法。换句话说,每个电梯下一步如何行为(开门or上行or下行or等待...),是由电梯根据梯内请求和Local Request Table的情况自行决定的,而外部新请求的派发是由Dispatch来决定。(比起调度器包打天下的架构,这样的设计电梯本身具有很强的主观能动性,各模块高内聚低耦合)
-
电梯内置行为决策系统
上图为电梯内嵌决策类的行为逻辑。该决策系统会根据内外情况给出一个字符串形式的命令(避免了一些奇怪的0/1/2/3...命令,代码可读性大大提高),而电梯本身每次只需要解析决策系统给出的命令并作出相应的行为即可。
决策系统的核心是look算法,一路到底/顶,捎带尽可能多的Request,保证了有限请求在尽可能在有限的步数内完成。
决策系统是一步到位的,第一次作业实现之后基本没有修改。
-
调度器的设计
调度器在笔者的设计中仅仅负责请求的派发,电梯具体行为的实现由其决策系统完成,所以调度器算法要简洁得多。与决策系统的一次到位不同,调度器是迭代开发的。
在第一次作业中,由于只有一台电梯,请求也只需要派发给一个Local Request Table,所以不存在什么派遣算法,笔者也就没有设计调度器类,而将Request直接从请求读取线程丢到电梯的Request Table中去。
从第二次作业开始,有了多部电梯的需求,如何派发Request变成一个关键问题。笔者设计了Dispatch类,且将其作为一个运行的线程。Dispatch线程从Wait Queue读取Request,经过分析抛给特定的一部电梯的Local Request Table。具体来说,派遣的原则是:可捎带+最小负载。
遍历电梯List,检查一部电梯是否能够捎带该Request,若能,则丢给该电梯,否则,检查下一个;若所有电梯均不可捎带,则将Request抛给目前任务量最小的电梯系统。
第三次作业增加了电梯类别,每部电梯的各项参数不同。笔者改进了调度算法,其派遣原则是:优先级+可直达+可捎带+超载判断+最小负载。
由于没(懒)有(得)设计换乘逻辑,笔者选择为三种电梯设计了优先级:C>B>A,并加入可直达判断。新的请求将依次检查C、B、A三类电梯,若可直达,则必在该类电梯内派发请求。在这类可直达的电梯中,依次检查每部电梯是否可捎带,若均不可捎带,则按最小负载分配。比起第二次作业的派遣算法,笔者加入了超载判断:即使Request可被捎带,若本电梯已预满载,则依然判定为不可捎带。
-
调度器与程序中的线程进行交互
调度器与程序中的其他线程进行交互的主要途径即是通过共享对象,而所有的写操作均只能针对共享对象。而具体的交互过程前文共享对象几节已有详细描述,下文中线程之间的协作关系一节亦将作解释,故此处不再赘述。
四、第三次作业可扩展性分析
上图展示了各线程之间的交互协作关系。
上图则是UML类图。
从两图出发进行可扩展性分析。首先可以看到,电梯具有基本类型Elevator,而后续添加的ElevatorB和ElevatorC则继承自Elevator类,那么如果又有新的电梯类别需求,可以在几乎不改动架构的前提下很容易地实现;另外,电梯内置的决策系统有默认的DecisionMaking类,还有特殊的Morning模式,其决策类DecisionMakingMoring继承自DecisionMaking类,如果有新的模式增加,显然也可以轻而易举地实现;增加电梯的需求通过ElevatorFactory实现,即工厂模式,做增量开发也非常方便。以上设计均使用到了多态。
五、Bug分析
-
自身程序bug
本单元作业没有出现致命的Bug,包括线程安全等问题都得到了较为妥善的处理。但是,由于笔者的个人态度问题,在前两次作业的最终提交版本中依然存在不应出现的一些小Bug/设计缺陷。
第一次作业时间戳初始化错误地放在线程start之后,导致时间戳初始化和电梯第一条状态信息输出的时序不可确定,最终在强测中掉了2个点。将时间戳初始化移到线程start之前即修复了该Bug。
第二次作业拓展开发了调度器算法,但由于个人疏(懒)忽(惰),在可捎带逻辑判断中未加入超载判定,导致在强测的8、9点中大量的Request堆积到某一部电梯系统中,最后超时错误。为调度器添加了两行超载判定逻辑即通过了测试点。
第三次作业强测AK,没有爆雷。但由于笔者没有设计换乘算法,在互测中被roommate使用极端数据hack,修复起来极为棘手,遂作罢。
-
死锁问题与线程有序死亡
作业提到要特别要注意死锁的分析。死锁的常见情况是多个线程拿到了不同的锁,又在临界区内存在共享对象的交叉访问,导致互相僵持、死锁。对于死锁问题,笔者认为如果合理地设计共享对象访问关系,且加锁时尽量减小粒度,只对必要的操作加锁,就可以避免死锁。笔者想着重讨论的是线程不死问题。
我们的线程多数在一个while(true)死循环中进行,何时跳出循环就是一个重要的问题。又由于测评机会限制CPU时间,故合理的作业设计都会加入wait-notify结构,而不是轮询。那么此时就会引入一个问题:当输入线程死亡后,如何保证其他线程有序死亡。一种很常见的Bug就是电梯线程不死而持续wait导致超时。
合理的做法是在共享对象中设置标记位,外层线程死亡时为共享对象添加标记;内层线程在处理完毕local request后,通过检查标志位来选择进入wait/自杀。同时需要注意的是,外层线程添加标记后还要唤醒内层正在wait的线程,使其进入上述检查标志位的逻辑中,从而保证所有线程有序终止生命。
-
性能与功能
本单元的测试重点除去线程安全问题外,在于性能。不同于第一次作业正确性方面屡屡爆雷,电梯系统的特点在于只要符合物理现实规范,均为正确,而上一单元求导系统的数据组织与输出都隐藏着非常多的正确性隐患。
对于性能,笔者认为不应选取非常极端的数据作为基准来优化算法。在实际测试过程中发现,一般情况下效率非常高的算法,在一些特殊情况中则显得十分愚蠢。可以这么说,这些数据往往非常具有针对性,而每种调度算法必然有其短板。如果我们为了种种极端数据不断地为调度算法打补丁,那么算法就会越来越臃肿,正确性和发量就岌岌可危了。而且可能会出现一些违反现实逻辑的决策行为,这是笔者不愿意看到的。
但Hack别人的时候当然就无所顾忌了。
本单元可以说收获良多。这是笔者第一次接触多线程程序设计,深入了解到了多线程的不确定性和线程安全问题,学习和实践了如何解决这些问题,深入理解了共享对象的选择、设计和实现,更加深入地体会到在动手coding前做好整体设计的重要性。
不同于第一次作业的多次重构,电梯系统完全做到了迭代开发。层次化系统由简单逐步走向复杂,但始终保持着宽广的扩展空间。
但这次也吃了一个教训,即如果态度不端正,没有做足细节测试和代码逻辑分析,即使初始设计得再合理也难以避免各种微小但又致命的Bug。