一、基于度量的程序结构分析
1.第一次作业
1.1 度量分析表
分析:
从 WMC 值和 LOC 值来看,我此次作业的在类的职责设计上出现了偏差,把复杂度集中留给了 Elevator(电梯) 类,而 Scheduler(调度器)类的复杂度相对 Elevator 类要低多了。原因是我把电梯自身的动作以及电梯下一个动作的选择都交给了 Elevator 类,实际上后者更应该交给调度器来完成。可见如果我们对问题的抽象设计不合理,可能就会导致这样的复杂度分布不合理的情况。下面分别是 Elevator 类和 Scheduler 类的方法度量分析,可以明显地看到复杂度集中分布在了 Elevator 类。
此外,如果单独看 Elevator 类的 WMC 值,不得不说该值还是较高了,我分析是我对输入请求没有做进一步的转化,导致为了配合调度逻辑工作就需要对这些请求在电梯类中做转化。可见如果我们没有充分考虑和优化职责的分配,可能就会导致这样复杂度出头的情况。总的而言,从 WMC 值和 LOC 值来看,本次作业反映出的一个问题就是抽象设计和职责分派的不合理。
从 FANIN 和 FANOUT 值来看,本次作业的类之间的耦合情况还算正常, 主要是 Elevator 类和 Scheduler 类之间有着调度和引用。
1.2 UML 类图
设计思路:
可以看出我采用的是一般的生产者-消费者模式。其中 Producer 类用于提供输入;RequestQueue 类是保存请求的类;Scheduler 类用于 put、get 以及管理请求队列,并负责告知电梯下一个主\从请求所在的楼层;Elevator 类主动从 Scheduler 类中 get 请求,并负责规划和控制自己的动作;State 枚举类仅定义电梯的运行状态。
调度策略:
在调度策略方面我主要采用了 LOOK 算法,也就是按楼层顺序依次服务请求,电梯在最顶层和最底层之间运行,如果发现当前运行方向上没有可满足请求则调转方向。基于该算法,我在电梯每到达一层时,向调度器请求获取该层的请求,并将这些请求(如果有的话)根据其属于上楼还是下楼分类,分别保存在不同队列中;电梯根据请求队列情况以及自身状态确定下一状态;若电梯当前运行方向上的请求皆已得到满足,则根据剩余请求情况使电梯停止或反向。
这种方法实现相对简单,唯一可能需要注意的点是电梯运行时下一目标楼层的确定,这涉及到此时刻请求队列的情况和电梯本身的情况,是逻辑判断集中的地方。但这种算法的性能表现不佳,原因是电梯的反复上下行容易造成不必要的折返,所以采用该算法也使得我的性能得分较低。
评价:
整体设计上遵照了生产者-消费者模式,”请求输入 <-> 调度器 <-> 电梯“的基本大框架已有。但是如前所述,类的职责分派不合理,电梯类的复杂度应该分担一部分到调度器上。
1.3 UML 协作图
设计思路:
本次作业中除主线程外我设计了两个线程,分别是 Producer 和 Elevator。Scheduler 和 RequestQueue 一起可以视作生产者-消费者模型中的容器。如上图所示, Producer 线程将读取到的输入送入 Scheduler,Scheduler 再将请求存入 RequestQueue;Elevator 线程会向 Scheduler 线程获得请求并完成请求。主线程仅负责创建 Producer 线程和 Elevator 线程。
线程之间的协同即是最简单的生产者-消费者模型,主要的线程安全问题就是对共享对象 RequestQueue 的访问,同步控制方法即是简单的使用 synchronized 关键字修饰 Scheduler 类的 get 和 put 方法。
2.第二次作业
2.1 度量分析表
分析:
从 LOC 和 WMC 来看,本次作业的复杂度分派相对第一次作业作了改进,主要是为调度器设计了它应该负责的部分,使得电梯的负担减轻。下面仍然是 Elevator 类和 Scheduler 类的方法度量,可以看出不存在多个高复杂度方法集中在一个类中的情况了。
此外,对比第一次作业也可以看到,此次作业的最大 WMC 值也是降低了的,我认为原因是 Task 类的引入。我将每个输入请求分解为了若干个 Task 类的对象,电梯和调度器将针对 Task 对象实施调度策略,从而达到简化逻辑的目的。总的来看,我认为本次作业在类的设计上比第一次作业有了进步,一定程度解决了责任分派不均、逻辑复杂的问题。
从 FANIN 和 FANOUT 可以看出,本次主要是 Task、Elevator 和 Scheduler 三个类之间的调度和引用,整体的耦合程度正常。
2.2 UML 类图
设计思路:
本次设计我认为更偏向于Worker -Thread模式:输入的请求需经过一定的转化使其方便电梯规划执行顺序,并由调度器分配给各个电梯;每个电梯会主动向调度器取任务,并只专注于解决分配给自己的任务;电梯与电梯之间不存在交互。
图中 Producer 类用于输入请求;Task 类是对完成输入请求的过程的细化,代表电梯当前需完成的任务;Scheduler 类包含 get、put 方法, put 方法会额外进行解析请求并将其转换为 Task 列表的工作;Elevator 类负责规划从 Scheduler 类领取的任务的执行顺序,并管理自身的状态;ElevatorState 和 TaskType 枚举类分别定义电梯的状态和任务的类型。
调度策略:
此次作业中我围绕着性能优化来设计策略的。由于要将多部电梯协调调度,为了减少策略及类设计的复杂度,我采取的方案是首先设计单部电梯的调度策略,然后再考虑总调度器如何协调好多部电梯。之所以采取该方案主要是为尽力符合单一职责原则,此外这样的策略我认为也有利于处理新增电梯种类这样的需求。
吸取第一次作业的经验,我不再把原始的请求直接交给电梯,而是把每个请求分为“人上电梯”和“人下电梯”两个待完成“任务”。之所以这样分解,主要是考虑到电梯的行为事实上就是不断到达某个楼层再开关门,并保证在其开关门过程中有人员进出即可;而某人上下电梯的楼层实际上就是电梯需要到达的楼层。此二者结合起来看,电梯运行的流程就会比较简单:只需到达当前待完成任务的楼层,再进行上下人操作即可。进一步地就是解决如何确定当前待完成任务。我采取的是简单的局部贪心策略,即电梯选择当前距离它所在楼层最近的那个任务作为当前待完成任务,但必须保证某人下电梯前他已在电梯中。局部贪心实现容易,且在大部分情况下可以通过减少电梯的折返路程实现优化。此时单部电梯的调度策略就可以基本完成。
在考虑总调度器策略时,我认为主要应解决忙闲不均的问题,其次是如何分配能使一个任务能在相对较短的时间内被执行。采取的策略是:将当前任务分配给已持有任务数最少且未达到最大载客量的的电梯;如果任务数为最小值的电梯有多个,则将任务优先分配给运行方向朝向该任务的电梯——若所有电梯运行方向都为反向,则将任务分配给能以最短折返距离返回该任务所在楼层的电梯。
评价:
本次作业的设计使得整体复杂度以及类之间职责的分派较第一次有所改进;调度器类和电梯类逻辑的分离使得程序有进一步扩展的空间。但是从UML类图中可以看到,Task 类和 Elevator 类都有较多的为实现调度策略而设置的 get 方法,这使得二者的封装性实现得并不好,这说明本次作业实际上在类的抽象设计上尚有问题,应进一步改进。
2.3 UML 协作图
设计思路:
本次作业除主线程外设计了 Producer 和 Elevator 两类线程。如上图所示,Producer 线程向 Scheduler 提供请求, Scheduler 根据请求创建相应 Task 对象; Elevator 线程会向 Scheduler 申请任务,Scheduler 再根据任务队列信息决定返回哪些任务;Elevator 再得到任务后,根据任务信息和自身状态信息规划动作。主线程仅负责创建 Producer 线程和 Elevator 线程。电梯线程之间无协作关系,彼此独立。
本次作业涉及到的主要问题是多个线程对象对共享资源的访问,以及如何控制多个线程在合适的时刻停止执行。对于第一个问题,我使用的是对 Scheduler 中的 get 和 put 方法及其相关方法加 synchronized 关键字解决;对于第二个问题,我采用的是为多个电梯线程设置共享变量的方法——该方法要求每个电梯线程会根据共享变量的值决定是否结束,同时,每个电梯线程有职责唤醒本该结束但正被阻塞的线程。
3.第三次作业
3.1 度量分析表
从 WMC 值来看,本次作业的类职责分派和复杂度控制基本保持了第二次作业的水平。本次作业同样设置了 Task 类以及 TaskChain 类用于进一步细化输入请求;新设置了 PathGraph 类用于计算最短路径。
此次作业 Scheduler 类的 LCOM 值较为突出,主要原因是该类中既需要管理和解析输入、又需要分析和选择路径,在一些方法之间存在实例不相关。
FANIN 和 FANOUT 值仍然表明此次作业主要还是 Elevator 类、 Scheduler 类、Task 类等之间的相互调用,耦合程度是正常的。
3.2 UML 类图
设计思路:
本次作业仍然是类似于作业二的 Worer -Thread 模式。与第二次作业相比,新增了 PathGraph 类用于计算不同楼层之间的最短路;新增了 TaskChain 类作为输入请求被最终转换成的对象,该类实际上维护的是一个 Task 类的链表。
本次作业中电梯类仍然只设置了一个类。原因是我认为不同类型的电梯尽管可达楼层以及移动时间不同,但除此之外,它们的属性的种类和行为都是相同的,可以在同一个调度策略下工作。
调度策略:
本次作业中我考虑的是,对于每一个输入请求,都计算出乘客出发楼层到目的楼层之间花费时间最少的乘梯及换乘方案,以此来使得性能得到优化。
此次作业不同电梯有不同的运行时间,同时同一楼层中可能涉及到换乘,因此两楼层之间(时间)最短的路径是不能直接算出的。为此我使用了数据结构中求图的最短路的方案解决这个问题。规定图中的每个结点都包含一个电梯类型属性和一个该类型电梯可停靠的楼层号属性;规定结点之间若可达(分为同类型电梯的直接运行可达和不同类型电梯之间的换乘可达)则以两个结点之间直接到达对方所花费的时间为权,否则其权为无限大。在计算最短路的算法中我采用的是 Floyd 算法。该算法尽管时间复杂度高,但它可一次性求出图中任意两结点之间的最短路,因此只需在程序运行开始时计算一次,程序运行中就可以直接取得最短路而不用再计算。
在任务分配时,我首先取得一条从出发楼层到目的楼层的路径,以该路径的第一个结点的电梯类型属性去分配该任务给对应类型的电梯。考虑到忙闲不均的问题,在选取路径时不应该一味选择最短路,故我采用的方式是一次性取得所有路径(一楼层可能有多部电梯可以停靠,因此两楼层间路径可能有多条),再根据电梯的空闲情况去选择一条路径作为最终方案。
对于每部电梯,我沿用了第二次作业的思路,让每部电梯采用局部贪心的策略执行任务。此时在保存每部电梯的任务时可以使用优先队列的数据结构,方便电梯选择任务、简化逻辑。
最后还需考虑乘客中途因等待换乘电梯而花费的时间。我采取的方法仅是当某部电梯空闲(无可执行任务)时,询问调度器是否有乘客将要换乘它所属类型的电梯,若有则提前移动至换乘楼层等待。
平衡设计:
在设计此次作业时我的基本出发点是保证功能的正确性为基础,再采用一定的优化策略或算法即可。此次作业的性能设计如上所述,并没有涉及到复杂的算法或是技巧性的设计,只是遵循了寻求最短路、局部贪心等基本规则。虽然无法达到最优的性能,但是我认为这样可以使得程序对具体策略的依赖程度减小,从而有利于正确性的保证和功能上的扩展。当然这种可扩展性也是有限的,比如如果电梯需要在多种运行模式(如有的电梯会包括工作模式、检修模式等)中切换,或者需要考虑不同楼层客流量和客户需求的不同(比如一座百货大楼,有不同的购物楼层和餐饮楼层等),那么现有的这种架构也无法应对。
从设计原则的角度来看,我认为本次设计主要涉及到开闭原则和单一职责原则。
在开闭原则方面我认为此次设计有一定的体现,具体表现在:如果会有新类型的电梯出现,可能只需新增电梯类并在类内部实现它的调度策略,而该类的对外接口可以和其他电梯类保持一致;如果有新增需求,可能只需新增任务类并新增一个抽象任务接口,从而使得该架构其他逻辑不变。但是调度器和一些具体逻辑绑定,其可扩展性相对较差。
在单一职责方面,主要表现是电梯、调度器“各司其职”,分担了职责。但是此次作业中调度器本身的内聚性不好。
评价:
本次作业在作业二的基础上新增了部分类、修改了调度器调度策略,此外的架构整体变化不大,实践了可扩展性设计的原则。但是调度器类中的逻辑组织比较散乱,使得其 LCOM 值突出,应该考虑进一步的抽象和细分调度器的职责。此外,在评论区中有很多同学谈到对不同类型的电梯采用不同的调度策略,这和我的想法相反。是否确实采用不同的调度策略,以及建立不同的电梯类更为合适呢?究竟如何进行抽象设计更为合理呢?我认为这是我值得去进一步思考的问题。
3.3 UML 协作图
设计思路:
本次作业除主线程外仍然只有 Producer 和 Elevator 两类线程。如上图所示,主线程在一开始负责初始化 PathGraph 从而计算得出路径;Scheduler 接收请求后通过 PathGraph 获得路径并生成任务(TaskChain);Elevator 向 Scheduler 申请任务, Scheduler 根据任务信息决定返回哪些任务;Elevator 获取任务后根据任务信息和自身状态信息规划动作。Producer 线程由 Main 创建, Elevator 线程由 Scheduler 创建。Elevator 线程之间无协作关系,彼此独立。
本次作业的线程协同设计和第二次作业相比并无多少不同,因此面临的共享资源问题和线程终止问题和第二次作业类似,我在处理这两个问题时也采用的相同的方法。
二、Bug 分析
本次作业一在强测和互测中没有出现Bug。但作业二和作业三在强测中分别出现了CTL和RTLE的问题,第三次作业在互测中还出现了RE的问题。
显然所有这些问题都是线程调度没有做好的结果。经查发现原因如下:
-
- 对于第二次作业的CTL,原因是我采用了轮询的方式去决定何时结束进程。具体想法是:主线程(Main)首先等待输入线程结束,然后轮询判断是否所有请求都得到满足,一旦都满足后主线程再负责结束所有电梯线程。这就容易导致CPU在主线程的这个轮询中花费掉大量时间而CTL。
- 对于第三次作业的RTLE和RE,原因是在控制电梯线程结束的逻辑不完善导致的。原先的思路是:调度器和电梯共享一个布尔变量,该布尔变量的值为真且电梯本身没有任务时电梯便退出;该变量的值由调度器决定,当再无请求可分配且输入线程已停止时置其为真。假设仅有一部电梯A在工作,且存在有其他电梯因无请求可取而阻塞在调度器的 get 方法中。那么当A完成它的工作后,那些阻塞的电梯如果不被唤醒则仍被阻塞,无法结束。我在这里即缺少把这些阻塞的电梯再唤醒的逻辑。
总之,我因为对线程调度部分内容的不重视,犯下了如轮询这样的低级错误,确实应该在每次坐电梯时都自我反省一遍。
三、互测相关
在互测时我使用的是评测机。我认为本单元作业出现问题的关键就是线程调度的不确定性,如果单单阅读代码可能很难发现错误。相比之下,第一单元出现的问题主要是一些逻辑上的不完善,尚可通过阅读代码找错。
我在评测机生成数据时采用的仅是随机生成的方式,并没有关注如何去针对线程安全等做针对性测试。
四、心得体会
本单元的作业使我进一步实践和体会了面向对象编程思想,提升了对问题的分析、抽象能力。尤其是SOLID原则,在最初接触它们时感觉是很抽象的,但经过两个单元的训练以及老师的讲述后,再看它们时已经觉得比较亲切了。
本单元也使我第一次接触到多线程编程。从接触它的概念到磕磕绊绊地完成三次作业,既收获了不少新知识和新技能,也着实受到了不少的教训。但总之,教训需要深刻反思,学习仍需要加足劲。
最后,感谢所有辛苦付出老师和助教们,也感谢所有乐于分享的同学!