OO第二单元作业总结与心得

第二单元作业总结与心得

1. 作业架构设计简述

这一单元的作业没有经历过大的重构。但由于前两次作业的架构在线程设计上的考虑不够全面,第七次作业在并行架构上做出了堪比重构的丑陋的增量开发(或许也称不上增量开发了)。

这一部分将简述作业代码的整体架构,不涉及并发部分与电梯的调度实现,这些内容将在后两个部分叙述。

1.1 第五次作业

首先在此给出第五次作业的类图。

本文的类图仅记录了说明作业架构需要的部分内容,类的数据成员实际上均为私有,但可通过getter或setter访问的成员将标为公有,类图中展现出来的私有部分为实现过程的部分重点,将在后文进行介绍。

如图所示,这一次作业设计了BuildingElevator两个主要的类。Building用于管理电梯和请求对象,Elevator由记录其数据的ElevatorStatus和管理其运行的ElevatorOperator组成。负责进行调度工作的是作为Building的一个成员的Policy接口对象。

1.1.1 数据组织方式

第五次作业涉及到五幢独立的楼座,每座楼仅有一部电梯。最简单的实现方式是直接将请求数据存储在Elevator类中,由电梯线程直接访问并处理。但考虑到后续作业必然会有的“一楼多电梯”的需求,本人选择了设计Building这一抽象“楼座”概念的类,将请求存储在Building中,各Elevator也以一个序列的形式存储在Building中(本次作业中这个序列只有一个元素)。

1.1.2 Elevator的设计

Elevator类由一个ElevatorStatusElevatorOperator组成。(还包含一个所在Building的引用。)这样的设计旨在将电梯的数据和运行行为分开。但访问电梯数据的接口和电梯的各种行为方法(open()close()等)全部写在了ElevatorStatus里,每次对电梯进行操作基本上都需要先通过Elevator获取其ElevatorStatus,然后对ElevatorStatus进行操作,这样的设计违背了"status"的初衷。但由于开发的惯性使然,这种不合理的设计沿用到了后两次作业中。

1.2 第六次作业

这里给出第六次作业的类图。

相较第五次作业,这次作业的架构变化主要如下:

  • 新增MultiBuilding类整合所有Building
  • 新增DistributedRequest类实现请求的归属判定。
  • Policy接口新增distribute方法实现请求的分配,OrderlyDistributedPolicy基类实现了均匀分配策略。
  • BuildingElevatorStatus分别添加了区分横/纵向楼座/电梯的数据成员,新增了处理横向电梯的策略类。

1.2.1 横向电梯的实现

横向电梯与纵向电梯的区别不大。将所有楼座的同一楼层看作一个“横向楼座”,并为电梯赋予环形移动的能力即可将其视作上一次作业的电梯进行处理。为此,BuildingElevatorStatus各自添加了一个布尔值成员标识其类型,ElevatorStatus的部分成员也进行了更名(例如floor改为posgoDown改为goNeg)。此外,新增的GreedyLoopPolicy处理了环形移动的策略,应用于横向电梯;ElevatorOperator也因两种电梯运行速度不同添加了timeToMove成员。

1.2.2 请求分配的实现

为了实现请求的分配,本次作业设计了DistributedRequest类,代替PersonRequest类存储在Building中。(ElevatorStatus中的请求均为该电梯的请求,故可沿用PersonRequest。)PersonRequest对象经由策略类的分配方法被包装为DistributedRequest对象,电梯在进行调度时会无视未分配给自己的请求。

1.2.3 策略类的层次结构

策略类指实现了Policy接口的类,在程序中起到调度器的作用(但并没有单独开线程,详见并发设计部分)。因此策略类需要同时实现单个电梯的运行策略以及多台电梯的请求分配策略。为了实现后者,Policy接口添加了一个分配方法。但在此次作业的实现中,两个具体的策略类在分配方法的实现上是完全相同的。为了减少代码重复,本人选择了设计一个OrderlyDistributedPolicy基类供两个策略类继承。

