面向对象第二单元总结

本文为面向对象课程第二单元“电梯”作业的总结。

线程同步分析

这一单元的三次作业中我使用的多线程架构大致是相同的,整个程序有两类线程:

  • 输入线程:使用课程组提供的官方包从标准输入读取输入指令;
  • 电梯线程:根据调度指令运行电梯并使用官方包输出操作信息。

作为程序核心的调度器并不设置单独的线程,而是使用类似观察者模式的形式,由输入类和电梯类分别在有新的输入和电梯状态变化时通知调度器类,在各自的线程上执行相应的方法,调度器类内部会根据需要进行适当的线程同步。

输入线程不会直接访问任何共享的数据,只会在新的输入到达时通过方法调用通知调度器,因此无需进行任何线程同步操作。电梯线程则因为可能需要从不同线程接受调度指令,因此使用了 LinkedBlockingQueue 类作为指令队列,从而避免其他线程添加指令和电梯线程取出指令时的竞争。

而调度器类因为在几次作业中的调度需求不同,其内部的线程同步设计也有所不同。

第一次作业

我在第一次作业中为 Morning、Night、Random 三种输入类型设计了不同的调度器类,它们分别使用了不同的线程同步设计。

  • Morning:对于此类输入,我为输入的请求维护了一个请求队列,它也是唯一可能同时被输入线程和电梯线程访问的数据,即输入线程读取到有新的请求时需要放入队列,而电梯线程在开门后需要从队列中取出请求,因此使用了 PriorityBlockingQueue 类。其他状态数据均仅会被电梯线程访问,无需同步。

  • Night:由于此类输入会一次性全部给出,因此输入线程读取完全部请求后才会开始调度电梯,不会出现竞争的情况,无需同步。

  • Random:此类输入的处理要复杂一些,输入线程和电梯线程都有可能触发调度操作,因此也都有可能访问调度状态信息,所以使用了一个锁对象将所有公有方法全部用 syncronized 保护起来以避免竞争。考虑到调度操作的耗时基本都很短,这种粗粒度的同步方法对性能的影响是很小的。

第二次作业 & 第三次作业

这两次作业中仅设计了一个通用调度器类,同步方法与第一次作业中的 Random 调度器类似,也是使用一个锁保护所有公有方法。但是由于调度器的设计变得复杂,方法调用关系也更复杂了,可能出现这些加锁的方法相互调用的情况,所以不再使用简单的 synchronized 块,而是使用了允许同一线程多次获取的 ReentrantLock 类。

调度器设计

第一次作业

第一次作业中为不同的输入类型设计了不同的调度器类,它们共有一个抽象基类 Scheduler,它实现了一些简单的工具方法和一个用于创建不同类型调度器的工厂方法。此外 Scheduler 类还要求其派生类实现 InputRequestListener 接口和 ElevatorWatcher 接口。当输入线程读取到新的请求或是发现输入结束后,会通过 InputRequestListener 接口分别调用 onNewRequestonInputFinished 方法通知调度器;调度器中保存了电梯线程的引用,在执行调度时会通过后者的 addCommand 方法向它的指令队列添加新指令;而每当电梯执行完一个指令后,都会通过 ElevatorWatcher 接口调用 onCommandComplete 方法通知调度器。

MorningScheduler 负责处理 Morning 类型的输入,onNewRequest 方法会将新的请求添加到请求队列中,onCommandComplete 方法会触发一次调度。调度时先确认电梯状态,若电梯位于一楼且门已打开,则从请求队列取出请求直到电梯满载,然后关门;否则若轿厢内有人,则按楼层从上到下运送乘客,若没人,则将电梯恢复到一楼开门的状态。

NightScheduler 负责处理 Night 类型的输入,onNewRequest 仅仅将请求先按出发楼层分别存储起来,直到 onInputFinished 被调用时,才触发第一次调度,此后则在 onCommandComplete 被调用时触发后续的调度操作。调度算法十分简单,仅仅是按照楼层从高到低的顺序运送乘客。

