301
社区成员
发帖
与我相关
我的任务
分享写在前面
在面向《对象程序与设计课程》课程(OO)学习中的核心即为面向对象的思考分析方法,这种面向对象的思想在诸多问题的解决中带来了不同思路.在第二单元学习与实践过程中,笔者对于对象选择、封装复用以及高内聚低耦合等诸多思想获得了诸多体会。
本文用于总结OO第二单元的学习时间体会,供未来回顾学习,也为更多面向对象学习者留下少许资料以供参考。
作为多线程作业的开头,本轮作业为较为经典的电梯调度问题,核心任务为对任意时刻到来的乘客规划电梯行动,以尽可能少的成本满足所有乘客的需求.其中对于成本的计算主要来源于两者,即时间成本与电力成本,时间方面需要减少总运行时间同时尽可能避免单个乘客长时间不受处理,电力方面则需考虑减少路径.
对于性能的处理,本轮采用的策略是在电梯有限处理同向可捎带最近请求,当无同向请求时进行方向逆转(孩子不会花式算法),按本地测试效果来看性能表现基本能达到要求,但由于担心后续优化问题,将决策部分独立到Strategy类中封装,万一性能不够可以较简单的替换.

目前类架构较为简单,主输入提交到调度器,由于后续肯定要实现自主调度,所以设置管理类ElevatorManager,进行电梯进程的管理.输入按指定的电梯id分发到二级RequestList中等待执行,等待执行的Request由决策类处理,转化为电梯指令,Elevator线程负责执行,执行操作时调用静态Output类中统一输出方法.
第一次迭代中,共实现9个类.
其中三个类主要功能为自定数据类型,RequestList用于存储各电梯的Request表,Waiter存储电梯候乘者信息,Replan存储由规划类产生的电梯运行指令.
其余六个类为主要功能类,Main类负责生成初始request列表、请求输入对象并启动一个电梯管理器ElevatorManager线程,ElevatorManager初始化6个电梯对象并启动电梯线程,而将获取的Request分发到各个电梯的Request列表中.电梯线程启动后借助静态类Strategy获取最新行进规划并运行,执行接送、开关门等行为时借助Output类进行输出.

在本次作业中为后续的迭代也做了几点准备:
1.电梯各项性能参数都以类内全局变量的形式管理,保证电梯的种类、运行参数易于修改,预备进行多种电梯的实现,保证电梯实现后性能可变.
2.使用静态类Strategy处理任务分配策略,使用了Replan类管理规划指令,在性能不足或准备添加复数方法(优化就交给你了,未来的我)时能够直接替换任务完成策略.
吸取前三轮迭代作业中基础类臃肿的教训,这一轮作业中不希望将过多的方法集成到elevator类中,也是出于留下决策策略修改后路的考虑,在实际Request与电梯行动之间增加了决策类.当然,目的达到了,elevator目前只需按照规定的路线在指定楼层接送乘客即可,逻辑清晰(写起来很爽).但事实证明,代价总是有的,由于分离了request列表与电梯本身,电梯的"已安排任务"将会相对更难改动(参考往年电梯维修需要清空安排的任务),需要涉及将电梯任务还原回request的问题.
针对这一问题,笔者计划将这部分功能借助waiter实现,waiter本身目前仅相当于一个elevator易于处理的数据结构,可以增加toString,toRequest等转化方法,实现电梯任务与标准请求之间的转化.
本次作业未出现架构层面的重大bug(不用重构了,好耶),但经过测试时仍然出现了一些实现细节上的小问题.
第一条与电梯存储行进路线的链表结构相关,由于后续试图提高电梯任务列表的刷新速率以优化性能,在电梯开门后,关门前增加了一个任务更新点,导致在特殊情况下关门前检测可能出现任务链表为空的情况,此时有一处未添加非空检测的getFirst()调用,导致RE,进程异常中断.解决方式即加上非空检测,但也警示笔者多线程任务执行流程变化可能性多,在容器调用中需要更为谨慎非法访问的问题.
第二条严格而言并非bug,不会造成问题,由于对多线程代码规范写法不熟悉,第一轮编写中将requestList的wait与notify操作分到了电梯与点题管理类中,经过后续学习发现这样的写法不规范,此类操作应封装在共享对象行为中,修改后在线程中仅会出现sleep行为,一定程度上提高了代码规范性.
本轮迭代作业主要包括两项功能,即调度器的任务分配决策功能与新增reset指令.
对于任务分配策略,笔者采用损失函数的形式进行评估,在电梯中实现一个按一定加权比例考虑排队长度、可捎带性、行进距离三个因素的方法,调度器择取损失最小者进行任务分配.
对于reset指令,处理方式其实类似第一轮作业中指定电塔进行操作,需要实现任务整理并返还到调度器与电梯自身参数修改两个步骤.其中任务整理信息来自三方:已分配未处理的request,已处理等待执行的候乘数据,执行中而未到达的乘客数据.
至此,其实任务的主要内容已经进本完成,但本次作业的难点才真正体现,由于Reset指令的加入,电梯可能需要"吐出"已接收甚至已在执行中的任务,这部分任务并非来自于标准输入,产生时间与reset时间相关而内容不定,因而产生了一系列运行漏洞.
笔者主要从两个极端情况出发进行处理;
受益于前一轮迭代留下的准备,本次作业迭代工作量较小,新功能类数量仅增加了一个,即上文所述PersonCount类,主要扩充部分在于ElevatorManager中,包括任务分配策略的实现与reset相关的特判处理.实现后发现其复杂度迅速上升,因此将任务分配策略的运算部分封装到Scheduler类中,优化ElevatorManager结构.