1.3 第七次作业

这里给出第七次作业的类图。

相较第六次作业,这次作业的架构变化主要如下:

  • DistributedRequest类添加target成员,支持多段请求的目的地指定。
  • 新增HandlerShutter两个线程类分别处理请求发送与程序终止任务。(于并发部分详述)
  • BuildingElevator类各自添加了evaluate方法用于楼层和电梯的分配。
  • ElevatorStatus类添加了表示开门信息的info成员。

1.3.1 多段请求的实现与分配

多段请求的出现使得调度的层次又增加了一层中转楼层/楼座的分配,这一项工作整合进了Policy接口的distribute方法。中转的存在使得从前根据PersonRequest的目的地确定电梯目的地的做法不再适用,因此DistributedPolicy添加了target成员,标识分配后请求的首个目的地位置。每当一个乘客走下电梯时,若该乘客的请求没有完成,一个新的请求会被生成并交由Handler线程处理。新请求与原请求的不同之处在于起始位置的不同,便于策略类进行分配。

2. 并发编程设计与实践

本单元作业出现了并发编程的需求。这之中涉及到了线程的设计与协作,以及线程安全的保护等。这一部分将按三次作业的顺序阐述三次作业在并发编程上的设计与迭代。

2.1 第五次作业

参见第五次作业类图,这次作业涉及到两个线程类RequestControllerElevatorOperator。再加上主线程,共有三类线程同时运行。由于主线程的工作仅是一些基本的初始化与RequestConroller的启动(见下方代码),可主要考虑两个线程类的协作。

public static void main(String[] args) {
    TimableOutput.initStartTimestamp();
    for (int i = 1; i <= NUM_OF_BUILDINGS; ++i) {
        BUILDINGS.add(new Building(i));
    }
    RequestController controller = new RequestController(BUILDINGS);
    controller.start();
}

这次作业的协作图如下。

2.1.1 电梯运行逻辑

电梯运行线程为ElevatorOperator类。该类采用状态机实现,run方法为一个巨大的循环体,每次循环线程根据ElevatorStatusstate枚举成员执行不同的操作。不同的操作会为线程设定不同的睡眠时间,在循环体末尾进行睡眠。在电梯开门后,线程会尝试通过Policy进行放出乘客和拉入乘客的操作,并实时进行策略的调整。这个过程需要访问并修改Building中的请求列表。当电梯处于关闭状态且目的地未设定时,线程调用wait方法,等待新请求的加入将其唤醒。

2.1.2 请求加入流程

请求的加入由RequestController执行。当接收到新请求时,该线程调用BuildingaddRequest方法,并调用Policyadjust方法进行策略的调整。随后调用notify方法,将可能处于waiting状态的电梯线程唤醒。

2.1.3 程序终止过程

程序的终止需要所有线程结束运行。主线程在进行初始化操作后自动结束运行。RequestController在所有请求接收完毕后为所有Building调用turnDown方法,随后结束运行。BuildingturnDown方法会将其电梯的turnedDown 成员设为true,使其在完成任务欲调用wait方法时结束线程的运行。

2.1.4 线程安全实现细节

为了便于debug与理解(实际上可能并没有起到这样的作用),本单元所有代码的加锁方式均统一地采用形如synchronized (object) { }的加锁与形如object.wait()object.notify()object.notifyAll()的方法调用

容易看出本次作业由多个线程共享的资源集中在Building类和ElevatorStatus类中。(这两个类的数据成员被Policyadjust方法访问,两类线程均会调用Policyadjust方法。)故本次作业对这两个类进行了加锁。

RequestController线程的同步块写在Building类的相关方法中。ElevatorStatus线程的同步块分为两个部分:对ElevatorStatus对象的加锁写在run方法的循环体前半部分,操作执行完毕且睡眠时间设定完毕后线程退出同步块进行睡眠;对Building对象的加锁写在需要访问请求列表的子方法中。

