面向对象第二单元总结——多线程

面向对象第二单元总结——多线程

同步块、锁及调度器

第一次作业

本次作业为单部电梯。

 .
 └── src
    ├── Config.java
    ├── Elevator.java
    ├── Main.java
    ├── ManageRequest.java
    └── Request.java

我仅采用一个线程,即电梯。至于输入的请求,我在Main.java中直接采用:

 elevator.start();
 ElevatorInput elevatorInput = new ElevatorInput(System.in);
 elevatorInput.getArrivingPattern();
 while (true) {
     PersonRequest personrequest = elevatorInput.nextPersonRequest();
     if (personrequest == null) {
         break;
    } else {
         Request request = new Request(personrequest);
         manageRequest.add(request);
    }
 }

这样做可以减少线程复杂性,较大地降低了难度。

本次存储请求的数据结构为LinkedList,位于ManageRequest.java中。对于该类中的方法,需要择取添加synchronized锁,以避免冲突、死锁等错误的发生,例如:

     public synchronized void add(Request request) {
         requsetList.add(request);
         notifyAll();
    }
     
     public synchronized void elevatorUpdate() {
         while (requsetList.isEmpty() && elevator.isEmpty()) { ... }
         if (elevator.isEmpty()) { ... }
         LinkedList<Request> temp = new LinkedList<>();
         for (Request request:this.requsetList) { ... }
         for (Request request:temp) {
             requsetList.remove(request);
        }
         notifyAll();
    }

保证了线程安全。

 

第二次作业

本次作业为多电梯。

 .
 └── src
    ├── Controller.java
    ├── Elevator.java
    ├── GetRequest.java
    ├── Main.java
    ├── Queue.java
    ├── Request.java

不得不采用多线程做法,从而共享对象的访问更加频繁。我在本次作业采用了简单的生产者——消费者模式,输入线程GetRequest充当Producer,电梯类Elevator充当Consumer。因此二者的共享对象需要采用加锁即sychronized,共享对象为一个等待队列类Queue,里面队列容器为ArrayList容器,为线程不安全的容器,因此对该容器的增加、删除操作均需要使用sychronized修饰语进行同步保护:

     public synchronized Request get(String id) {
         while (requests.isEmpty() || !hasActivated()) { ... }
         Request tem = null;
         for (int i = 0; i < requests.size(); i++) { ... }
         return tem;
    }
 
     public synchronized boolean hasDirect(String id) {
         for (Request request : this.requests) { ... }
         return false;
    }
 
     public synchronized boolean ifDirect(Request request, String id) { ... }
 
     public synchronized void put(Request request) {
         requests.add(request);
         this.in = 1;
         notifyAll();
    }
 
     public static synchronized Queue getQueue() {
         return QUEUE;
    }
 
     public synchronized void setEnd() {
         this.end = true;
    }
 
     public synchronized boolean isEmpty() {
         return requests.isEmpty();
    }
 
     public synchronized boolean hasActivated() {
         for (Request request : requests) { ... }
         return false;
    }
 
     public synchronized ArrayList<Request> getRequests() {
         return requests;
    }

第二次作业调度器设计主要以综合评估电梯已承载人数为调度方案,每次获取新的PersonRequest后即考虑将其送进已承载人数最少的电梯。这种调度方法的考虑主要是避免电梯存在长时间waiting,但之后发现这并不等价于性能的理想。

这次作业架构并非很理想,在强测互测中出现了较多的bug,明示重构。

 

第三次作业

本次作业新增了不同种电梯。

 .
 └── src
    ├── Config.java
    ├── Elevator.java
    ├── ElevatorList.java
    ├── Initialization.java
    ├── Main.java
    ├── MyPersonRequest.java
    ├── NewPassengerOrElevator.java
    ├── Pair.java
    ├── Scheduler.java
    ├── Strategy.java
    └── WaitList.java

第三次作业主要策略变化:

在请求类中存储了回调函数对象,调度器需要在请求完成后进行回调;

Scheduler在需要换乘时,向 ElevatorList 分派两个请求,在前一请求中附加回调函数,用于分派后一请求。

