面向对象第二单元总结

面向对象第二单元总结

本单元的任务是电梯调度,涉及到多线程并发的处理。经历了第一单元从面向过程到面向对象的转变,我利用工厂模式,继承多态等思想,在三次作业中很好地实现了迭代开发,后两次作业都仅需要稍加改动,进行一些功能的扩充,就可以高效率地完成任务。

1. 第一次作业分析

1.1 架构简述

第一次作业需要实现单电梯的运载。在电梯运行时,会不断有请求进入,因此需要多线程的并发操作。在本次作业中,我创建了两个线程,一个负责读入请求,一个负责运载乘客。具体的,几个类如下所示:

  • 容器

    • FloorQueue:每一层的等待队列,封装实现增添请求,取出请求,判断是否为空的方法
    • WaitQueue:总体的等待队列,包含每一层对应的FloorQueue,封装实现增减需求,获得相应楼层等待队列,结束运行等方法。
    • Elevator:电梯容器,用于描述电梯状态,含有requests变量记录电梯内请求,floor描述电梯楼层,open,close,getIn,getOut等方法实现电梯开关门上下客等需求。
  • 线程

    • InputThread:输入线程,将读取到的请求放入WaitQueue中
    • Process:电梯线程,实现run方法,负责操控电梯的运行

各个类之间依赖关系的UML图如下所示。

其中输入线程的运行流程如下:

电梯线程的运行流程如下:

主线程的主要功能仅为创建共享对象和两个线程,它的UML协作图这里就不额外展示了。

本次作业没有设置调度器,是因为当时把调度器的概念搞混了,制造了一个四不像,最后只能提交一个不带的版本。我一开始以为调度器需要掌控电梯的运行,每次调度器工作时候不仅需要管理等待队列,还需要查看电梯中的人数方向,指挥电梯是否需要掉头。因为调度器线程需要读取电梯状态,我加上了锁,多层锁的嵌套产生了虚假唤醒的问题。所幸最后歪打正着,及时止损,提交了不带调度器的版本,为后面加入“真正的”调度器预留了开发空间。

1.2 运行策略

查阅资料,常见的电梯调度运行策略主要有以下几种:

  • FCFS:优先把先进队列的请求送到目的地
  • Scan:循环往复,类似与公交地铁
  • Look:Scan算法的改进,区别在于当前方向上没有请求且电梯为空时则掉头

我最终采取了Look算法,在Process线程中得以实现。每层上下客结束后,通过checkdirection方法来判断是否需要掉头。电梯外请求按照出发楼层分类,电梯内请求按照目标楼层分类。每当电梯到达某一层时,检查是否需要上客或下客。下客的标准是到达目标楼层,上客的标准是他的请求方向和电梯当前运行方向相同。有则开门,无则甩过直接走。每当准备移动到下一层时,检查是否需要掉头。

此外,对于两种特殊模式,我设置了两种运行方法:

  • Morning:当输入尚未结束且人数未达电梯上限时先不要出发,等一会
  • Night:电梯每次都运行到有请求的最上面一层,向下扫楼

1.3 锁和临界区的选择

本次作业是一个典型的生产者-消费者模式,它的锁是显而易见的:作为“托盘”的WaitQueue。输入线程作为生产者,读取到请求后进入临界区,将请求放入WaitQueue。电梯线程作为消费者,进行方向判断以及上下客的时候进入临界区。

但电梯和经典的生产者-消费者有一个很大的不同在于:电梯是可稍带的。传统的消费者每次拿走自己的一份即可,如果吃完了就等待新的生产。但是电梯可以一次“吃”多个请求来进行调度。当电梯可见的请求数量越多时,它越能做出更好的调度方案。因此在设计时就需要尽可能让生产者生产的请求能够第一时间传递给消费者。遗憾的是,我在第一次作业中由于对多线程不熟悉,走了相反的方向。消费者只有当队列为空时才能让出控制权获得读入。伪代码如下:

void producer(){
    while(true){
		request = getinput();
        synchronized(waitqueue){
            waitqueue.add(request);
            notifyAll();
        }
    }
}
void customer(){
    while(true){
		synchronized(waitqueue){
            if(empty){
                wait();
            }else{
                //run the elevator;
            }
        }
    }
}