由于两类线程在执行过程中都会遇到同步块的嵌套,故为了避免死锁,嵌套严格遵循先锁ElevatorStatus,再锁Building的加锁顺序

2.2 第六次作业

第六次作业引入了MultiBuilding类,MainClass因此发生了些许变化。

public class MainClass {
    private static final MultiBuilding MULTI_BUILDING = new MultiBuilding();

    public static void main(String[] args) {
        TimableOutput.initStartTimestamp();
        RequestController controller = new RequestController(MULTI_BUILDING);
        controller.start();
    }
}

与第五次作业相比,本次作业的区别主要在于新增了加入电梯的操作。协作图如下。

加入新电梯的流程与加入新请求的流程类似,此处不再赘述。

2.2.1 线程安全实现细节

本次作业对第五次作业的加锁方式做出了一些改动。为了避免同步块嵌套带来的不便,本次作业采用了仅对Building进行加锁的方式,所有对BuildingElevatorStatus对象的访问均需要在对Building对象加锁的同步块内进行。这种加锁方式不够直观,是种不合常理的做法,属于设计纰漏。

2.3 第七次作业

参见第七次作业类图,本次作业新增了HandlerShutter两个线程类。这两个类的出现是为了弥补前两次作业不够合理的请求加入方式与程序终止方式造成的一系列问题。

这次作业的协作图如下。

2.3.1 请求加入流程

本次作业加入的请求共有两个可能的来源:

  • 输入的新请求
  • 移出电梯后仍未到达目的地的乘客发出的更新请求

前者可通过与前两次作业相同的方法处理。而后者若使用同样的方法则会出现问题。

前两次作业中,加入请求的操作在输入线程(即RequestController)进行,该线程仅对相应Building对象加锁,且不会出现嵌套。在本次作业中,第二种请求的加入在电梯线程进行。加入请求时,线程正处于对电梯所在Building加锁的同步块中。若进行请求的加入,则必须对被加入请求的Building对象加锁。此时即会出现同步块的嵌套,且不同Building对象的加锁顺序不确定,有可能出现死锁。因此,第二种请求的加入不应在电梯线程内进行。

本次作业为第二种请求的加入设计了Handler线程。电梯线程将新加入的请求发送给Handler对象,Handler线程在请求队列不为空时被唤醒,用与RequestController相似的方法将请求加入到对应的Building中。

2.3.2 程序终止过程

由于本次作业中新请求不仅由输入线程发出,故不能够在输入请求获取完毕后立即发出终止整个程序的信号,而应该在此前提下所有电梯停止运行后再终止程序。

本次作业为实现这一点设计了Shutter线程。Shutter类拥有一个计数器成员。每当一个输入线程或电梯线程被创建时这个计数器都会自增(电梯线程被唤醒时也会自增),当输入线程接收完所有请求或电梯进入空闲状态时这个计数器便会自减。每次自减时,该线程会判断计数器是否为零,若是则终止程序的运行。

2.3.3 线程安全实现细节

部分加锁的细节已经在2.3.1中提到。

本次作业的同步块不可避免地会遇到嵌套的情况。但2.3.1的设计规避了Building加锁嵌套造成的死锁,对新加入的线程对象的加锁总在同步块嵌套的末端进行,也不会造成死锁。

3. 电梯调度的实现与策略

这一单元作业的代码中充当调度器的类为实现了Policy接口的策略类。调度器并没有单独设计在一个线程中,而是包装在了策略类中供各个线程在需要时调用。这在1.2.3中有部分提到。

3.1 第五次作业

第五次作业中的Policy接口包含三个方法:

  • adjust
  • getIn
  • getOut

其中adjust方法的作用是设定ElevatorStatus中的destination成员,getIngetOut方法的作用是控制电梯开门后的拉入乘客、放出乘客的行为。由于ElevatorOperator的行为是让电梯运行到destination的位置后开门、放出乘客、拉入乘客、关门、前往下一个destination……直到destination == 0后进入空闲状态,因此合理设定destination和开门后的行为即可控制电梯的运行。

