北航面向对象设计与构造 第二单元分析与总结

北航面向对象设计与构造 第二单元分析与总结

0 介绍

本单元为以电梯为背景的多线程程序项目

1 项目架构迭代

本节内容概览:结合三次作用的UML类图,分析线程安全相关设计和调度策略

总体上线程分为三类:主线程输入线程电梯线程,共享对象为RequestTable。采用生产者-消费者模型实现乘梯请求的发送和及时处理。电梯的控制采用状态模型的思想实现。在结构的生成上还使用了工厂模式来构造电梯。

我将修改分为静态和动态修改两类。静态修改为对象属性的修改,动态修改为关于线程交互机制(比如电梯运行)的修改。

1.1 第五次作业

hw_5_uml

共享对象为RequestTable,包括RequestMap容器。其所有Public方法(构造方法除外)全部加synchronized锁,锁整个对象,原因是未来可能会出现对容器内部元素的操作,在外部调用方法时使用同步块加锁的方式可能无法保证线程安全。

构建机制

hw5_sequence_diagram

主线程先构造RequestTable,根据RequestTable构建输入线程InputThread,再使用InputThread和RequestTable输入ElevatorFactory构建电梯Elevator以及控制器controller,组装成ElevatorThread。然后start启动InputThread和ElevatorThread。ElevatorThread与Elevator的区别在于后者是容器,仅包含静态的信息,而前者包含了电梯运行相关的动态属性,如开关门、运行方向等。

运行过程

每个电梯线程ElevatorThread拥有一个专属的controller,调用其nextMove()方法向controller“发问”,结果是修改电梯自身的direction、open等属性来获取下一步的行为,为了省去对下一状态的编码解码,我将this作为nextMove()方法的参数传入。行为总体分为两类:移动和开关门。开关门属性open的优先级高于移动,即当open置位,就开门上下人。

nextMove()中,通过look策略判断控制电梯的运行,当需要上下人时,将相关PersonRequest放入ElevatorThread的outQueue和enterQueue队列中,并将ElevatorThead的open属性置位。这一过程通过controller中的needOpen实现。

由于nextMove()涉及确定电梯的下一个动作,需要与RequestTable交互,而且涉及大量的if判断结构,也就是导致线程不安全的check-then-act模式,于是我将nextMove()方法的几乎所有内容都加了synchronized锁,锁对象时RequestTable。

InputThread中的run方法中也对RequestTable加了相同的锁(向RequestTable推送请求)。这两处为RequestTable仅有的访问请求来源。

可知,InputThread就是生产者,ElevatorThread为消费者

结束机制

InputThread中读到null时,将自身end属性置位,电梯线程的controller使用isEnd()读取InputThread中的end属性,将电梯线程的end置位,电梯线程在nextMove()结束后首先检查end属性,若为true,从run方法中返回。

调度策略

总体调度:自由竞争
电梯运行:look
时间性能很好,但是非常费电。

1.2 第六次作业

hw6_uml
第二次作业的主要更改在于机制而非整体架构的变化。为电梯的运行机制增加速度和人数限制的功能,为电梯的结束机制增加maintain的结束方式。

构建机制调整

hw6_sequence_diagram
这里的改变添加了电梯的属性,可以说是一种静态的更改
这里的构建机制还保留了新电梯的创建,从InputTable也发出的创建电梯线程的请求,是一种动态的修改。

运行机制调整

这里的修改是动态的修改。原因在于maintain机制产生了未到达目的地需要返回请求队列的请求,这本质上创造了一条新的从ElevatorThread的Controller访问RequestTable的路径。由于存在下人需求,其判断逻辑与needOpen有相似性,我在controller中添加了私有的needMaintain方法,判断是否需要维护,并给ElevatorThread方法的相关属性赋值。
为了统一向RequestTable的访存请求发送位置,我在controller中添加了addUnfinishedRequest方法,在这里再次对RequestTable加锁实现线程安全访问RequestTable

结束机制调整

由于maintain产生的结束机制调整也是动态修改。因为可能存在输入线程结束但是存在因为维护未到达目的地的请求,所以不能仅凭输入线程结束和请求队列为空结束电梯。我采用的机制是在RequestTable中添加unfinishedRequestNum属性,记录未完成的请求个数,将unfinishedRequestNum == 0加入电梯停止的判断条件。在controller中添加finishRequest方法,由ElevatorThread调用,访问RequestTable实现对该属性的维护(自减1)。RequestTable接收到生产者(InputThread)的新请求时,该属性自增1。

1.3 第七次作业

hw7_uml
本次作业主要增加了可达性、开门数限制和换乘。动态的修改更多了。而且在架构上产生的新的部分Guider以满足换乘请求。机制大量增加。

