2022面向对象第二单元总结
目录
第五次作业
UML 类关系图
UML 类协作图
架构分析
- 生产者-消费者模式
- 第一级:
RequestInput -> RequestQueue -> Dispatcher
- 用于输入线程和分派器线程之间的交互
- 第二级:
Dispatcher -> RequestTable -> Elevator
- 用于分派器线程和电梯线程之间的交互
Dispathcher
中有五个RequestTable
对象,对应五个楼座,会把接受到的请求按楼座分派给不同的请求表RequestTable
中有十个RequestQueue
对象,对应十个楼层,电梯根据所在楼层获得对应的请求队列,从中取出请求
- 第一级:
- \(LOOK\) 策略
- 如果电梯内有乘客,则直接按原方向运行
- 接人条件:请求方向和电梯方向相同
- 转向条件:当前运行方向上没有请求,且反方向(要包括当前楼层,否则会漏接)有请求
- 线程等待条件:整个楼座均没有请求时,电梯主动
wait
,让出资源
- 量子电梯
- 对于开关门和移动,我们只需关心等待的时间是否足够,一旦足够,便可以瞬间完成动作
- 以开关门为例,大致做法如下:
- 每次开门时记录下当前时间戳 (利用官方包的
println
的返回值),然后wait(400)
- 如果当前楼座有新的请求,则
Dispatcher
会唤醒电梯,电梯尝试接人 - 用
System.currentTimeMillis()
获得当前时间戳,如果时间差delta >= 400
,则瞬间关门 - 否则再次等待
wait(400 - delta)
- 每次开门时记录下当前时间戳 (利用官方包的
- 优点:在保证总等待时间最短的情况下,尽可能地多接人
同步块的设置和锁的选择
-
RequestQueue
设置为线程安全类,利用synchronized
同步了put
、get
、getAll
、isEmpty
等方法 -
利用
static synchronized
包装了线程不安全的官方输出方法public static synchronized long println(String s) { return TimableOutput.println(s); }
-
在电梯休眠和唤醒电梯时,利用
synchronized
获得电梯对象的锁 -
由于
synchronized
已经能满足需求,所以没有利用ReentrantLock
、ReentrantReadWriteLock
等显式加锁
调度器设计
Dispatcher
作为伪调度器- 管理候乘表,将不同请求分派到不同楼座不同楼层
- 负责管理电梯,并在新请求到来时唤醒相应电梯
第六次作业
UML 类关系图
UML 类协作图
架构分析
- 沿用了第五次作业的二级生产者 - 消费者模式,纵向 \(LOOK\) 策略,量子电梯
- 横向电梯 \(LOOK\) 策略
- 如果电梯内有乘客,则直接按原方向运行
- 接人条件:请求方向和电梯方向相同,请求方向为使距离更短所走的方向
- 转向条件:
- 如果当前位置有乘客未接,则直接转向(否则会导致电梯一直转圈)
- 如果前方两个楼座没有请求,且后方两个楼座有请求,则转向
- 线程等待条件:整个楼层均没有请求
- 调度策略:自由竞争
- 优化方案:
- 优先接和电梯内乘客相同目的地的乘客
- 减少电梯开门次数,从而减少等待时间
- 经过测试,对于各类型数据性能均有所提升
- 优先接距离远的请求
- 先完成时间长的请求,减少电梯总运行时间
- 经过测试,相比于先来先服务,对于相同出发地的聚集性数据,有较大的性能提升
- 优先接和电梯内乘客相同目的地的乘客
同步块的设置和锁的选择
- 相较于上次作业,设置了两个线程安全类
RequestQueue
和PersonQueue
,用synchronized
同步了put
、get
等方法RequestQueue
用于RequestInput
和Dispatcher
间进行交互,可存储电梯请求和乘客请求PersonQueue
作为PersonTable
候乘表类的组成部分,在Dispatcher
和Elevator
间进行交互,只存储了乘客请求
- 仍没有使用
ReentrantLock
、ReentrantReadWriteLock
等锁
调度器设计
Dispatcher
作为伪调度器- 管理候乘表,将不同请求分派到不同楼座不同楼层,并且分开存储纵向请求和横向请求
- 负责增加、管理电梯,并在新请求到来时唤醒相应电梯
- 对于多电梯的调度,在阅读了大量学长的博客,权衡了性能和实现难度之后,最终采用自由竞争策略
第七次作业
UML 类关系图
UML 类协作图
架构分析
- 沿用了第六次作业的二级生产者 - 消费者模式,纵向、横向 \(LOOK\) 策略,量子电梯以及优化方案
- 多个生产者对一个消费者
Elevator, RequestInput -> PersonQueue -> Dispatcher
- 当乘客下电梯时,如果此时未到达终点,则重新扔进乘客队列
- 调度策略:自由竞争
- 路线规划:
- 基本策略
- 由于距离、电梯数量、电梯运行速度等多个因素的影响,不好计算路径的权值,因此并未选择最短路算法
- 考虑到乘客上下以及等电梯的时间较长,因此遵循尽可能少换乘的策略,保证最多两次换乘
- 在换乘次数相同的情况下,寻找距离最短的路径,且横向移动优先
- 综合上面两点,对任意出发地和目的地,最多可能有十种不同的路线,按优先级依次判断这些路线是否可行即可
- 贪心算法
- 每次只规划出下一个最优目的地,而不是完整路线
- 能很好地适应动态规划
- 实时动态规划
- 在乘客被电梯接上之前,可随时更新路线
- 每当新增电梯时,更新候乘表中所有乘客的路线
- 基本策略
同步块的设置和锁的选择
- 设置了两个线程安全类
PersonQueue
和PersonSet
,用synchronized
同步了put
、get
等方法PersonQueue
作为一级托盘,用于RequestInput
、Elevator
向Dispatcher
投喂乘客请求PersonSet
作为二级托盘PersonTable
候乘表类的组成部分,在Dispatcher
和Elevator
间进行交互
- 利用了
synchronized
可重入的特性,将电梯确定方向以及休眠这一段代码用synchronized
加锁,这在 bug 分析里会详细介绍 - 仍没有使用
ReentrantLock
、ReentrantReadWriteLock
等锁
调度器设计
Dispatcher
作为伪调度器- 管理候乘表,将不同请求分派到不同楼座不同楼层,并且分开存储纵向请求和横向请求
- 负责增加、管理电梯,并在新请求到来时唤醒相应电梯
- 规划乘客路线,并在新增电梯时更新路线
- 对于多电梯的调度仍采用自由竞争策略
数据构造
概述
- 前两次作业的测试数据基本上为第七次作业的子集,因此只介绍最后一次作业的测试数据
- 数据总共分为五种模式,每种模式分为五种主题,每个主题又包含四种时间特征,基本上有 \(100\) 种数据类别
- 请求数量、输入时间、电梯总数量、每条路线上电梯数量均可控,改一下参数即可满足强测和互测的不同要求
- 除此之外还有一些特殊数据
五种模式
模式 | 乘客请求特征 | 电梯请求特征 | 功能 |
---|---|---|---|
全面模式 | 任意起点终点 | 任意增加电梯 | 测试整个系统的正确性 |
单楼座 | 所有请求的起点和终点在同一楼座 | 只在这一楼座增加纵向电梯 | 测试纵向单电梯运行和多电梯调度 |
单楼层 | 所有请求的起点和终点在同一楼层 | 只在这一楼层增加横向电梯 | 测试横向单电梯运行和多电梯调度 |
双楼座 | 所有请求的起点和终点在两个楼座 | 只在这两个楼座增加纵向电梯,横向电梯任意 | 测试换乘 |
双楼层 | 所有请求的起点和终点在两个楼层 | 只在这两个楼层增加横向电梯,纵向电梯任意 | 测试换乘 |
五种主题
主题 | 特征 | 功能 |
---|---|---|
随机 | 任意起点终点 | 测试整个系统的正确性 |
单请求 | 多个电梯请求 + 单个乘客请求 | 测试系统的启动和停止 |
单方向 | 同一楼座或楼层的请求方向相同 | 测试运行策略和调度策略 |
最远距离 | 纵向请求起点终点必须为 1 或 10,横向请求起点终点必须为 A 或 E | 测试运行策略和调度策略 |
同出发地 | 每次会连续投喂 8 - 10 个相同出发地的请求 | 测试满载、运行策略和调度策略 |
四种时间特征
- 零时瞬时输入:所有请求均在 0 秒时同时输入
- 非零瞬时输入:所有请求在不是 0 秒时同时输入
- 聚集输入:所有请求在 1 秒的时间间隔内完成输入
- 分散输入:所有请求在 \(n\) 秒的时间间隔内完成输入,其中 \(n\) 为请求总数
特殊数据
-
使五部电梯行为完全一致的数据
- 用于 hack 第一次作业的输出线程安全问题
- 该数据一次性把房里 5 个输出线程不安全的人都 hack 出来了
[1.0]1-FROM-A-10-TO-A-1 [1.0]2-FROM-B-10-TO-B-1 [1.0]3-FROM-C-10-TO-C-1 [1.0]4-FROM-D-10-TO-D-1 [1.0]5-FROM-E-10-TO-E-1 ...
-
压力测试数据
- 50 电梯 5000 请求、批量聚集输入
- 用于测试大规模请求下电梯系统的正确性
自动化测试
基本流程
-
编译打包
os.system("javac -encoding UTF-8 -cp " + JAR_PATH + " -d class/ -sourcepath src/ " + main_path) with open("MANIFEST.MF", "w") as mf: mf.write("Manifest-Version: 1.0\nMain-Class: " + self.main_class + "\n") os.system("jar -cfm code.jar MANIFEST.MF -C class/ .") os.system("jar -uf code.jar -C " + OFFICIAL_PATH + " .")
-
运行
in_pro = Popen("datainput_student_win64.exe", shell=True, cwd=WORK_PATH, stdout=PIPE) cmd = "java -jar " + self.player_path + "code.jar" with open(self.player_path + "res/out{0}.txt".format(i), "w") as out: self.processes.append(Popen( cmd, cwd=WORK_PATH, encoding="UTF-8", stdin=in_pro.stdout, stdout=out, stderr=out))
-
检查正确性:具体见下面的 SPJ 设计
SPJ 设计
-
利用异常抛出和捕获机制
- 如果在某一环节检查出了问题,直接抛出相应异常,由最外层的
check
函数捕获,输出相应错误信息,并保存信息到本地 - 因为要检查的地方太多(大概有20多种),如果用分支结构会使得程序结构特别复杂
- 如果在某一环节检查出了问题,直接抛出相应异常,由最外层的
-
超时检查
- 设置运行时间上限为 250s
process.communicate(timeout=250)
,并捕获TimeoutExpired
异常。如果超时,则kill
当前进程。错误类型为RunTimeExceed
- 参考了学长的代码,利用
ctypes
库获得程序运行时间和 cpu 时间。错误类型分别为RealTimeExceed
和CPUTimeExceed
- 设置运行时间上限为 250s
-
输出格式检查
- 利用正则匹配每一行的输出信息,如果匹配失败,则格式错误,一般情况下为 java 抛了异常。错误类型为
WrongDataFormat
(如果把标准输出out
和 标准错误err
分开定向,可以精确识别异常,但没有这么做的目的是为了更好地还原运行时的状态) - 电梯 id 是否存在。错误类型为
ElevatorNotExist
- 输出时间序列是否递增。错误类型为
TimeReverse
- 利用正则匹配每一行的输出信息,如果匹配失败,则格式错误,一般情况下为 java 抛了异常。错误类型为
-
电梯行为检查
-
对于五种行为,检查是否合理的同时改变电梯状态。每种行为可能出现的错误如下:
-
\(ARRIVE\)
错误 错误类型 走之前未关门 MoveBeforeClose
等待时间不足 MoveTimeNotEnough
楼座、楼层等越界 FloorOutOfBounds
、BuildingOutOfBounds
重复到达 AlreadyArrive
瞬移两层及以上 FloorJump
、BuildingJump
电梯错位(比如A座电梯跑到B座) FloorDiff
、BuildingDiff
-
\(OPEN\)
错误 错误类型 已经打开 AlreadyOpen
横向电梯不可开门 Can'tOpen
-
\(CLOSE\)
错误 错误类型 重复关门 AlreadyClose
开关门时间不足 OpenCloseTimeNotEnough
-
\(IN\)
错误 错误类型 电梯未开门 NotOpenButIn
乘客不存在 PersonNotExist
乘客不在这(位置不对、已上电梯、未下电梯等) PersonNotHere
超载 OverLoad
-
\(OUT\)
错误 错误类型 电梯未开门 NotOpenButOut
乘客不存在 PersonNotExist
乘客不在这(不在这个电梯、已下电梯等) PersonNotHere
-
-
结束状态检查
- 电梯是否关门。错误类型为
ElevatorNotClose
- 乘客是否到站。错误类型为
PersonNotArrive
- 电梯是否关门。错误类型为
-
记录信息
- 正确:输出运行时间、cpu 时间、总等待时间等,用于比较性能
- 错误:记录错误类型,错误发生时的现场状态(乘客、电梯等),错误输出,以及按电梯 id 分类后的输出
多进程并行测试
- 利用
Popen
,可开多个进程并行测试- 用一列表管理所有
Popen
对象,之后依次communicate
即可 - 注意要把每个进程的输出重定向到不同的位置,而不能全用
PIPE
,否则会因为PIPE
空间不够,导致未占用PIPE
的进程阻塞,使得输出的时间戳跳变 - 如果在
communicate
中设置了timeout
,要记得kill
掉进程,否则当电梯系统无法停止时,它也会一直运行下去(即使python程序结束了),导致无法进行下一次启动
- 用一列表管理所有
- 可实现房里 7 个人的多程序联测,对于电梯这种运行时间长的程序,可大大提升测试效率
- 可对一个人的程序多进程跑点,能很好的 hack 到复现率较低的 bug
bug 分析
本人 bug
- OJ
- 第六次作业互测被刀了一个点
- 错误原因:当纵向电梯处于 1 层时,如果在电梯尝试接完人(且没有接到)和判断运行方向这一间隔内,1 层来了请求,则电梯会转向,走到 0 层。程序逻辑如下:电梯没有接到人 -> 1 层来了人 -> 电梯发现 1 层有没接的人 -> 电梯转向
- 解决方案:在判断方向时增加特殊判断,保证电梯在 1 层时只能向上,在 10 层时只能向下
- 其他强测和互测没有被测出 bug
- 第六次作业互测被刀了一个点
- 本地
- 第七次作业发现一个典型的 bug,而且和互测被刀的点逻辑很像
- 错误原因:候乘表没有请求,电梯判断出应该 wait -> 来了请求 -> 分派器 notify 电梯 -> 电梯 wait,此时分派器因为还存在没有完成的请求而不会停止,而电梯也会陷入无限的 wait
- 解决方案:将电梯确定方向以及休眠这一段代码用
synchronized
加锁,防止在 wait 之前就被 notify
- 第七次作业发现一个典型的 bug,而且和互测被刀的点逻辑很像
他人 bug
-
第五次作业:hack 了 5 个人 6 个 bug
- 5 个人输出线程不安全:
TimeReverse
- 其中一个人因为策略原因运行时间超时:
RealTimeExceed
- 5 个人输出线程不安全:
-
第六次作业:hack 了 4 个人 5 个 bug
saber: NullPointerException, CPUTimeExceed lancer: AlreadyArrive berserker: Overload alterego: CPUTimeExceed
-
第七次作业:hack 了 6 个人 10 个 bug,但因为复现率低以及挡刀的问题,oj 只测出来 5 个 bug
saber: RunTimeExceed lancer: RunTimeExceed rider: PersonNotHere caster: ConcurrentModificationException, Can'tOpen, RunTimeExceed assassin: PersonNotHere, PersonNotArrive, RunTimeExceed alterego: PersonNotArrive
测试策略有效性
- 三次作业均有 hack,且数量较多
- 不存在别人 hack 到的 bug 我没 hack 到,只存在我 hack 到的别人没 hack 到
- 测出了很多复现率极低的 bug (第六次作业被刀后,引入了多进程跑点,使得测试强度有了质的提高)
- 综上来看,所构造的数据以及测评机是有较高强度的,应该能超过强测和互测
心得体会
线程安全设计
- 学会了
synchronized
同步块的使用 - 学会了原子类
AtomicInteger
、AtomicBoolean
等的使用 - 对于
ReentrantLock
、ReentrantReadWriteLock
等锁有了一定了解 - 对于
BlockingQueue
等线程安全的容器有了一定了解
层次化设计
- 相较于第一单元作业层次化并不明显
- 整体框架使用了两级托盘,使得不同的生产者和消费者具有不同的层次
数据构造与自动化测试
- 电梯月的精力全放在了 OO 上,而 OO 上的精力主要放在了造数据和写测评机。整个代码量达到一千多行,甚至比电梯还多的多(
- 相比于以往测评机的简单对拍,这次实现了较为复杂的 special judge
- 实现了多进程并发测试,对于运行时间较长的程序能极大的提升效率,且便于测试难以复现的点
- 数据构造和写测评机的能力有了很大提升
不足之处
- 没有实现真正意义上的调度器,而是选择自由竞争策略。但是代码写起来确实简单,三次强测也都 99 + ,
还是很香的 - 第六次作业本地测试力度不够,没有进行多进程跑点,导致被 hack 了一个复现率极低的 bug,与金刚无缘了
- 三次作业只有整体框架一致,但细节上都是只考虑到当前需求,可扩展性不强,因此每一次都进行了小规模重构