StandardScheduler 则是不受输入类型限制的通用调度器,用于处理 Random 类型的输入,onNewRequest 会用新请求更新调度器状态并触发调度,onCommandComplete 也会触发一次调度,调度算法是被广泛使用的 LOOK,这里不再赘述。

第二次作业

第二次作业中为了支持多个电梯的调度,将 InputRequestListener 接口的 onNewRequest 方法拆分成了 onNewPersonRequestonNewElevatorRequest,并添加了 ElevatorController 类作为 Scheduler 类和 Elevator 类之间的中间层。每个 ElevatorController 对象负责控制一个 Elevator 类,它维护了它控制的电梯的请求队列,并实现了 ElevatorWatcher 接口以调度电梯处理这些请求;而 Scheduler 类不再实现 ElevatorWatcher 接口,仅负责添加新电梯或是将输入的请求分配给各个 ElevatorController

ElevatorController 采用的调度算法与第一次作业基本相同,第二次作业的核心在于 Scheduler 类中将请求分配给不同电梯的算法。由于采用了为不同电梯分别维护请求队列的做法,如果每当由新请求就将其分配给一台电梯,就可能会分配不公平或是之后新添加电梯导致部分电梯闲置。因此,调度器在分配请求时会先检查电梯的状态,只有当电梯闲置时才允许分配一个任意的请求,否则分配的请求必须不会导致电梯超载,请求的方向必须与电梯运行方向相同,且其出发楼层必须在电梯运行方向的前方(即电梯无需改变运行方向即可处理这个请求),如果新的请求找不到满足条件的电梯,就会留在全局的请求队列中等待下次调度。

有两种情况会触发分配请求的调度操作,一个是在 onNewRequest 被调用时,调度器会将新请求放入全局请求队列并触发调度,另一个则是在电梯开门准备装载乘客时,这两者缺一不可,否则会出现全局请求队列非空但电梯却因其请求队列清空而停止工作的状况。为了实现后一种情况,这次作业中又添加了 ElevatorControllerWatcher 接口,它由 Scheduler 类实现,当电梯准备装在乘客时,ElevatorController 会通过这一接口调用 requestScheduling 方法,触发一次调度以分配新的请求。此外,这一接口还提供了 lockunlock 方法,方便使用 Scheduler 类维护的 ReentrantLock 对象进行线程同步。

对于符合分配要求的请求,调度时会估计每台电梯处理请求所需的时间(包括抵达出发楼层前的等待时间和从出发楼层的到达楼层的所需的时间),并选择预计耗时最短的电梯。

第三次作业

第三次作业的调度器设计与第二次作业大致相同,仅对新的要求做了一些改动,除了增加了不同型号电梯的支持以外,还添加了换乘功能。具体实现方法是在每次分配请求时额外考虑几种事先确定好的换乘方案(例如奇数层和偶数层之间换乘),为它们和直达的方法一同估计耗时,并选择预计耗时最短的方案。每次考虑换乘仅将请求最多分为两段,如果选择了换乘方案,则将前半段分配给电梯,后半段保存下来。然后为 ElevatorControllerWatcher 接口增加了 notifyRequestCompleted 方法,ElevatorController 在处理完一个请求后会调用这个方法,此时调度器会检查完成的请求是否有对应的后半段行程,如果有的话就将其放入全局请求队列中再继续调度。

可扩展性分析

image

image

这次作业的可扩展性主要体现在三个方面:请求调度算法、电梯调度算法和电梯型号特性。

请求调度算法由 Scheduler 类提供,由于在第一次作业时为不同的输入设计了不同的调度算法,随后几次作业的代码虽然仅实现了一个通用的调度器,但还是沿用了支持不同调度算法的架构。Scheduler 作为一个抽象基类仅提供基本的工具方法和接口约束,其派生类 StandardScheduler 中实现了通用的请求调度算法,若有扩展需要,只需创建新的派生类即可。