本次作业有三种不同型号的电梯,并且每一种电梯所能够到达的楼层不尽相同。在主类中创建了输入线程以及托盘类,在输入线程中根据输入将乘客请求添加到分配器,或者按照增加电梯请求增加电梯。并且在托盘类中根据每个指令的出发楼层和到达楼层将不同的指令分配给不同类型的电梯,如果要换乘则先规定一个换乘的楼层。

在电梯线程中,根据不同的模式实现了三种不同的策略,主要是在上升模式的状态转移策略中有所不同。

而增加了换乘之后,分派器又需要考虑来自电梯的输入,电梯也要考虑向总队列的输入。因此,共享对象的交互变得非常频繁,因此在原有的架构上,没有增加更多的加锁操作。而是在线程类对总队列,电梯等待队列的对象进行加锁。

 

可扩展性分析

以下为UML Sequence Diagram:

基于第三次作业考虑,可扩展性还是较强的。我在第三次作业中的线程通信更简单安全,在每次的作业中注意了降低耦合度,较低的耦合度可以带来一定的可拓展性。对于电梯的进化或者退化,仅需要修改构造器,加入或删减电梯属性即可。但如果需要更近一步的迭代,则需要设计较稳定的调度策略,我在编写时发现调度方式可能大相径庭,故第二次和第三次作业都是采用的独立的调度策略,所以调度策略的可拓展性较差,需要在每一次几乎都单独重写调度方法。至于之前的调度方法,大概率就不可重用了,必须重新写。但电梯的状态转移、单部电梯的运行逻辑和高层的请求分派逻辑基本相同,且在程序编写过程中,考虑到了高内聚低耦合的设计策略,每一个类各司其职,因此当程序的需求发生变化时,不会产生“牵一发而动全身”的情况,也有一定的可扩展性。

 

BUG!

Bug分析

  • 自己

    本次第二次作业在互测和强测中均被查出CTLE,出于Random模式。查看代码后,发现是由于错误的进行了notifyAll语句导致的CTLE。第三次作业未在强测发现bug,但在测试中测和弱测时,发现了CPU时间过长的现象。后面发现是由于在Random模式下,一直询问输入是否结束导致的。解决办法较为简单,只用将轮询改为wait-notify模式。若输入未结束且Queue内没有请求,就wait等待;当输入了请求再notify唤醒。Queue获取请求的方法,我初步使用的等待条件是只要Queue中没有任何请求就进入等待。但这种考虑是不周全的,如果是在Queue为空了之前,输入结束就到来了并执行了一次notify,那么等到Queue为空了后,再次进入等待时,以及没有可以唤醒它的语句了。因此需要将获取请求的方法时,使用的条件增加为Queue为空且输入还没有结束。

  • 他人

    本次由于数据特殊性和难复现性,仅在第二次作业中成功Hack过两个点。

    • 测试策略

      • 黑盒测试,一次运行30个子进程

      • 有效性:模拟评测机运行情况,并发现了强测未发现bug

    • 线程安全相关

      • 所有测试数据均为随机生成,实际上由于线程安全问题的不确定性

      • 同时运行多个测试进程,增大了线程安全问题出现的概率

Hack策略

构造评测机,进行大数据量的多workers黑盒随机测试(瞎猜)。

 

心得体会

多线程编程尽量减少线程间的通信,并且禁止多个对象在同一个锁上调用wait()方法。这样已经解决了绝大部分线程安全问题,余下的问题是:调度器尝试激活电梯时电梯会小概率自行激活,导致调度器被阻塞,后续电梯notify调度器,请求调度时会发生死锁。

造成这个问题的原因是调度器已判断电梯处于WAITING状态,又尚未激活电梯时,电梯自行唤醒,使调度器被阻塞。这时将if判断放进同步块只会增大阻塞发生概率,不能解决问题。

  • 保证线程安全,合理使用synchronized块。

  • 避免轮询,使用wait和notifyAll。notifyAll如果在不恰切的地方使用也会导致CTLE(第三次作业有感)。

  • 如何在正确的时刻结束线程,三次作业结束线程的判断均不一样,如果提前结束线程或由于死锁无法结束线程是很可惜且严重的失误。

posted @ 2021-04-24 04:01  SprLau  阅读(247)  评论(0)    收藏  举报