BUAA-OO-2022-Unit2 博客总结

BUAA-OO-2022-Unit2 博客总结

本单元的任务为电梯系统模拟,经过三次作业迭代后支持纵向任意层停靠电梯、横向特定层停靠电梯、换乘,难点在于理解多线程以及在多线程下保证线程安全。

架构分析

笔者在从第一次作业到第二次作业的迭代过程中进行了重构,而从第二次作业到第三次作业仅修改了调度策略与电梯的实现细节。

第一次作业

第一次作业的需求较为简单,可以概括为五栋楼,每栋楼中各有一部电梯,乘客自始至终仅能在同一栋楼移动。

笔者在设计之初,猜测后续可能的迭代路线是从每栋楼一部电梯升级为每栋多部电梯,因此笔者精心设计了Building Class,来模拟实际环境中每栋楼有多部电梯的场景,并采用了三种不同的调度器,具体实现细节如下。

Inputter

在笔者的实现当中,Inputter负责接收官方包输出的所有请求,并传递给SuperDispatcher。该类与SuperDispatcher类可以看作是生产者——消费者模式,因此笔者将InputterSuperDispatcher均视为线程类。共享资源是等待请求队列,其类型为RequestQueue,将在下文介绍。

RequestQueue

在笔者的实现当中,RequestQueue为唯一的共享资源类。在第一次作业中,由于笔者对于锁机制较为生疏,因此选择对该类所有方法进行了加锁,后面得知这是一种较为低效的实现方式。

SuperDispatcher

该类的功能是从Inputter类得到PersonRequest后,按照PersonRequestgetFromBuilding()方法将其分配给不同的BuildingDispatcher

Building

笔者将Building类视为一个容器,存储一个共享等候队列,用于容纳BuildingDispatcher和可能的多部Elevator。因此笔者没有将其视为线程类。若后续迭代每栋有多部电梯,同样可以作为Building的属性存储其中。

BuildingDispatcher

BuildingDispatcher为常规意义上的调度器,负责选择将请求送至哪部电梯,而由于该次作业仅有一部电梯,因此选择是唯一的,笔者将其视为线程类。

Elevator

笔者同样将Elevator类视为一个容器,用于容纳ElevatorDispatcher,以及记录一些输出相关的信息,笔者未将其视为线程类。

ElevatorDispatcher

ElevatorDispatcher负责其操控其对应的唯一一部Elevator,命令其更新内部请求以及移动;Elevator仅表征电梯状态。笔者将ElevatorDispatcher视为线程类。

Outputter

由于指导书中提出,官方的输入包并不是线程安全的,这意味着有可能出现后获得时间戳的Info先输出的情况,因此我们需要封装线程安全的输出类。为实现该功能,笔者采用了单例模式,一种使用static关键字的简单实现方式如下:

import com.oocourse.TimableOutput;

public class Outputter {
   public static synchronized void println(String msg) {
       TimableOutput.println(msg);
  }
}

第二次作业

在第二次作业中,加入了可在同一层任意栋停靠的横向电梯,并限制请求的起终点必须在同一栋或同一层,因此纵向电梯的运输和横向电梯的运输事实上是互不干扰的。

在笔者第一次作业中,有楼的概念,每一栋楼可能有多部电梯共享一个等候队列,按照这个思路,笔者可能需要在第二次作业中继续加入Floor类。但实际上,并不需要BuildingFloor的概念,只要为每栋楼和每层楼放置一个共享队列,SuperDispatcher在接受请求的时候,根据请求的类型和起终点将其送至不同的等候队列,并处理增加电梯的请求。

所以只需要删除第一次作业中的Building类,并修改SuperDispatcher的分发策略,同时,笔者在区分纵向电梯与横向电梯的时候并没有使用两个类,因为不确定之后是否可能存在纵向电梯与横向电梯的相互转换,以及斜向运动,所以笔者采用二维结构来刻画电梯的运动,在初始化时,纵向电梯的运动没有横向分量,横向电梯的运动没有纵向分量。

第三次作业

在第三次作业中,加入了换乘需求,解除了请求起终点的限制,且电梯容量和运行速度可定制。

笔者在此作业中架构并没有产生改变,只将SuperDispatcher调整为单例模式,便于手动添加请求,同时在请求处理和线程关闭上作了些许调整。具体来说,笔者采用了静态方法来记录请求的中转,比如如果一个请求需要经过纵向电梯运向横向电梯,再从横向电梯运向纵向电梯,最终到达目的楼栋目的层,那么在第一次换乘完成后,重新new一个请求放入SuperDispatcher进行分发;第二次换乘完成后,同样操作,特别地,此时new的新请求的起终楼栋是相同的。