如果说是传统的生产者-消费者的话,这段代码当然是没有问题的。然而,电梯每一次运行都需要Sleep大量的时间,这段时间并不需要对waitqueue的控制权,我则由于疏忽将它们都放在了临界区里,输入线程根本没有机会将请求放入。这就可能产生一种极端情况:电梯每次从临界区退了出来之后,马上又自己抢到了锁,直到跑完所有请求才会wait。而输入线程放入一个请求后,电梯线程被唤醒,将输入线程阻塞在外面,目光短浅的跑完这一个请求继续wait。

也就是说,由于同步块设置过大的原因,我的Look算法退化成为了FCFS算法!

这个问题的解决办法是缩小同步块,进行精确定位,我在第二次电梯作业中进行了修改,真正实现了Look算法。

1.4 bug分析

这一次强测炸掉了,得了27分。但出问题的反而不是过大的同步块,而是一个极其愚蠢的笔误,发生在getIn函数中

void getIn(){
	for(request:requests){
        if(elevatorfull()){
            return;//这儿应该是break
        }else{
            elevator.add(request);
            temp.add(request);
        }
    }
    remove(temp);
}

我在把请求放入电梯时,先将他们暂存在temp中,遍历结束后再统一从waitqueue删除。因此在电梯满了之后需要结束循环,但我手滑写成了return,直接跳出函数,没有把这些请求从等待队列中移除。这个大bug,中测竟然没有测出来。。。所以说一定要多做课下测试。

在互测中,有一名同学的问题和我类似,他忘记判断电梯是否为满载状态,能hack我的样例基本也都能hack他。只需要构造多个同时到达的请求即可。

2.第二次作业分析

2.1 架构简述

第二次作业需要实现多电梯运行,每台电梯都能够达到所有楼层。每一台电梯都有自己的等待队列,因此为了实现迭代开发,优化任务分配,需要添加新的调度器线程。输入线程读取到请求后交给调度器线程,调度器线程再将请求分配给各个电梯自己的等待队列。

与第一次作业相比,新增类如下:

  • MainQueue: 调度器的容器,读取到的请求会暂存在其中等待调度器分配
  • Scheduler:调度器线程,用于分配请求。

本次作业中各个类的UML图如下所示:

各个线程协作图如下,为了表示方便,只绘制了两个电梯线程:

经受了第一单元的洗礼之后,本单元作业可扩展性还是很好的。与第一次作业相比,只需要在输入线程和电梯线程之间加入一个调度器,稍微改一改就好。这样的架构也为下一次作业不同类型的电梯提供了迭代开发的空间。

2.2 调度策略

电梯运行策略由于与上一次相同,这里不再赘述,这里主要分析一下调度策略。

我的调度策略第一版是朴实无华的人数平衡。每次添加请求时,计算出每一台电梯内人数与等待人数之和,将该请求放入人数之和最小的电梯等待队列之中。这个方法比较简单无脑,实现起来也很容易。

第二版策略就比较花里胡哨了,我寻找了多个变量,除了人数这一个变量外,还有电梯运行方向与请求方向是否符合,该请求是否可以被稍带,该请求的运行长度等变量。然后为他们设置参数开始调参。最后发现一个比较合适的参数为:

\[size + 0.9*length + direction \]

其中size为电梯负载,length为运行长度,direction为是否同方向。

调参蛮玄学的,试了试之后发现参数过少,效果并没有特别大的提升,也就不了了之了。

2.3 锁和临界区的选择

与第一次作业相比,本次作业的锁除了各电梯的等待队列之外,新增了总体的待分配队列MainQueue。输入线程将请求放入调度器时需要锁住MainQueue,调度器将请求分给各个电梯时需要锁住MainQueue。这都是很容易理解的。

一个比较值得讨论的地方在于:调度器需要根据电梯状态来进行调度,那读取电梯状态时需要锁吗?我认为从性能的角度考虑,不是必须的。毋庸置疑,从正确性的角度看,是否加锁没有影响。因为对于电梯状态的读取仅仅影响到调度器的选择。最差最差,也终究好过随机分配。至于可重入性,不加锁确实满足不了,不同的CPU调度可能会带来不同的运行状态------不过加锁也会造成不同的运行状态,因此在可重入性上也是没有太大差别的。

