面向对象设计与构造第二单元总结

曾华旭-22371554 学生 2024-04-20 19:44:27

面向对象设计与构造第二单元总结

一、架构模式

这一部分主要介绍了电梯调度各次作业中类和线程协作的设计与迭代,概括了多线程程序的整体框架,包含UML图和UML协作图。

  • 1.线程和类的总体架构 根据题目背景,结合可扩展性,我采用了生产者-消费者模型来构建电梯调度程序;并且以输入线程-调度线程-电梯线程作为三个节点形成两级流水线结构,其中输入-调度以输入队列InputQueue为共享托盘;调度-某电梯以电梯门口的队列ElevatorQueue作为共享托盘,为了方便电梯取用,后者还做了分楼层处理。该模式示意图如下。

    img


    在本设计当中,每个电梯只需要调度本电梯门口的队列;调度器负责将请求从总队列中分配到各个电梯的门口队列中。

  • 2.类的设计和迭代

    • 首次(第5次)作业中类的设计的要点有:

      • 分离电梯类和电梯调度策略类分离。具体来说,将电梯建模成状态机模型,具有WAIT,OPENED,EXITED,WORKED,ENTERD,CLOSED,MOVING等状态,实现了up,down,open,close,work,enter,exit等状态转移过程;策略通过将Operation类的对象交给Elevator通知后者下一步的行动。这样做的好处是电梯能够相对而言比较简单,调整策略时电梯类的修改较小,符合SRP;但有比较明显的缺点,即在我的实现中,存在大量的私有数据交换。

        img

      • 另一方面,由于各个队列比较相似但不完全一致,因此采用了我继承的方式构建了各个队列。RequestQueue实现了add,wait,setEnd等方法,再由各个子类实现它们相应的方法。

        img

    • 第二次作业(第6次) 提出了全局调度的要求和重置请求。

      • 全局调度需要调度器掌握部分电梯的状态,在此次作业中,我的线程设计不够好,Schedule线程无论是在判断是否应该停止还是在选择分配的目的电梯时都采用了直接查询电梯状态的方式,因此调度器和电梯的耦合程度较高,且许多电梯的方法都改为了同步方法。在此种设计中不幸出现了死锁问题且难以简单解决,因此我对类的设计做了微小调整,加入信号量ElevatorWaitingSignals作为Schedule和Elevator的共享对象,存储每个电梯是否处于空闲等待状态。
      • 对于重置请求,我将其当作和之前实现的电梯动作平行的一个操作,只需实现reset函数即可。重置时对于已经分配给电梯的请求,将其返回到InputQueue中即可,但这一过程涉及到比较复杂的同步控制问题,将在第三节:“同步块和锁的设置”中详细讨论。由于重置请求需要及时响应,而本调度器有可能由于多种原因阻塞而不及时分配,因此在我的设计中,重置请求不经过InputQueue队列,直接由InputThread转发给ElevatorQueue。其余的类几乎没有变化。本次作业的UML图如下。

        img

    • 第三次作业(第7次) 引入了双轿厢电梯。

      • 双轿厢电梯的需求在类设计的主要难点在于双轿厢电梯的地位问题,即将其看作两个独立电梯还是看作一个电梯的两个部分。为了进行高效的协作,我将其建模成两个独立的电梯。在进行电梯调度时,我将双轿厢电梯视为与单轿厢电梯地位相同的电梯进行分配。不过由于之前的作业中电梯是以运行在全楼层的单电梯的形式对外提供接口的,因此为了减少修改量,我新增了电梯井道ElevatorWell类封装单轿厢或两个双轿厢电梯对外提供统一接口。双轿厢电梯有一些不同于单轿厢电梯的特性,比如楼层有范围限制,输出不同等。然而绝大部分属性和方法同单轿厢电梯完全一致,因此我引入DoubleCarElevator类作为Elevator的子类对原有功能进行了扩展

      • 另一方面,第六次作业中直接将电梯当作共享对象来使用的缺陷进一步凸显,许多地方的同步和互斥逻辑难以理清,针对这个问题我进行了一些更改,把电梯中大部分锁去除,改成使用共享对象实现电梯与调度器之间的通信,如新增了共享对象ElevatorResettingSignals,NumberDirSharing;同时新增另一个共享对象ChangeFloorOccupied实现双轿厢电梯对换成楼层的互斥进入。此次作业的整体架构如下。

        img

      • 最后补充在迭代设计中的一个小问题。在之前的设计中,由于对未来的需求并不能准确预测,因此一些类的设计并不是十分完善,但在明显有限的迭代过程中,也没有必要为了设计的美观直接重构,可以在原来的基础上通过继承的方式添加新的功能。具体来说,原先我设计了Output类负责输出,但是后来新增了RECEIVE输出,又新增了双轿厢电梯的输出,因此我分别新建了ReceiveOutput和OuputDouble继承原有Output,扩展出新的功能。这样设计虽然不是特别美观,但是减小了工作量,效率比较高;同时也是OCP的践行。

    • 可扩展性和可改进点分析。在最终设计的基础上,程序还有没有进一步扩展的能力呢?

      • 本设计有哪些可以改进的地方?我设计的Schedule类保留了刚开始的单类形式,没有将其拆分,因此兼具了查询电梯状态、决定分配方式、进行分配的多个操作,违背了SRP原则,比较臃肿,如果进行改进,可以按照上述多个功能将其拆分成Search,ScheduleStrategy,Schedule等类,可以有效减少它超标的复杂度。

        img

      • 另外,程序中还有一个圈复杂度较高且设计比较有缺陷的地方,即电梯在状态转换/策略在接受电梯状态决定决策向量时使用了冗长的switch-case进行分配。似乎可以使用“表驱动法”进行改进,将二元组<枚举变量,方法>作为静态变量保存起来,再通过它调用,避免了大量switch-case的语句。