静态修改

乘客层面:
主要为对PersonRequest的封装(封装为MyPersonRequest)以实现换乘机制。主要是添加了tmpFromFloor和tmpToFloor,以标记本次乘坐电梯的出发楼层和目的楼层。还添加了动态的transTable属性,每次由Guider.guide()方法设置,记录乘客的乘梯选择。

//封装后的乘客属性
private int tmpFromFloor;
private int tmpToFloor;
private final PersonRequest personRequest;
private boolean isUpdate;
private HashMap<Integer, Integer> transTable;

电梯层面:
主要为Guider模块的添加,所有电梯共享一个。根据当前的所有电梯的可达性构造静态图,使用dfs算法获得所有可能路径并根据某种策略选出较优的(具体见后续部分),guide(int from, int to)方法,向乘客MyPersonRequest返回换乘表HashMap(具体说明见下一节)。这样看来我的调度器Guider并不是一个线程,也没有和其它线程直接交互,而是通过isUpdate属性以及电梯向乘客发出的apply请求间接交互。

动态修改

构建过程增加了Guider部分
hw7_sequence_diagram

由于乘客存在乘梯选择,电梯无法根据经典的look策略无脑接收同楼层同方向请求,我采用了握手机制(灵感来自上学期CO课程)来实现乘客的上下电梯。乘客的transTable为HashMap,内容为<可乘坐电梯编号,目标楼层>的键值对。电梯到达乘客所在楼层后,先乘客发出请求MyPersonRequest.apply(int id),根据对HashMap搜索的结果来确定该乘客是否进入电梯。
开门数的限制比较容易满足,需要开门的电梯调用RequestTable中添加的applyOpen方法(synchronized修饰)根据返回值wait或返回即可。
maintain和add电梯的情况下,有向Guider的图中增加和删除边的机制。

1.4 总结

1.4.1 调度策略

前两次作业都使用look策略自由竞争满足性能需求,电量需求听天由命。
第三次作业在调度策略上是对自由竞争的修改,引入了握手机制以尽可能少的代码变动来实现调度。为了保证一定的自由性,我在选取路径的过程中针对可以到达最终目的地的每一部电梯获取最短路径(目的是减少等待时间),在这个过程中给换乘的路径punish(额外增加路径长)来试图减少换乘,这里平衡了性能和换乘导致的开关门耗电。图搜索算法保证了电梯除非维护,不会停在不可达楼层。

可见,我的解决方案很难称得上是调度器,因为路径规划的结果没有直接的作用于电梯而是作用于乘客,通过不断地修改tmpToFloor将乘客引导至目的地,可能的话,称为“导航员”更合适。

1.4.2 降低cpu时间

第三次作业在优化性能之前的大部分精力都用在了降低cpu时间上,由于每次dfs图搜索寻找路径都相当消耗cpu时间,我进行了如下优化:

  1. 减少图的边数。由于dfs的时间复杂度高度依赖边数,我在建图是仅将可达的相邻楼层建立边。
  2. 更新机制,在MyPersonRequest中添加isUpdate属性。当乘客进入请求池中时强制置为false,每次获取换乘表后置true。对请求池中的乘客,请求池接收到maintain、addElevator请求时属性全部置为false。该属性置true的唯一时机为电梯apply它的时候,在这个函数中,乘客根据isUpdate值决定是否调用Guider.guide方法向Guider“发问”。
  3. 预搜索策略。在Guider启动dfs进行全局搜索之前,首先运行一个预搜索方法preSearch()方法,根据存储的电梯可达性判断是否存在直达电梯,如果有,则直接构建换乘表,不启动全局图搜索。这里还有个cpu时间和性能平衡的问题,如果大量的相同请求同时投入,但是又只有一座直达电梯,这样会相当耗时,我的办法是根据路程分类处理,距离小于6层的请求有一部直达电梯即可,而运行距离更大的请求需要至少两部直达电梯,否则启动dfs全局搜索。
1.4.3 线程安全考虑

我使用的保证线程安全的方法论(如果称得上是方法论)是编码的时候“统一”放置。

这一方面是共享对象统一放置。RequestTable为唯一的共享对象,尽管它包括了乘客等待队列、电梯维护请求等待队列、电梯维护状态、Guider调度器、开门电梯数量这些不同机制需要的变量。这可能使得同步块面积增加导致并行度下降,但是优势是显而易见的:方便管理各个线程对共享对象的访问,保证线程安全。结果是我在三次作业中几乎没有出过线程安全问题。