本轮迭代并未测出重大bug,但在复杂多线程情况下结果表现出了过强的不确定性,笔者决定作出一点优化.
问题的发现是笔者注意到强测中一个数据点出现了98s/120s的运行时间,性能较差,有超时风险,于是打算分析该数据点做优化,但放到本地第一次运行结果仅为55s(多线程,很神奇罢),惊奇于运行效率波动性之大,笔者使用同一数据点在本地进行了10轮测试,测试结果主要聚集于65s左右,最低50s,最高达到了118s接近超时.虽然笔者已经知道了多线程运行会出现结果的不确定性,但如此大的波动范围是笔者没有想到的.
通过分析几次运行情况的分支产生处,笔者注意到原因在于损失函数.损失函数以设置加权系数的形式综合考虑了若干个性能影响因素,但各个因素的影响力相近,因而易出现不同状态电梯损失函数结果相同的情况,采用的解决方法为在各个系数中添加了较小的小数部分,将损失函数返回值设置为Double,以此尽可能保证少出现相同损失计算结果.
经过修改后再次进行该数据点的10轮测试,测试结果聚集于67s,基本未变,而极端结果最大差减小到35s左右,最高用时不超过85s,运行结果相对更加稳定.
本轮迭代任务只有一个,即借助新型reset添加双轿厢电梯,单个电梯井设置中转楼层,上下各一台电梯.双轿厢电梯本身的运行行为其实借助已有的电梯便可完成,对笔者来说难点主要在于本轮迭代可以说硬性要求实现换乘行为(所有电梯都可能置为双轿厢所以不能偷鸡,岂可修).
本次迭代中采取双轿厢电梯与普通电梯等效的处理方式,实现已有电梯类子类DelevatorA, DelevatorB并重写移动方案,在进入交换楼层时进行自检,自动吐出目标地点超过交换楼层的请求并上交到调度器重新分配.
为减少调度器的修改预防bug,实现双轿厢电梯的任务分配实现了二级调度,即调度器视角视一对双轿厢电梯为一台电梯进行调度,实质与一个双轿厢管理器DelevatorManager进行交互,进行二级调度下放任务.
本轮迭代主要额外实现的类共三个,上下双轿厢电梯DelevatorA, DelevatorB与二级管理器DelevatorManager.其中存在一个问题,为何要将功能实际相同的上下轿厢电梯分为两个类,笔者主要考量为这两个类主要内容并不多,仅覆写了移动方法并增加了交换层的专用出客与移动策略,没有合并的必要,尽管出现了一定程度上的代码重复,但二者将更易于维护,调试中也可以更直观的进行监控.

截至最后一轮迭代,主要类架构如下图所示.

为了尽量提高减少锁产生的等待性能,此架构中对于线程简单信息共享尽可能采用二元互动,即图中总管理器ElevatorManager与调度器Schduler、Schedule与各个电梯间通过一个请求表RequestList进行交互,所有的锁相关的操作都围绕RequestList展开,一个RequestList内置加锁的读写方法,其链接的两个线程通过获取对应的锁进行读写操作.其中获取锁的种类取决于同步块的操作内容,包括读锁与写锁.
如总结-1中所述,调度器实际相当于一个请求整理分发的中枢,调度器在正常运行中将外部输入的请求+reset产生的重分配请求+换乘产生的新请求整理到ManagereRequestList中,并按照短期评估损失程度最小的方式分发到各个电梯的次级RequestList(i)中,实现调度作用.
得益于设计之初决定的决策与电梯相分离的架构方式,在这一轮作业中笔者的电梯类实际只有简单依照指令移动与上下乘客的行为,电梯类的功能基础而纯粹也就带来了较大的拓展空间:在第三次迭代双轿厢电梯时,只需在覆写时添加交换楼层处的特判逻辑即可,对于更多更复杂的电梯种类,电梯概念本身就是一个移动中满足乘客需求的机器,在此基础上插入实现了对应指令接口的决策系统即可实现新种类的电梯,后需有性能优化需求是也只需针对决策中的算法即可.
其中为解决电梯与决策系统的交互,利用RequestList接受Scheduler向下分发的请求,并通过策略类中静态类Strategy中的加载任务指令方法loadRequest,传入请求列表获取任务规划.对于第三次迭代中加入的双轿厢电梯则是通过实现类似的次级结构,即实现了一个两电梯的次级调度器进行管理