```
protected void changeState() {
    Operation operation = strategy.getNextState();
    switch (operation.getBehavior()) {
        case UP:
            up();
            break;
        case DOWN:
            down();
            break;
        case OPEN:
            open();
            break;
        case WORK:
            work();
            break;
        case CLOSE:
            close();
            break;
        case IN:
            enter(operation.getPassengerSet());
            break;
        case OUT:
            exit(operation.getPassengerSet());
            break;
        case ALL_EXIT:
            exitAll();
            break;
        case WAIT:
            waitForClients();
            break;
        case NORMAL_RESET:
            normalReset(((ResetOperation)operation).getNormalResetRequest());
            break;
        case DOUBLE_CAR_RESET:
            doubleCarSet(((ResetOperation)operation).getDoubleCarResetRequest());
            break;
        default:
            throw new RuntimeException("Unknown Operation of elevators");
    }
}
```
  • 3.线程协作关系
    下面以UML协作图(时序图)的形式分析最终设计中线程之间的协作关系。这一部分做简单展示,具体的实现在第二部分“调度器设计和调度策略”和第三部分“同步块和锁的设计”详细介绍。

    • 主线程负责开启和维护各个线程。

      img

    • 输入线程将乘客请求放入输入队列,将重置请求直接转发给电梯线程。

      img

    • 调度器前和输出线程协作,后和电梯线程协作;分别通过托盘Queue和若干信号共享对象(ElevatorResettingSignals/ElevatorWaitingSignals/NumDirSharing)实现。

      img

    • 电梯线程和输入线程存在协作,作为消费者接收reset请求;和调度器有协作,作为消费者接受乘客,同时作为生产者提供部分电梯状态。

      img

  • 4.设计稳定性分析

    • 稳定的内容:在我的设计中,整体的二级流水线结构没有发生改变,核心类请求模拟器、调度器、电梯、策略和各个队列都没有被删除,它们的内部结构也大体稳定。比如说单个电梯持有的调度策略类我在第一次作业实现之后基本上没有更改过;各个队列的父类也没有修改,子类视情况有一定更改;电梯类虽然有不少更改,但是状态机-原子动作的模式也没有变化,除了对输出的处理,open,close,up,down等原子性方法基本不变。核心线程输入线程、调度线程、各个电梯线程不变。
    • 易变的内容:程序指定的输出是非常容易改变的,每次作业都不太相同,因此我认为似乎我专门设置一个类来进行输出的操作没有必要;终止条件的判断也比较容易改变,几次作业我都有较大的改动,而且出现一些同步控制的调整问题;总体调度策略在后两次作业中有较大更改。

二、调度器设计和调度策略

