OO第二单元博客总结

OO第二单元博客总结

第一次

设计策略:

直接使用生产者,消费者模型,没有中间调度器。电梯Elevator直接作为消费者,读入请求,完成请求,生产者类ElevatorReader负责放置请求。受到Concurrent包的启发,单独设置线程安全的数据类,存放队列和”全局结束“标志位。在生产者和电梯之间传递上述类的一个实例进行数据同步传输。

电梯的结束策略:如果当前电梯内为空,队列为空,并且全局结束标志位置位,结束线程。在上次诸老师班讨论课中,有同学将这种方式专门提出来归纳为”延迟结束“,即:外部控制线程只传递结束信号,当线程截获信号时,由他自己完成必要的”临终处理“,例如释放锁,完成还没有完成的任务,最后结束自己,而非由外部直接结束。在课程讨论群中,一部分同学提到了结束时的结束时间不安全,例如当前任务还没有完成等。采用上述方法,便可以避免这个问题。

调度算法:

和某些dalaos商量后,最终大家决定采用SSTF,并认为这种算法在足够随机时能够达到更好的效果。为了解决SSTF针对某种数据点的致命低效问题,基本所有使用SSTF且追求性能分的同学都引入了返回接人的策略。如果电梯发现,有和主请求同方向的人,但是已经落在了电梯的后面,调用算法计算是返回接人还是直接完成任务更节省楼层。在大部分情况下,返回接人更省楼层,因此可以返回接人。这个策略延续贯穿了三次作业。

基于度量的代码结构分析:

如图所示:

如前所述,第一次作业分为四个类:主类,读取线程,电梯线程,安全容器

时序图如下:

基本流程:Main主类启动ElevatorReader和Elevator线程,Elevator在队列为空时阻塞,在ElevatorReader读取到请求后存放到队列种,被Elevator获取并执行。当读入到null时,将线程安全容器的结束标志置位,并结束ElevatorReader自己。电梯发现队列全空,指令执行完毕,且结束标志置位时,结束自己。

  • 代码复杂度:

    • 方法复杂度:

    • 类复杂度:

    • 依赖矩阵:

    从方法复杂度中可以看出,haspeopletoget()方法的复杂度略高,该函数是判断是否有需要”返回接的人“的函数,如前所述,要分别计算”返回接人是否更加合算“,一共分类了四种情况,导致了大量的if-ese语句。更理想的情况,可以将四种情况作为四个函数分开执行,减小复杂度。

​ 所有的类复杂度控制在合理范围内。

评测

本次代码没有在公测和互测中出现bug。

第二次

采用策略:

加入了电梯调度器,每个电梯有一个自己的队列。新增Person类用于保存信息。新增调度器用于从生产者读取请求,并完成向各个电梯的队列的分配。各个电梯的主请求来自自己的队列,但其捎带范围包括所有电梯的队列内的人,折返捎带范围仅仅包括自己的队列。

调度算法:

单个电梯沿用了第一次作业的算法(保证局部最优),而调度器主要负责负载均衡。事实上,很多分布式系统的底层思想和此很像,需要兼顾均衡负载+局部最优。笔者曾经打算写一个优先保证全局最优+完美兼顾局部负载均衡的算法,但由于线程之间的数据交互过于复杂,遗憾该版本由于本地无法复现的奇怪的RTLE原因,未能通过中测,因此最后依旧提交了负载均衡+局部最优的版本。在强测数据公布后手动测试,发现相比我提交的版本,该未通过中测的版本性能普遍优于我提交的版本。

具体的分配原则:

  • 调度器和读取器延续生产者-消费者模型,调度器读取到请求
  • 调度器分别计算将此任务分配给每个电梯后电梯预计结束时间的时间增量t
  • 选择时间增量最小的作为暂定最优解。
  • 审查,如果有空电梯,优先将此任务分配给空电梯,如果有预计结束时间相差六秒以上的电梯,优先负载均衡,将请求分给最快的电梯。如果前二者都不满足,选择时间增量最小作为最优解。

基于度量的代码结构分析

  • 代码结构(UML类图)

  • 时序图

  • 代码复杂度:

    • 方法复杂度(仅截图按复杂度排序后的第一页,拿复杂度超标的进行重点分析)

    • 类复杂度

可以看到,Elevator。run主运行方法复杂度过高。这个方法延续到了第三次作业,成为了本单元作业的一大败笔。我将主要的算法逻辑全部写在了run方法中,除了一些非常具体的下级逻辑,例如"获取主请求","下人"外,诸如上下移动,更新电梯内部状态,判断超载,判断移动方向并合理睡眠400ms, 输出到达楼层等,全部集成在了一个方法中。这就是因为迭代过程中,懒得开新方法,想着"能加一点是一点",最终导致了结构的臃肿。