调度策略

纵向电梯

请求分发

事实上,在笔者的实现中并没有真正的调度器,而始终采取一种自由竞争的策略,即电梯到达某一层或某一栋,电梯调度器在更新电梯请求时会尽可能将请求放入该电梯,这个过程中并没有考虑到电梯运行速率的不同或现有容量进行选择性的分发。

移动

笔者采取扫描策略,在第三次作业中,若在电梯移动方向的前端不存在等候请求,且不存在能够使得电梯内部请求横向换乘的时候,就改变方向。

横向电梯

请求分发

类似于纵向电梯的自由竞争。

移动

笔者采用固定方向的循环扫描策略,由于仅有5栋楼,因此性能损失不大,且大幅降低了电梯调度bug的出现,由电梯内部的电梯调度器判断是否应该在某一层开门更新请求。

优化途径

笔者并没有进行较多的优化,最终性能分97.87。

请求更新

一个小技巧是在关门sleep()结束后,输出关门信息之前更新请求,可以最可能接受新请求,虽然实际操作中发现该优化影响不大。

调度

  1. 优先为速度快的电梯进行调度,但相比自由竞争需要考虑等待损失。

  2. 细化横向调度,适时转向。

对象锁

锁类中的对象而不锁方法,可以尽可能实现并行,因为锁方法会将该实例锁住,导致每次仅有一个线程可以访问实例,阻碍了并发。

易错点分析

输出

在第五次作业中,笔者并没有理解官方包线程不安全的危害,而在本地也没有测试出时间戳靠后的Info先输出,因此以为自己规避了输出的线程不安全,导致互测因为线程不安全被hack多次。

其实,规避输出的线程不安全很简单,仅需要封装单例模式的输出类,具体实现前文已经介绍。

加锁

在更新请求时,需要对等候队列进行遍历,并删除,因此需要使用迭代器。而如果在遍历过程中忘记给等候队列加锁,而此时有其他进程访问同一个等候队列并对其进行了删除操作,就会触发异常。

调度策略

谨慎优化,很多同学因为调度策略过于复杂导致电梯轮询ctle或是死循环rtle,优化的工作量实际上是成倍增加的,因为需要进行广泛的测试,比如笔者在实现过程中便遇到了如下问题:

  1. 电梯在同方向前端无等候请求时调头,导致有些电梯内部请求始终无法到达换乘地点。

  2. 在电梯边界区域没有进行特判,导致电梯在某种情况下有可能到达0层。

请求更新

笔者在第三次作业横向电梯更新请求时,没有判断该电梯是否可以到达某一栋,但依旧过了中测,说明课下依旧需要进行有强度的测试。实际上,只有某一个请求的起始楼栋和终止楼栋对于电梯均可达时,电梯才可以接受该请求。

电梯关闭

电梯关闭的方式需要精心设计,在笔者最初的实现当中,如果换乘未结束时便终止输入,此时对应等候队列没有请求的电梯会被关闭,导致换乘无法完成。为了解决这个问题,笔者选择在所有请求处理结束后,统一关闭电梯,这需要一个全局变量对请求数进行记录。在与其他同学的交流中,笔者发现也有部分同学采取了信号量的方式。

数据构造与测试

笔者在互测中,主要对输出线程不安全、电梯轮询以及运行超时进行了测试。

对于输出线程不安全,只需要足够复杂的数据便有可能复现。

对于电梯轮询,需要先通过随机数据判断对方是否可能存在轮询,再结合代码进行针对性构造。

对于运行超时,需要阅读对方的代码,尽可能构造出能够使对方死循环的数据,比如在输入终止前给出大量同楼层需要换乘的请求,引发聚集。

感受

电梯单元使笔者了解了多线程编程,并对线程安全的设计有了初步的认识,使笔者意识到曾经那些容易浮现的bug都是好解决的bug,虽然笔者认为就OO而言,电梯月要比表达式月更为轻松。就层次化设计而言,笔者认为其对第一单元更为关键,而由于第二单元类的数量较少,只需要选择好设计模式便可以高效地完成任务。

posted @ 2022-05-04 14:16  yufu06  阅读(38)  评论(0编辑  收藏  举报