其他分享
首页 > 其他分享> > BUAAOO_第二单元总结与反思

BUAAOO_第二单元总结与反思

作者:互联网

BUAAOO_第二单元总结与反思

题目简要回顾:电梯

第一次作业单部多线程电梯模拟运行)

第二次作业多部同型号多线程电梯模拟运行、可动态增加电梯

第三次作业多部不同型号多线程电梯模拟运行、可动态增加电梯)

一、架构介绍

1. 第一次作业

第一次接触多线程,第一次作业由于一头雾水,未完成作业无效,因此仅作简要介绍。

包含以下6类:

  • MainClass:主类,用于生成并启动其他线程。
  • InputThread:输入进程,将输入的电梯使用请求输入等待队列waitingQueue
  • Allocator:调度器进程,将等待队列waitingQueue中的请求按一定算法分配给特定电梯的处理队列processingQueue(为程序可拓展性而写,在本次作业种仅一部电梯)。
  • Elevator:电梯进程,处理请求的主要进程,内部有电梯的运送算法。
  • WaitingQueue:等待队列类,其实例化对象由MainClass进程、InputThread进程、Allocator进程共享。负责在分配给电梯前作暂存起缓冲作用,降低类间耦合度。
  • ProcessingQueue:处理队列类,每个Elevator类有一个该实例化对象,由MainClass进程、Allocator进程、Elevator进程共享。

2. 第二次作业

设计策略

采用默认生产者消费者模式。

生产者:输入线程

消费者:电梯线程

托盘:调度器线程

架构介绍

第二次作业架构理应延续第一次作业的架构,主要对Allocator中的调度算法进行了修改,另外Allocator中增加了对于增加电梯请求的处理。

包含以下6类:

  • MainClass:主类,用于生成并启动其他线程。
  • InputThread:输入进程,将输入的电梯使用请求输入等待队列waitingQueue
  • Allocator:调度器进程,将等待队列waitingQueue中的请求按一定算法分配给特定电梯的处理队列processingQueue
  • Elevator:电梯进程,+处理请求的主要进程,内部有电梯的运送算法。
  • WaitingQueue:等待队列类,其实例化对象由MainClass进程、InputThread进程、Allocator进程共享。负责在分配给电梯前作暂存起缓冲作用,降低类间耦合度。
  • ProcessingQueue:处理队列类,每个Elevator类有一个该实例化对象,由MainClass进程、Allocator进程、Elevator进程共享。

UML图如下图所示:

