#BUAA-面向对象设计与构造 ——第二单元总结#

魔幻电梯月终于结束辣!

第二单元主题

第一次作业:

需求:

给出乘客出发楼座,出发楼层,到达楼座,到达楼层,将该乘客送达至目的地

  • 一共有五台电梯,分别对应五个座ABCDE

    • 每个座的电梯在1-10楼间运行,初始都在楼座的1层

    • 限乘人数:6 人

  • 满足上下行,开关门,乘客进出

  • 保证在电梯系统时间不超过系统时间上限的前提下将所有的乘客送至目的地。

  • 电梯每上下运行一层、开关门的时间为固定值,仅在开关门窗口时间内允许乘客进出

第二次作业:

需求:

给出的乘客信息不变,同时有限制:

出发楼层==到达楼层+出发楼座==到达楼座=1;

也就是说这个乘客要么在同楼座之间竖着移动,要么在同楼层之间横向移动

  • 共有ABCDE五个楼座

  • 每个楼座可以有多台电梯

    • 初始默认在各楼座一层

    • 可以在楼座的1-10楼之间运行

    • 上下行,开关门,模拟进出

  • 每个楼层可以有多台环形电梯,有以下两种方式

    • 初始默认在A座的各层

    • A - > B - > C - > D - > E - > A

    • A - > E - > D - > C - > B - > A

    • 左右行,开关门,模拟进出

    只能横着走,同楼层的时候横过去

相较第一次作业增加了横向电梯,同时电梯的数量可以动态的增多

第三次作业:

需求:

给出的乘客信息不变,满足

出发楼层==到达楼层+出发楼座==到达楼座!=2

  • 乘客的起始位置和目的位置可以在楼座和楼层上都不相同

  • 实现了电梯的定制化:

    • 纵向电梯的运行速度,可容纳人数作为可设置的参数传入

    • 横向电梯的运行速度,可容纳人数,可开关门信息可设置的参数传入(也就是说横向电梯只能在指定的楼座开关门)

三次作业的架构和思路

本单元的三次作业中我的架构变化不大,并不像第一单元中每次作业都在上一次作业上做了大幅度的修改,基本还是做到了增量开发,因此以下着重描述第一次作业的架构。

这里先讲一下生产者——消费者模式

生产者——消费者模式

在这个多线程的经典模式中有三种角色:生产者,消费者以及托盘。

托盘上会放置共享对象,生产者负责生产,消费者自然负责消费

  • Producer:生成Data,并传递给Channel

  • Channel:

    • 接受Producer的传递,保管Data,如果不适合接受,就让Producer等待wait

    • 响应Consumer的请求,传递Data,如果不适合响应,就让Consumer等待wait

    • 当达到了响应条件时,应将等待的消费者和生产者都唤醒notifyall

    • put和get方法都应该是线程安全的

  • Consumer:从Channel中获取Data并使用

在这里有需要注意的点是:尽可能的将生产者和消费者对托盘执行的操作都封装在托盘这个对象之中(包括同步和非同步方法),这有利于程序的扩展,同时利于减少线程安全问题的出现。

典型的模板如下:

