2021 OO 第二单元总结博客

写在前面

又过去了三周,到了一个停一停向后看的时间。总体讲,多线程作业写得还是很坎坷的,调试变得玄学之后,本就bug丰富的代码更加雪上加霜。下面就忠实地分析和记录一下我被电梯支配的历程。

在我们开始之前,先重温一下这三次作业的需求。

  • Homework5 模拟单部多线程电梯的运行,电梯支持Morning、Night、Random三种运行模式,可捎带,限乘6人。

  • Homework6 模拟多部同型号电梯的运行,并要求能够响应输入数据的请求,动态增加电梯

  • Homework7 模拟多部不同型号电梯的运行。型号不同,指的是开关门速度,移动速度,限乘人数,以及最重要的——可停靠楼层的不同。

一 同步块的设计与锁的选择

1 Homework5

1.1 同步块设置

本次作业的同步块都是实例方法中的同步块。第五次作业只要求实现一部电梯,我将总请求队列waitQueue设置为输入线程和电梯线程的共享对象。

  • waitQueue的写操作:新请求进入输入线程和人要进入电梯时。
  • waitQueue的读操作:判断电梯运行结束和判断当前楼层是否有人进入时。

对于以上情况,对各线程进行了同步处理。

1.2 锁的选择

我主要运用synchronized方法加锁,锁的是输入线程和电梯线程间的共享变量总请求队列waitQueue

1.3 锁与同步块中处理语句之间的关系

直接将锁加在总请求队列waitQueue上,如电梯人员进入时:

synchronized (waitQueue) {
	waitQueue.remove(requese);
}

电梯输入线程有新请求时,直接锁住整个语句块:

