BUAA_OO_2021_ 第二单元 - 难度巅峰之多线程电梯
buaa_oo_2021_ 第二单元 - 难度巅峰之多线程电梯
写在前面
早就耳闻了面向对象课程第二单元的难度,在面临一个全新的领域——多线程时,或多或少都会手足无措吧。对于一个普普通通的计算机专业的学生来说,没有大佬们对于代码强大的理解与拓展能力,只能看着入门教程一点点自学,十分痛苦。多亏了廖雪峰老师网站的java多线程入门,我才对多线程思想有了些许体会。但俗话说“师傅领进门,修行在个人”,对于这个单元的作业,最最重要也是最最基础的就是 wait()
和 notifyall()
的使用来对线程进行调度;另一个最最重要也是最最基础的就是 synchronized()
,对于锁的使用还是十分抽象的。但经过了这三次作业的摸索,我对这二者有了一个较深的理解。
oo上机时的实验代码也给我带来了非非非常大的帮助(虽然后来被锤有bug)上课时老师给的多线程的例子难免非常简单, 而实验代码清晰的架构以及对多线程的操作的示例都给我前两次作业带来了很大的启示。如果学弟学妹们无意中刷到了这篇博客,听学长一句劝:不要死磕自己丑到爆的架构,在入门的时候借鉴借鉴优秀代码,会事半功倍。
一、同步块的设置与锁的选择
三次作业使用的同步锁是synchronized()
,而未使用lock
。
synchronized()
可修饰的对象有:
- 修饰一个代码块:被修饰的代码块称为语句同步块,其作用范围是使用大括号{}括起来的代码,作用的对象是
synchronized(object)
中的object。 - 修饰一个方法:被修饰的方法称为同步方法,其作用范围是整个方法,作用的对象是调用这个方法的对象。
- 修饰一个静态方法:作用范围是整个静态方法,作用对象是这个类的所有对象。
- 修饰一个类:作用范围是整个类中所有方法,作用对象是这个类的所有对象。
三次作业的同步块统一采用的是使用 synchronized()
锁住代码块,而非给方法加锁,更没有给类加锁。一是为了性能考虑,毕竟加锁的代码块越小越好,二是我想锻炼一下自己对于synchronized()
的理解,给小块代码加锁,虽然对于互斥问题的解决更加复杂,但这也正是多线程编程的精髓。
总体而言,这三次中都存在的电梯安全问题是输入线程和电梯线程对于等待队列的读写操作。
在三次作业的电梯类中,我采用的是look算法进行调度,因此在每次有新请求进入电梯后,都要对要去的最高(最低楼层)进行更新,但整个过程都是对于本部电梯内部属性的操作,并没有导致线程冲突。只有在第七次作业中加入了换乘操作,才导致了不同电梯之间的线程安全问题。
下面介绍具体到每一次作业中的呈现形式:
第五次作业
本次作业架构比较简单,只有一部电梯因此共享对象并不多。
共享对象有:waitqueue(等待队列)
waitqueue的同步问题主要涉及以下几点:
-
inputthread线程在输入后需要将需求 写入 waitqueue,并唤醒电梯
synchronized (waitqueue) { if (request == null) { waitqueue.close(); waitqueue.notifyall(); try { elevatorinput.close(); } catch (ioexception e) { e.printstacktrace(); } return; } else { waitqueue.addrequest(request); waitqueue.notifyall(); } }
-
elevator线程需要读取 waitqueue中是否有人,若没人则需要
wait()
synchronized (waitqueue) { if (elevatorrequests.isempty() && waitqueue.nowaiting() && waitqueue.isend()) { return; } if (waitqueue.nowaiting()) { try { waitqueue.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } }
-
elevator线程需要读取 waitqueue在某一层的人,如果有满足上电梯条件的请求,则还需要将请求写出 waitqueue
由于对于waitqueue的读写操作涉及算法核心,因此不附代码
第六次作业
本次作业增加了多部电梯,因此增加了scheduler调度器类,来对各个请求进行分配,架构与前一次单部电梯相比复杂了不少,也造成了更多的线程安全问题。
共享对象有:totalqueue(请求总队列),waitqueue(每部电梯单独的等待队列)
所有同步问题如图:
从图中可知各个线程对于总等待队列和各个电梯的等待队列的线程安全冲突问题,因此在每次读写操作时,都要使用synchronized()
解决同步问题。如:
//scheduler调度器线程中: synchronized (totalqueue) { if (totalqueue.isend() && totalqueue.nowaiting()) { for (elevator elevator : elevators) { waitqueue waitqueue = elevator.getwaitqueue(); synchronized (waitqueue) { waitqueue.notifyall(); } } return; } if (totalqueue.nowaiting()) { try { totalqueue.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } } //解除总等待队列的锁,可以继续进行输入
//elevator电梯线程中: synchronized (waitqueue) { if (elevatorrequests.isempty() && waitqueue.nowaiting() && totalqueue.isend() && totalqueue.nowaiting()) { return; } if (waitqueue.nowaiting() || (type == 1 && !totalqueue.isend())) { try { direction = 0; waitqueue.wait(); } catch (interruptedexception e) { e.printstacktrace(); } } direction = waitqueue.getrequest(0).gettofloor() - waitqueue.getrequest(0).getfromfloor(); }//解除电梯等待队列的锁,调度器可以继续进行分配
第七次作业
本次作业增加了电梯的种类属性,为了优化性能,因此增加了换乘策略,造成了不同电梯间互相改变等待队列的线程安全问题。
共享对象有:totalqueue(请求总队列),waitqueue(每部电梯单独的等待队列)
所有同步问题如图:
从图中可以看出,由于增加了a类电梯和b类电梯之间的换乘策略,导致了在不同电梯之间也会产生线程安全问题,因此除了上文给出的第六次作业中的代码外,还会出现以下形式的同步块与锁的设置:
//换乘操作 int tofloor = request.gettofloor(); int fromfloor = floor; int id = request.getpersonid(); personrequest newrequest = new personrequest(fromfloor, tofloor, id); elevatorrequests.remove(request); timableoutput.println("out-" + request.getpersonid() + "-" + floor + "-" + idnumber); i--; synchronized (totalqueue) { totalqueue.getrequests().add(0, newrequest); totalqueue.notifyall(); }
二、调度器设计
第五次作业
“就一部电梯,调度器有啥用呢?”
开始做第五次作业之前,我就是这样想的。于是把已经建好的scheduler类默默删掉了。
事实也是这样,一部电梯的确不需要调度,而这次作业的核心应当是对于单部电梯调度算法的设计,比如指导书给出的als算法,以及最常用,性能也不错,容易实现的look算法(这三次作业的random模式使用的都是look算法,真香!)而调度器这东西,虽然知道在后面一定会再加上,也知道在第一次添上调度器的话有利于之后作业的拓展,但俗话说:“一口吃不成胖子。”我知道自己水平咋样,就不如刚刚接触多线程的时候,整个简单点的架构吧~
于是第五次作业并没有调度器,所有输入的请求直接进入等待队列,等待电梯的临幸。
下面介绍一下三种模式下电梯的运行策略:
- night
night
模式的两大特点:
- 目的楼层确定(都是一楼)
- 所有乘客同时到达
因此可以将其看作静态请求。对于处理静态请求,总会有一个最优解,而我采取的策略是
电梯从高处往低处接,每次接六个,如果不足六个则接完就结束
这个策略十分简单,性能也还不错,然而对于night
模式还是有别的最优解的,只是代码会复杂很多,而在我能力范围之内,这种策略的性价比很高。
- morning
morning模式的最大问题是,人并不是一口气都到一楼,而是动态添加的。
由于是动态模型,不同人的算法可能会造成很大的性能差异,而我采取了一种比较稳妥地方法:
等人
只有当来了六个人,电梯满员时,才会发车,否则就一直等着,除非识别到已经停止输入了。这种方法带来的一个玄学问题是:无法保证在等人的过程中是不是有时间能够把电梯上的人先送上去。但如果要实现这样的机制的话,代码量应该是十分惊人的,我也就没有再深究。
- random
我采用了传统电梯搭载的look
算法,并在其基础上进行了些许优化。其运行策略如下:
1、获取等待队列中所有上行请求的最低层 和所有下行请求的最高层 ,并判断电梯当前所处位置离谁更近,然后去往更近的那个楼层,并改变电梯的运行方向次序。
2、电梯按照选择好的运行次序运行,并把目标楼层设定为电梯中所有请求的目的楼层的最高层。
3、电梯在每一层进行遍历:首先遍历电梯中的请求有没有目的楼层是该楼层的,并处理出电梯的请求;然后遍历等待队列中有没有前往楼层方向与电梯运行方向相同的,并在该楼层上电梯的请求,若有,则上电梯;若请求的目的楼层要高于目前电梯运行要去的最高楼层,则将电梯的最高楼层属性更新。
4、当电梯到达目标楼层(即所有请求的最高楼层或最低楼层)后,电梯掉头,重复上述行为。
这种算法与日常生活中的电梯十分相似,也很好理解,经过实践,性能也说得过去。
第六次作业
第五次作业欠下的债,早晚都要还的。
第六次作业要把请求们按照一定的策略分给多部电梯了,因此需要设计调度器以及调度器算法,来实现请求的调度。
调度器的遇到的难点如下:
1、如何制定合理的分人策略,是影响电梯性能的最关键因素
2、如何处理好调度器线程与电梯线程、调度器线程与输入线程之间的矛盾,是电梯能否顺利运行的关键因素
制定的分人策略如下:
- night
night模式的分配策略比较容易想到,毕竟是静态请求。
1、将总请求队列中的人从高往低排序
2、按照每六个为一组,轮流分给各部电梯
这样做也会遇到些许问题,比如无法统一处理高层请求,对于人数较少的情况无法调动所有电梯。但我认为这些问题导致的性能差异微乎其微,便没有再进行优化。
- morning
实在没有想出完美的解决办法,于是采取将请求均分给所有电梯的调度策略。
1、每次提取出总等待队列的第一个请求,进行处理。
2、遍历所有的电梯的等待队列,选择等待请求最少的电梯
3、将提取出的请求分配给所选择的电梯
4、重复上述操作,直到总请求队列为空
这样的策略有显而易见的性能问题,比如每部电梯为了等够6个人,等待的时间特别长。
- random
random模式的调度策略比较复杂,我总体按照的是“顺路原则”进行分配。具体分配过程如图:
虽然并不能保证这样的算法是最优的,但可以保证顺路原则的的确确提高了很多性能。
第七次作业
换乘!换乘!换乘!
随着换乘算法的加入,第二单元作业难度达到了高潮。
难道非换乘不可吗?当然不是,最简单的调度方法便是:
1、所有符合高楼层条件的请求一律给c类电梯
2、所有奇数层到奇数层的请求一律给b类电梯
3、其它所有请求一律给a类电梯
这样,第七次作业的调度器就更改完毕啦!
我按照这样的调度策略,写了第七次作业的原始版本,并得到了不换乘的性能时间,然后便开始设计换乘调度策略。
1、所有符合c类电梯的请求,直接分配给请求最少的c类电梯,不换乘
2、所有奇数层到奇数层的请求,直接分配给请求最少的b类电梯,不换乘
3、所有偶数层到偶数层的请求,直接分配给请求最少的a类电梯,不换乘
4、所有偶数层到奇数层的请求,先分配给a类电梯,运送到最邻近的奇数楼层后,换乘给b类电梯
5、所有奇数层到偶数层的请求,先分配给b类电梯,运送到最邻近目的地的偶数楼层后,换乘给a类电梯
哈哈,换乘也不过如此嘛!
真的不过如此吗?
不是!且不说这种换成策略的性能问题,单看整个换乘过程,明明是为了换乘而换乘啊!我提交了“优化后”的这个版本,果然不出我所料,这个版本的性能比不优化的原始版本还要差...没错,我的换乘策略是负优化。又考虑到第一单元就是由于各种优化算法才导致了强测出锅,我得到了一个结论:如果能力不足,谨慎优化!
三、从功能设计与性能设计的平衡方面,分析和总结自己第三次作业架构设计的可扩展性
第七次作业uml图
第七次作业uml协作图
本次的架构设计为“消费者——生产者模型”,生产者为inputthread
线程,消费者为elevator
线程,并不复杂。
关于可扩展性,在这样的架构之下,分配调度算法与运行算法分离,一定程度上提升了可扩展性,但总的来说,可扩展性还是不高的,毕竟在写程序的过程中有着“反正写完这三次就没了”的心里,没有太多考虑程序的可扩展性。
四、分析自己程序的bug
第五次作业
未发现自己程序bug,也未被hack,并且代码提交一次通过,没进行debug。(所以下次作业就飘了)
第六次作业
bug警告! 本次作业强测稍炸,出现的锅是由于morning算法的进程结束问题,导致4个morning模式的测试样例线程没能正常结束,从而导致rtle。这个bug是我测试的时候样例过弱导致的,很明显也很容易de的一个bug,竟然被放生到强测中,真的不应该。
第七次作业
未发现自己程序bug,也未被hack,但在写程序中出现过轮询导致的tle的bug,原因是wait()
方法调用条件有误造成程序没有正常进行等待。
在debug过程中,的确出现过死锁的问题。出现了锁的嵌套,并且同步块过大,就十分有可能出现死锁。而且有的死锁竟然很难复现,这给我的debug造成了一定的困扰。好在我及时定位到了bug,并绞尽脑汁地把嵌套的锁拆开,不惜牺牲一些性能,也要保证线程安全。
五、分析发现别人程序bug所采用的策略
由于本单元作业的输入是随机投放的,这就导致只要不是c组,想要hack别人,就只能借助评测机了。可能是由于评测机生成的样例太弱,这三次作业都没能成功hack别人。
我也曾经尝试过肉眼debug,但由于别人的代码架构和我的代码或多或少有些区别,而且也没有注释,导致肉眼debug变得十分困难乃至不可能完成。我也着重查看了锁嵌套的情况,并思考有没有死锁情况的发生,但同学们的代码都很强,并没有发现有什么异常。
遥想上个单元,三次作业都没使用评测机,只是通过自己的火眼金睛,即使在a组,都能成功hack别人,但在多线程这个单元,肉眼不太管用了,甚至评测机的效率都不太高了...
六、心得体会
- 线程安全问题是多线程编程最重要的问题之一,线程不安全的多线程是没有意义的。当然,如果把所有的类都用
synchronized()
锁起来,线程也就百分百安全了,但也就由多线程退化成单线程了...如何在尽可能优化性能的前提下保证线程安全,是一件值得琢磨的事,这也许就是多线程的魅力吧。 - 层次化设计在多线程中是必须的,毕竟要把每一个层次抽象出来,每一个线程也都分工明确。一个好的架构对于多线程而言是前提,多学几个架构也没啥坏处。但把模板架构与具体任务结合起来的时候,就要花一点心思了。
- 本单元作业又跟上一单元一样,在第二次作业翻车,肯定是有点遗憾的,不过还有两个单元呢,希望之后的单元别再翻车了呜呜呜。