所以唯一的问题就在于,电梯状态读取的加锁与否,对于性能会有什么样的影响呢?我们不妨设想一个极端场景:调度器线程决策时被频繁打断,导致先前读取到状态不能反映最新情况。可是加锁的话也同样会造成这个问题:统一在某个时间节点做出判断后,调度器线程被打断,这样整体反而都受到延迟了。并且即使不加锁有误差,也顶多在一个请求上产生影响,下一个请求在调度时候都已更新到了最新状态。更何况,去除不必要的锁可以降低代码复杂度。因此我最终没有在调度器中加入电梯的锁。

2.4 bug分析

本次作业被测出的bug为:在同一时间内出现多个请求。本质问题还是在临界区上。

我初始的设计是在电梯开门之前就告诉他会有哪些请求将要进入电梯,暂存在temp变量之中。等到电梯开门后再放进去。这样同一时刻到达的请求就只有第一条能真正被立刻读取到,其他的请求只能等电梯把第一个请求运送完再出发。我后来修改为在关门之前上下客,成功修复了bug。修改前和修改后的代码逻辑如下所示:

void original(){
	temp = getTemp();
	synchronized(queue){
		isneed = isneed();
	}
    if(isneed){
        open();
        getIn(temp);
        getout();
        close();
    }
}
void now(){
    synchronized(queue){
        isneed = isneed();
    }
    if(isneed){
        open();
        synchronized(waitqueue){
            getIn();
        }
        getOut();
        close();
    }
}

本次作业在互测中没有发现别人的bug

3. 第三次作业分析

3.1 架构简述

第三次作业增添了电梯的种类。不同电梯的运载能力,运载速度和可达楼层不一样,需要换乘以及新的调度方法。总体而言,在线程方面与第二次没有太大区别,依然是输入,调度器和电梯线程三类。不同之处在于电梯的一些方法需要继承,重写。生成电梯时候需要用到工厂模式。与第二次作业相比,新增的类如下:

  • Factory:电梯工厂,用于生产电梯、开启线程一条龙服务。
  • ElevatorA, ElecatorB, ElevatorC:继承自原本的Elevator类,根据其本身的特性对一些方法进行了重写。
  • Pattern:共享变量包装类,包含电梯数量,运行模式和换乘数量三个变量

UML类图如下:

各个线程之间的协作图如下所示:

基本运行思路是,如果输入线程读取到电梯请求,则调用工厂来生成一个对应的电梯。如果读取到乘客请求,则放入调度器中。调度器根据一定的策略放入对应的电梯等待队列。电梯将请求运到终点或者换乘位置放回调度器等待换乘。

3.2 换乘及调度策略

首先介绍一下换乘(运载)策略:

  • A类电梯:没有特殊限制,可以随意运载
  • B类电梯:可以运载任意奇数至奇数层的请求,或者距离大于7的奇数至偶数层的请求。换乘时,选择电梯之内请他请求距离自己目的地最近的楼层一起下,以节省开关门时间。如果没有,则在终点的前一层下。
  • C类电梯:可以运载任意自己范围内的请求,或者起始于自己可达楼层且终点距离自己楼层大于13的请求(保证换乘开销在合理范围之内)。换乘时,同样优先在另一端最近的楼层下去,否则则固定在3或者18楼层换乘。

然后是调度策略:

经过调参优化,最终选定的公式为:

\[type * (size + length + 5 * direction); \]

其中type是与电梯类型有关的量。因为相对而言越快的电梯承受能力越高。我调参后的A为5,B为3,C为1

3.3 锁和临界区的选择

与第二次作业相比,这次加上了换乘,因此需要对换乘数量的变量额外加锁。

之前提到过,所有共享变量都封装进了Pattern中,其中包括待换乘的请求数量transfernum。这个变量的加入主要是基于如下考虑:原先的结束条件是在发出结束输入之后,分配完当前调度器内容后调度器线程就结束了,如果电梯和请求队列都为空则结束电梯线程。但是由于换乘的出现,电梯下客之后又会加入到调度器等待队列中。因此需要引入transfernum变量,只有当该变量也为0,即目前电梯及等待队列中没有需要换乘的请求时,才可以结束线程。