如之前的算法所言,gettime()方法用于获取电梯的预计完成时间,为了实现该思路,我又实现了一个电梯类,它不实际睡眠,但是模拟一个电梯的运行方式返回预估时间。该gettime()方法其实是Elevator.run方法的移植,原因已在上一段阐明,不再赘述。

haspeopletoget()方法沿用于作业1,已经分析。

Scheduler.run() 方法也是一个败笔,将在作业3进行分析。

从类复杂度来看,Scheduler调度器的类圈复杂度明显较高,这是由于在我的算法中需要大量判断"谁大,谁小,最大,最小",充满了if-else结构。这些功能可以封装掉。

评测

本次作业没有在公测和互测中发现任何bug

在互测中,有一位同学的代码在特定情况下无法结束,多个数据点在本地可以稳定复现,但是我本人交了7组,在评测机都没有命中,同屋内有其他人交了相同原理的测试点却命中了 (脸黑?)。

第三次

设计策略

本次作业,我几乎完全基于作业2完成了迭代,一共也没有加多少行代码。反正在作业2中,每个电梯的队列是独立的,因此不管是新增多少电梯,都是换汤不换药的事。至于请求的拆分,我在调度器设置了一个容器,每当一个需要换乘的请求到达时,调度器将第一部分请求分给电梯,将剩余部分放入队列。当电梯每次结束一个请求时,通知调度器。调度器检索队列,如果发现存在余下的换乘部分,完全仿照"输入读取线程"的行为,向调度器--生产者交互队列中放入剩下的部分。这样做的好处显而易见:最大限度地完成了从未实现向已实现功能的等价转换。唯一需要新添加的不过是电梯的结束条件新增怕那段"剩余部分还未完成的请求"容器为空。

这样的弊端也显而易见:电梯线程直接调用了调度者线程中的方法,修改了调度内部的数据,而调度者此时仍在运行!!虽然我已经尽力保证了修改幅度最小,但这种写法并不推荐,很可能带来危险。最好的方法,至少应该采用研讨课中提到的消息机制,电梯仅仅负责传递消息,而具体的逻辑处理,由调度者自己的线程进行处理(没错,我又一次偷懒了,但是我基于近乎形式化的分析,证明了我的这种操作确实不会造成线程安全问题)。

调度算法

和作业2完全相同,具体迭代方式已经在上一小节说明

