BUAA2024春-OOUnit2总结

oo第二单元总结

​ 第二单元的主题是多线程分析与设计,背景是电梯调度,需要模拟一个实时的多电梯系统,通过多个线程的并发协同,按一定的要求完成请求,将乘客送达目的地。第五次作业比较简单,指定了请求需要分配给的电梯,主要目的在于初步熟悉多线程程序的编程方法;第六次作业不再指定请求的分配方式,需要自行实现调度策略,同时新增了RESET种类请求;第七次作业新增了双轿厢电梯和相应的运行约束。

程序架构分析

UML图

三次作业后最终的架构如下。

在这里插入图片描述

UML协作图:

在这里插入图片描述

程序有三种并行的线程:输入线程InputThread,调度线程ScheduleThread,电梯线程ElevatorThread,它们之间通过RequestPool类共享对象实现交互:InputThreadScheduleThread共享待分配的请求池waitRequestsScheduleThreadElevatorThread共享分配给某部电梯的、待处理的请求队列elevAllocatedRequests

线程间的交互模式主要采用生产者——消费者模型。InputThread获取输入,得到请求对象,将请求放入waitRequestsScheduleThreadwaitRequests中取出请求,按照一定的方式分配给某部电梯,放入它对应的elevAllocatedRequests中;电梯从elevAllocatedRequests中取出请求并完成执行。

生产者——消费者与结束标识

本单元的架构中,线程之间存在着多对生产者——消费者关系。InputThread接受输入,产生请求,是生产者ScheduleThread取出请求,准备下一步分配,是消费者;它们间的托盘是待分配的请求waitRequestsScheduleThread将请求投放给电梯,是生产者ElevatorThread取出请求并完成执行,是消费者;它们间的托盘是分配给每部电梯的待执行请求队列elevAllocatedThread

线程什么时候知道自己该结束了呢?生产者完成生产后,为“托盘”加上结束标记;消费者不断访问“托盘”,如果托盘为空且已经有了结束标记,即托盘中已经不会再有新内容了,则此时结束线程。

在hw5,输入线程检测到输入结束后,就表明一定不会再有新的请求需要处理,就可以为waitRequests加上结束标记,从而使调度线程和电梯线程依次结束。而在hw6加入RESET后,电梯线程也可能成为请求的生产者(RESET前要将所有已搭载的乘客重新调度),此时如果还以输入结束作为结束标志,则可能会导致部分请求没有处理完。为解决这一问题,我参考往届学长的博客,采用单例模式实现了RequestCounter类,计数总请求数和已完成(乘客到达目的地)的请求数,保证所有乘客都送达后再发出结束标记。可以说这种方式是抓住本质的。

