这一单元主要学习的多线程编程的相关内容。比较大的感受是多线程程序和单线程程序相比需要考虑的因素大大增加,再加上有些bug很难复现,导致调试的难度大大增加。与第一单元通过大量的测试调试程序不同,这一单元在更多的时候我都是静静地端详着代码,理顺每一处的逻辑来寻找错误。
一、第五次作业
1. 程序分析
(1)设计策略
第五次作业比较迷茫,由于刚开始接触多线程,写起来磕磕绊绊的。在架构上比较机械地套用了生产者-消费者模式,Input类和Elevator类分别充当Producer和Consumer,Request类用于存储乘客请求,并由Main类进行主控。通过将方法设置为Synchronized来保证Request是线程安全的。
(2)程序结构分析
设计上的一个缺陷就是Elevator类承担了调度和控制电梯运行两个职责,不符合SRP原则,因此复杂度也过高。同时在增加电梯数目后很难进行扩展,违背了OCP原则,因此之后的作业只能进行重构。
(3)时序图
a. 主线程
b. 输入线程
c. 电梯线程
2. bug分析
这次作业我在强测和互测均没有出现bug,互测阶段同屋的代码质量都很高,层次清晰、可拓展性强,我仔细阅读了其中的一份的代码,在第六次作业中借鉴了它的思路。
在中测阶段我曾思考过CTLE的问题,在调试时我发现了一种较为简单却又十分有效的方法,在之后两次作业均有使用:由于几个线程的run方法几乎都是 while(true){} 的死循环结构,在条件语句控制出错时就可能错过wait语句进入暴力轮询,因此在调试时可以在while(true){} 中插入打印语句,若输出时某个线程进行刷屏式的大量输出,说明该线程中就存在着暴力轮询的现象。
3. 优化策略
在调度算法方面,我采用的是look算法,并进行了一些改动:
- 每到一层对电梯上的乘客和请求队列的乘客进行搜索,若前进方向上的楼层没有请求的乘客且没有电梯上的乘客要到达,则转向。
- 每到一层就搜索所有乘客,如果有人要下电梯或是有人要上电梯且要前往的方向与运行方向相同,则开门。
- 开门后,让该层等待的乘客全部进入(不管请求方向是否与运行方向相同)。
- 在开门后立刻睡眠400ms,关门前不进行睡眠。也就是说所有人都是在关门的一瞬间进出电梯的,最大可能地让临界时刻到达的乘客进入。
该算法的优化效果比较理想,强测中所有数据点都拿到了性能分,有7个数据点得分超过99。
二、第六次作业
1. 程序分析
(1)设计策略
这次作业我对上次的代码进行了重构,大致的设计思路契合了Worker Thread模式。
由于需要进行乘客请求的分派,我增加了一个Scheduler类,这个类可以主动获取电梯的运行状态,通过比对乘客信息和电梯的运行状态来选择适合的电梯分派请求。分派在接收到请求后立刻进行,分派后请求进入各个电梯的请求队列,由电梯自身来进行调度。Input类的职能类似于Client,负责读入并提交请求信息到Request类中。电梯类的职能类似于Worker,进行单个电梯的调度和运行控制。
由于电梯容量的限制,在单个电梯的调度策略上和第五次作业有所不同。整体上仍然是采取不设置主请求的look算法,但是上电梯的限制变得更为严格:只有当电梯运行方向与乘客请求方向相同时才允许乘客登上电梯。并且还需要增加电梯是否已满的判断。
(2)程序结构分析
相比上次作业,这次作业我新增了Scheduler类作为全局调度器,但是电梯类仍然比较臃肿,其中的几个方法的复杂度也比较高。单元总结中老师建议的方法是将电梯运行和电梯调度两个职能分开,增设一个类用来处理局部调度,达到高内聚低耦合的目的。
(3)时序图
主线程、输入线程与第五次作业相同。
2. bug分析
这次作业中我犯了一个致命的错误,在程序中同时开了两个System.in,导致第一条电梯指令被忽视。究其原因还是对线程安全理解得不够透彻,没有意识到资源的共享使用可能会造成运行的不确定性。 关于缓冲区的问题我在pre中也遇到过,同时使用两个System.in有可能造成信息的缺失,但是当时只是通过实验发现了这件事,并没有查阅资料继续深究这个问题,导致这次出现错误,也算是给我以后的学习敲响了警钟。
3. 优化策略
需要考虑的因素有以下几点:
- 电梯内的人数
- 电梯请求队列的人数
- 电梯运行方向与该请求的方向是否相同
- 该请求的出发、到达楼层是否已经在电梯的请求队列中出现过
我采取了一种比较简单的方式,对于每个请求我设计了一个计算方法,将以上四种因素考虑在内。算出所有电梯对于该请求的优先程度之后,选择优先级最高的电梯分派请求。经过评测机的反复测试比对,我选取的参数如下所示:
- 初始化 priority = 0
- 当电梯内和请求队列都为空时,priority加上10
- 乘客方向和电梯运行方向相同时,priority加上4
- 乘客的出发层和到达层若与电梯的出发、到达层重复,则将priority分别加上5和4
- 根据请求队列人数和电梯内人数再调整priority
priority = priority - pow(2, 电梯内人数 - 4)
priority = priority - 请求队列人数
三、第七次作业
1. 程序分析
(1)设计策略
这次作业的架构保留上一次作业的架构,几个主要的类改动不大(由于与官方接口名称冲突,将Request类改为Channel类)。
为了处理换乘问题我通过继承PersonRequest增加了Person类,新增endFloor属性用于存放乘客的最终目的地,原有的toFloor属性用于存放暂时的目的地。在这个类中我穷举所有需要换乘的楼层来确定Person对象的toFloor属性。以下是设计时参考的表格。
设计ElevatorA、ElevatorB、ElevatorC三个类来继承Elevator类,并增加了可停靠楼层的属性。除了UML图中列举的类,这次作业中我还使用了枚举类Action来记录电梯的运行状态,使用SafeOutput对TimableOutput进行同步封装处理。
(2)程序结构分析
在这次作业,负责进行换乘处理的updateRequest方法使用了大量的if-else语句,复杂度较高且十分难以维护。在层次的划分方面较为合理,大多数的类都做到了高内聚、高扇入和合理扇出。
(3)时序图
主线程、输入线程与第五次作业相同。
a. 调度线程
b. 电梯线程
2. bug分析
这次作业我在强测中未出现bug,在互测中被hack三次。
程序的错误原因是在Scheduler类的elevators变量中使用ArrayList容器但是未进行同步控制。运行时在循环的同时其他线程修改了elevators变量,抛出ConcurrentModificationException异常。解决方法是将ArrayList类型修改为CopyOnWriteArrayList或者对elevators加上同步控制语句(由于电梯的增加操作占比很小,而每次选择电梯都需要遍历电梯列表,这种方法的效率较低)。
3. 优化策略
在这次作业中我仍然保留第二次作业的优化思路,综合考虑多个因素进行请求的分派。这种简单的优化策略效果也还不错,在强测中所有的数据点得分都高于95分,仅有两个点得分低于99分。
四、基于SOLID原则分析
1. SRP(单一职责原则)
在第七次作业对电梯类的设计中,并没有很好地实现单一职责原则。为了在线程安全方面避免犯错,我并没有单独设置对电梯进行局部调度的类,而是将调度和运行都交给电梯类控制(分派职责由Scheduler类完成)。
2. OCP(开闭原则)
我认为自己的设计在对开闭原则的实践上仍然存在不足。由于电梯类同时进行运行控制和局部调度,当需要修改其中一个运行属性时可能会对另一个运行属性造成影响,在添加新功能时需要修改原有实现。在Person类进行换乘的选择时也运用了难以维护的硬编码,在电梯停靠楼层发生变化时修改异常困难。
3. LSP(里氏替换原则)
在第七次作业中我较好地使用了里氏替换原则。我设置了一个电梯类,其中包括了电梯的全部属性和方法,而不同种类的电梯只需要在构造器中通过设定相关属性即可方便地使用电梯类。父类(Elevator)出现的地方都可以使用子类(ElevatorA、ElevatorB、ElevatorC)来代替。
4. ISP(接口隔离原则)
在接口隔离原则方面做的还不够。虽然三个电梯子类可以通过继承实现复用,但是没有使用接口增加抽象层次,当部分种类的电梯的功能增加时无法很bingwei好地处理,同时电梯类中的方法功能较为分散,内聚性不够高。
5. DIP(依赖倒置原则)
除了对共享资源Channel类的访问以及对输入状态的查询之外,我的程序中各个线程并没有调用其他线程类的方法,没有出现循环依赖的现象,较好地实现了依赖倒置原则。
五、评测机搭建
在评测机的编写中,最为复杂的就是实现与程序的实时交互了。在评论区大佬的启发下,我使用了python的time模块和subprocess模块,以下是关键代码:
cmd = 'java -jar hw.jar' f1 = open("answer.txt", "w") f2 = open("wrong.txt", "w") res = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=f1.fileno(), stderr=f2.fileno()) # 通过PIPE实现定时投放数据,将输出和错误信息分别重定向到answer.txt和wrong.txt pre = 0 # 初始时间为0 for i in range(len(timeList)): # timeList存放着时间 time.sleep(keyList[i] - pre) # 睡眠时间为此次投放的时间减上次投放的时间 res.stdin.write(bytes(InstrList[i], 'utf-8')) # 写入电梯指令 res.stdin.flush() pre = timeList[i] res.stdin.close()
这种方法实现的评测机与官方的评测机存在一定的误差。根据第五次作业中测数据观察,误差时间在1秒以内,但是卡点进入的乘客可能出现与官方不同的情况,导致数秒的偏差。但是整体上可以观察到时序的问题,在互测中提交数据时可以得出正确的结果的。
但是这种方法实现的评测机与官方的评测机存在一定的误差。根据第五次作业中测数据观察,误差时间在1秒以内,但是卡点进入的乘客可能出现与官方不同的情况,导致数秒的偏差。但是整体上可以观察到时序的问题,在互测中提交数据时可以得出正确的结果的。在之后两次作业中运行时间和官方评测机的行为也十分接近。
六、心得与体会
经过这三次作业明显感受到程序的迭代变得较为容易,不再需要进行频繁的重构。书写代码的时间和代码量相较第一单元都有较大的降低,更多的时间投入在了设计程序的架构上。一个好的架构能够使多线程程序的编写难度大幅降低,对线程安全、死锁等问题的调试也更为容易。
这次作业的评测机搭建难度相较上一单元提高了许多,能用python实现程序的实时交互也是一件相当有成就感的事情。而在正确性判断方面的逻辑也较为复杂,通过完成这一过程实际上也对自己的代码逻辑进行了检验。
在第三次作业结束后我花了一些时间看了看《Java并发编程实战》一书,发现这单元使用的并发编程知识只是并发编程的很小一部分而已,例如原子变量、线程池等高级的操作可以更好地完成并发程序的开发。电梯作业结束了,对多线程的学习其实才刚刚开始。