OO第二单元总结


架构分析

第一次作业

第一次作业架构

三种线程:输入线程、调度线程、电梯线程

输入线程和调度线程共享waitQueue队列,调度线程和电梯线程共享processingQueue队列(有六个,放一个列表里统一管理)。

Passengers类用来放进入电梯的乘客,其实和RequestQueue类差不多,但是因为这部分不需要同步控制,所以很多操作都不需要,于是我新建了一个Passenger类管理。

Strategy我按指导书的要求建了这么一个类,不过在后面的作业中发现这个类好像没什么用,我就删掉了。

第二次作业

第二次作业架构

第二次作业增加了电梯重置请求,我在第一次作业的基础上做出了如下修改:

  1. 修改单电梯调度:研讨课发现好多人都用的LOOK算法,比我自己写的逻辑清晰很多,所以第二次作业我也改为Look算法,参考了往年的学长的博客,电梯的Action分为:OPEN、CLOSE、REVERSE、MOVE、WAIT。每次都从T_T中选择一种,然后调整电梯的属性。

  2. 增加多电梯调度:receive方法拒绝自由竞争,我用的均分方法,但是忽略了高并发的问题,强测别狠狠hack了T_T

  3. 增加reset处理:本来尝试新增SafeResetRqst类,增加ArrayList<SafeResetRqst> resetRequests对象,作为InputThread和ElevatorThread之间的一个托盘,但是导致ScheduleThread不太好分配(可能出现检测到某个电梯的resetRqst为空,然后在往该电梯的请求队列中分配请求时该电梯的resetRqst不为空的清空)。

    于是换成新增ResetTable类,每个电梯都可以与InputThread、ScheduleThread共享resetTable对象,但是只改变自己对应的resetRequest,然后ScheduleThread中多电梯调度通过调用resetTable类中的dispatch方法实现。

  4. 结束条件改变:因为waitQueue会重新接受来自电梯乘客表、等待队列的请求,所以ScheduleThread、ElevatorThread结束条件改变。我参考了往年学长的方法,用了单例模式设置一个Counter类的counter对象来计数,当InputThread结束且总请求数sum==完成请求数finished时,则可以结束ScheduleThread、ElevatorThread。

第三次作业

unit2-3

第三次作业增加了重置为双轿电梯的请求。我在第二次作业的基础上做了比较大的改动,具体有如下几个方面:

  1. 增加类ElevatorFactory:使用简单工厂模式生产不同的电梯,同时把需要共享的对象waitQueue、processQueues、resetTable作为ElevatorFactory类的属性,这样可以在”生产”电梯时给每个电梯“装配”这些属性。此外,这个类我也使用了单例模式,方便在其他类调用这个类里的方法。
  2. 增加类DoubleElevatorA、DoubleElevatorB管理双轿电梯:SingleElevator、DoubleElevatorA、DoubleElevatorB都是ElevatorThread的子类,其中SingleElevator就是之前作业实现的普通的单轿厢电梯
  3. 新建了一个类Status让双轿电梯共享彼此的状态:同一个井道的两个电梯必须知道互相的状态,避免在换乘楼层发生碰撞。
  4. DoubleElevator的单电梯调度策略:在Look算法的基础上做了以下两点修改
    1. 在电梯里没人且电梯请求队列为空时,增加了一个判断“电梯如果在换乘楼层,则继续移动一层”:为了防止两个轿厢相撞,电梯最好不要在换乘楼层久待,所以没请求的时候就赶紧离开
    2. 电梯的Action枚举里增加了一个动作FORCE_OPEN_AND_REVERSE,即电梯到达换乘层时必须强制开门把电梯里的乘客仍回waitQueue,然后转向:这个动作放在LOOK算法的实现方法getAction()的最开始判断。为什么要把FORCE_OPEN和REVERSE这个动作放一起呢?最开始没理清,没把这两个放一起,导致之前的代码其他的动作判断全都得加判断“电梯如果在换乘楼层,根据方向判断是否需要反向,如果不需要转向再判断…(原本的LOOK算法逻辑)”。后来突然意识到电梯到达换乘楼层就肯定不能继续MOVE了,一定要转向的(REVERSE),那干脆这两个动作就放一起好了(感觉这个就很像OS里说的原语,不可分割)。这样之前的代码逻辑除了上面说的那一点,其它都不用改动了。
  5. 删除strategy类:第三次作业了,我仍不知道这个类有什么作用,所有就把它删掉然后把单电梯调度策略写在电梯线程里了。

协作图

unit2-协作图

三个线程组成两个“生产者-消费者”模式,其中电梯线程重置时需要将未完成的请求重新传递回总请求队列,再次由调度线程分配。

变与不变

三次作业中,请求队列表和电梯里的乘客表没有特别大变化,三个线程的结构没变,但是每个线程内部易变

  1. 输入线程:请求类型增加,逻辑修改简单,只需略微修改
  2. 调度线程:分配策略需要改动
  3. 电梯线程:随着电梯类型的改变,电梯的调度策略有略微变化

同步块的设置和锁的选择