3.1.1 策略类调用位置

由于destination的设定需要针对请求等候情况和电梯的状态综合考虑,故adjust方法需要在这些因素发生改变时及时调用。

参见第五次作业协作图adjust方法在新请求加入时和电梯放出乘客、拉入乘客的操作中调用;getOutgetIN方法在电梯放出乘客、拉入乘客的操作中调用。

3.1.2 策略设计

虽然代码中实际使用的策略类命名为ScanningPolicy,但其应用的策略实为look策略。

电梯向一个方向运行,拉入同方向的乘客,直到该方向不存在待接取乘客或已接取乘客的目的地便转向朝另一方向运行。

3.2 第六次作业

第六次作业的Policy接口新增了一个方法:

  • distribute

该方法接受一个PersonRequest参数,返回一个DistributedRequest对象,实现请求的分配。

3.2.1 调度的层次

这次作业体现出了调度的不同层次:

  • 单台电梯的调度层次(第五次作业涉及到的层次)
  • 同一Building多台电梯的调度层次(这次作业新增的层次)

这两个层次的调度均由策略类实现。第五次作业涉及到的三个方法实现较低的第一个层次,这次作业新增的distribute方法实现较高的第二个层次。

由于本单元作业在第二个层次上均采用静态分配的策略,故distribute方法只需要在加入新请求时调用,使请求与具体的电梯绑定即可。

3.2.2 策略设计

这次作业的策略设计相较第五次作业多出了两个新的内容:

  • 横向电梯的调度策略
  • 高层次的电梯分配策略

横向电梯在逻辑上与普通的纵向电梯基本相同,但其可环形运行的特点是纵向电梯不曾拥有的。再考虑到作业要求中只存在五个楼座,环相对较小,本人在横向电梯上采用了一种贪心策略,命名为GreedyLoopPolicy。其运行逻辑为按与电梯所在位置的距离远近遍历不同位置(从近到远),一旦发现存在任务的位置(存在等待的请求或乘客的目的地在此处)便将destination定为该处。

本次作业的电梯分配策略采取了简单的平均分配策略,即将请求均匀地按顺序分配给所有电梯。这种策略实现在了一个单独的OrderlyDistributedPolicy基类中,ScanningPolicyGreedyLoopPolicy均继承了这个基类以实现电梯的平均分配。另外,为了防止新加入的电梯没有请求可以处理,每当新电梯加入,该Building的策略对象都会调用redistribute方法对待接取的请求进行一次重分配

3.3 第七次作业

第七次作业没有为Policy接口添加新的方法。但题目的要求为调度工作新增了一个更高的层次。

3.3.1 新的调度层次

由于本次作业的请求存在一次电梯的接送无法彻底完成的情况,请求的达成需要进行一次路径规划。基于不同的规划结果,该请求需要投放到的Building对象也可能不同。为了规范这种潜在的混乱局面,本次作业将路径规划的调度层次实现在了Policy接口之外,写入了RequestController的实现中,规定所有请求不进行非必要的中转,且只在一个中间楼层通过横向电梯进行中转。这种把策略的制定写在Policy接口之外的做法违背了policy接口的设计初衷,属无奈之举。

3.3.2 电梯分配策略的改变

由于电梯参数呈现出了一定程度的自由度,本次作业抛弃了第六次作业使用的平均分配策略。(但OrderlyDistributedPolicy的命名没有更改。)Elevator类和Building类各自添加了一个evaluate方法,用于确定其在电梯分配与中转楼层分配中的权重。

4. BUG总结与反思

4.1 自我BUG分析

在前两次作业的过程中,本人在策略类的实现上出现过许多因考虑不周造成的细节错误。这些错误现象十分明显,在本地的debug阶段便能够轻松地找到并进行修改。但类似的错误在第五次作业和第六次作业中重复出现,体现了策略类编写的复杂性。