这个部分结合我的两级流水线架构,介绍了单个电梯在调度门口队列的调度策略以及负责将请求分配给各个电梯的总调度器的设计和策略。

  • 1.单个电梯的调度策略 基本采用了look策略,结合了ASL的部分优点。
    • look策略:除了常规的电梯属性,增加一个电梯的一个状态,即当前运行的预期方向。初始时刻电梯运行的预期方向向上(A轿厢向下)。
      • 关门结束以后(closed)先结合电梯内部乘客情况和电梯外乘客情况决定是否进入空闲等待状态,若不等待,查看是否需要转换方向,判断结束后即开始向预期方向运行。
      • 到达一个楼层以后(waiting)根据内外乘客情况判断有无开门需求,若无,根据上述方法继续运行。
      • 由于在开关门期间有可能有新的请求加入,且能够顺路搭载,因此我采用了开门后,有需要的乘客出电梯,等待0.4s再让所有符合条件的乘客进电梯,最后执行关门动作的方式。
    • 借鉴ASL的调整。上述方法基本可以达到较高的性能,不过我设计之初有些担心上述策略导致个别乘客等待过久的情况,因:结合了ASL的思路,即在预期运行方向上选择可搭载的最早的请求作为主请求,在电梯单向运行的一趟中保证为主请求留下一个空位,在主请求送达以后更换主请求。
  • 2.请求调度器的设计和调度策略 我采用的是基于一些规则进行的调度,不过似乎设计得并不理想,在第三次作业中性能得分较低,可能的原因有只考虑了运行时间和乘客等待时间因素,没有考虑电梯耗电量的问题。
    • 规则集
      • 首先排除正在reset的全体电梯,如果均在reset,则等待。
      • 从全体电梯中选择顺路且人不是很多的电梯,所谓顺路即单电梯调度中运行方向同乘客前行方向一致且乘客所在位置位于电梯的运行方向一端。人不是很多指的是电梯内部和电梯门口的等待的乘客的总数小于电梯的载客量。如果找到了这样的电梯,则从中选出能将乘客直接送到的电梯(排除了不能直接送达的双轿厢电梯,这里没有考虑电量的因素),再找到从中找到能够最快送到达乘客出发楼层的电梯,如果中间某一步没有找到,则在前一个步骤中随机选出一个;如果没有找到则进入下一步。
      • 从中找出空闲等待的电梯,若无,则等待。
    • 可能存在问题的分析 上述规则的设计仅基于一些常见的情况和一些边界的情况进行考虑,因此不是很完备,在实际测试中很可能表现既不如单纯随机的设计,更不如性能计算导向的影子电梯设计。它可能存在的问题有:
      • 电量不在考虑范围内,可能存在电梯动作过多,或者单电梯由于能够直接将乘客送达的特性往往被优先选择,导致电量偏大。
      • “顺路”判断的缺陷,上述规则中优先选择了顺路的电梯,但是“顺路”设计预期方向的判断,然而空电梯如果停在楼层中央,实际上并没有真正意义上的预期方向,有可能看似“不顺路”其实满足搭载条件。一个可能的例子是某个电梯被启动起来以后在乘客没有达到满载时,那些顺路的请求会倾向于被分配给此电梯,导致其他同样空闲的电梯未被启用。这可能导致运行和等待总时间变长;不过这一实例下本规则反而能够节省一些电量,因此也未必完全是缺陷。
  • 3.请求调度器和其他线程的交互 从上述请求调度器的设计可以看出,调度器线程需要协作的线程包括输入线程和电梯线程。
    • 与输入线程的交互 本设计相对简单,即输入线程是生产者,调度器是消费者;前者向托盘内装入请求,后者取出进行分配。具体的同步控制见下一个部分。当输入结束时,向输入队列设置end信号,由调度器接受。
    • 与电梯线程的交互 这个部分设计相当复杂。一方面,调度器是生产者,电梯是消费者,前者将请求放在托盘里面,后者取出。当确认再也没有乘客会被分配以后,调度器将end信号发送到电梯门口队列,由电梯接收,电梯线程决定停止。了。另一方面,调度器需要查询电梯的状态决定如何调度以及是否停止。前者的要求相对不那么高,只需要查询到大致的状态即可,即便有误差也不影响整体运行,只是策略略差;后者则要求绝对准确,稍有不慎就会出现线程无法停止线程提前停止的情况。由于这两个方面的相互依赖,又容易出现死锁的情况。下面分别介绍这两方面的协作。
      • 生产者-消费者模式的协作。调度器将请求放入托盘后电梯;电梯从中按需取请求,同前一流水级一致。
      • 调度器查询电梯状态的首次尝试。在第六次作业中,我先进行了一次尝试,即调度器持有电梯对象,直接查询电梯状态,而且也没有区分电梯的攸关状态(isResetting/isWaiting)普通状态(当前运行动作/人数/楼层) 等,甚至将其耦合在一起,比如iswaiting的实现依赖于数量计算等等。这就导致电梯的大部分查询方法和状态转移方法都需要包装成为同步方法。这看似勉强能够实现,却但是实际上和我其他部分的实现相互作用导致了难以解决的死锁问题。具体地说,由于reset时候电梯需要将人返回到输入队列中,因此存在以elevatorQueue和inputqueue为锁的对象,elevatorQueue在外,inputqueue在里的同步块;又由于查询电梯状态的需要,又存在反向的锁,为了解决这个问题,我引入了共享对象来解决。
      • 调度器查询电梯状态的最终尝试。在第六次作业中进行了一点改进,在第七次作业中全面更新了上述方法,使用共享对象实现了同步控制,即上文提到的两个信号量。具体在下一个部分介绍。
      • 调度器和电梯停止的判定。我的设计是调度器先决定停止,并向每个电梯发出信号(通过ElevatorQueue),自己停止,每个电梯接收到信号以后决定停止。这里面唯一需要解决的问题是调度器如何停止。我的条件是在持有ElevatorWaiting的锁的同步块中判断输入队列EndAndEmpty,并且全体电梯处于空闲等待状态,这个时候可以断言再也没有乘客需要接送,可以停止。只不过电梯空闲等待的设置我还是没有处理得很清楚,当电梯均为空闲状态时调度器向电梯投放一个请求以后电梯可能来不及变成waiting状态调度器就进入终止判断并判定中止,这个时候再同步投放重置请求(这是由inputThread完成的)导致电梯将人踢出就会导致没法再将此乘客进行调度和接送,导致第三次作业互测中被发现bug。修复方法是当调度器向ElevatorQueue分配请求的时候就把ElevatorWaiting信号置为0。