/* 当前是Channel类 */
public synchronized Data getOneData() {
        while (salver.isEmpty()) {
  // 如果盘子为空,就让这个消费者线程等待,注意此处while的使用
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        Data data = salver.get(0); 
  // 此时返回盘中的第一个元素
        notifyAll();
  // 执行完后记得唤醒
        return data;
    }
public synchronized void addOneData(Data data) {
    while (salver.isFull()) {
  // 如果盘子满了,就让这个生产者线程等待,注意此处while的使用
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        this.salver.add(data);
        notifyAll();
    // 执行完后记得唤醒
    }

电梯运行策略

我在电梯运行这块并没有花太多的心思,其实就是模仿现实生活电梯的运行方式

核心思想是:尽可能保证电梯运行方向不变(横向电梯是顺逆时针,纵向电梯是上行or下行)

电梯先从自己的请求队列的队首中获取一个请求,作为当前的主请求(但并不从队列中移除):设nowUp是电梯去接人的运行方向,futureUp是接到那个人的运行方向,根据这个关系设置主请求的到达层为运行终点destination

  • 如果nowUp==futureUp,电梯只需要在到达destination前的每层中询问自己的等待队列,是否有人要沿当前的方向行走而且电梯当前容量足够容纳,如果满足,就把这个人接到电梯内部的队列inQueue中,并修改destination的值,该名乘客到达目的地后,将其从内部队列移除即可

    • 这个地方要小心的是destination既是控制循环的值,并且它也会在循环中被改变

  • 如果nowUp!=futureUp,电梯在到达主请求的出发层前,在到达每层时询问等待队列中是否有要沿当前的方向行走而且电梯当前容量足够容纳的。

    • 如果存在的话,和上一种情况的操作相同。电梯此后一直沿该方向运行直至无该方向请求,此时主请求相当于被抛弃(因为前面没有移除他,所以没关系)

    • 如果不存在的话,电梯就把主请求接上(此时再移除),然后反向运行。

第一次作业

架构

一共有四种线程:主线程,输入线程,调度器线程,电梯线程(一台电梯就是一个线程)

因此形成了两套生产者和消费者,分别是:输入线程——调度器 调度器——电梯

这其中的Data就是乘客请求,托盘分别是所有请求形成的队列waitingQueue以及专属某个电梯的请求队列elevatorQueue

输入线程读入乘客请求Person,放至请求的大队列:waitingQueue中,调度器需要按照乘客的要求,把这个乘客放置到某个电梯的请求队列elevatorQueue中,电梯线程运行时又从自己的请求队列中取出请求,运送乘客

所以此时的调度器的作用仅仅是进行任务的分类,显得不太重要,因为也可以选择在输入线程中增加判断,把相应请求分到对应电梯即可。

 

第二次作业

上次所写内容基本可以沿用,关于新增的横向电梯本质和纵向电梯没有区别,将楼层的增减转化为顺逆时针移动即可:

  • 顺时针走法:(当前楼座-'A'+1)%5+'A'

  • 逆时针走法:(当前楼座-'E'-1)%5+'E'

由于此时乘客请求只能同楼层或同楼座,所以这一次作业的本质只是电梯数目的增多,此时就引入了新的问题:对于能够完成同一请求的多部电梯,该将这个请求分给谁?

原来调度器只是把等待队列中的人分到不同的楼座里即可,现在它还需要分到不同楼座的不同电梯中的等待队列,所以同一个楼座的电梯的等待队列是应该管理起来的

此时引入了一个新的类buildFloor,本质是一个哈希表,结构如下

$$
        HashMap<Integer,ArrayList<RequestQueue>>
$$

key为楼层或楼座,value为在这一层(座)中所有电梯的等待队列的列表

这里采用的是指导书的基准策略,将请求均衡分配。这里我创建了两个调度器实例,一个负责横向电梯的调度,一个负责纵向电梯的。同时等待队列分为两级,第一级是所有的横向请求队列acrossWaitQueue及纵向请求队列lengthWaitQueue,第二级就是每个电梯的等待队列elevatorQueue,调度器作为其中的桥梁。

在调度器中增加了针对每一个楼层(座)的一个指针,指向当前要分配给的电梯,分配完一次后,指针自增,因此调度器需要知道buildFloor当前的信息,这个过程是需要加锁的,以免此时有增加电梯的请求,改变ArrayList

我采用的是静态分配的方式,即请求由调度器决定分给哪部电梯,此后这个请求就只会出现在该电梯的队列里,对其他电梯是不可见的。另一种调度思想自由竞争的优势可能就在于它是动态的,抢到请求的那个电梯也正好是当前能最快处理的,因此会有较好的性能(不过相较于静态分配也有它的缺点在,线程安全的问题比较难处理)。

 

第三次作业

架构

由于电梯可以斜着走了,所以需要新做的事情是规划乘客的行走路线

此处先将所有的乘客请求相统一:也就是取消了第二次作业中的横向和纵向请求队列,综合成了一个大队列包含所有请求,因为所谓的横向和纵向请求都不过是斜向请求的特殊情况而已,细分之后反而带来了一些麻烦。同样的,调度器也再次合为了一个。

由于我采用的是静态调度,也就是这个乘客只要分配给了某个电梯,他就应该知道自己在哪里下电梯,所以我在乘客类中增加了四个属性:当前出发的楼层/楼座;当前要到达的楼层/楼座。

设置乘客当前的到达值工作我分给了调度器完成。调度器对于从waitingQueue中取出的某个请求,分析该请求的当前值,是否可以直达(即该乘客通过乘坐一部电梯就可以到达)

  • 可以的话,即按照第二次作业的策略分配给对应电梯

  • 如果不行,则调用buildFloor中的canArrive方法,找到合适的中转楼层,设置该中转楼层为当前的要到达楼层。

    • 其中合适的中转楼层m的定义为

      • 该楼层的横向电梯首先要满足停靠起始和终点

      • |X-m|+|Y-m|最小的m(X和Y为乘客的最初出发和最终到达楼层)

    然后按策略分配给对应电梯

电梯在完成该乘客的这段请求后,需要做三件事:

  • 设置乘客的当前出发的楼层/楼座

  • 设置当前要到达的楼层/楼座为最终要去的楼层/楼座

  • 将该乘客再次放到总请求队列中等待调度器调度(因此电梯线程此时也共享了总请求队列)

所以:此时的总请求队列一共会存在两种生产者:输入线程以及电梯;调度器作为唯一的消费者(算是一种隐式的流水线模式吧)

第三次作业的迭代情况大致如此,另外一个需要注意(同时也是很重要的一点)是:电梯的结束条件

ps:强测和互测时挺多同学的bug都是因为这个问题

由于此时电梯之间需要协作,大家不再孤立,所以所有电梯的结束条件变为了

               当前输入已结束 && 没有待处理的乘客请求了

为了控制这一点,我新增了一个类:Counter,其中只有两个属性分别指示输入结束和还未结束的乘客请求数目,后者在输入线程中进行增加,在调度器取出乘客后判断到curToFloor==toFloor&&curToBuilding==toBuilding成立时,进行自减。

每次调度器开始循环时,都需要判断当前输入是否已经结束以及待处理的乘客请求是否为零,当满足这个条件时,调度器设置大的等待队列为结束,再进入下一条的分支判断:如果大的等待队列结束且为空,则设置每一个电梯的等待队列的结束标志。

所以这个时候的调度器也负责了控制的工作,它是整个架构中掌控全局的角色。

 

 

三次作业中的bug分析

三次强测在第二次作业中出现了ctle的问题,检查后发现是横向电梯的循环控制条件写的有问题(后面bug修复的时候改了五六个版本,每次都WA的更多了[捂脸],不得不说我的bug挺会长的,找到了挂点最少的方式,属于是漏水的水管,堵住一个地方,其他的地方开始疯狂的漏)

在第一次作业课下自己测的时候也是ctle,其实核心问题都是相同的,因为底下电梯的逻辑写的有问题导致了电梯把人困住了,而电梯的结束条件中包含了内部队列为空的条件,所以电梯无法结束,始终轮询

关于轮询

我认为应该就是在每个线程的run()方法中,都存在着while循环,然后当这个线程并没有做什么有意义的事情

(比如往共享对象中添加元素,删除元素等等)的时候,它并没有通过wait()方法进入共享对象的等待队列,或者是退出while循环,结束执行。反而一直在执行while循环中的一些无意义语句,就会导致轮询。

 

解决办法

通过上述的理解,那么可以在每一个run()方法的while循环里加一个有当前线程标识的输出,当碰到合适的数据的时候,通过看是哪个线程一直在run,就可以定位到对应的错误。

// InputThread
public void run(){
    while(true){
        System.out.println("i am inputThread i want to add!");
    }
}
// Elevator
public void run(){
    while(true){
        System.out.println("i am Elevator " + name + "is running!");
    }
}
........ 
    // 每一个线程都加相应输出

合适的数据

上面有提到,要碰到合适的数据的时候,代码中的轮询错误才可能通过输出直观的显现,可以用自动测试机或者自己造的数据,先不管代码的输出是什么,通过命令行跑一遍。

与此同时打开任务管理器,调至性能页面,可以看到当前CPU的占用率,当占用率超过50%的时候,便可以考虑这组数据是可以测出轮询问题的,再通过上面提到的方法,便可以比较快速的定位。(也可以在代码里面加一些语句,也可以看到cpu时间)

这个是之前测的时候的一个页面(看到CPU占用率几乎接近100%,轮询确实是非常耗费资源的一个方式qaq)

然后这组数据输出调试后,是会一个明显重复在输出自己的信息的线程的。

 

后面测的多了之后,发现可以听电脑的风扇,如果开始疯狂转,也是出现了轮询(属于是大道至简了

关于互测

其他课的压力上来之后,对互测着实没有太多的热情,在前两次作业可能会有输出线程不安全的问题,如果同房的代码中没有线程安全的输出类,大概率都可以hack到。

加上自己也不会写评测机,电梯单元的数据又难以手搓,所以其余的事情便没有再做了。

多线程

三次作业中全部用的sycronized同步块,没有选用ReentrantLock等锁的设计,保证线程安全主要就是对所有的共享对象(涉及两个以上的线程对其进行读写)的操作进行上锁即可,这里有一个需要注意的点是利用共享对象的迭代器进行循环时,也是需要上锁的。并且应保证临界区尽可能的小,一是为了性能另外也可以减少线程安全问题的出现。

所以此时所需要考虑的就是在多共享对象的情况下,如何避免死锁。

我采用的方法比较朴实,就是分析当前的所有共享对象有哪些,共涉及哪几个线程,是否会有相互等待的情况出现。只要分析清楚了自己程序的结构,死锁问题也是可以避免的,以我第二次作业的架构为例:(只是草稿,但大概意思差不多)

 

 

写在最后的话

好的,又安全挺过了一个单元o(TヘTo)

这个单元在第一次作业的框架搭出来后,后续还是比较顺畅的,没有出太大的问题,但一开始de轮询问题的时候还是熬了好几天的夜。

以至于后两次作业显得有些麻木,写出来之后也没有那种非常开心的感觉

依旧是崩溃焦虑周而复始,下个单元接着披荆斩棘趴

 
posted @ 2022-04-30 15:00  Tian_Kuang  阅读(27)  评论(0编辑  收藏  举报