【面向对象】电梯与多线程的是非故事——第二单元课程总结

一、多线程编程的详细设计策略

生产者-消费者模型

其实这三次的电梯作业,都可以看作是生产者-消费者模型:输入线程是请求的生产者,调度器是消费者。同时,调度器相对于电梯而言,也是生产者,而电梯是消费者。

这就像食物链一样,环环相扣,逐级传递,构成了“生产者-消费者模型”的链式结构

(其实就是好好理解PPT,照着课上讲的自己也来整一个

所以我一开始就确定了这样的一个架构,并沿用了三次作业,即:

  • 构建一个线程安全的共享队列RequestQueue用来作为生产者-消费者关系中的”托盘“,用于缓冲和传递请求,并且通过这个共享对象来传递结束信号。其中使用的容器时JDK中的线程安全的并发队列LinkedBlockingQueue<>()。当共享队列为空时,使用wait()使访问它的线程进入阻塞;当请求加入共享队列时,使用notifyAll()唤醒等待的线程。

  • 构建一个输入线程InputHandler,向共享队列中添加请求;输入结束时,对共享队列设置结束信号

  • 构建一个调度器线程Scheduler,从输入线程与调度器线程中的共享队列读取请求,并将读取到的请求派发给电梯线程与调度器线程之间的共享队列。

  • 构建一个电梯线程Elevator,由调度器启动。电梯从共享队列中读取调度器分配的请求,并按照自己的执行策略执行这些请求。

  • 线程结束的方式:在共享队列中设置一个布尔变量isOver,初值为false,并配套一个方法public void setOverSignal(),用于将isOver设置为true。当线程从共享队列RequestQueue中读取请求时,会检查这个信号是否为真。

    输入线程读取到null,跳出循环并设置该变量为真,来通知调度器线程输入已经结束。

    如果队列为空且isOver信号为真,则说明输入已经结束,且已经执行完了所有的任务,此时共享队列抛出一个异常,调度器线程捕获到这个异常就会跳出循环。

    调度器跳出循环以后,与输入线程类似,会将其与电梯间的共享队列的isOver信号设置为true,通知电梯线程的结束。


根据三次作业的不同要求,调度器和电梯这两类线程的具体设计又有些许差别(输入线程没有区别,只是替换了三次作业的不同官方接口)。具体的方案如下:


第一次的傻瓜电梯

  • 调度器就像一个”复读机“,重复着从一边的共享队列读取请求,在把请求塞给另一边的电梯线程。
  • 电梯就像个傻子,”拿着半截就开跑”,只要从调度器读取到一个请求就去完成这个请求。

第二次的ALS电梯

  • 由于只有一个电梯,所以调度器的分配策略并没有变化,仍然是第一次的复读机
  • 电梯线程发生了较大的改变。一是楼层发生了改变,增加了地下层。
  • 二是为了适应可稍带的电梯调度,电梯内部新增了两个队列readyInreadyOut。其中readyIn用于存储从共享队列中读取到的、还没有上电梯的请求;readyOut用于存储进了电梯但是还没有到达目的地的请求。
  • 每到一层楼就读取一次共享队列,并且扫描readyInreadyOut来判断是否有人需要上下。
  • 由于电梯的载客量没有限制,为了尽可能地缩短时间,所以我采用的ALS策略是:在主请求运动的方向上,尽可能的多接人上电梯,同时途中下人。
  • 这样的调度,不算高效(强测只有89分多),而且从实际角度出发并不好,因为有可能让乘客上电梯但是电梯运动的方向可能和乘客想去的方向相反。

第三次的“神奇多电梯”

  • 在第二次作业的基础上,第三次作业除了变成了多电梯,每台电梯可以停靠的楼层不同,所以还产生了换乘的需求。
  • 枚举所有楼层组合的发现

    1. 枚举了所有楼层请求组合(23个选2个),本次作业的253种请求中,有87种请求需要换乘。且存在很多种请求,能独立完成该请求的电梯多于一部(例如FROM-1-TO-15三部电梯都能完成)。
    2. 所有的请求,至多需要两部电梯完成(即需要换乘的请求可以拆分成一个换乘请求对不存在需要三部电梯协同完成的情况)
    3. 这253种组合中,可以发现C电梯的使用频率是显著低于A, B电梯的
    4. 所以调度器要做到任务的均匀派发宏观上的时间最短,是本次作业的一大挑战。
  • 调度器线程的变化是最大的

    1. 由于增加了换乘的需求,所以调度器要对读取的请求做出判断。调度器中新增一个换乘单元,用于存储、派发换乘请求对的后半段请求。

    2. 如果一个请求不能被单个电梯执行完成,则拆分这个请求,并将其加入换乘单元。

    3. 拆分方式:

      • 观察三部电梯的停泊楼层,可以发现只有1楼和15楼是三部电梯都可达的,所以很自然的想到把所有换乘请求的换乘楼层都设置为1楼或15楼
      • 可以选择在中间楼层进行换乘。比如FROM-3-TO-14可以选择在5, 7, 9, 11, 13楼换乘B电梯,而不是15楼换乘。这样两部电梯运行的楼层数会更少。

      分别实现了这两种换乘方式,经过实验,综合考量下,我还是选择前者的换乘拆分方式。至于为什么,请往后看

    4. 请求分配策略:
      • 由于C电梯的使用频率最低,所以C电梯能单独完成的请求优先分派给C。
      • 优先顺序:C-->B-->A
      • 其余没有做更多的优化调度策略。继续优化的方向我认为应该结合各个电梯的负载情况,使相同的时间内,每个电梯的利用率都尽可能高(类似于操作系统中讲到的进程的调度与硬件资源的分配)
    5. 结束线程的条件:相比第二次,还需要增加一个当换乘单元为空时,才能对电梯发送结束信号。(这里偷懒就没有用wait()和notifyAll(),用了while轮询+sleep())

  • 换乘单元的设计:

    1. 内部采用Hashmap<PersonRequest, PersonRequest>()保存数据,Hashmapkey是换乘请求对的前段,value是换乘请求对的后半段。与此前采用的Hashset<Pair<PersonRequest, PersonRequest>>这种二元组结构相比,这样可以兼顾数据结构的简洁和高效,代码量更少,逻辑更加清楚。
    2. 换乘单元与调度器、电梯、电梯共享队列进行有限的单向交互,主要还是为了为了降低对象之间的耦合。
    与其他线程的交互逻辑:
    1. 当调度器线程发现请求需要拆分时,则将拆分得到的两个请求存入换乘单元。

    2. 电梯下客时,会询问换乘单元下电梯的请求是否是换乘请求的前半段。是则有换乘单元把后半段请求投放到合适的电梯的共享队列。

    3. 当调度器发现请求需要拆分时,则将拆分得到的两个请求存入换乘单元。

  • 电梯线程的变化

    1. 在于增加了载客量,而且每个电梯运行的速度也是不相同的。由于调度器在分配任务时,已经将任务根据楼层分给了各个电梯,所以楼层变化对电梯线程的影响不大。
    2. 设置了最大载客量capacity,为了能尽可能地多载客,参考实际生活,电梯应该开门时应先下后上。当人数达到载客量以后,电梯地readyIn队列不再添加请求,此时优先执行readyOut中的请求。
    3. 电梯的内部调度策略,主要是在第二次作业的基础上做了些许的优化,总体和第二次的调度差别不大。
  • 综合实验测试的发现

    1. 两种拆分请求的方式,最终的运行时间差别不大,甚至第二种方式(中间楼层换乘)会更慢。这有些违反直觉。

    2. 究其原因,我认为:

      • 一是因为中间楼层换乘的楼层,对于单个请求,电梯移动请求路程时减少了的。但相对于大量的、随机的不同请求,这无形中会增加电梯开关门的时间开销,以及移动的路程(离散的)。

      • 二是在1楼或15楼集中换乘,电梯运动的确定性和可预测性更高(更加集中的),可以减少开关门的时间。这也不难理解,比如现实中的地铁也不是每个站点都是换乘站。换乘站集中的人群可以提高效率和可预测性。

      • 三是可能1楼15楼集中换乘的方式更适合我的电梯内部调度方式。

  • 最终结果

    最终效果还是符合预期的,没有出错就是最好的结果,虽然效率真的一般般(离A组90只差不到0.1分QAQ)。


二、基于度量的程序结构分析

度量类的属性个数、方法个数、每个方法规模、每个方法的控制分支数目、类总代码规模
计算经典的OO度量
画出自己作业的类图,并自我点评优点和缺点,要结合类图做分析
通过UML的协作图(sequence diagram)来展示线程之间的协作关系(别忘记主线程)
从设计原则检查角度,检查自己的设计,并按照SOLID列出所存在的问题

(一)第五次作业

  1. 类图

  2. 协作图

  3. 度量分析

    • 类class

      • OCavg:平均方法复杂度。
      • WMC:带权方法复杂度。
    • 方法method

      • ev(G):核心圈复杂度。
      • iv(G):方法设计复杂度。
      • v(G):圈复杂度。

(二)第六次作业

  1. 类图

  2. 协作图

    由于只有一个电梯,所以协作关系并没有发生改变,与第一次作业相同

  3. 度量分析

    • 类class

    电梯类承担了太多的职责,从代码量方面可能看出。其中的一些方法太过复杂,扩展性和可维护性较低。

    • 方法method

      Method ev(G) iv(G) v(G)
      Dispatcher.Dispatcher(RequestBuffer) 1 1 1
      Dispatcher.createElevators() 1 1 2
      Dispatcher.run() 2 2 3
      Dispatcher.startAllElevators() 1 2 2
      Elevator.Elevator(RequestBuffer) 1 1 1
      Elevator.arriveAt(int) 1 1 1
      Elevator.closeDoor() 1 2 2
      Elevator.emptySleep(int,long) 1 2 2
      Elevator.getNextTo() 1 3 3
      Elevator.inAndOut() 1 13 13
      Elevator.inPerson(PersonRequest) 1 1 1
      Elevator.move(int,int) 2 8 9
      Elevator.moveDown(int,int) 2 4 6
      Elevator.moveUp(int,int) 2 4 6
      Elevator.openDoor() 1 2 2
      Elevator.outPerson(PersonRequest) 1 1 1
      Elevator.run() 2 6 7
      InputHandler.InputHandler(RequestBuffer) 1 1 1
      InputHandler.run() 3 4 4
      Main.main(String[]) 1 1 1
      RequestBuffer.RequestBuffer() 1 1 1
      RequestBuffer.addRequest(PersonRequest) 1 1 1
      RequestBuffer.getFirstRequest() 3 3 3
      RequestBuffer.isEmpty() 1 1 1
      RequestBuffer.removeFirstRequest() 1 1 1
      RequestBuffer.removeRequest(PersonRequest) 1 1 1
      RequestBuffer.setOverSignal(boolean) 1 1 1
      RequestBuffer.size() 1 1 1
      States.getDirection(PersonRequest) 1 1 1
      States.getDirection(int,int) 3 1 3
      States.toString(States) 2 2 7

(三)第七次作业

  1. 类图

  2. 协作图

  3. 度量分析

    • 类class

      由于基本沿用了第二次作业的电梯,电梯类承担了太多的职责,从代码量方面可能看出。其中的一些方法太过复杂,扩展性和可维护性较低。

    • 方法method

      Method ev(G) iv(G) v(G)
      Main.main(String[]) 1 1 1
      Elevator.Elevator(RequestBuffer,TransferUnit,long,int,String) 1 1 1
      Elevator.arriveAt(int) 1 1 1
      Elevator.closeDoor() 1 2 2
      Elevator.floorSleep(int,long) 1 2 2
      Elevator.getNextTo() 1 4 4
      Elevator.in(boolean) 4 5 5
      Elevator.inAndOut() 1 2 2
      Elevator.inPerson(PersonRequest) 1 1 1
      Elevator.isFull() 1 1 1
      Elevator.move(int,int) 2 6 7
      Elevator.moveDown(int,int) 2 5 6
      Elevator.moveUp(int,int) 2 5 6
      Elevator.openDoor() 1 2 2
      Elevator.out(boolean) 1 5 5
      Elevator.outPerson(PersonRequest) 1 1 1
      Elevator.putIntoReadyIn() 1 4 4
      Elevator.run() 2 7 8
      InputHandler.InputHandler(RequestBuffer) 1 1 1
      InputHandler.run() 3 4 4
      RequestBuffer.RequestBuffer() 1 1 1
      RequestBuffer.addRequest(PersonRequest) 1 1 1
      RequestBuffer.getFirstRequest() 3 3 3
      RequestBuffer.isEmpty() 1 1 1
      RequestBuffer.removeFirstRequest() 1 1 1
      RequestBuffer.removeRequest(PersonRequest) 1 1 1
      RequestBuffer.setOverSignal() 1 1 1
      RequestBuffer.size() 1 1 1
      Scheduler.Scheduler(RequestBuffer) 1 1 1
      Scheduler.addToElevator(PersonRequest) 3 4 4
      Scheduler.closeElevators() 1 2 2
      Scheduler.createElevators() 1 2 2
      Scheduler.isAllFinish() 3 2 3
      Scheduler.run() 2 5 6
      Scheduler.startElevators() 1 2 2
      Scheduler.transferFloor(PersonRequest) 1 4 7
      TransferUnit.TransferUnit(RequestBuffer[],HashSet[]) 1 1 1
      TransferUnit.addTransferRequest(PersonRequest,PersonRequest) 1 1 1
      TransferUnit.contains(PersonRequest) 1 1 1
      TransferUnit.isEmpty() 1 1 1
      TransferUnit.transferring(PersonRequest) 3 3 3

基于SOLID原则的评价

  • SRP(单一责任原则):避免类的功能重合和一个类做太多事。

    有调度器、电梯、输入、阻塞队列、换乘单元这5种类,3类线程。电梯类要做的事情有点多,整体的代码量也比较大,接近300行。换乘单元被4个线程共享(调度器+3*电梯),这里的设计不太好,最好改成单例模式。

  • OCP(开放封闭原则):对扩展开放,对修改封闭。

    电梯的属性是new的时候传入构造的。其实可以把电梯公共的部分做成一个抽象的父类,三类电梯分别继承这个公共的父类,可以提高扩展性。

  • LSP(里氏替换原则):子类应该包括父类的所有属性。

    木有继承。

  • ISP(接口分离原则):避免接口的责任重合和一个接口做太多事情。

    木有设计接口。

  • DIP(依赖倒置原则):模块之间尽可能依赖于抽象实现,而不是模块之间的依赖,抽象不能依赖于细节。

    主要就是换乘单元被太多的线程共享使用,这块耦合度太高,不是一个好的设计。


三、自己程序的BUG

还好还好,三次作业的强测都未出现任何错误这都得归功于我优秀而简单的架构设计(叉腰)

所以总体来说本单元的设计应该没有Bug,而且能确保线程安全性

但是!!在第三次多电梯竟然被找到了个REAL_TIME_LIMIT_EXCEED实在是晚节不保

关于无法复现的Bug

而且这还是个无法复现的TLE超时Bug。本地使用程序对我的电梯定时投喂了四十多次,每次的运行时间都稳定在54s到55s之间。

至于为什么会出现这个问题,我仔细检查了代码,可能是Hashmap线程不安全的问题。但是我认为其他线程对换乘单元Hashmap访问都是互斥的。所以我并没有检查出什么问题。在Bug修复阶段,我一行没改直接就提交上去就对了……运行时间也符合本地的测试结果。

可能是小概率的偶发事件?希望能有大佬能帮忙解答一下。

所以为了保证绝对的对换乘单元的互斥访问,为了绝对的线程安全,我后来对Hashmap加了更多的synchronized……


四、如何互测

由于我太菜了不会写评测机,所以本单元的互测中我既没有找到别人的Bug,也没有在互测中被找到什么致命的Bug(那个互测的TLE根本无法复现所以那个不算Bug呜呜呜呜!!!!)

  • 第一次作业很简单,互测屋内甚至还有单线程的,所以互测没有Bug是非常正常的。

  • 第二次作业的互测仍然没有发现别人的bug,也没有被找到bug因为我没有bug。看了看记录,一个同学出现了线程安全的问题,另一个同学被抓了个TLE的问题。

  • 第三次作业依旧找不到bug。最后,本组也只有我被找了这个无法复现的TLE。

总体来看,我这三次所处在的互测屋,线程安全的问题真的很少,大家基本都做到了共享对象的互斥访问,线程之间是安全的。

与第一单元不同的是,第二单元的电梯运行只要是符合逻辑的就是正确的,并没有什么标准答案。所以互测的门槛也相应变得更高。

认真查看阅读8个人的代码,查看里面的逻辑错误或者线程安全问题也几乎是不可能的。所以这也可能是大家互测越来越佛系的原因吧……主要还是没有评测机,说到底还是一条懒狗


五、心得体会

线程安全:

我认为要保证线程安全,最重要的是以下几点:

  • 尽可能减少共享对象的数量(降低耦合)
  • 保证共享对象的访问、写入、删除等操作是互斥
  • 理清线程之间的同步、互斥关系

我的设计中,共享对象有:共享队列RequestBuffer,存在于输入线程与调度器线程之间、调度器线程与三个电梯之间;以及换乘单元TransferUnit,被调度器线程、电梯线程共享。电梯的共享队列也被换乘单元共享。

共享队列的代码如下:

import com.oocourse.elevator3.PersonRequest;

import java.util.concurrent.LinkedBlockingDeque;

public class RequestBuffer {
    private LinkedBlockingDeque<PersonRequest> requestQueue;
    private static final int MAX_NUM = 55;
    private boolean overSignal;

    public RequestBuffer() {
        this.requestQueue = new LinkedBlockingDeque<>(MAX_NUM);
        this.overSignal = false;
    }

    public void addRequest(PersonRequest val) {
        synchronized (this) {
            this.requestQueue.addLast(val);
            notifyAll();
        }
    }

    public void setOverSignal() {
        synchronized (this) {
            overSignal = true;
            notifyAll();
        }
    }

    public PersonRequest getFirstRequest() throws Exception {
        synchronized (this) {
            while (this.isEmpty()) {
                if (overSignal) {
                    throw new Exception("finish");
                } else {
                    wait();
                }
            }
            return requestQueue.peekFirst();
        }
    }

    public void removeRequest(PersonRequest p) {
        synchronized (this) {
            requestQueue.remove(p);
        }
    }

    public void removeFirstRequest() {
        synchronized (this) {
            requestQueue.removeFirst();
        }
    }

    public boolean isEmpty() {
        synchronized (this) {
            return requestQueue.isEmpty();
        }
    }
}

设计原则:

  1. 我认为多线程并发程序设计中,重中之重就是保证线程安全。一切的性能优化都应该是建立在线程安全的基础之上。所以应该降低每个类之间的耦合,每个类管好自己的事情就好了。

  2. 避免无谓的synchronized。第二次作业中,我遇到了电梯无法同时接受多个请求的情况。在去除了一些不必要的synchronized之后(比如对开关门方法的修饰),电梯的运行更加合理高效。

  3. 善用wait()notifyAll(),避免轮询。

  4. 理清楚线程之间的关系:互斥关系、同步关系等,找到适合的模型例如生产者-消费者模型。

  5. 搞了个DebugInfo类,用来输出调试的信息(类似操作研讨课上也有同学提到)

    import com.oocourse.TimableOutput;
    
    public class DebugInfo {
        private static boolean isDebug;
    
        public static void setIsDebug(boolean isDebug) {
            DebugInfo.isDebug = isDebug;
        }
    
        public static void println(String str) {
            if (isDebug) {
                synchronized (TimableOutput.class) {
                    TimableOutput.println(
                            String.format("%s: %s",
                                    Thread.currentThread().getName(), str)
                    );
                }
            }
        }
    }
    

六、自己的一点感受

第一次接触多线程编程,还是踩了不少坑,遇到了很多障碍。讨论区的这个帖子记录了第一次作业踩过的坑。

如何优雅地退出所有线程使程序结束

很明显,本次作业,最先退出的线程应该是负责输入的线程。

而电梯线程的退出应该是输入线程结束 and 请求队列为空 and 所有电梯处于等待状态

理想的状态应该是所有线程主动地退出并释放资源,比如让他们主动地结束run()方法。有以下思路:

  1. 维护一个共享变量
  2. 定期检测这个共享变量
  3. 当输入线程结束,改变这个共享变量并通知其他线程
  4. 其他线程主动结束run()方法,线程中止

通过抛出异常的方式来结束线程是和同学讨论的结果。现在回过来看,这个方法非常的好用。

本单元没有特别刻意地去追求性能分,主要达到的是正确性。对于一些多线程的设计模式比如单例模式啥的,没有深入研究使用。

多线程编程非常的巧妙,需要注意的细节也很多,这个单元只是一个小小的入门。总体来说还比较顺利。继续加油啦😁

posted @ 2019-04-21 21:30  叮叮猫不是猫  阅读(298)  评论(2编辑  收藏  举报