三次作业的迭代分析
  • hw5比较简单,在这次作业中主要建立起了线程协作的整体架构,也即上述输入——调度分配——电梯执行三层架构,这一整体架构在三次作业中保持稳定。
    • 调度:本次作业不涉及调度,数据输入时直接指定了请求将要分配给哪部电梯。
    • 电梯运行:电梯的模拟采用了电梯线程ElevatorThread电梯Elevator分离的设计,电梯的属性(编号、载客、运行速度等)封装在单独的电梯类中,每个电梯线程管理一个电梯类对象。电梯的运行采用了朴素的LOOK策略,由策略类对象Strategy分析电梯当前状态给出建议(OPEN,CLOSE,MOVE,REVERSE)。
  • hw6有两大方面迭代需求:
    • 实现RESET请求。在输入调度阶段,由于RESET请求需要被尽快执行,实现了RESET请求的**“插队”——如果检测到RESET请求,则将它放入waitRequestelevAllocatedRequests的最前面,优先被调度分配。在电梯运行**阶段,RESET请求作为电梯的一种运行逻辑,同样由策略类分析并给出建议,电梯完成“开门、放人、关门、开始重置、结束重置”的一系列操作。由于RESET后电梯中原有的乘客要重新加入调度,因此待分配的请求池waitRequest也成为了电梯线程ElevatorThread的共享对象。
    • 实现调度策略。在hw6中不再指定请求分配的电梯,需要自行实现性能较好的调度策略。我采用了影子电梯的调度策略,在分配某个请求时,深克隆每部电梯当前的状态,计算每部电梯完成已有请求+新增请求所需的时间,将请求分配给完成时间最短的电梯。我通过ShadowElevator类中的静态方法simulate()实现模拟,将深克隆的电梯和请求队列作为参数传入,运行逻辑与ElevatorThread类似,只是将sleep()换为了加上对应的时间,最后返回完成请求所需的总时间。
  • hw7的主要工作是实现双轿厢电梯,以及对调度策略作相应的调整。
    • 实现双轿厢电梯。本次作业新增了一类RESET请求:DCElevatorReset,电梯接受到后需要重置为双轿厢电梯,即A、B两个电梯共用一个编号和电梯井道,A电梯只能在换乘楼层以下楼层运动,B电梯只能在换乘楼层以上楼层运动,两部电梯不能同时处于换乘楼层。
      • 对于电梯运行,我没有用新的类实现双轿厢电梯,而是通过在原有的电梯类中新增属性isSplit来标识双轿厢电梯。新增了电梯的一种运行逻辑TRANSFER,由策略类分析并给出建议:当电梯同时满足是双轿厢电梯在换乘楼层有请求需要换乘才能完成三个条件时,电梯开门放人,并创建新的请求投入请求池waitRequests。后文会用单独一节介绍如何实现双轿厢电梯不碰撞,即不同时处于换乘楼层。除此之外,双轿厢电梯的运行逻辑与普通电梯大致相同。
      • 对于调度,我最终选择在调度线程ScheduleThread中完成双轿厢电梯两个轿厢线程的创建和启动。这是因为,如果由电梯线程ElevatorThread完成这一工作,新增新的电梯(线程)并将其加入调度管理,则会导致elevators也成为共享对象,ElevatorThreadScheduleThread的通信需要复杂的同步控制,稍有不慎就会出现难以预料的线程安全问题。在调度器线程中创建新的电梯并直接加入自己的管理,电梯线程只需要管好自己内部的一部电梯——这样的设计既符合逻辑,也方便实现,不易出错。
三次作业的变与不变
  • 稳定的部分:在几次迭代中,输入——调度分配——电梯执行这一整体架构是始终未改变的。在hw5一开始我就确立了这种架构(尽管hw5相当于没有调度环节),事实证明这一决定是正确的。确立了这一架构,也就为之后的迭代确立了锚点,在迭代时只需要考虑哪些环节需要新增哪些工作,也就相当于基于架构将迭代工作划分成了几个模块。
  • 易变的部分:迭代中的变化主要集中在调度电梯运行两部分。调度可能采取不同策略(如hw6),也可能由于电梯运行的变化而需要调整对电梯的管理(如hw7)。电梯运行则承担了模拟电梯的主要逻辑,对于迭代是最敏感的。
扩展性分析

应当说,本单元作业的架构比较稳定,扩展性也较好。正如前文所述,如果需要新增电梯的运行逻辑、新增或修改调度策略,都只需要明确职责后修改对应的部分即可。共享对象实现为线程安全的类,而在线程中没有出现同步控制逻辑,这也为较好的扩展性打下了基础。

同步块和锁

多线程编程的关键是线程安全,线程安全和线程同步通过锁来实现。

在hw5中,由于对多线程尚不熟悉,同步控制逻辑比较混乱,分散在了共享对象类和线程类中。尽管通过了强测和互测,但在之后的课程中我了解到,这其实是一种非常不好的设计,很容易埋下死锁或是线程无限等待的隐患。因此在后两次作业中,我将同步控制全部放在了共享对象类(RequestPool类,hw7双轿厢相关的Flag类、Wake类)中,用synchronized关键字对方法加锁,而线程类中不出现同步块。这种设计比较清晰,也划分清楚了类的职责(线程类只需要运行逻辑,共享对象来保证线程安全),debug时也比较容易定位问题。