synchronized (waitQueue) {
    if (request == null) {
        waitQueue.close();
        waitQueue.notifyAll();
        try {
            elevatorInput.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return;
    } else {
        waitQueue.addRequest(request);
        waitQueue.notifyAll();
    }
}

当电梯需要对waitQueue进行读操作时,也会加入synchronized (waitQueue)后再读取,由此判断电梯下一步的运行策略。

2 Homework6

2.1 同步块设置

由于第六次作业加入了多部电梯,因此需要实现调度器对各请求进行分配。调度器的加入引入了更多共享对象。第五次作业中的waitQueue扩展为调度器、输入线程和各电梯线程共享的总待分配序列totalQueue,并且为每一部电梯引入了自己的总等待序列waitqQueue,具体变化可由图1形象化解释。

因此,如程序中存在x部电梯,则有x+1个共享对象。在读写这些变量时需设置同步块。

此外,由于存在多个电梯线程,为保证输出的稳定性,需要对输出这一静态方法加锁,将输出方法设计为同步块。

2.2 锁的选择

本次作业仍使用synchronized方法加锁,在对共享对象有读写操作时,在共享变量上加锁;对输出这一静态方法加锁。

2.3 锁与同步块中处理语句之间的关系

其实,第六次作业对waitQueue的加锁方式和第一次大同小异。而totalQueue由于涉及线程较多,需谨慎加锁。在调度器中分配totalQueue请求时,我锁了整个语句块,如:

synchronized (totalQueue) {
    temp.addAll(totalQueue.getRequests());
    totalQueue.clearQueue();
}

在判断电梯结束条件时,要注意,waitQueuetotalQueue锁存在嵌套关系时,务必要谨慎,否则很容易发生死锁。我就是没处理好这个嵌套关系,导致电梯线程总会无法停止运行。由此,我在调度器线程中加入了对各个电梯的依次notifyAll,保证了电梯线程的正常结束。

synchronized (totalQueue) {
    if (totalQueue.isEnd() && totalQueue.noWaiting()) {
        for (int i = 0; i < elevators.size(); i++) {
            WaitQueue waitQueue = elevators.get(i).getWaitQueue();
            synchronized (waitQueue) {
                waitQueue.notifyAll();
            }
        }
        return;
    }
}

同时,多个电梯同时运行,而输出并不是原子操作,因此需要注意对输出函数加锁,即:

public static synchronized void outPut(String s) {
    TimableOutput.println(s);
}

3 Homework7

3.1 同步块设置

由于第七次作业相对于第六次作业的改动只有增加了不同型号的电梯,本质上仅需要修改调度算法,整个多线程的框架没有调整。因此,我程序中同步块的共享对象与第六次完全一致,仍是输入线程和各电梯线程共享的总待分配序列totalQueue、每一部电梯自己的总等待序列waitqQueuei

3.2 锁的选择

锁的选择延续第六次作业。锁住totalQueuewaitQueue两类共享对象,锁住输出这一静态方法。

3.3 锁与同步块中处理语句之间的关系

由于锁没有变化,锁与同步块中处理语句的关系也与Homework6完全保持一致。

二 调度器设计及算法分析

在我的历次作业中,调度器的作用仅为从输入线程获取输入的请求,再将请求加入合适电梯的等待队列waitQueue。至于电梯运行时如何处理请求(包括处理顺序、捎带规则、换乘条件等)都由电梯类实现。

1 Homework5

1.1 调度器设计

由于第hw5中仅支持一部电梯,实际上调度器没发挥什么作用,因为全部请求都要由那一部电梯处理。调度器依靠总等待队列waitQueue与输入线程和电梯线程产生交互。

  • 与输入线程交互:

    输入线程读入用户输入的请求,并将其放waitQueue中。

  • 与电梯线程交互:

    调度器将总等待队列中的人加入到电梯的等待队列中,以便电梯执行具体请求。

1.2 电梯运行算法

第一次的电梯运行算法非常傻,我采用的是支持稍带的FCFS(First Come First Serve)算法。即电梯会将第一个请求作为主请求,在实现主请求的过程中可以将同方向的请求捎带进电梯。如果主请求完成,则更新主请求为最早进入电梯的人的请求,直至电梯内人数为空,则去服务waitQueue中第一个请求。这种算法性能很差,但可以勉强完成任务。

2 Homework6

2.1 调度器设计

在本次作业中,初始有三部电梯,在之后的运行过程中可以添加0-2部,这时调度器的作用就变得明显起来。我的调度器Scheduler类实现了将总等待队列totalQueue中的请求分配至各个电梯发waitqueue的功能。按三种不同需求,分配方式如下:

  • Morning:我电梯中的morning采用等人机制,即来满六人后或检测到Crtl+D时,调度器才会开始工作,并把这六个请求一并塞给空闲且编号最小的电梯,同时notify该电梯线程。

  • Random:该模式下分配遵从如下优先级:同方向可捎带请求>空电梯>人数最少的电梯。

    即当一个请求进入Scheduler中的总等待队列totalQueue后,首先寻找符合如下条件之一的电梯(以该请求上行为例):

    • 电梯上行,且当前楼层this.floor小于该请求的出发楼层fromFloor
    • 电梯下行,且当前楼层this.floor大于该请求的出发楼层fromFloor

    若有若干符合该要求的电梯,则将请求分配至|this.floor-fromFloor|最小的一部电梯中。但若没有符合上述条件的电梯,则选取电梯队列中一空电梯,令其服务该请求。但若没有空电梯,则将该请求给予waitqueue.getSize() + cabinQueue.getqueue()(即电梯等待人数与当前服务人数之和)最小的电梯。

  • Night:由于该模式下不需要等人,所有人员都在同一时刻到达,因此night可在同一时段获得所有请求。我们将night中的请求按照目标楼层Tofloor由大到小排序,并六个一组、六人组满后统一分配给一个空电梯。

为实现调度器的功能,调度器依托totalQueuewaitQueue两个共享变量,会与输入线程和电梯线程进行交互。和第五次作业相比,由于电梯线程增加,调度器要精准的选择与哪一个Elevator线程产生数据交互,即如图:

2.2 电梯运行算法

吸取第五次作业性能过差的教训,本次作业中,我对电梯的运行算法做了改进,采用了大家普遍使用的look算法。较FCFS算法的改进为,当电梯为空时,不再直接无脑服务等待队列中的第一个人,而是继续服务通向请求。比如电梯在上行过程中空了,那便会寻找比当前楼层高的楼层是否还存在未服务请求,如果存在,则会去服务该同向请求而不是waitQueue中的第一个,性能因此得到了一些改善。

3 Homework7

3.1 调度器设计

本次作业要求支持不同型号的A、B、C三类电梯,需要对调度器进行调整。本次作业中,Night、Random、Morning三种模式采用统一的调度方式。进入电梯的优先级模式与A、B、C三种电梯的速度顺序统一,即能给C的给C,不能给C的尽量给B,实在不行才使用A电梯,具体调度策略为:

  • {1,2,3,18,19,20}集合内楼层的转移请求直接分配给C电梯。
  • fromFloor为单数时,toFloor为单数或|fromFloor - toFloor| > 3时,请求分给B电梯。
  • 其余请求分给A电梯。

并且需要注意,每次分配时要遍历电梯队列,将请求加入该种型号电梯中当前电梯中人数和等待人数之和最小的电梯。

该调度器相比于第六次作业,仅修改了调度的分配算法,而线程信息交互没有任何变动。因此程序中线程交互也与第六次作业完全一致。

3.2 电梯运行算法

电梯运行算法中实现了A、B电梯的相互换乘

需要换乘的请求满足如下条件:

  • 出发楼层和到达楼层有且仅有一个为偶数
  • 出发楼层与到达楼层绝对值之差大于3层

可将该逻辑表示为fromFloor*toFloor % 2 == 0 && fromFloor*toFloor % 4 != 0 && |fromFloor - toFloor| > 3。则需要换乘的是单数楼层-偶数楼层,和偶数楼层-单数楼层,且间隔多于3层的请求,对于上述两种情况分别采用如下措施(尽量提高B电梯的利用率,减少A电梯的使用):

  • 奇数楼层-偶数楼层:由B电梯首先运送,等到达toFloor - 1层时,将请求换乘至A电梯运输。
  • 偶数楼层-奇数楼层:由A电梯首先运送,等到达fromFloor + 1层时,将请求换乘至B电梯运输。

换乘的程序实现方式为将请求拆分为两个,当乘客于换成层下来的时候,向总等待队列中添加关于该乘客从当前楼层到目标楼层的新请求。

为实现上述换乘,电梯有三种运行模式:

  • C电梯运行模式:沿用第六次作业中的Random算法。
  • B电梯运行模式:只在奇数层停靠,并在每层遍历是否有需要换乘乘客离开。
  • A电梯运行模式:沿用第六次作业中的Random算法,并在每层遍历是否有需要换乘乘客离开。

具体图示如下:

三 第三次作业架构设计的可扩展性

1 UML类图分析

我的第三次作业共包含六个类(MainClass、InputThread、Elevator、Scheduler、WaitQueue、TotalQueue),三类线程(输入线程、电梯线程、调度器线程)。其中,各类主要功能如下:

  • MainClass:负责创建总队列TotalQueue并启动三个线程。
  • InputThread类:负责处理标准输入的各项请求,并存入总队列TotalQueue。
  • Elevator类:负责电梯运行。其中包含一个WaitQueue对象,从TotalQueue中分得本电梯需要处理的请求。
  • Scheduler类:调度器,负责将TotalQueue中的各请求分入WaitQueue
  • WaitQueue类:电梯等待队列。
  • TotalQueue类:总队列。

我的类图如下:

2 UML顺序图分析

根据如上所述创建线程的相互关系,绘制顺序图如下:

3 架构可扩展性

3.1 当前架构可扩展性分析

在当前的电梯架构下,再引入不同功能电梯,仅需要修改调度器的调度算法即可,无需对整体程序进行重构。而且电梯运行时采用的look算法的性能尚可,无需在电梯运行规则上进行重构。平衡功能设计与性能设计讲,该架构的扩展性中等。

3.2 可能的架构改良方案

然而,当客户需求改变时,在我第三次作业的架构下,会导致Elevator类无限加长。对此,可以采用一些现成的设计模式改善当前架构,这也是我第四次研讨课分享的初衷和主题。为避免Elevator类中的run方法过长,可以运用状态模式,利用如下所示的类图使各类之间构成一状态机:

为避免每新加入一种电梯就需要新加一个switch语句并写一个新的run方法,可以运用策略模式(类图如下)。每当新添加一种型号的电梯,就新建一个子类,并在子类中实现新电梯遵循的运行算法。

通过实现上述两种设计模式,可以使程序具备更良好的扩展性。很可惜的是,我并没有在自己的代码中实现。将全部逻辑都写在Elevator类中极大限制了我代码的扩展性。

四 自身bugs的分析

1 Homework5

在第五次作业中,我由于采用了FCFS算法,导致电梯性能极低。其改良办法是在第六次作业时修改了电梯运行算法。

2 Homework6

在第六次作业中,我被强测和互测找出很多bug,可以概括为两类:

  • Ramdom模式的RTLE。这是调度器算法出了问题,在分配人员时将同向这一优先级抬得过高,导致在很多请求同时进入时我会将所有请求都给一个电梯,无法用到新加入的电梯,进而导致运行过慢,程序超时。
  • Morning模式的CTLE。这是由于我的Morning模式在等人的过程中,CPU一直在判断是否满6人了,造成轮询。

3 Homework7

在第七次作业中,由于换乘算法使性能分摇摆不定。尤其是FROM-2-TO-16的请求属于偶数楼层-偶数楼层的请求,对于这类请求没有使用换乘,即在这类请求里没有让C类电梯发挥作用,让一些测试点的性能分受了很大委屈。

五 发现别人程序bug所采用的策略

1 测试策略及有效性说明

在互测中,我沿用了第一单元的方法,仍然是通过测评机对别人的bug进行黑箱测试。因为没有找到测试CPU times的方法,我只实现了检测是否超出规定楼层、是否吃人或生孩子、是否超载、移动时间、运行时间等错误,也造成我的测评效果并不好。仅在第六次作业时测评出一位同学或许因为抢人机制处理不妥,造成响应某些请求时,所有电梯均在两层之间死循环移动。总体讲方法并不是非常有效。

2 发现线程安全问题的策略

线程安全问题的直观表现即多次运行结果存在差异。因此我在互测时设置了每个测试点对每位同学测试5次。

3 与第一单元测试策略的差异

第二单元的测试无疑更加复杂并更加耗费时间。在第一单元时,我们只需要关注表达式结果和格式的正确性即可,但第二单元由于线程安全的问题,bug可以出得五花八门。而且有限次Accpeted并不能说明我的程序能正确运行这个测试点,一次Wrong Answer就能说明我的程序不能正确运行这个测试点,所以永远无法在测评的时候证明正确性,只能付出更长的测评时间,去提高发现错误的可能。

六 心得体会

1 线程安全

线程安全无疑是本单元最核心的议题。我真正感受到多线程的玄学是在第六次作业,引入多部电梯时。时常会发生叫醒了这个线程,那边又睡着了起不来了。通过反复print大法debug,才逐步摸清了锁的机理。直到第七次作业,一个电梯甚至不能再自己的等待队列和总等待队列都为空且已经ctrl+D就结束自己的线程,因为有可能会有人想换乘过来。最后我的解决方法是设置一个表示有多少请求还未解决的全局变量,增加一个判断条件,才使程序能够正常结束。

在探索中我发现了static真香!很多线程安全问题和我们以为复杂的锁,都可以通过静态方法、全局变量解决。

还有一件重要的事情是,尽量不要出现锁的嵌套,这样很容易就再也醒不来了。我们在写代码之前最好有一个全局的设计,尤其是要设计好锁和同步块。不要把锁当成一个保证线程安全的万金油,一读写就用,我们应该用更全面的设计来减少锁的嵌套,否则无脑锁最后锁死了自己。

2 层次化设计

虽然在三次作业之中我并没有进行重构,但我还是遗憾并没有提前学习状态模式策略模式,导致自己写出了一个滑不到头的Elevator类,也降低了程序的扩展性。就像广大程序员给设计模式下的定义:

设计模式是一套被反复使用的、多数人知晓、经过分类编目的优秀代码设计经验的总结。

这明明就是我们可以用的轮子,设计模式提供了更高层次的抽象,我们应该学着如何用模式表示自己的思想,而不是闭门造车地造自己的代码结构。这启示我要在构思属于自己的东西之前,要多虚心地学些东西。

3 吐槽时间

  • 磨刀不误砍柴工。其实就是要在动笔写之前多想多学。我这次是用拉跨的性能和真实的bug买了这个教训。我在第五次作业时真不该想一出就是一出,完全不听从课程组“多学习调度算法”的建议,直接自己莽了一种电梯运行方式。性能实在是太残损了,而且还让我第六次作业时仍在重写算法。也应该提前规划好自己的程序结构,对那些已经总结好的优秀架构有所借鉴 。
  • 不要轻易地相信自己一拍脑袋想出来的算法没问题,不要过度自信。你说你非要在提交前半小时还瞎改那个morning干嘛...最后出了1mol轮询bug。本来那个短暂的时间加上刺激的心情就决定了能诞生一个优化更好的代码的概率是微乎其微的。还是不要抱佛脚在最后改来改去了吧,多在事前花一些功夫不好吗(枯)。

凡是过往,皆为序章。多线程暂时告一段落了,对以后的OO好吧。

posted @ 2021-04-26 00:12  Yu_ji  阅读(87)  评论(0编辑  收藏  举报