SOLID原则

  • 单一责任原则:

    • 输入类负责读取输入,并且将请求转交给调度器

    • 调度器:决定是否拆分请求并拆分,并将请求根据特定算法分配给电梯

    • 电梯:负责读取其本身的队列,在其内部实现具体的运行算法并完成运行,其功能较多

    • 时间估计类:估计某个电梯的剩余时间,其操作时,要求电梯类传递自己的几乎所有变量值到该类中,因此和电梯类的耦合性很高,但毕竟也属于"单一责任"。

  • 开放封闭原则:

    • 框架结构几乎完全延续,让第一单元次次重构的菜鸡本鸡也体会了一下"迭代式开发"的感觉(当然了,受到之前代码的架构限制,第三次作业的一些性能优化无法开展,损失了一(亿)点点性能分。
    • 三次作业的迭代中,不断添加并独立了新类,例如保存人员信息的PersonRequest类,相比于第一次作业的调度器类等。一些类之间的接口发生了变化,但逻辑几乎未变。具体对于三个核心类而言:
      • 电梯类:从作业1到作业2从"直接和Scanner交互变成了和调度器交互,从作业2到作业3新增了下人时通知调度器的功能。但总体架构延续。
      • 调度器类:从作业2到作业3新增了拆分请求并按需打入拆分后的请求的能力,是严格意义的增量开发,没有任何一行和新增功能无关的代码
      • 输入线程:从作业2到作业3新增了对于电梯请求的支持,由于电梯请求和人的请求使用了同一接口进行抽象,都是送进同一个队列,修改很小。
  • 里氏替换原则:不涉及继承,本来想将时间估计类和电梯类之间搞一点继承的方式,但是发现难以完成这个抽象,导致了很大一部分duplicate代码(时间估计类和电梯本身运行的算法几乎相同,唯一的区别是不真正睡觉,不用管线程安全),但是由于sleep方法不允许重写,导致难以将二者统一描述。笔者仍在思考如何更好地解决这个问题。

  • 接口分离原则:只有Runnable接口和Request接口,后者统一抽象ElevatorRequest和PersonRequest,实现了较好的抽象,降低了代码复杂度。

  • 依赖倒置原则:电梯类和电梯时间估计类,调度器类相互耦合依赖,但是这本身是设计策略,没有更好的方法。

  • 功能和性能的平衡:

    很难

    受到上学期毛概课展示中“***谈治国理政:底线思维”(最高领导人的名字无法通过cnblogs审查)启发,先写一个迭代最小,实现最简单的版本通过中测,然后向性能更优的方向迭代,以不破坏整体框架为底线逐步演进,直到整体框架限制内的局部最优方案,所谓”能做些啥就先做些啥“。但是,所谓”整体框架不受破坏“毕竟仍然是一个相对主观的概念。我也曾经提到过一个版本的代码为了实现性能而工程结构极其糟糕,各个线程相互传递大量的信息,进行大量的判定,大量的耦合,大量的check-then-act问题的处理,导致这个版本无法通过中测。随后,我就贯彻了“只做必要增量开发,尽可能向已有框架靠拢转化” 的方法,本来以为第三次作业的性能分扬了,但最终结果却有点出乎意料,这也再一次体现了基础框架的重要性。

基于度量的代码分析

  • 代码结构

  • 时序图

  • 代码复杂度

    • 方法复杂度

    • 类复杂度

gettime方法,run方法,haspeopletoget方法延续了之前的代码,分析于之前的章节。dealer方法是拆分请求的方法存在大量的特判来判定如何拆分(if-else),除非改变算法,这部分很难避免。

在类复杂度中,一大片的红色突出显示了自己和dalaos的架构能力差距。一方面,自己的算法确实比较复杂,导致了判断较多,流程较复杂,另一方面,写程序的时候也是满脑子正确性,还没有到有意识维护代码风格的能力。比如Ocavg最高的Scheduler, 其实很多方法是可以拆开/封装成新类/归并到其他太简单(do nothing)的类中,但是遵循错误的感觉,写到了Scheduler中,不仅难以复用,还增加了复杂度。由于对开始时对多线程不大熟悉,导致死板地将大量流程写在了run方法里,Scheduler和Elevator的接近一Main到底的run方法就被沿用了下来,成为了历史遗留问题。翻看了一些人的博库,看到某dalao的设计风格“所有的方法控制在三四十行”,深感惭愧。但是总体而言,复杂度过高的算法相比第一单元有明显减少(抓住一切机会赶快夸夸自己)

评测

本次作业没有在公测和互测中测出任何bug

在互测中,测出的两位同学的bug,都是电梯超载导致的,究其原因,是超载处理的代码逻辑考虑不周全。但是奇怪的是,一位同学的本地稳定复现超载bug的代码依旧在评测机始终无法判错。

心得体会

  • 主观地讲,这三次作业比Unit1简单,作业2到作业3的第一版迭代的扬性能分版本在数个小时就完成了,甚至比我写这篇总结的时间还短。

  • 自己架构设计能力还很糟糕。

  • 我个人避免死锁的经验是:避免形成环路。在那个无法避免环路的迭代版本中,毫不意外的翻车了。其他版本都出其的顺,导致看到不少同学抱怨死锁,CTLE,RTLE之类的问题时内心毫无波动。当然了,导致的负面影响是多线程debug实战能力没有得到充分的训练。

  • 多线程开发真的挺难的,如果不掌握正确的开发技巧,诸如消息传递机制之类的。多线程和祭祖出其的像,你需要在大脑中并发考虑,模拟所有线程的可能情况,然而人脑毕竟是单线程的,当数据耦合,逻辑业务越来越复杂时,这种考虑的复杂度呈指数上升,到最后就难以考虑清楚了。笔者的冯如杯项目的一环需要3D建模,其中一个流程需要进行平滑操作。某软件会在这个操作卡成PPT,打开任务管理器,发现它不使用显卡计算也就罢了,CPU占用率只有20%不到,呈现一核有难,11核围观的惨案。一个如此小的电梯程序,同学们被线程安全困扰地如此之深,那些大型的复杂软件又是如何做到安全地多线程并发的呢?这个问题依旧值得调研。

  • 自己在单元查找别人bug的策略主要是黑箱测试,且数据主要是随机生成。由于看了前一届的博客得知本单元互测bug很少+有点偷懒懒得改评测机,没有手动构造边界数据进行测试,看到后来的互测结果后,感觉自己损失了亿点点,下个单元我一定好好测(flag立起来)。

  • 感谢某位其实我不知道真实姓名的1706学长(or 学姐)提供的评测机源码,代码风格出其的好,扩展性很强,一看就知道作者是大佬。 得益于良好的代码结构,很快就能改到今年的作业要求上。

猜你喜欢

转载自www.cnblogs.com/alangy/p/12722075.html