三、同步块和锁的设计

这个部分结合整体架构和调度策略的数据共享需求,介绍了线程之间的同步和互斥机制在方法和同步块级别上的具体实现,包含第三次作业中防止两个电梯相撞的方法

  • 1.队列的同步控制与锁 这一个部分的控制比较容易,和实验课的方法一致。只需要将全体队列的add方法,get方法,查询方法都加上同步锁即可,由于在我的设计中不存在针对队列的check-and-modify等操作,因此在外部使用的时候也不用再重新在外部添加同步块。
  • 2.调度器查询电梯状态的同步控制与锁的具体实现。这里有三个共享对象,其方法均为同步放方法。一个是ElevatorResetting,负责保存电梯是否在重置,当电梯进入重置时负责将它置为1,重置结束置为0,并notifyAll;调度器查询时可能在这里wait。一个是ElevatorWaiting,负责保存电梯是否在空闲等待,每个信号灯保存一个电梯井的状态,当有双轿厢电梯时,两个轿厢分别有一个分信号管理它们是否在空闲等待,当同时空闲等待时总信号才为1。最后一个负责作为电梯人数和运行方向查询的信号量,当电梯正在进行人数改变和方向改变的操作时,调度器无法查询,这一个信号量的设计是由于在我的调度策略中,为了防止负载不均衡需要在一些依赖于电梯人数和运行方向的条件不满足时做等待。
  • 3.双轿厢电梯AB轿厢的同步控制与锁。使用了ChangeFloorSignals来保存电梯的换成楼层是否被占用。从上面的介绍可以看出,我的双轿厢电梯策略和单轿厢完全一致,因此运行动作完全由策略决定,不考虑换成楼层是否有电梯,如果有,则等待即可。当电梯决定进入换成楼层时就抢占这把锁,抢到了就持有这把锁直到离开换乘楼层(为了保证正确性,我的设计中只要电梯进入了换成楼层就一定会在关门的下一个动作离开) 再释放。这里有一处优化在于双轿厢电梯不能简单复用单轿厢电梯的移动方法,否则可能使得电梯运运行单层过久削弱性能。应该直接等待0.4秒再去抢占锁,等到抢到锁以后再进行状态转移和输出。

四、bug分析

