OO第二单元电梯系列(多线程)完结撒花

(一)三次作业总结分析

(1)第一次作业

作业要求

和第一单元表达式求导相比,这次作业在类的功能划分和结构设计上更加容易。作业需求是实现对单部电梯的运行模拟,并对电梯上下楼层,开关门,乘客进出等行为做出正确性要求,对电梯在完成所有乘客请求花费的时间做出性能要求。

思路分析

做作业之前,感觉最大的问题来自多线程,虽然看往年博客得知第一次作业使用简单的生产者-消费者模型就可以实现,但是对于线程安全问题头脑中还是毫无概念。

通过课上讲解和对课下实验代码的理解,我尝试使用了Java内置的synchronized关键字实现同步块,保证生产者和消费者只有一个能够进入同步块(PS:这三次作业也都是使用synchronized关键字加锁)。

我定义了一个生产者类RequestInput,一个消费者类Elevator,它们都继承Thread类。创建好的两个进程共享一个乘客等待队列waitList。

线程安全问题之所以存在,是因为有共享对象,这里的waitList显然需要通过同步与互斥来维护线程安全。通过查阅网上容器线程安全的博客,我发现Java的Collections工具类中提供了synchronizedList()方法,在工具类中找到synchronizedList()实现方法,发现是为List类的每个方法加上了synchronize关键字修饰。

生产者-消费者模型中,还需要实现两者对共享数据的互斥访问,具体为输入模拟类读取请求add进waitList,电梯取请求从waitList中remove。

在电梯进程run方法中对waitList加锁

