学会与“有生命力”的对象打交道——面向对象设计与构造第二章总结
面向对象第二章博客总结-多线程电梯
OO的多线程电梯作业已经结束了,回顾三次作业,我对多线程设计、编程和调试有了初步的认识与看法,在任务不断加深的过程中感受到任务结构所存在的优势和问题。借助这次的博客,现在从以下几个方面进行总结:
- 三次任务的电梯设计策略
- 度量性分析
- 分析程序的Bug
- 互测策略
- 心得体会
1.多线程电梯设计策略
第一次作业
初次接触多线程编程,我采用最基础的生产者-消费者模型来实现,共享对象为RequestList
,生产者线程(主线程)负责接受请求并向请求队列投递请求,而消费者线程(电梯)则采用对RequestList
的逐个取出指令并执行。
第二次作业
在第二次作业的结构中,采用了输入(主)线程-调度线程-电梯线程的三线程结构,结构已有意识地为多电梯运行和任务分拆建立结构,具体来说,有以下特点:
- 输入线程与调度器线程之间的通讯采用“托盘”结构:而后的作业涉及任务的分拆与电梯的反馈,通过“托盘”结构,可以使任务来源对调度器是透明的,调度器将对子任务和新任务视作同样的任务,从而简化结构。
- 调度器线程与电梯线程之间通讯采用“观察者”模式:便于多电梯的注册。
- TaskList:列表中以运行任务为单元进行优先队列的维护,实现具有一定优化的ALS调度策略。
第三次作业
第三次作业需求的主要变化在于多任务调度、不同的电梯运行属性和运载任务的分拆,为此第三次的结构在第二次结构拓展而来,并主要做了如下的改进:
- 类的抽象和工厂模式:引入了电梯工厂根据关键词创建不同类型的A、B、C电梯,适应不同的速度、容量和停靠楼层。
- 电梯反馈与请求池:第三次作业中由于停靠楼层的不同,因而任务需要进行分拆,对于需要分拆的任务,结构中有以下的类和线程执行对应功能:调度器——将新任务取出并生成可执行的子任务,电梯线程——反馈子请求完成情况,请求池——接受电梯的反馈,基于乘客当前楼层与目标楼层以决定是否生成新任务。
- 基于楼层的任务列表:虽然Elevator和TaskList在结构上与第二次作业保持相同,但TaskList实质内容已发生较大的改变,通过在每个楼层建立接放乘客的列表(类似于楼层类),以实现Look的扫描算法。
2.OO作业度量分析
对于这三次作业,我从第二次作业才开始认证考虑并实现架构,第一次作业基于最基本的生产者-消费者模式,类的定义和功能很少且相对固定,因而在此仅展示类图,而将具体的OO度量放在作业二和作业三共用的架构上。
作业一类图
作业二和三的度量分析
作业二和作业三所用的架构是一致的,作业三相较作业二,对于每部电梯除了其停靠楼层、速度和容量做了微调,其主要差异集中体现在主调度器中调度算法实质地填充(在作业二中主调度器是一个形式化的空壳)。
类图
方法和类的复杂度分析
通过观察上述表格,复杂度过高的方法集中于两个关键词:调度和扫描;复杂度过高的类集中于电梯和任务列表的过耦合。
- 调度:对于调度器,由于本次作业中电梯的相关属性已经非常详细地给出了,因此我的调度策略其实是固定套路,自然会有许多的
if-else if-else
的条件判断,因而分支复杂度就很高。不过,在完全基于先验知识的情况下,我认为固定策略是一种较优的选择,在这一点上方法复杂度过高不可避免。 - 扫描:扫描模块主要功能是判断电梯是否需要沿方向继续行驶并对方向进行改变,但由于我的接放乘客乘客列表完全独立于电梯(也就说没有用单独列表维护在电梯中的人),结构非常独立和松散,因此当要统计和改变运动趋势时运行开销会很大,解决方法也就如上文所述使用列表维护电梯中的乘客。
- 电梯和任务列表的耦合:主要原因是在划分功能时,把电梯和其对应的任务列表进行了错误的划分,电梯物理运行+乘客行为这样的划分方式在拓展功能时遇到了很大的阻力,因为电梯和乘客之间的相关性随着功能增多而越来越强,解决方法应该考虑电梯为主而任务列表为辅的架构,将主要功能都侧重于电梯实现。
ev(G):Essentail Complexity,用来表示一个方法的结构化程度,范围在\([1,v(G)]\)之间。
iv(G):Design Complexity,用来表示一个方法和他所调用的其他方法的紧密程度,范围在\([1,v(G)]\)之间。
v(G):循环复杂度,可以理解为穷尽程序流程每一条路径所需要的试验次数。
OCavg:代表类中方法的循环复杂度的平均值,具体来说是由于条件分支和嵌套复杂情况。
WMC:代表类的总循环复杂度,具体来说是方法之间互相调用复杂情况。
依赖性分析
根据上述数据,除去一些tool静态工具类,本次作业各类之间的依赖性比较正常,因而侧面反映出对各模块功能的划分还是比较正确的(当然,不过两者作为整体对外还不错)elevator
和taskList
两个类之间耦合是个意外
协作图(第一部分已给出)
时序图
SOLID分析
-
SRP-单一责任原则:从宏观上看,各线程各司其职,功能划分比较合理;从微观上看,每部电梯与其对应的任务链表责任划分不清晰,导致后期优化时实现复杂度和运行复杂度都比较高。
-
OCP-开放封闭原则:有意识但做得不够标准不够好,在第二次作业时,为第三次作业的相关功能预留了空白的函数,但并没有这些具有拓展潜力的部分抽象为接口和类(如调度算法、电梯自有属性等)以实现严格的开闭原则。
-
LSP-里氏替换原则:未涉及类的继承。
-
DIP-依赖倒置原则:符合,特别是对于
RequestPool
和Elevator
,两个模块在新功能加入后基本没有改动。 -
ISP-接口分离原则:符合,主要是因为在程序中实现的接口类方法并不多,只有主调度方法和楼层方向的工具类。电梯的运行算法采用了Look,由于维护数据结构的特点,几乎完全内嵌不好变更运行策略。
3.分析程序的Bug
在本章的作业中,我的程序在公测和互测阶段均没有被发现Bug,我认为这主要取决对架构的重视、课下大量的自动测试和保守的synchronized。因而在本章中,我将着重讨论自己在结构设计时如何避免产生bug的一些想法:
- 线程安全:线程不安全都源自于对线程间共享数据的处理不当,因而其实解决这样的问题其实可以从尽可能少的共享数据+共享数据操作完全安全两个方面来考虑,要做到这样,我的程序实现以下方面:
- 线程间的功能解耦:程序结构中三类线程的功能高度封装,绝大多数的工作都是建立在私有数据之上,这使得共享数据的数量非常少。
- 线程通讯的两个唯一:唯一的共享对象,唯一的通讯接受接口,对于
Dispatcher
,其数据的输入接口有且仅有RequestPool
的get类方法,对于Elevator
,其数据的输入接口仅有TaskList
的方法,这样使基于notify
和wait
的守护者模式变得很安全(因为线程的守护对象是唯一的)。
- 楼层数据的标准化:任务中,负的楼层、楼层方向的设置与判断、楼层合法性的判断等情况如果直接地引入对电梯的数据处理是不利的,为此,这些数据在电梯中应当被标准化(在电梯内部以自定义的语言理解,而内外沟通则使用可逆的转换),我在程序中定义了
FloorTool
静态方法类集中解决这些问题:
public static int index2Floor(int index);
public static int setDirectionDown();
public static int setDirectionUp();
public static int setDirectionStill();
public static boolean isDown(int dir);
public static boolean isUp(int dir);
public static boolean isStill(int dir);
public static String getDirectionName(int dir);
public static boolean isLegalFloorIndex(int floorIndex, int[] legalList);
public static boolean isDirectTransport(int from, int to, int[] legalList);
public static int getDirection(int from, int to);
- 打印Log信息:我的Log信息主要由线程对共享数据的读写、线程状态的变化两部分构成,当出现线程安全问题后,利用Log信息将更加方便地找出问题。
@<Elevator A>:State -> Rest
@<Elevator C>:State -> Rest
@<Elevator B>:State -> Rest
<Dispatcher>:Get a New Request '1-FROM-3-TO-1'
<Dispatcher>:Task <1-FROM-3-TO-1> dispatched to C
@<Elevator C>:State -> Recover
<Elevator C>:A new Task '1-FROM-3-TO-1' Have Been Validated
<Elevator C>:Direction Change: STILL -> UP
<Elevator C>:Direction Change: UP -> STILL
<Elevator C>:Direction Change: STILL -> DOWN
<Dispatcher>:ID 1 Task Finished
<Elevator C>:Direction Change: DOWN -> STILL
@<Elevator C>:State -> Rest
@<Client>:State -> Input Terminated
<Elevator A>:Have Received Input Terminate Signal.
<Elevator B>:Have Received Input Terminate Signal.
@<Elevator A>:State -> Recover
<Elevator C>:Have Received Input Terminate Signal.
@<Elevator A>:State -> Normal ShutDown With 0 Tasks
@<Dispatcher>:State -> Normal ShutDown
@<Elevator B>:State -> Recover
@<Elevator C>:State -> Recover
@<Elevator B>:State -> Normal ShutDown With 0 Tasks
@<Elevator C>:State -> Normal ShutDown With 1 Tasks
@<Main>:State -> Normal ShutDown
4.互测策略
- 互测结果总结
- 本章的测试中,我采用的是自动化为主+手动为辅的测试策略,自动测试改动自何岱岚(再次膜)同学开源的评测系统,实现数据生成+数据投放与接收+正确性判断全套功能,而手动辅助测试属于定点轰炸,在第二章中关注ALS调度算法的漏洞,在第三章中关注主调度器策略和任务终止时的处理。
- 最通过手动测试帮助我发现了同学的Bug:这个Bug存在于第二次作业中,这位同学的ALS调度算法在电梯接乘客方向与乘客运动方向相反时会直接失效,完全变为傻瓜调度,进而调度严重超出了规定时间。
- 线程安全测试策略
- 时间临界点的投放输入:以0.0秒、三部电梯完全休眠时、接近终止时间时等临界点投放。
- 高并发输入:在自动化测评时将随机时间按固定梯度划分,实现批量指令同时投放。
- 测试策略与第一章内容的差异
- 手动评测:输入数据为定时投放,设计的测试数据需要借助Python、Java、Bash等脚本手段进行投放。
- 自动评测:受助于优秀的大佬同学开放自动评测的关键技术,在第二章作业中我实现自动评测的过程中并没有很大的阻碍。除了手动评测时实现定时投放外,另一个重点则是正确性判断(在第一章可使用sympy,但本章中需要手动实现指导书中的判断逻辑)。
5.心得与体会
异步线程间的通讯
本章作业中,模块划分好了,各个线程专人专事,在功能上就没什么大问题,但难点在于各个线程之间协同运行,我所遇到的最大问题就是异步线程之间的通讯和同步问题,为解决这个问题,我基本还是使用了多线程编程中的生产-消费者及守护者模式,仅使用wait()
和notifyAll()
两种线程状态控制,这种模式及其思想大致出现在了程序架构的如下几个方面:
- <HM5,输入线程-电梯>,<HM6, 输入线程-调度器>:基础的托盘式结构。
- <HM6, 调度器-电梯>,<HM7,调度器-电梯>:托盘型结构的变形,调度器通过观察者模式主动向电梯发送调度请求,请求被放置于每个电梯独有的“Cache”中,在电梯while循环中会专门调用方法去清空Cache并生效任务。
- <HM7,输入线程,电梯-调度器>:增加电梯对调度任务的反馈,电梯自己既是间接消费者,也是直接生产者。
我认为在这种模式驱动下的线程其特点最重要的就是隐私性和主动性,线程之间由于存在”托盘式“的设计,线程内部的隐私性都很高,共享对象很少,需要考虑的线程安全问题就要少很多;同时,借助守护者模式,消费者线程主动地按需获取和主动地进入结束状态,这令外部线程对本线程运行状态的影响和干预缩小到了局部而固定的代码上,减少未知情况的讨论,当然,这也对线程自身运行的逻辑完备性提出了更高要求,否则如果出现问题其他进程也束手无策。
在逻辑完备性的考虑上,我认为最需要注意的便是多条件下的守护者模式对终止条件的判断,进过多次的尝试,我逐渐地摸索出了在编码实现的模板:
public synchronized getRequest(){
while(requestList.isEmpty()){ // 守护条件
if(inputTerminate && runningTask==0){ // 守护条件下的特例
return null;
}
wait();
}
return requestList.remove(0);
}
- 首先,一个线程一般只能守护一个共享对象:如果出现对多个共享对象的守护,很容易出现顾此失彼和死锁的情况,就像HM7中调度器需要在电梯完成子任务后得到反馈并安排新的任务一样,电梯反馈的内容要么被传回至
RequetPool
和输入线程传入的请求同等对待,要么子任务的下达由电梯线程运行调度器的方法完成。 - 其次,守护条件及其特例的书写要有规范:通过本章作业中,一种守护条件+特例的组合方式就是上述的模板,首先明确当要守护时,必要不充分条件是请求列表为空,在进入了while循环后,通过剔除特别情况使得执行
wait()
时是守护的充要条件。当然,还有一种书写思路,那就是直接将守护条件写成充要条件,并在后续的操作中对特例进行判断。 - 最后,阻塞线程的唤醒一定要考虑wait语句前的所有条件分支:
while()
循环条件的变量当然算一个,但是在进入while后条件判断分支也必须考虑,在模板中:requestList
,inputTerminate
,runningTask
三者对wait的执行都有影响,因此任何对while的条件判断及其内部语句中的成员产生变化的,都需要加上对应的notifyAll()
语句。
程序设计的不足
-
对其他的线程通讯方式尝试较少:
上文已经提到,基于生产-消费模式的信息交互模式对线程自身逻辑的封闭性和完备性要求很高,但随着多线程编程后续功能和情况逐渐复杂,通过主动获取托盘信息的方式可能显得不可行。这时候可能就需要外部线程直接使用
interrupt()
等手段让程序陷入异常态进行处理,因此还需要对更多的线程协调和通讯方式进行了解。 -
锁优化:
本章作业并没有涉及到实际业务情况中高并发的情况,因而并没有促使我过多的考虑锁优化,几乎所有的共享对象都采用的是
synchronized()
的方法。 -
电梯和其一对一对应的列表耦合重:
耦合性过高的问题随着优化方法的尝试和优秀同学架构的分享而逐渐显现,总结其原因,主要还是在于对电梯功能划分时策略不够好:原设计中将电梯本身和乘客的行为分割并分别用
Elevator
和TaskList
实现,但实际上后续当需要结合乘客分布和电梯状态进行预测时,两者由于平级,因而耦合很大。现在考虑,还是应该将
TaskList
作为辅助,以Elevator
为主进行运行和乘客的运动。 -
任务列表
TaskList
结构过松散:由于实现Look算法,我实现了基于每一层楼的
PickList
和PutList
以表示电梯需要在此层接放的乘客,这种方式确实能很好地实现Look算法,但是如果在改进时涉及到仿真预测、乘客选择性拿放时,这种松散的数据结构就需要很多的辅助数据来维护,也就是方便于一次性写入但繁琐于后续修改。目前的初步改进想法是,保留
PickList
但去除PutList
,乘客的投放交由电梯内部一个小队列处理。