OO第二单元总结

OO第二单元总结

第二单元作业为电梯作业,主要考察了多线程的相关知识,强调了线程安全、程序层次化设计等相关内容。

一、同步块&锁

Homework_1

由于第一次作业是单电梯,笔者只设计了两个线程-即输入和电梯线程,进行直接交互,共享对象只有请求队列,输入线程在请求队列中添加请求,电梯线程从请求队列中取走请求。当对请求队列添加或删除元素或读取元素时,为保证线程安全,需要对请求队列加锁并将涉及到读写请求队列的代码设置为同步块。而其它与请求队列无直接关系的代码段可以放在锁的外面,以保证多线程运行的效率。例如下面代码:

//输入线程的run方法
	public void run() {
        while (true) {
            PersonRequest request = elevatorInput.nextPersonRequest();
            synchronized (waitList) {  //输入请求,保证线程安全将请求队列加锁
                if (request == null) {
                    waitList.close();
                    waitList.notifyAll();
                    break;
                } else {
                    waitList.addRequest(request);
                    waitList.notifyAll();
                }
            }
        }
        try {
            elevatorInput.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
Homework_2

在第二次作业中,由于电梯不止一部,需要将总请求分给不同电梯,所以加入了调度器,具体结构图如下:

电梯列表、请求队列以及每个电梯的任务队列为共享对象。

  • 当输入线程与调度器交互时,在输入线程中增加人的输入请求时需要对请求队列加锁,增加电梯的输入请求时需要对电梯列表加锁,在调度器中访问请求队列或者电梯列表时需要对请求队列加锁,以保证请求队列对象的线程安全。
  • 而当调度器与各个电梯线程交互时,在调度器向某电梯的任务列表分配任务时,由于不影响其他任务队列,所以只需要将对应的任务列表加锁以保证其它电梯的正常运行,在电梯访问或者取出自己的任务队列元素时需要加锁,以保证各个电梯任务队列的线程安全。
  • 同样地,在同步块处理语句中,往往涉及到加锁对象的读/写,若不涉及,可以放在锁的外面。
Homework_3

第三次作业架构与第二次作业基本相同,共享对象也别无二致,故在此不作赘述。

二、调度器设计

Homework_1

第一次作业由于比较简单直接,故未设计调度器,输入线程与电梯线程进行直接交互。具体分配策略如下:

  • 若为Night模式,则让电梯在1层与有人等待的最高层之间往返,下楼时观察是否有请求,若有请求则捎带,直到电梯人数达到上限。
  • 若为Morning模式,则让电梯在1层与电梯内人的目的地的最高层之间往返(会让要去更高层的人先进电梯),虽然有时有失公平性,会延长某个乘客的等待时间,但是却使得电梯的总运行时间最短,效率最高。
  • 若为Random模式,首先观察电梯内是否有人,若没有人,则寻找离电梯最近的有人等待的楼层,将其设置为目的地;若有人,每到一层,都扫描附近楼层(上、下都扫描)看是否有能捎带请求(人要去的方向与电梯运行方向一致),直到可以捎带的人数加上电梯内人数到达电梯容量上限,这时选择可捎带请求所在最低/高层(若为上行,则选择最低层;下行选择最高层)作为下一站的目的地。类似LOOK,但貌似不是标准的LOOK算法。
Homework_2

第二次作业加入了调度器,由调度器将总的请求队列分发给各个电梯,然后各个电梯的运行策略类似第一次作业。调度器调度策略如下:

  • 先搜寻是否有较优的可分配电梯。遍历电梯列表中的各个电梯,看是否存在电梯内人数+请求队列人数未达到容量上限,且电梯运行方向与人要去方向一致的电梯,若存在不止一部,则选择离人最近的一部电梯将其加入对应的任务队列。
  • 若不存在这样的电梯,本着尽量平均分配的原则,选择电梯内人数+请求队列人数最少的电梯。
  • 这样的调度器调度策略保证了可以将请求队列中的请求及时地分配给各个电梯。

调度器通过请求队列与输入线程进行交互,当请求队列到达末尾时停止调度,否则将所有已存在请求分配完毕后等待请求;通过任务队列与各个电梯线程进行交互,调度器将到来的请求分配给各个电梯后,电梯运行,若输入完毕、任务队列为空且电梯内无人则停止运行,否则当电梯内有人时,将人送至目的地,电梯内无人时,等待任务的到来。

Homework_3

第三次作业加入了特种电梯,每种电梯的可到达楼层和容量各有不同。稍微调整了调度策略如下:

  • 根据请求判断可以选择的电梯
  • 在可选电梯中,选择电梯内人数+请求队列人数最少的电梯,若存在多部,则优先级C>B>A(C、B、A电梯速度依次递减)。

调度器与各个线程交互的方法基本上与第二次作业相同。

三、第三次作业的分析和总结

UML类图

UML协作图

UML时序图

第三次作业各个类的功能如下:

  • Elevator:电梯线程类,拥有电梯需要的属性,例如id、类型、所在楼层、状态、分配策略等,负责根据策略类传来的目标楼层进行移动,改变状态。

    Srategy:策略类,包括在电梯的属性中,可以根据任务队列以及乘客的到达模式、电梯最大容载量分析电梯的下一个目的地,传给电梯以指导电梯运行,可以计算电梯到达时需要进入的人。用以分担电梯的脑力活动,使得电梯可以傻瓜式按照指令运行。

    StateOfEle:相当于一个抽象类,是电梯各个状态的汇总。

    • StateWait:电梯等待状态,为初始状态

    • StateUp:电梯上行状态,睡眠一定时间后改变电梯楼层并将电梯状态设置为“Arrive”

    • StateDown:电梯下行状态,同为睡眠一定时间后改变电梯楼层并将电梯状态设置为“Arrive”

    • StateArrive:电梯到达状态

    • StateOpen:电梯开门状态,这之后乘客可以出和进

    • StateClose:电梯关门状态,这之前乘客需要完成出和进的动作

    ProcessingList:电梯内乘客列表,对电梯内乘客进行管理,可以向电梯中加入人、计算电梯内的人最高的目的地、判断电梯内是否还有人、人走后清除电梯内的对应人。

  • TaskQueue:任务队列类,为电梯的任务列表,可以在调度器中添加请求、在电梯中消耗请求,判断总的或者某一楼层的任务队列是否为空,计算人数。

  • Scheduler:调度器线程类,通过与输入线程和电梯线程交互,管理任务队列和总请求队列,负责消耗请求队列中的请求,经分析后加入到对应的任务队列。

  • WaitList:总的请求队列,可以加入、移除请求,判断请求是否为空、是否结束。

  • InputThread:输入线程类,判断请求是否结束,若为人的请求,则将其加入总任务队列,若为电梯增加请求,则在总的电梯列表中加入对应电梯。

  • MainClass:总线程,负责初始化所需对象并启动各个线程。

在性能设计方面,笔者未做模拟电梯、换乘等性能优化,基本的性能保证的方法处于策略类和调度器类中,在性能不至于过差的情况下也大致保证了功能设计的合理。若要优化性能,初步考虑是在调度策略类中加入对应方法,例如计算每部电梯接收此请求后的模拟运行时间的方法,有需要的话可以增加类,例如将判断换乘的图相关内容放在一个新的类中,这样可以将总请求队列中的请求更加合理地根据各个电梯的状况分配给合适的电梯,在本架构的基础上也基本可以完成。

四、bug分析

Homework_1

第一次作业强测未测出bug,互测出现了Random模式下的RTLE。

  • 综合数据发现:在判断可捎带人数时,只计算了一侧的可捎带人数,而没有实现平均效率更高的两侧扫描。具体来说,若电梯上行时,只扫描了当前电梯停靠楼层以下的楼层直到达到电梯最大容载量,然后去最低的楼层接到人,再一层一层向上,但是当下面楼层人数较多时,运行到上层时,很有可能由于电梯已经满员而无法捎带,此时电梯只能在送完电梯内所有人后,再返回送更高层的人。平均来说,效率较扫描两侧、接附近的人的策略较低。所以更改为此策略。
  • 当然,第一次作业还有一个bug,虽然在第一次作业中没有体现,却在之后的强测中成为了一个隐藏的炸弹。
Homework_2

第二次作业出现了2个bug,发现过程十分崎岖坎坷,而这很大一部分原因都在于第一次作业。

  • 第一处在于调度器策略的问题导致RTLE以及效率极低。当初在写完第二次作业时笔者发现轮询的情况经常发生,排除了没有wait和notifyAll在了不合适的位置的情况,经过了艰难的print大法调试后,终于发现了无论电梯是否在等待任务队列的到来,在电梯类中都会访问改变状态的函数,后来就将其改成了没有等待任务队列时才改变电梯状态,才解决了CTLE的问题。但这带来了一个新的问题,几乎只有一部电梯在运行,且慢的离谱,后来笔者发现自己的调度器最开始的策略是:寻找较优的电梯(即电梯内人数+请求队列人数未达到容量上限,且电梯运行方向与人要去方向一致的电梯),若找不到就先不分配,导致输入请求无法及时分配,而造成大量时间浪费,所以后来更改为将来到的请求及时分配,才解决了一部分的RTLE问题。
  • 第二处的发现在于bug修复时始终有一个点超时,在观察电梯输出数据后,经过仔细检查,发现了在第一次作业中留下的隐患,在策略类的计算目的地的函数中,判断完种种条件后,最终竟然没有更新目的地的值???这导致了电梯上行时,无法捎带楼下向上的请求,使得效率极低。所以这证明了,就算第一次作业强测互测过了,也不能说明自己的作业中没有惊天大bug,果然不能太相信自己: (
  • 后续进行反思时,主要觉得有两点原因:1. 写第一次作业时,有些细节没有想太明白、有些函数没有进行仔细的检查,但是第二次作业中又过于相信之前的作业,所以出现了debug时间极长的情况。 2. 对多线程的调试方法、容易出bug的点了解不多,不太熟悉,所以在出现bug时,无法精确定位bug所在地点。
Homework_3

第三次作业出现了一个手残+致命的bug。策略类中未传入电梯最大容载量的参数,导致计算可进入电梯的乘客请求时按照之前的最大容载量6进行判断,C电梯超载出现WA,A电梯不满使得性能下降。反思原因如下:

  • 未进行更多的测试,包括代码静态检查以及构造数据手动测试。若进行全面的静态分析,一定会发现这个低级的错误,而若构造较多请求同时进入C电梯的数据时,会发现出现超载情况。

  • 前面的作业没有考虑全面,没有将最大容载量作为参数传入而是直接手动体现在代码中。这样的后果是,若更改最大容载量或者每部电梯的最大容载量不同则需要手动更改涉及到此处的全部地方,很容易疏忽而产生未修改完所有的情况。所以这种可变参数在迭代最初时就应该考虑其存在形式,尽量做到修改时可以更改更少的地方。

五、hack策略

由于能力、时间、精力等问题,未花费太多时间为测试做准备,实属遗憾。

主要应用了观察别人静态代码的方式,检查是否有读/写共享变量时未加锁的情况、是否有层层锁嵌套出现死锁的可能、是否有notifyAll在不合适的位置导致轮询发生的可能、是否有该wait却没有wait导致轮询的情况、是否有特殊情况导致策略失效出现RTLE的情况等等。

在测试方面,本单元的测试难度明显比第一单元上升了一大截。体现在如下几个方面:1.多线程的执行顺序不同导致的不稳定性,有些bug很难复现,只有特定的执行顺序才可以体现出来,但是程序无法控制执行顺序 2. 编写自动评测机时,自动生成数据部分较为简单,但是判断结果的正确性时,考虑的因素却有很多:CTLE、RTLE、电梯运行和接送乘客不合理导致的WA等等,需要花费较多时间。

六、心得体会

  • 多线程作业中的一大难点就是线程安全,对线程安全的理解是在一次次尝试、一次次失败中逐步加深的。

    • 在第一次作业中,初步了解、初次接触多线程,是一个较为艰难的过程。受到实验的启发,本想加入调度器线程,但是奈何三个线程、一堆共享变量无法将思路梳理清晰,最终无奈之下减为两个线程,共享变量也只有等候队列,读写时都加锁,在未输入完成且处理完毕时应用wait-notify方式减轻CPU负担,记住这两项貌似问题不大,其余的时间花费在调度策略和结构分析上,但后来发现自己对于多线程一些更加深入地问题没有透彻的理解。
    • 第二次作业中,在CTLE的鞭策下,开始逐步理解多线程中重要的概念和易错点。什么时候会出现轮询?在该等待的时候反复访问CPU资源,或者在不该被叫醒的地方频繁被叫醒,或者,在wait时还在反复做无谓的事情,这时需要对程序深入分析,追本溯源,将线程联系起来,观察共享对象的变化情况以及可能出现问题的地点所在,在可能的地方打印出关键结果,若打印结果出现反复横跳,那么极大可能是出现了轮询。什么是notifyAll?notifyAll指的是所有等待(对象的)线程,尽管哪一个线程将会第一个处理取决于操作系统的实现,但是其它线程也已经被唤醒。什么时候需要notifyAll?在对共享对象修改,可以唤醒等待的线程时才需要加notifyAll,而不是加了锁最后就要notifyAll,所以notifyAll若加错了位置反复唤醒了不该唤醒的线程,就有可能导致轮询。
    • 若第二次作业理解了线程安全的大部分问题,那么第三次作业的线程安全基本可以得到保障。虽然没有出现死锁问题,但在讨论中,我也进一步了解了有关死锁的相关内容。什么时候会出现死锁的情况?当A在等B的锁,而B也在等A的锁的时候,当锁嵌套时需要特别注意这个问题,通过在锁嵌套的位置打印关键信息,可以观察到锁的使用情况以及谁拿到了锁而又陷入了等待导致程序停滞,从而可以精准定位bug,解决的方法一般是删去不必要的锁或者更换写法。
  • 有关程序结构的层次化分析方面,相比于第一单元,本单元的程序更加有层次,各个类各司其职,虽然有些部分的耦合情况较为严重,有些类还可以进行精简,但是整体的结构还是较为清晰的。良好的程序结构也使得后面的迭代开发变得没那么困难,果然,如果在迭代开发前想清楚需要的类、类与类之间的关系(交互式,作为属性、继承?)、每个类需要完成的工作,那么不出大问题的情况下,是可以在此基础上进行增量式开发的。除了整体架构,在电梯运行方面,笔者应用了状态模式,将总的状态定义为一个抽象类,上行、下行、开关门等状态继承该类,在类中判断电梯的下一步运行模式,类似于状态机,但是将其封装使得程序更加简洁,有层次。

  • 虽然本次作业出现的bug较多,但是学到的内容也是无可比拟的,在一次次磨练中成长也不失为一件有意义的事(不过以后还是要多多测试、多多阅读代码,少出bug多学知识,做到学有所用。

posted @ 2021-04-25 23:48  Miffy0  阅读(104)  评论(1编辑  收藏  举报