//RequestTable的所有属性,鱼龙混杂
private final HashMap<Integer, HashSet<MyPersonRequest>> waitRequestMap;
private final HashMap<Integer, Boolean> maintainMap;
private int unFinishedReqNum;
private int[] curOpenNum = new int[12];
private int[] curOnlyEnterNum = new int[12];
private Guider guider;

这个“统一”还是同步块位置的统一,例如我的电梯线程对RequestTable的各种所有访问请求都通过controller中的方法实现,这也易于管理对共享对象的访问。

尽管锁对象只有一个,但是锁的类型还是与电梯和输入线程对共享对象的访问有关的。输出线程向RequestTable发出各种请求,电梯线程的控制器controller根据请求队列状态获得电梯的下一状态,controller向RequestTable完成请求以及返回未完成请求,maintain电梯结束工作后经由controller向Guider删除边。共四类锁。

三次作业中,总体的类架构(生产者消费者模式等)是相对稳定的,易变的内容为Controller.nextMove()方法(对应机制的变化)以及RequestTable的相关属性(对应需求类型的变化,肯定需要修改)。电梯的总体运行机制(状态模式)没有较大的变化,迭代过程还是比较成功的。

2 出现过的bug以及debug方法

在本单元作业中,我在强测和互测中均未被测出bug。因此我主要总结我在课下测试中出现的bug以及debug方法。

2.1 第五次作业bug

由于我采用了预先将将要进入电梯的PersonRequest放入enterQueue的机制,而enterQueue又与电梯容器分离,所以未对enterQueue容量做出正确限制。

look算法的实现细节有问题,电梯线程在nextMove时,自身为空且队列为空时,陷入wait,重新获得锁后,应重新进行nextMove的判断。

2.2 第六次作业bug

第六次作业的中测bug算是我de的比较痛苦的一次。尽管原因非常简单。就是电梯maintain放人的时候恰好会有刚好到达目的地的乘客,这些乘客被我不加区分的放回了请求队列,但是由于已经到达目的地,在逻辑上无法被电梯识别而进入电梯,导致等待队列非空无法结束最终RTLE。在本次作业中我第一次体会到了多线程bug的难以复现性,大概平均下来每运行5次才能复现一次。

第二个bug是我使用同学的评测机测试出来的,结果为RE,但是相对容易解决,因为java报错会返回出错的行地址,识别出是maintain机制问题。然后人肉模拟一下可知是还没在RequestTable中配置完电梯维护状态参数就启动了电梯导致HashMap返回null值。

2.3 第七次作业bug

电梯转向机制错误,from hll’s评测机,在电梯初始位置1层且向上时,请求队列为空,反转了电梯方向导致了电梯在一层连续到达两次

maintain电梯的开门仍然受到开门数量限制,与同学讨论时发现,未被测出过。

前期的主要问题在于cpu时间过长(但是没有ctle),观察中测的反馈时发现有一个测试点甚至达到了9.69s,解决方案见上一章降低cpu时间相关部分。

debug思路

由于本次作业中存在若干机制,或者说可以被分为若干类(如上下人、nextMove、maintain、add elevator、end等等),debug的一个里程碑是找到出错的机制。

对于wa类型的bug,如果能通过观察错误输出优先定位出错误的机制是最好的。否则也只能通过运行程序复现bug,print出错的函数调用路径及关键变量值来确定。

re类型bug,关键在于观察错误输出,根据错误输出获得出错代码的位置,然后就可以定位到错误机制来源。

rtle类型bug,非常依赖复现且相当耗时,需要print来帮助寻找错误机制。

ctle类型bug,本人没有出现严格意义上的ctle bug,而只出现的时间较长的问题,通过静态分析的方式定位了问题点并解决。

print技巧,可以print各个电梯线程的状态,如apply for lock、wait、get lock、end。print关键动作,如elevator x get passenger y等来观察线程的状态以及电梯的动作是否合理。

3 心得体会

首先要说的就是:评测机yyds,虽然我的bug不多而且大部分为中测bug,评测机由于测试效率极高,可以较快地找到隐藏bug。而且对于wa类型的bug,人肉判断也是非常费时的,评测机的自动化检测机制也大有帮助。

静态分析是必须要走的一步。由于多线程电梯的特性,bug的出现与电梯状态有关,就算是通过print打印出了电梯状态,最终也需要分析出bug出现的机制,在bug不易复现的情况下,过于依靠print也会消耗更多时间降低debug效率。所谓print不过是减少了静态分析的代码量而已。

概率统计知识,使用评测机debug时,由于某些bug并不是总能复现,或者在无法找到评测机的检测程序接口的情况下,可以多次测试,根据结果计算出无效修复且未被发现的概率,一旦计算出是小概率事件,就认为是有效修复(

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值