![Top-Level Package](http://m.qpic.cn/psc?/image

线程设计

线程协作关系

UML协作图如下:

image

线程启动与结束:

线程安全问题

共享对象包含以下三个:(由于MainClass仅起到初始化对象、并启动线程的作用,启动后不对共享对象进行读写,因而不存在与其共享产生的线程安全问题,下忽略)

核心算法

调度器设计

采用分布式调度器,在等待队列不为空时启用。取等待队列中最早入队的一项需求,

电梯处理算法

电梯内部的处理算法根据arrivingPattern的不同,分为了以下三种算法。

:此部分每个处理部分都需要对处理队列进行复杂操作,均全程对对应电梯线程的处理队列加锁。

3. 第三次作业

设计策略、架构介绍、线程设计

本次作业继续采用第二次作业的设计与架构,没有较大的变化。

UML图如下:

image

UML协作图如下:

image

核心算法

调度算法

相比第二次,在调度器调度过程中,增加了判别某请求是否可加入某电梯的过程(调用ableToReach()方法),并将请求加入可到达相应楼层的等待人数最少的相应处理队列,此基础上,型号优先顺序:C、B、A。

电梯处理算法

dealRandom()进行了优化:在电梯内处理队列为空,而处理队列非空时,增加在去往主请求所在楼层的过程中,也可捎带的功能。

其他改动
  1. 本次作业增加了电梯型号的输入,增加ArrayList<Long> types域,存储每个电梯的型号,在Allocator中可单独访问types获得某电梯型号,减少Allocator与Elevator的关联性,降低耦合度。(电梯中不存储型号,在初始化时即根据型号决定各参数指标常量)
  2. 另外,对三个模式中功能类似的“在每层检查电梯内请求并收人/放人”的代码部分进行整合,形成carryPersonFloor()方法与kickOutPersonFloor()方法,增加代码简洁性易读性。

可扩展性

采用分布式设计,调度器与各电梯间耦合度很低,功能可分离增加,可扩展性较好。

graph LR InputThread --> waitingQueue waitingQueue --> Allocator Allocator --> processingQueue_1 --> elevator_1 Allocator --> processingQueue_2 --> elevator_2 Allocator --> processingQueue_3 --> elevator_3

二、bug分析

本地测试发现

1. 死锁问题

程序写完的首要任务是将程序成功运行起来,于是遇到了第一个问题即程序死锁问题。笔者第一次接触多线程编程,并无此意识,直至后来查阅了许多网上资料,才终于发现了bug的原因,即死锁。

2. ArrayList类对象加锁、notify的问题

调试过程中,又出现了线程安全相关的数据不安全问题,经检查出现在这样一段代码中:processingQueue.wait(),程序无法继续进行,原因大概率是某个用到processingQueue锁的位置在结束时并没有notify的原因。但回过头检查,唯一可能会与这里产生线程安全问题的位置好像加锁了、也通知了?

synchronized(processingQueues) {
    processingQueues.notifyAll();
}

Allocator类中的对象processingQueues是一个ArrayList<ProcessingQueue>类型的对象。原来笔者天真地认为,对processingQueues加锁会递归地对其所有子元素同样加锁,然而事实却是只会对其本身,即proessingQueues加锁,其每一个元素processingQueue是不会被加锁的,因此产生了线程安全问题。

解决办法:将以上代码更改为:

for (ProcessingQueue processingQueue : processingQueues) {
    synchronized(processingQueue) {
        processingQueue.notifyAll();
    }
}

才能达到实际的效果(每个processingQueue加锁)。

事实上如此看来,procesingQueues根本不需要加锁,因为它并非实际的共享对象,相当于Allocator类内部的本地变量,不存在任何线程安全问题。

3. 忘记加锁

程序可以运行后,会报各种奇怪的错误,如访问越界异常,可能就是由线程安全问题造成的,即某处忘记加锁了。以processingQueue为例,当Allocator类中正在对其中的某个请求做访问,然而Elevator类中却对该需求做了处理并退出队列,导致对该需求的操作访问越界。

另外,忘记加锁导致的线程安全问题也可能造成如下情况:在Allocator类中已做暂存取出的需求,在Elevator类中却已经处理完毕,后又在Allocator类中推入其他Elevator的处理队列,导致一个需求被处理两次,结果错误。

解决办法:仔细分析线程运行依赖关系,对该对共享对象加锁的位置加锁,保证线程安全。

强测发现

第二次作业强测有7个点没有通过,其中三个点rtle为150s开始输入数据,需要在剩余60s内运行完毕。经验证为算法问题,需要进行优化才可能通过。

剩余四个点bug:Elevator类中,曾经我在编写经条件判断进入的dealMorning()dealNight()dealRandom()时,默认能够进行到此步时,其处理队列非空,在此前提下进行编程。然而事实上,我们观察以下代码,这种情况是可能发生的:

while (true) {
    synchronized (waitingQueue) {
        synchronized (processingQueue) { // 等待队列结束,且无未处理请求,电梯线程结束
            if (noPersonAwait() && processingQueue.isEmpty()) {
                return;
            }
        }
    }
    synchronized (processingQueue) {
        if (processingQueue.isEmpty()) { // 等待队列仍有未处理请求,但处理队列为空。等待调度器转移请求并给锁;
            try {
                processingQueue.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }        
    // 等到锁了,可以开始处理处理队列中的请求
    switch (arrivePattern) {
        case "Morning":
            dealMorning();
            break;
        case "Night":
            dealNight();
            break;
        default:
            dealRandom();
            break;
    }
}

第一次作业中只有一部电梯,不存在”调度算法“可言,因而等待队列若未终止或等待队列非空、而当前处理队列又为空,那么将来本处理队列中一定会等到需求排入、继续进行,我们可以默认进入deal()阶段,处理队列一定非空。然而自从第二次作业加入调度算法后,即使当前等待队列未终止或等待队列非空、且当前处理队列又为空,wait()结束获取锁的那一刻,可能等待队列中的需求并不是排到了本电梯的处理队列中、而是排到了其他电梯,wait()依然可以退出;那么此时显然,处理队列依然为空,却要执行deal()阶段。

因此在编写deal Morning()时,未考虑到在处理队列为空时,电梯不运行,依然停留在一楼的情况,在结尾处changeDirection()调转了方向,导致再次进入该方法时,运行方向错误,电梯从一楼“向下运行”,因永远到达不了目标层而没有尽头,最终导致rtle。

解决办法:在dealMorning()中加入条件判断,若依然在一楼(未运行)则不调转方向。

第三次作业强测中未发现bug。

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

本单元可能出现bug的核心位置包括以下三个方面:调度算法、电梯运行算法、线程安全问题。

所采取的策略:本次debug采用了讨论区董翰元同学的时间戳输入法,可按时间输入需求,以在更大限度上操纵可输入数据的灵活性。利用此工具、结合分析他人代码的核心算法部分,试图找出可能存在的bug。

线程安全问题:从分析代码出发,确定共享对象与对其进行更改、读取的部分,据此构造数据,并结合时间戳工具多次运行。

与第一单元的差异:第一单元为单线程,当输入数据固定时,所得到的结果是确定的,即是否有bug也是确定的;然而本单元多线程则并非如此,由于线程执行的不确定性,输入固定数据每次所产生的结果是不确定的,因而bug是否产生也是不能确定的。因此测试时要注意构造数据的微妙之处。

四、心得体会

本单元第一次接触多线程编程,可以发现多线程的构造设计上与单线程问题是有很多不同之处的。多线程编程除了要考虑架构设计相关问题,还需要考虑线程设计的问题。本单元提示了采用生产者消费者模型,实际上已经大大降低了设计的难度,生产者-托盘-消费者三个线程已然明确,可见将一个实际情况利用建造模式抽象的过程是多么的重要。确定线程的数量与逻辑层次、与共享对象的分配,是多线程编程设计过程中的首要任务之一。

设计完成后,程序中产生的线程安全问题通常也是足以致命的,并且亲身经历告诉我这类bug由于其不可复现性真的非常难de。我总结出的debug经验是,首先利用好时间戳工具,然后观察错误输出的数据特征,在设置断点时要设置Thread类型的条件断点,这样保证之前程序还是大概率按照原来情况运行的;切勿单步运行到特定位置,因为这样大概率是与原来情况不同的运行情况,不能复现。另外,注意锁的添加:不必要的锁不要加、必要的地方一定要加、注意加锁的对象与顺序(避免死锁);有wait的一定要notify。

最后,我还注意到了本次作业主要区分为采用分布式和集中式两种设计成策略的同学。查看往届相关博客后,我了解到了集中式的优势,即性能较高,电梯利用率较高,但经过思考我认为“抢人”的机制拓展性不是很好、且在与ALS算法结合时有较多困难。综合第一单元的失败经验,我决定求稳从而选择了较为稳妥的分布式策略,虽然性能上略输一筹,但拓展性良好、架构清晰,都是不可多得的优良编程体验。

标签:加锁,队列,处理,电梯,线程,BUAAOO,反思,processingQueue,单元
来源: https://www.cnblogs.com/peppermint1113/p/14697736.html