如下图(时序图)程序起点为主线程,主线程初始化管理器,电梯组与主请求列表并启动主管理类并传入电梯组和请求列表.

对于共享楼层问题,每对双轿厢电梯共享一个换乘楼层空闲标识位,进入共享楼层需等待标识为空闲并将标识置为繁忙,每个电梯当误后续任务时默认主动离开换乘楼层.
第二单元三轮迭代作业中,笔者也遇到了若干的bug,但其实强测和互测中出现的bug并不是很有技术含量,笔者的代码中出现的包括链表容器读取未做size>0检查、清除完成的请求循环参数有误这两个bug(看我看我,我宣布个事,我是个××.jpg).
虽然没啥有谈论价值的bug,但多线程实际调试中bug的的分析解决方面笔者确实有不少收货.由于多线程的玄妙运行方式,实际调试中采用普通的打断点方式对于出现一次的bug通常无法复现,笔者主要的方式为针对较简单的问题采用print大法,当情况复杂确实需要断点数据时,笔者采用尽可能一调到位的策略.由于调试中主要带来的运行变化来自于调试中使得一个线程运行速度与其他进程不同步.因此仅在第一处断点有较为符合真实运行情形的数据.
当需要寻找的bug确实隐蔽时(如第三轮迭代中笔者本地出现电梯随机吃人的问题),笔者通过查阅资料发现了模拟单条线程运行上下文的方式.即通过错误的输出定位出错电梯前后所经历的关键事件并构建一个仅有一台电梯的系统,这样调试中不存在分支,调试效果绝对准确.(当然,缺点就是太麻烦)
笔者的测试主要思路即针对电梯的某一限制(如客容量,换乘层)进行攻击,结合互测经验,有如下几个bug易产生的攻击点:
第二单元的测试突破点相对比第一单元明显多了很多,因为测试数据在内容维度之外又多出来一个输入时机的维度,因此设计程序时更难考虑周全,这也意味着可以寻找更加"巧合"的数据.
(1)
线程安全:线程安全方面笔者感觉问题主要会出现在两个方面:数据对不对和程序能不能停下来.
数据方面其实就是围绕共享对象展开: 共享对象的读写的冲突必须得的正确的协调,这一部分其实并不复杂,只需进行恰当的synchronized操作.为了避免这方面出现问题,关键在于理清线程之间的关系,明确每个共享对象所面对的线程.进一步地,程序架构本身应当易于理解(不能把自己绕进去),一些技巧包括但不限于减少一个共享对象关联的进程数、杜绝线程之间的直接交互、减少共享对象的传参操作等等.
能不能停下的问题则涉及到各个进程的结束条件判断:笔者采用了三个标志: 输入是否结束,自身任务是否结束,程序整体任务有没有结束.第一项通过ElevatorManager进行统一管理,输入结束时在进程树中递归设置.第二项则有线程自身判断,本次作业中依据自身分配到的请求列表是否已全部完成.第三项是笔者在第二轮迭代中为应对reset问题设置的PeopleCount类进行进出客技术统一管理.
(2)
层次化设计:层次化设计方面其实第二单元体现的并没有第一单元那么明显,要搭建一个好的层次化架构就需要深刻的面向对象的问题分析方式.笔者在每一个类实现一个方法时都会下意识去问自己"这个行为应当由这个对象完成吗?".在第二单元的作业中有很多容易混淆对象的时刻,例如路线的规划的执行者应当是电梯还是调度器?请求的各个部分的整合应当交付哪一级任务列表?一个面向对象的程序系统一定是各个部分各司其职,符合高内聚低耦合的特性,而层次架构是也应当一次为基准设计.
在第二单元的设计之初,笔者就是顺着这一思路将决策部分离到电梯类之外封装,这一操作在第二轮迭代中实际带来了不必要的麻烦(经过决策的请求还需进行还原),但来到第三次迭代就能比较优雅地仅覆写基础方法实现双轿厢电梯,站在总结的视角看,这一设计短期内确实有不思变通之嫌,但长期迭代中保障了电梯的拓展便捷性.