同步块我全部使用的方法同步块,听到好几个同学代码块同步造成“缝隙”出现难以复现的bug,感觉还是在方法里同步不容易出问题(虽然可能会造成一些不必要的同步影响性能)。大多数共享对象的类里的方法我都是用synchronized修饰来同步,最后一次作业为了防止两个轿厢相撞新建的类Status里,我用的是读写锁。因为感觉大部分的共享对象还是写操作为主,读操作不多,用读写锁收益不大(但是好歹学了读写锁,还是想用用,所以最后一次作业尝试了一下),而且读写锁不能直接用wait(),实现等待需要新建一个Condition类的对象与锁关联,有点麻烦。

调度器设计

调度器单开一个线程ScheduleThread,当一个“中间商”,从Input Thread的总请求池拿请求,然后根据自己的调度策略分配给特定电梯的请求队列。

调度策略

第一次作业不需要自己实现调度策略。

第二次作业用的均分策略,设置一个circleNum,从0-5循环,每次分配完一个请求就+1,等于6就归零。如果当前circleNum对应的电梯在重置,就跳过。但是这么分其实并不完全平均,所以互测被hack了(详见bug分析)

第三次作业的调度策略可能是估算等待时间和权重赋值的结合吧。因为第二次作业性能挺差的,然后第三次作业的时间较为充裕,所有还是想改改调度策略。调度逻辑如下:

  • 对于每个请求者,遍历所有电梯
  • 对于每一个电梯
    • 查看电梯的当前状态,计算电梯移动到请求者所在楼层需要多久。

    • 考虑一些特殊情况:

      • 对于单轿电梯,如果当前电梯在重置,score翻倍。
      • 如果当前电梯的请求队列人数过多(这个“多”的权值自己设置,我设置的是超过电梯最大限乘人数就算多),score翻倍。
      • 对于双轿电梯,如果该请求者能进去该电梯但是该电梯不能直接把它运送到目的地,score翻倍。
  • 找出score分最小的电梯,准备把请求者分配给它。
    • 如果该电梯还在重置,就等它重置完再把请求者加入到它的等待队列。
    • 如果该电梯不在重置或者是双轿电梯,就直接把请求者加入到它的等待队列。

可以看出来,这个分配策略非常粗糙,处处透着不严谨:时间计算不严谨,不考虑同步问题,只看开始计算这一瞬间电梯状态如何;权重赋值不严谨,对于重置、请求队列人数过多、不能直接运送到目的地这些特殊情况我都直接当成效果差不多的不利条件,都把score分翻倍,没有赋予不同的权值。但是最终分配策略的效果似乎还可以?反正强测性能分还不错,挺让我意外的。但是其实这个分配策略对高并发的处理依旧不完善(见bug分析)

bug分析

第一次作业没用LOOK写单电梯调度策略,然后自己设计的策略逻辑又非常复杂,导致电梯出现移动到非法楼层的问题,bug出现在中测,强测、互测没出问题。

第二次作业在互测被高并发的数据点hack到了,因为如果某一时刻重置的电梯很多,并且这一时刻来了大量请求,就会出现所有请求都被分配给那个没重置的电梯的情况。于是修改成每个电梯请求队列设置一个上限,超过了就不再分配,同时让重置的电梯可以接受请求者。

第三次作业再次被高并发的数据点hack(有时候真的被自己蠢笑了),因为我调度策略大改,然后这个地方又没考虑周全,当时想着我重置状态的电梯可以分配请求应该就没问题吧,就没细想,然后再次被hackT_T。原因就出在我的score在“请求队列人数多”这个特殊情况只是简单的翻倍,bug修复我把这里改成score *= 请求队列人数 / 该电梯最大限乘人数。

心得体会

其实很喜欢这个单元结合实际、贴近生活的题目,虽然刚开始写多线程非常地痛苦,因为无法单步调试,debug十分困难,尤其是在第二次作业,需要把未完成的请求者扔回总请求队列,并且ScheduleThread、ElevatorThread线程的结束条件要改变,稍有不甚就死锁了,我反复删删改改才比较完美地实现了线程控制。不过虽然过程很痛苦,结果还是不错的,至少这个单元我在强测没有因为同步控制不当出现线程不安全的bug。

以下是一些个人实现同步控制过程中困惑/不理解的概念的总结。

synchronized修饰方法的理解:

  • 如果一个类A里有多个synchronized修饰的方法,对于这个类的一个实例对象a,同一时刻只有一个synchronized方法可能被访问。因为锁的是这个实例对象。
  • 如果一个类A里有个synchronized修饰的方法,对于这个类的多个实例对象a1,a2,a3…每个实例的这个方法都可以被不同的线程同时访问。
  • 对于一类的对象,在synchronized修饰的方法里调用此对象的另一个synchronized修饰的方法是可行的。同样因为这个对象已经获得了锁。

传给方法的参数是对对象的引用,在方法里改变这个对象是在改变这个对象本身

wait和sleep的使用:

synchronized修饰的代码块里(假设对象叫a)如果需要等待应该用a.wait(); 方法里需要线程sleep可以写成Thread.sleep()

直接调用run 线程调度执行run
不会创建新的线程。因此,它不会表现出任何并发性。 start方法会启动一个新的线程,并在该新线程的上下文中异步执行run方法

Author: CuberSugar
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source CuberSugar !
  TOC