BUAA_OO_Unit2 总结

BUAA_OO_Unit2 总结

一、电梯架构分析

(一)第五次作业

1 整体架构分析

需求分析

本单元作业需要完成多线程的电梯调度程序,而本次作业仅需要支持上下行一种电梯和同座间换层指令的需求。

整体架构

本次作业采取输入线程和电梯线程两个线程,其中输入线程负责从控制台获得指令并分配到各个指令队列中,电梯线程则负责从指令队列中获得指令并控制电梯的运行。整体采用生产者-消费者模型进行架构,其中输入线程是指令的生产者,电梯线程是指令的消费者。程序整体的UML类图如下:

类的具体分析如下

  1. MainClass

    主类负责启动输入线程和电梯线程,同时创建所有电梯的请求队列传入电梯,并将所有请求队列形成的集合传入输出线程,从而构建输入线程和电梯线程的数据共享关系。

  2. InputThread

    输入类继承Thread类,作为输入线程。该类从课程组提供的输入包中获取请求,并根据请求的信息将请求分配到不同电梯的请求队列中,起到调度的作用。

  3. RequestQueue

    请求队列类,负责存放每个请求的集合并存储请求队列是否输入结束的信息。由于该类是两个核心线程的共享数据对象,所以其中的方法基本都进行加锁。

  4. Elevator

    电梯类继承Thread类,作为电梯线程。该类进行请求处理与信息输出工作,具体调度策略见后文分析。

  5. OutputThread

    输出类,里面只有一个静态方法,通过静态方法的加锁保证输出信息的线程安全。

调度策略

本次作业的电梯采用scan策略(虽然方法中写的是look),具体调度策略如下:

  1. 当电梯的请求队列为空时等待;

  2. 当请求队列非空时开始运行,运行方向为请求队列中首个请求的方向;

  3. 电梯开始运行后无条件捎带(即当前层有请求方向与运行方向相同的请求就带上);

  4. 电梯送完乘客后会继续运行至顶层/底层才调转方向。

2 调度器设计

本次作业没有单独设计调度器,指令的分发由输入线程InputThread负责。其中指令通过传入InputThread的请求队列集合进行分发,该线程通过获取请求的getFromBuilding来判断将请求放入哪一个请求队列,从而直接实现不同请求的分配。

3 线程协作关系

类协作的时序图如下

总体来说,MainClass类负责将共享数据区域进行创建与传递;InputThread线程负责传递请求给RequestQueue类,并向RequestQueue传递结束信息;RequestQueue类负责将请求交给电梯处理;最后所有电梯线程结束后通知主线程结束。

(二)第六次作业

1 整体架构分析

需求分析

本次作业新增两个需求。

①增加横向电梯,并增加相应的横向移动请求;

②增加创建电梯的请求,即在一层/一座上可能出现多部电梯。

整体架构

本次作业采取输入线程、调度器线程和电梯线程三个线程。新增调度器线程的考量是增加了创建电梯的请求,若还是集中在InputThread线程中处理,超出了所谓“Input”的职责范围。整体还是采用生产者-消费者模型,对于InputThread-Scheduler而言,InputThread是生产者,Scheduler是消费者;而Scheduler对请求进行分发后,则在Scheduler-Elevator之间形成了“一生产者,多消费者”模型。程序整体的UML类图如下:

类的具体迭代分析如下

  1. MainClass

    在主类中增加了启动Scheduler线程的语句,在InputThread-Scheduler之间放入一个单独队列作为共享数据对象。

  2. InputThread

    将调度的工作交出,只负责往单一的请求队列waitQueue中添加请求。

  3. Scheduler

    负责请求的调度。若获得的请求为电梯请求,则启动该电梯线程;若获得的请求为人的请求,则根据请求种类(楼层还是楼座)与请求位置进行请求的分配。

  4. RequestQueue

    对于提高性能的需求增加sortRequests方法,对于请求队列中的请求按照到达距离的远近进行排序。

  5. Elevator

    本单元作业没有将横向电梯与纵向电梯共同继承于一个总的电梯类。(不得不承认这是我在面向对象设计的一个缺陷)故对于横向电梯,类中另外实现了一套横向电梯的相关方法进行横向电梯的运行。

调度策略

本次请求的调度策略分为两个方面:多电梯分配策略于单电梯调度策略。

  • 多电梯分配策略

    参考往届学长学姐的博客发现多电梯之间自由竞争可以得到较好的性能,故多电梯分配采用简单的自由竞争方式。宏观来看,自由竞争就是所用同层/同座的电梯采用一个共享的请求队列,所有电梯共同接收请求队列中的信息;而谁获得请求队列中请求的接送权完全交给程序选择,哪个电梯线程在竞争中先进入临界区,哪个电梯线程就获得该请求。(其他电梯线程好像是白跑了,但是由于自由竞争电梯不断运动可能反而能更快捎上请求,所以自由竞争能获得较好性能)

  • 单电梯调度策略

    本次单个电梯采用的调度策略为look策略,具体调度策略如下:

    1. 电梯采用无条件捎带策略;

    2. 若电梯里有请求,则电梯里的请求先送,电梯运行方向就为电梯中请求的方向;

    3. 若电梯中请求送完,则先看看有没有同向可接(与电梯刚刚运行方向相同)请求,有的话电梯同向运行;

    4. 若无同向请求,则看看有没有反向可接请求,有的话电梯立马转向运行;

    5. 若请求队列为空,则电梯运行方向设为0,停止运行。