synchronized (waitList) {
    if (RequestInput.isEnd() 
        && waitList.isEmpty()
        && passengerList.isEmpty()) {
                        break;                      /** End Elevator Thread*/
                    } else if (waitList.isEmpty() 
                               && passengerList.isEmpty() 
                               && !RequestInput.isEnd()) {
                        waitRequest();              /** Wait For Requests*/
                    }

在输入模拟进程run方法中对waitList加锁

synchronized (waitList) {
	if (request == null) {
		/**End RequestInput Thread*/
	} else {
		waitList.add(request);
		waitList.notifyAll();
	}
}

(2)第二次作业

作业要求

电梯从一部到多部,其他条件仍然相同。需求仍然是在一定时间内完成所有乘客请求并正常退出程序。

思路分析

按照生产者-调度器-消费者的思路增加了一个调度器线程类,将生产者生产的乘客请求分配给电梯,这里要解决的问题主要是如何使得分配“均衡”,即避免出现其他电梯闲置而一部电梯始终工作的情况,我这里采用的方法比较笨,是随机分配请求。

因为完全没有考虑到分配的合理性,在用自己生成的请求量较大的数据测试时,发现前面提到的不合理运行情况严重,所以决定改变策略,让多部电梯自由竞争。事实证明,这种设计不论在迭代开发量上还是性能上都优于我的前一种设计。而在同步块的划分和锁的选择问题上,和上次作业保持一致,基本没有改动。


(3)第三次作业

作业要求

电梯具有不同型号,不同型号的电梯运行速度、载客量和可到达楼层不同。作业需求是多部电梯的行为正确,并且在性能方面添加了乘客等待时间综合考量。

思路分析

仍然采用自由竞争方式,让电梯去搭载可以搭载的乘客。这次作业很多同学都实现了换乘,我这次没有设计换乘策略,主要原因是担心换乘带来的负优化。

第三次作业的同步块设计和加锁方式也和之前相同,并没有太大变动。

UML类图

前两次作业我的Elevator线程类中有很多属性,导致类之间耦合高,在第三次作业中我将Elevator一分为二,成为电梯类和线程类,这样做的好处是电梯线程可以通过电梯成员变量的私有方法访问成员变量私有属性,而成员变量同时也可以在其他线程中被调用,这样一来,线程类只需要考虑如何重写run()方法,电梯类只需要考虑电梯的属性,类的职能划分更加明确,和其他类之间的耦合降低。

电梯的调度策略仍然是沿袭第一次作业设计的Strategy类,在策略类中对电梯的目的楼层计算。对于迭代中可能出现的更多中到达模式和后续开发中对电梯运行优化的设计,因为策略类和电梯类分离,只需要在策略类中进行增量开发即可。

UML协作图

一些方法复杂度有点高,原因是这些方法都是和楼层策略相关的,设计算法时十分面向过程,复杂度相对会高一点。

method CogC ev(G) iv(G) v(G)
ElevatorThread.hasRequest() 5.0 4.0 3.0 5.0
ElevatorThread.run() 9.0 4.0 5.0 5.0
Strategy.nightState(int) 10.0 4.0 4.0 5.0
Strategy.targetFloor(int) 3.0 5.0 5.0 6.0
Strategy.randomStateN(int) 19.0 6.0 5.0 7.0
Strategy.willOpen(int,int,boolean) 9.0 7.0 4.0 8.0

类的复杂度

class OCavg OCmax WMC
Strategy 4.0 7.0 28.0
ElevatorThread 1.95 5.0 39.0

代码规模

UML时序图

从分工上来讲,各线程之间较为独立,输入模拟器关注请求和命令的输入,多个电梯类关注取请求到各自的passengerList队列中,电梯类内部再实现电梯的运行工作,一个生产者和多个消费者共同维护一个共享的全局队列waitList,这样的分工模式比较简单明了,在之后可能增加多个生产者,请求和命令种类增加等需求,该模式都只需要做相应的增量开发,而不需要抛弃已有架构选择重构。

总的来说,第三次作业的类之间层次化和线程之间协作都比较符合实际需求,并且能够较好适应未来的新需求,适合未来的产品迭代和增量开发。


(二)程序bug

(1)第一次作业

三次作业在强测和互测中均未被测出bug。可能是运气原因,同屋的同学都没有什么攻击性,在互测中也没有发现问题。

我认为自己这次作业有两个比较大的问题,一个是电梯虽然实现了课程组要求的可捎带电梯,但在确定空电梯的主请求时有待优化,第二次作业前重写策略采用贪心算法后明显快了10s左右;另一个是由于模拟输入进程需要调用IO来读请求,在竞争waitList时Elevator具有优势,导致问题是电梯开始运行后模拟输入线程无法抢占到waitList的锁,也就阻塞了后来请求的插入,当时没有想到通过为Elevator线程同步控制块瘦身来给其他线程访问机会的方法,所以用很奇怪的sleep(1)下策给其他线程竞争锁的机会。

虽然在评论区同学帮助下完成了一个能够自动生成随机测试数据,定时投放测试数据的评测机,但是除了在课下自己测试电梯性能上有帮助,这个评测机没有其他太大用处,在hack别人时我没有专门构造边界数据。

(2)第二次作业

课下用半自动评测机测试时,发现多部电梯在一层同时开门接一个乘客的问题,这个问题是缺少同步控制导致的。

写法一:
public void arriveFloor() {
        TimableOutput.println("ARRIVE-" + floor + "-" + id);
        if (willOpen()) {
            open();
            close();
        }
    }

当到达一个楼层时,需要输出到达信息,判断是否开门,然后执行开关门系列行为或不执行任何行为,为了保证开关门的一致,写法一没有加锁,导致多部电梯 来到同一层时都判定应该开门。解决起来比较简单,为willOpen()方法和open()方法加上同步控制,使得waitList的状态改变能够被其他电梯发现即可解决问题,具体如写法二所示。

写法二:
public void arriveFloor() {
        TimableOutput.println("ARRIVE-" + floor + "-" + id);
        synchronized (waitList) {
            if (willOpen()) {
                open();
            }
        }
        if (isOpen) {
            safeSleep(400);
            close();
        }
    }

(3)第三次作业

这次作业课下测试时出现了输入结束后程序无法结束的bug,也就是说有某个或某些线程不能够正常终止。

我检查了ElevatorThread线程类和RequestInput线程类对于终止线程的条件判断,发现问题出在ElevatorThread中。在输入模拟器进程已经结束后,一部电梯发现自己的乘客都已经送到了且等待队列中也没有自己可以接的乘客,那么该线程应该终止自己,但是其他电梯可能在上一次竞争到全局队列锁的时候输入模拟器还未结束,其他电梯当时的反应是wait(),即等待新输入来唤醒自己,这时该电梯一旦结束,后果就是等待中的电梯是永远醒不过来的。

所以经过以上分析,解决方法就是在电梯线程结束自己前调用waitList.notifyall()方法,将所有还在等待新输入的电梯线程唤醒!

if (!hasRequest()) {
    if (!waitList.isEnd()) {
        waitRequest();
    } else {
        waitList.notifyAll();
        break;
    }                

在这三次作业中,我比较注意对同步块大小的控制和锁的使用,所以在自测和强测中都没有出现死锁、CTLE的情况,觉得这三次作业做得还是挺不错的。


(三)心得体会

多线程带来的最大问题就是线程安全问题,总结我遇到的线程安全和同步块设计问题主要如下:

  • 共享对象未加锁,多个线程同时读写共享对象结果不可预测。

  • 共享对象加锁后,一个进程一直占用锁,其他竞争不到锁的进程被阻塞。

  • 线程释放锁之后,所有被阻塞的进程共同竞争锁,竞争结果不可预测,但由于进程同步块的范围不同,一些进程在竞争锁的过程中处于劣势,而一些进程可能反复获得锁释放锁,增加性能开销。

  • 线程结束前处于wait等待状态,其他进程在结束前没有唤醒该进程,导致程序无法正常退出。

我认为解决办法有两方面:

  • 控制同步块的范围,足够保证线程安全又不会过度占用锁影响其他线程竞争锁。

  • 规划好线程之间共享对象的使用,加深对wait()和notifyall()方法的理解。

第二单元的层次化设计比第一单元清晰,电梯本身具有的对象特点也更直观,所以在设计架构时没有走太多弯路,唯一一次重构在第二次作业到第三次作业时,我把电梯从电梯线层中抽离出来,减少类之间的耦合。

最后想说,不需要每周重写的OO还是很美好的,希望自己在下单元中提前做好预习和架构设计,争取继续加油努力ヾ(◍°∇°◍)ノ゙~

posted @ 2021-04-23 11:19  pandadream  阅读(125)  评论(1)    收藏  举报