第七次作业在提交前找到的bug主要体现在线程的架构上面。HandlerShutter都是在debug的过程中进行设计并投入使用的。因此第七次作业的架构十分混乱,基本上难以承受更多的增量开发。

下面列出强测与互测阶段查出的bug:

  • 第五次作业,getIn方法未考虑电梯的载客上限。(bug1)
  • 第七次作业,Handler线程唤醒后可能无法处理全部的请求。(bug2)

bug1对第五次作业的强测和互测造成了毁灭性的打击(复刻第一次作业了属于是)。值得一提的是策略类的adjust方法和getIn方法均需要考虑到电梯的载客能力,但本人鬼使神差地只在adjust方法中进行了相关处理,考虑到了但没完全考虑。

bug2是一个比较隐蔽的,骗过了大规模测试数据的偶发性bug(也没有被互测hack到)。bug的原理是这样的:电梯线程会在放出乘客时对乘客的到达情况进行检查,若未到达其最终目的地便生成一个新的请求投放给Handler同时唤醒Handler线程。Handler线程唤醒后会将其待处理队列中的所有请求投放给对应的Building对象,然后重新进入waiting状态。然而Handler被唤醒时待处理队列只有一条请求,电梯线程投放剩余请求时,Handler线程正处于处理完请求后调用wait前的位置,电梯线程的notify方法无法起到唤醒的作用。随后Handler线程进入waiting状态,但此时其待处理队列非空,只有其下一次被唤醒时这些请求才能够得到处理。当Handler最后一次被唤醒后队列非空时,这部分请求将不会得到处理。同时这种情况下程序无法正常终止,表现出RTLE。

4.1.1 本地测试

吸取了第一单元的教训,从第六次作业开始,本人自己搭建了数据生成器与评测机进行本地测试。第五次作业仅编写了数据生成器且强度不够,没有查出bug1。

第六次作业的许多策略类实现的错误以及第七次作业的诸多线程架构错误都是通过生成的大规模数据与评测机的正确性检查找到的,可以说起到了很大的作用。

由于第五次作业吃了弱数据的亏,因此第六次作业的测试数据以单个Building的大规模请求为主,第七次作业的测试数据针对两个楼座与各个中转楼层生成大规模请求。奈何bug2在较小规模的数据下更容易触发,没有在本地测试中被及时发现。

4.2 他人BUG分析

这一单元本人没有对他人bug的原因展开深入的研究,后两次作业也没有成功找到他人的bug,因此对他人的bug知之甚少。

4.2.1 hack数据构造

本人在互测时采用了与本地测试同一思路的数据构造,即针对较少的楼座集中投放大规模的请求。这一做法在第五次作业取得了良好的效果(毕竟翻车了),但在后两次作业中颗粒无收。

与第一单元进行比较,这一单元的数据很容易制定随机生成的策略。第一单元的hack数据基本依靠手动构造细节处理上容易出错的数据来找到bug,第二单元的hack则基本依靠随机轰炸。

至于如何针对线程架构的bug构造数据,本人没有任何收获。

5. 心得体会

第二单元的复杂程度明显超过了第一单元,但吸取了第一单元的教训后,本人借助自行搭建的评测机以及数据生成器避免了许多较为隐蔽的bug,这不能不说是一种进步。然而,第二单元的三次强测分数的组成与第一单元差距不大。(一次翻车,一次小伤,一次基本完美)这还是让本人受到了打击。

并发编程是这一单元的重点,然而本人在这方面的做法并不是很好。过于偷懒而不好维护的加锁方式以及第七次作业不负责任的打补丁式的架构弥补方式都体现出了本人在这方面的不成熟。如果还有一次迭代开发,这代码恐怕就不得不重构一次了。

只好在下一单元继续努力。

posted @ 2022-05-03 17:18  hyc140  阅读(8)  评论(0编辑  收藏  举报