在作业中,我还为电梯类Elevator中的每个方法上了读写锁,主要考虑到调度线程在进行影子电梯模拟时,需要深克隆电梯的状态,此时电梯就会成为ElevatorThreadScheduleThread的共享对象。由于读多写少,用synchronized同步效率太低,因此选择读写锁。(只是我最后发现读写锁上假了,调度线程根本没拥有锁,更不会去尝试获取锁,根本没被锁住。不过也没有导致什么问题,原因应当是深克隆的时间远小于电梯状态改变的时间)

调度器设计

在hw6和hw7中,我的调度策略均采用了影子电梯的调度策略,在分配某个请求时,深克隆每部电梯当前的状态,计算每部电梯完成已有请求+新增请求所需的时间,将请求分配给完成时间最短的电梯。关于影子电梯的实现和调度线程与其它线程的交互方式,均已在前文有过介绍。

这种调度只关注了运行时间上的性能,而未关注耗电量等其它性能指标。一种优化的思路是,在影子电梯模拟时同时进行运行时间和耗电量的运算,最后综合各个指标加权计算电梯的评分,分配给评分最高的电梯。不过,由于最快完成请求的电梯通常进行的操作较少,耗电量一般也不会过大,只计算运行时间也未尝不可。

值得一提的是,在hw7中进行调度时,我将双轿厢电梯的每一个轿厢都视作一个独立的电梯,如果一个分配给它的请求不能被完成,将乘客在换乘楼层放出也算完成请求,这样在相同条件下,双轿厢电梯模拟得到的运行时间会显著小于普通电梯,基本上会被优先分配。由于其它bug,hw7的强测炸了,因此无法对这种策略的性能进行较全面的评估,不过理论上不会太差。

双轿厢的实现

分裂与唤醒

如前文所述,在接到DoubleCarElevatorReset请求后,我在调度线程中完成电梯轿厢(线程)的创建和启动。需要注意的是,调度器启动的A、B电梯线程不能立即投入工作,而必须等待对应id的电梯DCReset执行结束后才能接受请求。否则会出现在输出RESET_END_(id)前,某一轿厢就输出RECEIVE_x_(id)_(A|B)的情况,导致出错。不能通过让调度线程sleep一段时间等待电梯DCReset完成来解决这一问题——否则,如果在DCReset指令后紧跟一条普通Reset指令,调度器对普通Reset指令的分配就会被大大延迟,从而可能导致RESET_ACCEPTRESET_END之间的时间差超过5s。
为解决这一问题,我新增了Wake类,将其作为某一id的三部电梯(父电梯、AB电梯)的共享对象。父电梯在完成DCReset请求后,通过wakeUp()方法唤醒AB两部电梯,再退出线程。在电梯线程最开始检查是否为双轿厢电梯isSplit(),如果是,则需要等待父电梯将它们唤醒(waitWakeUp()),才能开始工作。

不碰撞

关于双轿厢不碰撞的实现,基本参考了讨论区里姜涵章同学的分享:双轿厢电梯共享一个Flag类对象,其中一个轿厢在尝试进入换乘楼层前(输出ARRIVE前)通过setOccupied()方法尝试获取进入换乘楼层的权限;如果此时换乘楼层已经被另一轿厢占用,则等待另一轿厢离开;某一轿厢移动一层离开换乘楼层后,通过setRelease()方法释放权限,并通知等待中的轿厢。

我在实现时还有一个细节,就是双轿厢电梯不会在换乘楼层等待请求。如果轿厢在换乘楼层时已经没有需要处理的请求,则先移动一层离开换乘楼层,再等待新的请求到来。

有关双轿厢的控制代码如下:

