BUAA OO 第二单元总结

BUAA OO 第二单元总结

第一次作业

架构思路

  • 整体架构

    第一次作业的核心问题是处理5个不同楼座的乘客请求。整体的架构使用的是生产者-消费者模型,输入线程和电梯线程分别作为生产者和消费者,请求队列作为共享变量。每一个电梯都有各自的请求队列,所有的请求队列装载于一个ArrayList容器中由输入线程管理。由于本次作业仅涉及各个电梯单独的运行和调度,故没有引入管理所有电梯的统一调度器(由输入线程进行简单的请求分配),从而只设计了电梯的运行策略。电梯的运行策略根据当前电梯的所在楼层,运行方向,内部请求和外部请求,通知电梯是否运行,运行的方向,该接的人等。

    在扩展能力方面,电梯的运行策略作为电梯的属性出现,具有一定的可扩展性(只需构造新的运行策略即可构造出不同运行模式的电梯)。

  • UML类图

  • 时序图

    sequenceDiagram participant M as MainClass participant I as InputThread Participant R as RequestTable participant E as Elevator participant S as Strategy activate M opt 初始化 M->>+I: 创建启动输入线程 activate I M->>+R: 创建5个请求队列 activate R M->>+E: 创建启动5部电梯线程,并与RequestTable绑定 activate E E->>+S: 创建策略类,策略类与RequestTable绑定 deactivate M end opt 处理请求 I->>R: addReqeust E->>+R: 获取请求队列,检查是否为空和是否已经结束 R->>-E: 返回请求队列 S->>E:指示是否需要开门 alt 需要开门: E->>E: 电梯开门 E->>E: 乘客出电梯 E->>+S: 生成要接送的请求列表 S->>-E: 返回乘客列表 E->>E: 乘客进电梯 E->>R: 删除已上电梯的乘客请求 E->>E: 电梯关门 end S->>E:指示是否需要移动 alt 需要移动: E->>+S: 请求下一步行动方向 S->>-E: 返回下一步行动方向 end end opt 线程结束 I->>R: 传递输入结束信号 deactivate I R->>E: 传递输入结束信号 deactivate R E->>E: 当所有请求处理完毕后结束 deactivate E end

同步块的设置和锁的选择

本次作业采用的就是基本的生产者-消费者模型。输入请求作为生产者,电梯作为消费者,请求队列作为共享变量。全程使用的为synchronized的锁,所有的锁都锁在请求队列上。加锁的时候为了确保安全,在涉及到共享变量的读写时都加上了锁,包括请求队列类内部的所有方法,电梯类中需要遍历请求队列和修改请求队列的方法(getOn(),whoToPickUp(),whereToMove())。但是事实上,由于本次作业中不涉及其他线程对请求队列的修改,所以上述几个电梯内方法中加的锁是多余的。

除此之外,对notifyAll()的使用也没有多做斟酌,基本上所有用到锁块的方法都加上了notifyAll(),甚至包括只读取了共享变量的方法(whoToPickUp(),whereToMove()等),由于最终评测时也没有出现问题(原因在于本次作业本质上仅涉及单一电梯的调度,不会存在进程阻塞的问题,当第二次作业引入了多部电梯共享一个请求队列后,就出现了进程安全的问题)。后来也没有再多做思考,为第二次作业出现的大问题埋下了隐患。

调度器的设计