2 调度器设计

本次作业单独实现了调度器类。该调度器拥有两个共享数据对象的访问权限:一个是waitQueue,负责接收来自InputThread的请求;另一个是电梯的personQueues,负责向电梯线程传递请求。而调度器的作用就是将来自第一个共享数据空间的请求分配到第二个共享数据空间中,具体调度通过判断请求种类(电梯请求或人的请求)和请求信息(请求的楼层楼座信息)实现。

3 线程协作关系

类协作的时序图如下。

本次类协作关系的改变主要来自于Scheduler类的增加。该类获得来自InputThread的请求后,负责新电梯线程的创建和请求的分发;其余类的协作关系基本保持不变。

(三)第七次作业

1 整体架构分析

需求分析

本次作业新增两个需求。

①增加电梯可定制需求,最重要的定制需求为横向电梯可达楼座的定制;

②放开请求限制,需要支持到不同层不同座的乘坐请求。

整体架构

整体同样设置三个线程,线程之间的关系也基本不变,增加ElevatorSet类对要换乘需求进行处理。程序UML类图如下。

类的具体迭代分析如下

  1. MainClass

    在主类中增加创建ElevatorSet对象的语句,并将该对象传入Scheduler类和Elevator类中,辅助这两个类进行线程结束的判断和请求的传递。

  2. Scheduler

    修改了判断请求队列结束的条件,具体修改逻辑见调度策略与调度器设计部分。

  3. Elevator

    在构造方法中传入waitQueue这一共享数据对象,增加在电梯出人时将未送完请求传递给waitQueue的功能。

  4. ElevatorSet

    主要负责两件工作:一件工作是根据已经送到的请求和总请求生成待送请求的功能;二是维护一个全局的stage变量,该变量表示输入线程生成的所有请求的总的处理阶段数(每个请求的处理阶段数含义见调度策略与调度器设计部分),可以用于判断电梯队列是否结束。

调度策略

本次作业需要额外设计的调度策略为请求的拆分策略。综合考虑实现的复杂度和性能后,本次作业采用的请求拆分策略为在指令一输入就进行拆分,拆分形成的执行阶段称为请求的处理阶段,具体拆分策略如下:

  1. 判断请求是否需要换乘。若不需要换乘则不拆分,并将请求处理阶段stage设为1;

  2. 判断请求的起始层/到达层上有没有满足连通需求的横向电梯,若有则将请求处理阶段设为2;

  3. 否则请求的处理阶段设为3。

  4. 请求具体的拆分方式为分为需要执行的请求nowRequest和执行完nowRequest后剩余的请求nextRequest两部分;而中转电梯的选择采取官方策略,本质思想就是换乘的距离越短越好。

2 调度器设计

本次作业调度器Scheduler的增量开发有两个:一个是增加了请求队列的数据来源;第二个是修改了结束请求队列的逻辑。

首先是请求队列数据来源的增加。在上次作业中,waitQueue这一共享数据对象的生产者只有InputThread,这次的生产者还有Elevator,各个Elevator将还没有送完的请求通过ElevatorSet类生成MyRequest,再送入Scheduler进行分配;

其次是修改了结束请求队列的逻辑。由于请求队列数据来源的增加,本次作业通过统计所有来自InputThread请求的处理阶段总数来判断是否结束请求队列。当该总数为0时代表所有请求处理完毕,此时可以正常结束请求队列。

3 线程协作关系

类协作的时序图如下。

本次类协作关系的改变主要来自于ElevatorSet类的增加,总的来说ElevatorSet类接受完成了部分阶段的请求,返回一个待完成请求;同时维护一个全局stage信息,从而可以通知Scheduler类结束线程。

二、同步块与锁的设计

(一)锁的设计

本单元的作业主要通过两种方式上锁:方法锁与代码块锁。锁的设置采用了“有共享数据就加锁”的原则,做到了访问上的绝对安全。具体上锁代码举例如下。

  1. 方法锁。

    对于此次作业,共享数据对象总是由RequestQueue类生成,故在该类的所有方法基本都进行上锁,例如

    public synchronized boolean isEmpty() {
       return requests.isEmpty();
    }
  2. 代码块锁。

    在电梯线程中有频繁的对RequestQueue对象的操作,故涉及该对象的操作外同样套锁,例如

    synchronized (personQueue) {
       for (int i = 0; i < personQueue.getSize(); i++) {
           MyRequest request = (MyRequest) personQueue.getRequests().get(i);
           int reqMask = request.getRequestMask();
           if ((lrreqDirection(request) == direction || direction == 0) && personNum < capacity
               && request.getFromBuilding() == building && (mask & reqMask) == reqMask) {
               inQueue.add((MyRequest) personQueue.getRequest(i));
               i--;
               processQueue.add(request);
               personNum++;
          }
      }
    }