3.4 bug分析

本次强测中没有出现bug,互测中被别人hack了一次,用的是4-17层的边界数据。由于我原本的设计中,换乘策略只允许同方向换乘,不能够先往相反方向先走一小段路再换乘,因此这些请求全部都由A型电梯来运行,导致了超时。

修改策略为增添先短后长的换乘策略,允许先南辕北辙走一小段再乘坐更快的电梯出发,用上BC两种类型的电梯,就可以很好的完成任务。不过根据测试发现,一点不修改也可以在200s内跑完,这位同学把一个测试点提交了好多次卡bug,可真是太辛苦了。

本次作业很遗憾,没能发现同屋同学的bug。

4. JProfiler的意外发现

本单元作业我使用JProfiler软件来协助调试,意外发现了它的一个特别不人性化的设定(也可以说是bug?)

Jprofiler用绿色表示运行状态,红色表示阻塞状态(在临界区外),黄色表示wait状态,这是背景。

先来看两张图,这是我写的一个用于测试的生产者-消费者模型,一个生产者对应两个消费者。每个线程进入wait状态前都会有输出。消费者线程在吃饭前会等待输入一个go来继续运行。

如图所示,消费者1在吃完之后,释放出锁,进入等待状态,交给生产者生产。生产者生产完之后唤醒两个消费者去抢锁,最后消费者2笑到了最后抢到了锁,消费者1回到等待状态,一切都是那么顺其自然。

但是!!!在 JProfiler中,被唤醒的消费者1由于没抢到锁,被该软件判定为阻塞状态!虽然它当前其实是出于wait状态的,但由于没能抢到锁,被误判为阻塞。这个bug在我调试中让我百思不得其解,耗费了很长时间去研究背后的原因,最后才发现是JProfiler的缘故。

5.心得体会

本单元三次作业给我印象最深的有两点:虚假唤醒问题和同步块大小控制问题:

首先是虚假唤醒问题,这个问题在我第一次作业中困扰了我很长时间。我将虚假唤醒分成两类:自己”唤醒“自己与自己唤醒同类。以生产者-消费者为例:

void producer(){
	while(true){
		synchronized(queue){
            produce();
            queue.notifyAll();
        }
    }
}
void customer(){
    while(true){
		synchronized(queue){
            if(size==0){
                queue.wait();
            }
            eat();
            queue.notifyAll();
        }
    }
}

producer问题即自己”唤醒“自己问题。生产者结束生产后,自己又把缓冲区的锁给抢走了,导致了无限循环无限生产。解决办法也很简单,加入缓冲区上限,进行条件判断即可。

customer问题是自己唤醒同类:它吃完苹果后,唤醒了另一个消费者吃苹果,但此时已经没有苹果了,这就会造成错误。解决方法也很简单,把if换成while,再判断一次是否被虚假唤醒即可。

上述问题单独抽离出来当然很容易发现。但是在实际复杂问题中,如果不小心出现了多个锁的嵌套,就很容易犯这些低级错误,比如我的第一次作业第一版,出现了三个锁的嵌套,但是只有两个wait,就出现了虚假唤醒问题(没有死锁真是奇迹)。这也说明了尽量要避免多个锁的嵌套使用,很容易一不小心出现问题。

另一个就是同步块大小的控制问题了,血淋淋的教训,直接让我的Look算法退化成了FCFS。设置同步块时,最佳原则是设置刚刚好的,不能多也不能少,特别是线程需要sleep的地方一定要从同步块中出来,不然其他线程只能一直等着sleep,特别耗时。在上文已经详细分析过,这里就不再赘述了。

不过总体而言,虽然第二单元的测试分数都很不理想,但也正是这些痛苦的debug过程让我基本掌握了多线程的使用方法,学会了JProfiler等调试工具的使用,甚至对于OS最近进程控制的学习也起到了很大的促进作用。第一单元培养的面向对象思维也在本单元发挥了重要作用,体会到了迭代开发的快乐,希望下一单元的作业能有好的结果。

posted @ 2021-04-25 21:22  苍穹一粟  阅读(129)  评论(1)    收藏  举报