本次作业没有单独引入调度器,分配请求的任务由输入线程担任。电梯运行策略的任务由运行策略类LookStratgey担任,具体内容为:

  • 电梯运行的策略为Look算法。

    • 当电梯内没有乘客时,如果同楼层有乘客的目的楼层与当前楼层的相对位置与当前方向相同,或者有外部乘客的来源楼层与当前楼层的相对位置与当前方向相同时,方向不变,否则改变方向。
    • 当电梯内有乘客时,保持当前方向运行。
  • 捎带的判断逻辑为:如果电梯已满,则不捎带;如果电梯未满,电梯内有乘客时,只捎带同方向的,电梯内没有乘客时,根据上述算法判断完方向后,再捎带同方向的乘客。

  • 乘客上下的行为逻辑:电梯开门后马上关门,开关门期间,先下乘客,然后多次上乘客(以免出现第一次上人后又有新的请求出现)。这一步的实现需要引入系统记录的时间,以保证开关门的时间差在400ms以上,具体实现为:

    long openTime = System.currentTimeMillis();
    getOff();
    while (System.currentTimeMillis() - openTime < 400) {
        getOn();
        long t = System.currentTimeMillis() - openTime;
        if (t >= 400) {
            break;
        }
        try {
            getRequestTable().wait(400 - t);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    

BUG修复和分析

在自测的部分没有出现死锁和进程阻塞的问题。主要的问题还在于电梯的运行逻辑,出现过电梯在两个楼层之间来回运行而不接乘客(原因在于接人的逻辑和方向判断的方法没有处理好),电梯运行至11楼或者0楼(原因在于判断方向的方法出现了错误)等BUG,定位之后发现都是低级的错误,判断逻辑缺失,笔误问题等,修复的时间也比较短。由于BUG定位比较容易,第一次作业没有用到调试工具。

在强测和互测阶段出现的BUG主要有两个:

  • BUG:由于负优化导致的问题。在写电梯运行策略时,写了一个添加了很多冗余判断的LOOK策略,导致在一些特定情况下性能十分差劲,以至于RTLE的问题。

    修复:删除冗余的判断逻辑和想当然的行为逻辑,修改为正确的LOOK策略,在修改的基础上稍加优化的就是,接人的时候优先处理目的楼层与当前楼层距离较远的乘客请求。

  • BUG:线程输出不安全导致的问题。在写程序的时候一开始注意到了助教的提示,但是自己写到后面却忘记了这件事,也没有进行自动化评测,导致出现了这样的问题。

    修复:参照评论区里同学提供的方法,添加了安全输出类,具体实现如下

    import com.oocourse.TimableOutput;
    
    public class SecureOutput {
        public static synchronized void println(String str) {
            TimableOutput.println(str);
        }
    }
    

Hack策略

与第一单元不同的是,本单元的主题是多线程,在Hack的过程中可能存在仅交一次无法测出BUG的情况。即:

本地测试没有问题不一定没有问题,但是如果有一次出现问题那就一定有问题。

需要对被测试同学的代码更加细致地阅读和理解,找出其中可能的漏洞。

我只有一次提交是成功的,Hack了三位同学,样例比较普通就不在此展示了。这几位同学出现的问题也是线程输出不安全的问题。这也说明这次存在这种现象的同学不在少数,大家都需要对多线程有更多的认识和理解。

第二次作业

架构思路

  • 整体架构

    第二次作业引入了横向电梯和同楼层,同楼座多部电梯,但是限制了乘客的至少只需要乘坐一次电梯便可到达目的地。整体的架构依旧使用的是生产者-消费者模型。输入线程作为生产者,电梯作为消费者。为了处理多部电梯的调度问题,引入了调度器。具体的实现方式为:每个楼层,每个楼座各一个调度器,装载于一个统一的ArrayList容器内。在读入请求后,由输入线程决定哪个调度器进行调度(事实上,这里还是没有完全将调度的任务从输入线程剥离开来),然后调度器根据各自的调度策略分配请求给电梯。电梯再根据各自的运行策略处理请求。

    在扩展能力方面,调度器的调度策略作为方法出现,可以调整,本次作业中横向调度器和纵向调度器的调度策略就不同,便于第三次的迭代开发。

  • UML类图

  • 时序图

    sequenceDiagram participant M as MainClass participant I as InputThread participant D as Dispatcher Participant R as RequestTable participant E as Elevator participant S as Strategy activate M opt 初始化 M->>+I: 创建启动输入线程 activate I M->>+D:初始化调度器 deactivate M end opt 处理电梯请求 I->>D: 接收添加电梯的请求 D->>+R: 建立该电梯的请求队列 activate R D->>E: 创建并启动相应电梯线程,并与RequestTable绑定 activate E E->>S:创建策略类,策略类与RequestTable绑定 end opt 处理乘客请求 I->>D: 添加乘客请求 D->>R: 将请求按照分配策略放入等待队列中 E->>+R: 获取请求队列,检查是否为空和是否已经结束 R->>-E: 返回请求队列 S->>E:指示是否需要开门 alt 需要开门: E->>E: 电梯开门 E->>E: 乘客出电梯 E->>+S: 生成要接送的请求列表 S->>-E: 返回乘客列表 E->>E: 乘客进电梯 E->>R: 删除已上电梯的乘客请求 E->>E: 电梯关门 end S->>E:指示是否需要移动 alt 需要移动: E->>+S: 请求下一步行动方向 S->>-E: 返回下一步行动方向 end end opt 线程结束 I->>R: 传递输入结束信号 deactivate I R->>E: 传递输入结束信号 deactivate R E->>E: 当所有请求处理完毕后结束 deactivate E end

同步块的设置和锁的选择

由于本次作业没有将调度器设置为线程,故与第一次作业相比,没有引入新的锁和同步块,notifyAll()的设置也没有做改变。但是由于引入了自由竞争,也就是多部电梯共享同一个请求队列,第一次作业中部分多余的锁起到了作用。具体而言:

  • 电梯的getOn()方法,由于在乘客上电梯的时候需要进行从请求队列中删除的操作,修改了请求队列,所以对于getOn()方法整体加上了锁块。
  • 电梯的whoToPickUp(),whereToMove()涉及到对请求队列的遍历,即需要获取请求队列中的元素,为了保证读写安全(防止乘客已经被别的电梯接走了,但是读取的时候仍然发现他在本楼层),对方法整体加上了锁块。
  • 由于电梯的结束条件需要读取请求队列的end属性,所以在run()方法的内层循环体外也加了一层锁
  • 不必要的锁:LookStrategy策略类中needToMove()之类的方法调用了已经加上了锁的whoToPickUp(),故不需要再套一层锁。同理,调度器中的dispatchRequest()方法调用了加上了锁的addRequest(),故不需要再套锁。

调度器的设计

本次作业中引入了调度器,且并没有设计为线程。在主线程中,为每一层楼,每一座楼都初始化一个调度器(纵向的调度器初始化的时候还需要初始化一部电梯)。

  • 与线程交互的方式

    输入线程获取请求,根据请求的类别(电梯/横向/纵向)和信息(楼层/楼座)找到对应的调度器,调度器根据请求的信息完成相应的操作。如果是增加电梯的请求,则在其拥有的elevators属性中添加一部电梯;如果是乘客的请求,则按照相应的调度策略分配请求给电梯进行处理。电梯获取请求后,根据各自的运行策略处理请求。

  • 调度策略

    横向调度器采用的是平均分配的策略。即:该楼层的电梯各自拥有一个等待队列,第i个到达的请求分配给该调度器管理的第i\%N部电梯,其中N为当前该楼层电梯的总数。

    纵向调度器采用的是自由竞争的策略。即:该楼座的电梯共享同一个等待队列,当一个新的请求添加到等待队列时,该楼层所有的电梯都能够参与该请求的处理,先到先得。由于锁块添加合理,不会存在一个乘客上多部电梯的情况。

  • 运行策略

    纵向电梯依旧采取第一次作业中的LOOK策略。横向电梯采用的也是类似LOOK的策略。

    关于方向的判断,当前方向为顺时针,则同方向楼座为当前楼座顺时针方向上相邻的两个楼座,当前方向为逆时针,则同方向为当前楼座逆时针方向上相邻的两个楼座。由于存在以下的情况,横向电梯的LOOK策略与纵向电梯的LOOK策略还有一处不同:

假设某一楼层只有一部电梯,当电梯在A座且当前方向为逆时针时,A,B,C,D,E座各有一个乘客请求,均为顺时针(如A去B,B去C,C去D,D去E,E去A),由于同方向上存在请求,所以电梯会一直逆时针运行下去而不会接人。因此需要修改其判断方向逻辑为:当电梯内没有乘客时,如果同楼层没有同方向乘客(不再考虑外部乘客!),则改变方向,并依照这个方向进行该楼层的接人以及后续的运行方向。

BUG修复和分析

在自测的部分出现的BUG没有出现死锁和进程阻塞的问题。发现的问题包括横向电梯运行逻辑和预想的不一致(原因在于横向电梯和纵向电梯许多逻辑时一样的,在重复的过程中出现了错误,这也充分说明了合理运用继承的重要性),横向电梯循环运行的错误(原因在调度设计中已经阐明)等。

在强测部分测出了严重的线程安全问题。其核心问题在于:

public synchronized boolean isEmpty() {
    notifyAll();
    return requests.isEmpty();
}

public synchronized boolean isEnd() {
    notifyAll();
    return isEnd;
}

RequestTable()类中的上述两个方法,多余的notifyAll()导致sleep中的线程被频繁地无意义唤醒,然后什么也没有做又继续sleep导致的线程阻塞。具体的例子为:两部电梯共享一个请求队列,请求队列为空之后,其中一部电梯isEmpty()唤醒了所有其余的线程(也就是另一部电梯),然后睡去,另一部电梯醒来后调用isEmpty()方法唤醒了刚刚睡去的这部电梯然后自己睡去,依次循环下去导致线程无法结束。

修复:将多余的notifyAll()删除,此处的多余指的是所有只涉及到读共享对象的方法和操作。

Hack策略

由于本次BUG过于严重导致没有进入互测环节。

第三次作业

架构思路

  • 整体架构

    第三次作业引入了换乘。整体的架构依然使用生产者-消费者模型。由于换乘乘客的引入,需要实现请求再分配的功能,所以新增了一个控制器和统一等待队列。整体分为两个层次。在第一层次,输入队列作为生产者,统一等待队列作为共享变量,控制器作为消费者。在第二层次,控制器作为生产者,各个调度器的等待队列作为共享变量,电梯作为消费者。不需要换乘的请求依照第二次作业的处理方式,对于需要换成的请求采用静态划分的方式,分为纵向-横向-纵向三个阶段处理,未处理完的换乘请求发回至控制器重新分配。

    在扩展能力方面,本次引入了统一的控制器,对于更复杂的换乘请求或者电梯请求能够更加统筹地规划,有利于扩展处理更复杂的请求。同时,电梯的参数可以自定义,从而能够实现处理更复杂的电梯。但是静态划分的处理方式在处理乘客请求方面还是会有性能上的不足。

  • UML类图

  • 时序图

    sequenceDiagram participant M as MainClass participant I as InputThread participant W as WAIT_QUEUE participant C as Controller participant D as Dispatcher Participant R as RequestTable participant E as Elevator participant S as Strategy activate M opt 初始化 M->>+I: 创建启动输入线程 activate I M->>C:创建控制器 activate C C->>W:初始化统一等待队列 M->>+D:初始化调度器 deactivate M end opt 处理电梯请求 I->>D: 接收添加电梯的请求 D->>+R: 建立该电梯的请求队列 activate R D->>E: 创建并启动相应电梯线程,并与RequestTable绑定 activate E E->>S:创建策略类,策略类与RequestTable绑定 end opt 处理乘客请求 I->>W: 添加乘客请求 W->>C: 传递乘客请求 C->>D: 分配给相应的调度器 D->>R: 将请求按照分配策略放入等待队列中 E->>+R: 获取请求队列,检查是否为空和是否已经结束 R->>-E: 返回请求队列 S->>E:指示是否需要开门 alt 需要开门: E->>E: 电梯开门 E->>E: 乘客出电梯 alt 判断乘客换乘需求,如果需要再次换乘: E->>W: 将需要再次换乘的乘客返还统一等待队列 end E->>+S: 生成要接送的请求列表 S->>-E: 返回乘客列表 E->>E: 乘客进电梯 E->>R: 删除已上电梯的乘客请求 E->>E: 电梯关门 end S->>E:指示是否需要移动 alt 需要移动: E->>+S: 请求下一步行动方向 S->>-E: 返回下一步行动方向 end end opt 线程结束 I->>W: 传递输入结束信号 deactivate I W->>C: 传递同意等待队列结束信号(请求处理完毕) C->>R: 传递控制器结束信号 deactivate C R->>E: 传递控制器结束信号 deactivate R E->>E: 当所有请求处理完毕后结束 deactivate E end

同步块的设置和锁的选择

本次作业新引入了作为线程出现的控制器。与第二次作业相比,新增了一处同步块,也就是第一层次中的控制器的run方法内。其加锁的对象为第一层次中的共享变量WAIT_QUEUE

除此之外,为了确保线程能够安全结束,引入了一个count变量。当换乘请求出现时,count--,当一个换乘请求处理完成时,count++。需要注意的是,由于可能存在多个换乘请求同时被完成(而不可能同时被添加),即多个线程试图完成对count的写操作,因此需要将count++包装为一个方法,并添加锁,这里锁的共享对象为控制器。

调度器的设计

本次作业引入作为线程出现的控制器,使用了单例模式,完善了电梯调度的系统。电梯的运行策略和第二次作业中的调度器没有做较大的改动。构造了一个Person类为PersonRequest的子类,在本次作业中仅用于处理换乘乘客的请求。(事实上能够将所有乘客进行统一,但是出于尽可能少地修改第二次代码的考虑,没有实现)

  • 与线程交互的方式

    控制器与输入线程交互,构成生产者-消费者模型。输入线程输入请求,如果为电梯请求,则直接将请求分配给调度器,如果为乘客请求,则添加到第一层的统一等待队列中。控制器根据统一等待队列中乘客请求的类型和信息分配给对应的调度器(承担了第二次作业中输入线程的作用),调度器再根据各自的调度策略和乘客请求信息分配给不同的电梯。对于需要换乘的乘客请求,如果没有处理完全,则再次返还给第一层次的统一等待队列中重复上述的操作。

  • 调度策略

    对于控制器的分配,需要对乘客进行分析。如果无需换乘,则按照第二次作业的方式分配到对应的调度器中;如果需要换乘且未乘坐过电梯,则包装为Person类后进行分配(在包装的过程中需要对状态进行初始化);如果是已经乘坐过一次/两次电梯的乘客,则获取乘客的状态后分配到对应的调度器中。具体而言,乘客的状态分为:

    0 -- (fromFloor,fromBuilding)
    1 -- (transferFloor,fromBuilding)
    2 -- (transferFloor,toBuilding)
    3 -- (toFloor,toBuilding)
    

    Person类需要重写getToFloor()getFromFloor(),根据对应的状态返回相应的值,从而能够实现正确的换乘。而换乘楼层的确定方式为:

    M >> (P-'A') & 1 + M >> (Q-'A') & 1 = 2
    &&
    (|X-m| + |Y-m|)为最小值
    

    由于本次作业引入了开门信息,故与第二次作业相比,横向调度器需要一定的改进。引入一个数组记录各个电梯的接乘客次数,在平均分配时,遍历所有能够接该乘客的电梯的接客次数,将该乘客分配给接客次数最小的电梯。

BUG修复和分析

本次作业做了一定的重构。一开始的设计是不打算引入顶层的控制器而是将乘客请求直接发还给输入线程的。而在第二次作业失败的经验之后,学会了使用JProfiler进行调试。实现完后测试过程中发现存在死锁的情况。具体来说:由于换乘请求的存在,一个乘客的处理过程可能需要多次调用电梯的getOn()方法。而getOn()方法是必须要加锁的,由于方法存在嵌套调用的特性,这就会出现:乘客1需要从A-1到B-3,乘客2需要从B-3到A-1,乘客2持有2楼横向电梯的锁,乘客1持有A座纵向电梯的锁,两个乘客都想获取彼此手中的锁而不得的情况,从而导致死锁。修复的过程就是重构,引入控制器和统一等待队列,将加锁的对象统一为顶层的等待队列,就可以解决死锁的问题。

在强测阶段没有BUG,在互测阶段被发现了一个BUG,其用例之一为:

[2.3]ADD-floor-7-10-4-0.2-30
[2.3]ADD-floor-8-10-6-0.6-18
[2.3]ADD-floor-9-10-8-0.4-21
[2.3]ADD-floor-10-4-8-0.6-3
[2.3]ADD-floor-11-4-6-0.6-27
[2.3]ADD-floor-12-4-4-0.6-25
[2.3]ADD-building-13-B-6-0.6
[2.3]ADD-building-14-D-6-0.4
[2.3]1-FROM-E-10-TO-D-4

原因在于:对于线程结束的判断条件不当,线程过早地结束导致请求没有被完全处理。

修复:引入一个flag变量,当出现请求时,处理完请求之后,flag置1,只有当flag为1时,控制器线程才可能结束。

Hack策略

本次Hack中没有投入太多的时间,Hack了4次,中了2位同学。用例比较长就不再赘述,两位同学可能的问题是:

  • 没有合理地加锁和同步块,导致出现一个人上了两部电梯的问题。

  • 对于横向请求的处理不当,导致误把将该乘客送至无法满足请求的电梯中。

心得体会

  • 线程安全方面

    线程安全的问题在第一次作业中就已经以输出线程不安全的形式暴露过,但是并没有引起自己的重视,反而觉得是粗心的问题。从而第二次作业中,也并没有对同步块和notifyAll()设置的合理性做过多的思考,导致出现了严重的问题。第三次作业中在自测环节依然出现了严重的线程安全问题(死锁),在拥有自动评测机和调试器的条件下及时发现了问题,并通过重构的方式解决了。综上所述,三次作业都有过线程安全的问题,因为自己知识掌握的程度,态度的端正性,以及工具的先进程度不同而有着不同的结果。首先说明,多线程编程需要对线程安全问题做细致认真的分析和考虑,同步块加的范围,锁的选择都是值得斟酌的地方。在本单元中,应该秉持的方针是:涉及到共享变量的读写都需要加锁,仅仅涉及到读操作的方法不需要notifyAll(),注意不要实现无意义的锁嵌套。其次,再次说明了阅读代码,理解代码的重要性。助教在实验和训练部分都给出了示例代码,教授了我们生产者-消费者模型,观察者模型,流水线模型等,认真阅读和理解不仅可以让我们掌握不同的模型,也能让我们对多线程编程有更加深刻的认识和理解,从而减少出现线程安全问题的可能性。最后,说明了调试工具的重要性。掌握有力的调试工具和评测工具可以让设计过程的测试和修复部分更加完备和有效。

  • 层次化设计方面

    与第一单元的作业一样,第二单元的作业在设计方面也十分强调层次化设计。在第三次作业中体现得最为明显。合理地划分层次:输入线程,统一等待队列,控制器线程,调度器,电梯,合理地分配各层次,各模块的作用能够让整体的架构更为清晰,可扩展性更强。我自己的实现有许多不足的地方,如前两次由输入线程承担了一定的调度作用,即便到了第三次,电梯请求的处理仍然由输入线程控制分配,又如初始化调度器的部分放在了主线程中,显得逻辑比较混乱,层次不明晰。

  • 感想

    第二单元的得分与第一单元相比有很大的差距,与其他同学相比亦是如此。究其原因,还是自己投入的时间不够,没有认真细致地思考,钻研。当然,也初步掌握了调试多线程的方法,以及多线程编程常用的模型和方法。希望自己能够在接下来的学习中更加端正态度,精益求精。

posted @ 2022-05-04 15:36  Longxmas  阅读(59)  评论(1编辑  收藏  举报