在设计时为了保证绝对的互斥访问,本次作业我在所有涉及共享对象的地方都加上了锁,但我深知这样的设计是冗余的,会影响线程并发的性能;而且未能尝试使用更为高效的读写锁,也是本次作业的一大遗憾。

(二)线程休眠与唤醒

除了线程互斥的处理,共享数据对象的线程还包含线程同步的处理。所谓的同步,就是线程按照一定次序访问共享对象的过程,其中一个应用场景就是线程的休眠与唤醒。

本次作业的线程休眠场景有两个,一个是贯穿三次作业的空队列休眠场景:当电梯对应的请求队列为空时,电梯线程应该休眠,等待新请求的加入;第二个则是面向第三次作业的线程结束休眠场景:由于第三次作业的线程结束不能仅依靠输入线程结束判断,所以电梯队列为空且输入线程结束后电梯线程依旧要休眠,等待拆分请求的到来/线程结束信号的到来。

而关于线程唤醒的策略,本次作业一开始采用的是“访问共享对象数据即唤醒”的原则,简单来说就是再所有加锁的地方都notifyAll。但是经过本地调试发现出现轮询,所以后面经过思考采用“需求分析法”进行设计。所谓的需求分析法就是分析线程休眠场景:例如上文分析请求队列为空时进行休眠,那就只在往请求队列中增加请求时才唤醒线程,具体实现实例如下:

...
/*线程休眠场景*/
else if (personQueue.isEmpty() && !personQueue.isEnd()) {
   if (direction != 0) {
       direction = 0;
  } else {
       try {
           personQueue.wait();
      } catch (InterruptedException e) {
           e.printStackTrace();
      }
  }
}
...
/*唤醒线程语句*/
public synchronized void addRequest(Request request) {
   requests.add(request);
   notifyAll();
}

三、bug分析

(一)自己程序bug

本单元作业的bug主要有三个:集合的删除、输出线程安全、轮询。其中前两个bug出现在第一次作业,第三个bug出现在第二次作业。

1 集合的删除

这个bug来自于不规范的集合删除。利用for循环对ArrayList进行删除时,需要注意每次i--,具体代码如下:

for (int i = 0; i < processQueue.size(); i++) {
   MyRequest request = processQueue.get(i);
   if (request.getToBuilding() == building) {
       processQueue.remove(i);
       i--; //这一句一定要加上
       outQueue.add(request);
       dealNextRequest(request);
       personNum--;
  }
}

2 输出线程安全

由于官方包的输出函数不保证线程安全,所以需要自己额外实现一个类保证输出线程的安全。具体如下:

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

该类实现了一个加锁的静态方法,保证输出操作的互斥,从而保证输出线程的安全。

3 轮询

由前文可知,本次作业的轮询主要来自于无意义的线程唤醒,相应的解决方案就是采用“需求分析法”分析唤醒。

(二)互测bug

互测主要采用自主debug过程中,hack到自己的数据hack别人的策略,总体hack成功率较低。

四、心得体会

(一)层次化设计层面

本单元让我体会到层次化设计对于代码优美程度的重要作用。前文提到,对于横向电梯和纵向电梯这两个明显具有共性的电梯类别而言,共同继承于一个电梯父类明显是一个更为合理的设计选择,但是本次作业并没有这样做,导致电梯类成为一个巨类,不仅影响美观,还增加了debug的难度。

(二)线程安全层面

本次作业遇到的线程安全问题仅在输出线程安全中,而且是忘记实现的问题而非设计的问题,故本次作业的线程安全还是保证的比较好的。但是美中不足的是,本次作业的线程安全是在基本无脑套锁的前提下保证的,这必然影响了多线程并发的效率(虽然在本次作业中没有体现出来)。以后在保证线程安全的同时应该主动寻求和线程并发效率的协调,尽量做到安全而高效。

(三)其他

总的来说电梯月是面向对象“更有料”的一个月,这不仅体现在多线程繁杂的前置知识中,也体现在多线程bug的不可复现性和轮询bug的隐蔽性中。当然这些也提示我,一方面要尽早掌握好前置知识(例如集合的增删、多种锁的实现等),从而能设计出更高效的代码;另一方面是多和同学交流(例如有关轮询的思考,本人就是和舍友xzh共同讨论出的,在此一并鸣谢)。总之,面向对象设计是一个思想>实现、讨论>单干的过程,希望能在后面的作业中多多注意。

posted @ 2022-05-04 01:40  LeVoyageur  阅读(26)  评论(1编辑  收藏  举报