电梯的这三次作业是对并发编程的一次管窥,感觉收获还是蛮多的。在设计上有好的地方也有不足,这里简单回顾总结一下
设计总述
电梯这个问题由于比较贴近真实生活,所以需求还是很好理解的。总的来说,我的数据处理流程如下(第二次作业):
- 使用官方接口读入下一条请求,请求进入总调度器的消息队列
- 总调度器取出下一条请求并生成相应的任务,将其分配给一部电梯(输入该电梯的子调度器任务队列)
- 每部电梯执行当前任务队列中的第一个任务,若执行完毕(接人/卸人完毕)则将该任务出队,并开始执行下一条任务
这里的一条任务指“到某一层搭载/卸载某位乘客”。因此一条用户请求至少要分为两个任务执行(到起始层 - 接人 - 到目标层 - 放人)
进一步的,在第三次作业中涉及任务切分,一个用户请求可能要被切分成若干子请求(FROM-1-TO-20切分成FROM-1-TO-15和FROM-15-TO-1),因此任务的执行存在先后依赖关系。所以第三次作业在第二次的基础上,改动如下
- 分配任务前先将任务拆分至必要的粒度
- 电梯执行完一个阶段的任务后调用总调度器的回调方法,告知总调度器可以分配下一个阶段的任务。这样就实现了一个闭环控制
考虑到更换策略的可能性,总调度器中的任务分配器Distributor
和电梯子调度器ElevatorScheduler
分别抽象出了相应的接口。实际实现了对应Scan、Look、CS-Scan三种调度算法的子调度器和一个专门为第三次作业A电梯设计的子调度器;分配器则只实现了两种硬编码的分配策略,时间关系并没有进一步设计优化。
线程间采用的是非常经典的生产消费模型,使用毒丸处理输入终止,由于问题在并发性上并不复杂所以没有遇到特别印象深刻的并发安全性问题。
调度策略
仅讨论第三次作业。采用静态拆分请求的策略
分析三部电梯的可停靠楼层与运行速度,发现A电梯是最特殊的:-3层和16-20层只有A可以抵达,而A的速度也是三部电梯最高的。
因此直觉上认为:A是最可能成为性能瓶颈的电梯,因为它经常要在楼层两端往返。所以调度要解决的第一个问题就是,如何为A电梯划分任务。
我最终采用的策略是:将目的地是-3至-1层和目的地是16-20层的任务划分给A,其余任务由BC承担。这样A将专注于两端的任务,而又不至于过于空闲(地下层如果只把-3层划分给A,A会经常空闲)。这样A的定位就是一个较高速的“班车”。
B的泛用性是最高的,也因此B的可优化空间很高。但是由于这次没有时间实现请求的动态拆分与分配,所以直接把中间楼层所有偶数层的任务划分给B,同时把奇数层任务划分给C。
设立三个中转层:1层、9层与15层。
这个设计的性能未必很出彩,但也不至于太烂,而且易于实现,属于比较中庸的做法。
具体的调度算法,B和C电梯使用Look,而A电梯使用专门改进的Look算法,确保在15-20层上行时不装载前往低楼层的用户,在-1至-3层下行时不装载前往高楼层的用户,这样可以充分利用电梯容量。
架构设计与度量分析
这次的设计,将子调度器和任务分配器接口抽离。这样的设计主要利于优化时的算法替换,比如当我为A电梯重新设计了一个子调度器后,直接将新的构造器替代老的传入A的构造函数即可。
从复杂度来看,最复杂的部分是几种子调度器和电梯类,考虑到调度算法的确较为复杂,个人认为这个结果是可接受的。
输入、输出模块与总调度器采用单例模式,各线程间通讯采用生产消费模式。
SOLID自评
- 职责单一原则遵循的不好,比如这次的
Task
类同时兼顾送人和移动到某个楼层两种“指令”,虽然没有导致太过严重的设计问题,但味道实际上很坏。 - 开闭原则遵循的不错
- LSP遵循的不错(主要是这次继承关系太少了……)
- 依赖倒置似乎没有问题,架构是面向接口而不是面向实现的。
- 接口分离有些问题,这次的调度器类直接封装了一个抽象基类,其实更稳妥的做法是先抽离接口再封装基类
Bug分析与测试策略
本单元强侧/互测均没有遇到bug,也没有捕捉到他人的bug
自己遇到的一个印象深刻的bug是,手抖少删了一行lock.lock()
导致程序卡死,查了很久才看到。这个故事告诉我们,除非有特别明确的优化需求/功能需求,否则还是应该尽量用更优雅的synchronized
而不是Lock
(这次用Lock
的主要动机是找个机会练练手……)
测试有很多可以谈的地方,包括面向正确性的测试和面向性能的测试。可以说这单元的测试更考验功底了,首先定时投入就是一个很烦人的事情,黑箱测试的门槛一下子提升了很多。
抛开单元测试,这里重点讨论如何进行黑箱测试
定时投入
首先需要解决的是定时投入的问题。最容易想到的是,测试程序也借助多线程实现向输入流的定时投放。
我第一次作业的评测机就是这个原理,通过Python的subprocess
模块调用写好的java程序,利用管道写入输入并取出输出。使用time.sleep()
来控制写入的时间,确保两次写入间隔给定的时间差。因此输入流程就是
time.sleep(delay_time)
popen.stdin.write(data)
popen.stdin.flush()
这三步不停循环。最后从标准输出流里取出输出即可
但是这个做法存在一个问题,即jvm的启动时间也被计算在内,再加上python的sleep并不算很精准,导致实际误差较大。因此下一个思路是,直接修改两个官方IO接口,使其可以接受带时间参数的输入。借助java自带的管道流实现数据投放。最终效果还不错。
项目地址:https://github.com/Mistariano/buaaoo-elevator-test-suit
无论哪种方法,这一步最终达到的结果为:将定时投入转化为传统的输入输出形式,方便生成测试用例,将测试划归为传统形式。
Special Judge
这次的输出特点为:正确输出不唯一,但正确性判定可以用规则固化,因此期望输出-实际输出的正确性评判方式可以用一个SPJ替代。
正确性判定规则:
- 电梯每次移动距离为1(Arrive是连续的,不允许瞬移)
- 电梯移动速度不能超出限制
- 电梯不能到达无法到达的楼层
- 电梯开门时门必须是关闭状态
- 电梯关门时门必须是打开状态
- 电梯开关门速度不能超出限制
- 电梯装卸乘客时门必须是开启状态
- 电梯到达新楼层时门必须是关闭状态
- 电梯载客时乘客必须在当前楼层等待
- 电梯载客时乘客必须在电梯外
- 电梯卸客时乘客必须在电梯内
- 电梯载客时电梯内人数不能超出电梯容量(仅第三次作业)
- 程序结束后所有电梯门必须为关闭状态
- 程序结束后所有乘客必须抵达目的地
用这套规则应该能测出所有的功能性错误,同时还成功测出了室友的线程安全问题……
并发测试
由于这次作业每次运行时长在30~60秒左右,正常的单线程测试速度会较慢,又考虑到电梯运行时绝大部分时间线程处于休眠状态,并不是计算密集型任务,因此这次引入了并发测试。使用Python的multiprocessing
库实现。
心得体会
并发编程可以说是现代开发的基本功,而电梯这单元的很多内容实践的是并发编程的基本功。
回顾这三周,不少事情夹杂在一起,忙忙碌碌,导致很多之前想做的事情没有来得及实践,略有遗憾。比如完全可以自己封装一套程序,让电梯的运行过程可视化,辅助进行性能分析;比如完全可以使用一些传统算法(dp、搜索)或者一些更高级的手段(决策树、nn等)来优化调度策略——但是精力有限。
即使如此,在这单元的表现总的来说是自我满意的。相比于表达式的草草收尾,这个单元可以说是稳扎稳打了。希望接下来的课程可以继续保持,也期待接下来的更多收获。