//ElevatorThread.java
public void run() {
        while (true) {
            if (elevator.isSplit()) { elevator.waitWakeUp(); }
            if (elevAllocatedRequests.isEmpty() && elevator.noMoreOut() && elevator.noMoreIn()) {
                if (elevAllocatedRequests.isEnd()) { return; }
                else {
                    if (elevator.isSplit() &&
                            elevator.getCurFloor() == elevator.getTransferFloor()) {
                        try { sleep(elevator.getMoveTime()); } catch (InterruptedException e) {
                            throw new RuntimeException(e); }
                        elevator.move(true);
                    }
                    try {
                        elevAllocatedRequests.waitForRequests();
                    }
                    catch (InterruptedException e) { throw new RuntimeException(e); }
                }
            }
            ...
        }

bug分析和debug心得

在这一单元我深度体验了debug的快感,也算是积累了一些心得。下面简单记录几个具有代表性的bug。

  • RECEIVE输出错误。hw6强测的版本中,我的RECEIVE信息在调度线程中分配请求后直接输出,由于线程行为的不确定性,可能会出现电梯实际已经接到请求并开始运行了,RECEIVE才输出,从而导致错误,在强测中白白WA了3个点。输出信息(ARRIVE,OPEN,RECEIVE...)都应该是电梯线程的工作。根本原因是类的职责划分不清,以及对线程的不确定性没有深刻认识导致的。

  • 调度策略失误。在hw6中,影子电梯模拟时,原本的调度策略是,请求到来时如果遇到正在reset过程中的电梯,则直接跳过,不模拟它的运行时间;这种策略在面对极端数据时会有严重问题。如互测中被hack的数据,5部电梯都在reset时遭遇了某一时刻投喂的大量请求,按照我的实现,会全部都丢给1号电梯,而其余5部电梯在reset结束后则永远闲置下去,最终导致RTLE。

  • 结束问题。在写代码的过程中,经常出现线程无法正确退出的bug,原因通常是某个线程在等待某个条件的发生(通常是等待新的请求),但这时条件已经不可能发生,线程就陷入了无限等待无法退出。在hw7中,强测就炸在了这一点上。在我原本的实现中,某一部电梯在接收到DCReset请求后,会新开两个电梯线程,将新建的电梯加入调度器管理,再结束本线程。但是新开的电梯线程的请求队列elevAllocatedRequests居然是new出来的,这就导致在请求都完成后,调度线程ScheduleThread对电梯请求队列设置的结束标记setEnd失效,最终导致程序无法正确退出,出现了一条DCReset指令就能卡RTLE的奇景。

至于debug的心得,主要有两方面:定位问题分析问题

  • 定位问题:由于多线程无法通过断点调试,只能通过添加输出信息来定位问题。在程序的关键节点输出关键信息,观察是否符合预期;如果程序卡住,则可以通过输出信息观察到在哪条指令前停住了(一般是在wait()前无限等待)。
  • 分析问题:需要充分认识到多线程的不确定性可能导致的问题。线程安全是永恒的主题。

对于找到他人的bug,主要的策略就是通过构造针对线程结束的特例(如只有一条RESET指令)和构造极端数据(如所有电梯都RESET时同时投放大量请求,将所有电梯重置为双轿厢电梯等)。对于很多同学(包括我自己)的程序,就算通过了强测,面对极端数据的压力测试时还是会暴露出很多问题。

心得体会

写到这里,oo第二单元也接近尾声了。在层次化设计上,我自认为还是做得比较好的,整体架构在迭代中基本保持稳定。但在线程安全方面,在hw6和hw7(尤其是hw7)狠狠地栽了跟头。尽管已经尝试应用课上学到的方法,如实现线程安全的类等等,但还是因为想当然而多有疏忽。

思绪万千,洋洋洒洒近五千字也仅是聊以记录。不论结果好坏,这一阶段的学习终归是结束了,闭上眼都在想电梯的日子也结束了。吸取教训,整装前行,准备开启新的篇章。

  • 12
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值