电梯调度算法由 ElevatorController 类提供,它是在第二次作业时添加的,因为当时只实现了一个通用调度器,所以没有为可扩展性专门进行处理,但是因为该类与其他类耦合度不高,所以只需进行简单的重构,使用类似 Scheduler 的抽象基类的架构即可实现可扩展。

电梯型号是在第三次作业中新添加的特性,主要由 ElevatorConfig 枚举类实现,其中包含了特定电梯型号的停靠楼层、移动速度、轿厢容量等信息,在在电梯时需要将它传递给 ElevatorController 的构造函数,添加新的型号只需为这个枚举类添加新的变体即可。

bug 分析

我在本单元的作业中遇到的唯一一个 bug 出现在第二次作业时。按照之前在调度器分析部分描述的请求分配算法,电梯闲置时允许给它分配任意请求,这也包括电梯从其所在楼层到出发楼层的运行方向(为了方便假设为上行)与请求的出发楼层到到达楼层的方向相反的情况,而在电梯为了前往出发楼层而上行时,调度器将会继续给它分配上行的请求,这就意味着电梯的请求队列中可能出现至多一个与当前运行方向相反的请求。

由于 ElevatorController 采用 LOOK 算法调度电梯,这对于电梯调度本身没有什么影响。但是由于电梯的超载判断放在了调度器给各个电梯分配请求时,ElevatorController 中需要维护一个数组表示电梯在各个楼层时预计的轿厢人数,以便判断新请求是否会导致超载,反向的请求导致维护的这个数组出现了错误。我当时采用的方法是在新请求加入请求队列时将出发楼层到到达楼层之间的对应的数组元素加一,在请求处理完毕时则将数组的对应元素减一,这个方法无法正确处理前述的反向请求,导致了超载。

在修复 bug 时,我改变了递增数组元素的时机,新请求加入请求队列时仅会为与电梯运行方向相同的请求递增数组元素,而反向的请求会在同向请求处理完,电梯运行方向改变时再进行相应处理,这样数组就可以正确反映电梯位于各个楼层时的轿厢人数了。

发现他人 bug 的策略

前两次作业的互测阶段时,我均沿用第一单元时使用的自动化测试为主、人工阅读代码为辅的策略,并在第二次作业时成功发现了一位同学在线程同步上的 bug,它会导致程序在处理以添加电梯的请求结尾的输入时有几率死锁,但由于经过多次尝试也无法构造出能被评测系统接受的数据,最后放弃了 hack。第三次作业由于之前实在无法构造出数据的阴影,加之有其他事情要处理而没有太多时间,虽然编写好了测试程序,但没有用它来测试其他同学的代码。

心得体会

这单元的作业主要设计多线程程序的编写,因为我以前就有相关经验,所以没有遇到太大的困难。

令我感到的惊讶的是,在帮助其他同学调试代码时,我发现许多同学的请求队列都是自己通过 Java 的 synchronizedwaitnotify 等机制自己手工实现的,最后写出了难以阅读、易出 bug 的代码,但实际上在 java.util.concurrent 包中已经有包装好的各种 BlockingQueue,其行为与我们需要的请求队列可以说是完全一致。这样的现象固然与部分同学自学能力的欠缺有关,但我认为课程组也在教学时过分强调了基础的同步原语和多线程的设计模式,却缺少了对 Java 预先包装好的众多高级同步功能的介绍。

此外,由于多线程同步问题导致的 bug 往往具有随机性,受到操作系统的线程调度机制的影响,难以稳定复现,这导致了 hack 的体验相当差。希望课程组能考虑允许在 hack 时对被测程序的线程调度进行一定的控制,比如设置线程的优先级,或是在特定线程启动前延迟若干时间,这样为提高在评测系统上复现 bug 提供一定的便利,具体的实现方法可以考虑提供官方包来包装 Java 自身的 Thread 类。

posted @ 2021-04-25 05:00  DDoSolitary  阅读(159)  评论(0)    收藏  举报