1.测评机搭建 本人实现了简单的评测机器,支持进行随机和有一定指向的输入的生成;能够基于约束对输出进行检查。生成的思路是采用面向对象的方式,将乘客/reset请求建模为类,各个属性如楼层/电梯可用随机数进行生成,有一部分可以方便的手动设置。进行测试的思路是采用面向对象编程,模拟电梯运行。先从输入中解析乘客信息,维护初始电梯信息。过程中针对每一项输出,对电梯运行的楼层/开关状态/载客状态/reset状态/时间,以及乘客的动作进行合理性分析。例如对arrive。

    def arrive(self, floor, time):
        if floor == self.cur_floor:
            raise CustomError(f"还没动呢怎么就到了 At{self.cur_floor} id:{self.elevator_id}")
        if abs(floor - self.cur_floor) > 1:
            raise CustomError(f"跑了多层 From{self.cur_floor} To{floor} id:{self.elevator_id}")
        if floor < MIN_FLOOR or floor > MAX_FLOOR:
            raise CustomError(f"幽灵楼层 From{self.cur_floor} To {floor} id:{self.elevator_id}")
        if not (time - self.last_behavior_time >= self.move_time + EPSILON):
            raise CustomError(f"超速 From{self.cur_floor} To {floor} id:{self.elevator_id}")
        if self.state != ElevatorState.PARKING:
            raise CustomError(f"没关门 Elevator:{self.elevator_id} From{self.cur_floor} To {floor}")
        if len(self.passenger_list) == 0:
            if not isinstance(self, DCElevator) or self.cur_floor != self.change_floor:
                raise CustomError(f"没有receive就动 id:{self.elevator_id}")
        if self.state == ElevatorState.RESETTING:
            raise CustomError(f"resetting时候乱动 id:{self.elevator_id}")
        if self.reset_ready:
            self.arrive_count += 1
        if self.arrive_count > 2:
            raise CustomError(f"未及时响应reset id:{self.elevator_id}")
        self.cur_floor = floor

完整代码见https://github.com/zenghxSSS/BUAA-OO-Elevators-judge
2.死锁的分析(RTLE/WA) 针对可能出现的死锁问题,我的方法是追踪全体synchronized方法,查看外部是否存在其他同步块,然后将加锁的对象建立成一棵树,如果出现了环,则说明在一定条件下会出现死锁。我使用了该方法找到了上述死锁。如果无环,可以确定不存在死锁问题。
3.wait-notifyAll模式下的轮询(CTLE)/线程无法停止(RTLE)/线程提前停止问题(WA)这几个问题我也全部出现过。如果出现轮询(必要时候没有wait),可以在每个循环内部使用print大法进行分析,直到找到出现极大量输出的情况;修复只需在对应位置加好wait-notify即可。对于线程无法终止问题大概是由于同步控制不好,notify信号丢失或者notify信号没有加够导致的,比较难以复现,如果没有指向明确的数据,我几乎没有复现出来过,即使采用密集数据轰击也很难找到,这可能是个难题;修复也比较困难,往往需要另外加同步块进行wait-notify,而这又可能会导致死锁,因此建议新建一个对象作为共享对象进行控制。线程提前终止的问题往往是前一个问题的反面,复现和修复较为困难,可能的方法和上面相似。
4.调度策略导致的CTLE分析 我在第二次作业出现了许多同学都出现的负载不均衡问题。当许多电梯都在reset的时候就会将瞬间到来的大量请求全部给到剩下的电梯,于是reset回来的电梯就空闲等待,原来的电梯超负荷运转,最后超时。解决方法适当等待即可。

五、心得体会

本单元的设计中我有许多体会,也仍然有很多困惑,由于时间实在是来不及了,简单写两条。

  • 一个是采用共享对象进行线程同步控制是相当重要的。我刚开始事实上把电梯当作共享对象的操作尽管看上去实现方便,但是后续带来了大量同步互斥问题。
  • 另一个是策略选择和复杂性的协调以及简单复杂策略的协调问题。要实现非常完善的策略可能造成程序过于复杂,正确性受到威胁,因此应该把握好复杂性。而一些相对简单的策略其实有时候有比较良好的性能,因此性价比非常高,这也是值得思考和斟酌的。
  • 对于层次化设计,正如上面所提到的,在有限迭代的基础上可以不急于重构,可以在不完美的实现中利用继承进行功能扩展;同时还应该善于识别出相似的功能,进行继承与接口设计。
...全文
117 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