北航2022OO第二单元博客作业

概述

本单元电梯作业,主要加深我们对多线程编程的认知,使我们掌握一些多线程编程的技巧。采用的电梯形式确实很好地展现了多线程常见的错误,充分锻炼了我们多线程设计的能力。

个人架构设计分析

第五次作业

UML类图

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 顺序图

分析

我的设计是,电梯自行检查等待队列,自行决定如何运行(开关门、上下行等)。而在第五次作业中,由于一座楼只对应一个电梯,所以电梯自己决定就好,不需要专门的调度器。

输入只需要一个输入线程。在第五次作业当中,输入结束就意味着所有电梯的任务确定了,所以可以直接设置结束信号。

等待队列作为电梯线程和输入线程共享的对象,所有同步块都设在它里面。

第六次作业

UML类图

顺序图

每个电梯的逻辑与第五次作业完全一致,仅仅是分为纵向/横向电梯,以及横向/纵向等待队列。Reader仅仅是换了个名字“Scheduler”,在读入后就直接分配乘客。故不再另外作顺序图

分析

第六次作业加入了横向电梯、增加电梯请求。

对于横向电梯,我直接copy了一遍纵向电梯、纵向等待队列,然后修改横向电梯的决策、运行策略,使之适合环形运行的规则,并且修改了横向等待队列具体实现。

对于增加电梯请求,这意味着需要调度器来分配乘客。我让调度器负责输入、电梯的增加、乘客的分配,每当有新乘客,直接把乘客按策略放进对应电梯的等待队列,剩下的就是电梯自己的任务了。这样一来电梯运行与调度就分离了。

第七次作业

UML类图

顺序图

分析

第七次作业加入了换乘和指定可开关座。

最大的问题在于,前两次作业中一旦输入完成,每个电梯是否还有任务就是确定的,即输入完成后就可以令电梯自行根据是否还有乘客来结束线程了。而本次作业中,即使输入结束了并且本电梯对应的楼/座已没有等待者,仍不能结束线程:因为也许某个乘客现不在自己的运行范围内,但经过换乘就变成了自己的乘客。所以结束标志改为:输入结束,且所有乘客都到达了最终目的地

由于要统计“所有乘客”,这是一个全局的量,所以需要一个公共的对象/类供所有电梯/队列调用,自然地想到将该功能合并到全局共用的输出线程。

而关于换乘,我直接令电梯在下乘客时判断,如果需要换乘,直接将乘客放进对应的队列。

调度/运行设计

第五次作业

由于一座对应一个电梯,哪座的乘客只能搭对应的电梯。所以“调度”其实就是电梯自身的运行策略。所以与其说调度策略,不如说这次作业是设计了电梯的运行策略

运行策略是:
1,如果电梯中没有乘客且等待队列没有人,则wait。被唤醒后,如果等待队列没有人(即是因为输入结束被唤醒),则结束线程。否则检查电梯运行情况。
2,假如电梯现在处于关门停止状态,则先检查等待队列中本层楼是否有等待者。若有,则看上或下哪个方向人更多,选择之。若没有,则检查上下方向,哪个方向总等待者更多,选择之。如果确定了方向,转到5。如果没有确定方向,本次运行结束,返回1。
3,假如电梯上行了一层。检查是否有下电梯或者等待队列是否有同向的人,有则开门,进行相应操作;否则不开门。然后检查现在电梯中是否有人,若无,转到2;否则继续向上运行,转到5。
4,假如电梯下行了一层。同3。
5,sleep0.4s,然后将楼层数修改。返回1,进行下一轮运行。

这个策略其实与look类似,效果乍一看也是沿一个方向运行直到该方向上没有等待者或者没有目的地在该方向。略有不同在于,该策略可能中途改向,去往人更多的方向。

这个电梯的运行策略,设想起来是很好的:总是尽量往人多的地方走,尽量一口气接到更多的人、送达更多的人。但是这种一侧人明显更多的极端情况只存在于设想中。事实是,强测数据仅有70行,很多是随机数据,不需要考虑这样的“极端”。相反,由于有时为了某方向上更多的一两个人,多了一两次掉头,得不偿失

虽然在本次强测中的分数还不错,但如果重来我会选择纯粹的look,毕竟它保证不会得不偿失地掉头,或许表现更好。

第六次作业

从第六次开始,将电梯运行策略调度策略分开讨论。

先讨论电梯的运行策略。纵向电梯的运行策略不变,横向电梯运行策略照搬纵向电梯,只是人为规定一个“上下”:向左两格为“下”,向右两格为“上”。后来的事实证明这是一个糟糕的决定,而这是在第七次作业结束后意识到的,留到第七次作业讨论。

再讨论调度策略。更具体地说,应该是乘客分配策略。我采取的是平均分配,即轮流分配给每部电梯。这样有个问题,那便是不能一口气接走所有顺路的乘客,而是需要调用别的电梯来接,看起来是把工作量分摊了,其实有时一部电梯可以完成的任务,却让多部电梯多跑一趟。如果重来,我会选择自由竞争。

考虑自由竞争,或许会考虑这样一种情形:某层出现了等待者,所有电梯都朝其运行。但是只有一部电梯先到达并接走了所有乘客,此时其它电梯刚到中途,“白跑一趟”,又重新规划自己的选择。
乍一想浪费时间,其实相比其它粗陋的策略,它浪费不算多。相比平均分配,自由竞争下所有电梯白费的功夫更少:平均分配下,对于一层楼的多个请求,几部电梯都要到达并接客;而自由竞争也许只需要一部电梯到达接完,其它电梯在中途便可改向

第七次作业

依然将运行策略与调度策略分开讨论。

先讨论运行策略。与第六次几乎一致,判断时加入了可开关门的判断。除此之外,由于性能分还与乘客的总等待时间有关,所以我还加了一条:总是优先载目的地较近的乘客

再讨论调度策略(乘客分配策略)。由于第六次作业的性能分明显低于第五次,我初步判断是调度策略太烂,所以改为口口相传的自由竞争。在输入乘客时便决定它的路线:能一步到位的一步,能两步的两步,否则最多允许三步(两次换乘)。诚然,一步、两步也许在某些情况下可以通过三步甚至更多步缩短时间,但那不是多数情况,所以没有考虑复杂的换乘。对于三步的,首要找符合基准测试的中间层,如果有多个,对比电梯平均运行时间、平均服务人数。

但是很意外,性能分比第六次作业更低,这令我有点疑惑。非常感谢薛欣助教耐心地解答我的疑问。在薛助教的提点下,我意识到横向电梯运行策略照搬纵向电梯的愚蠢。在我的横向电梯运行策略中,我会优先接同向、目的地近的乘客走,送到后再返回接其他乘客。然而,须知电梯可以环形运行,并且总共就五座,所以彼时“反向”、“目的地较远”,在走过一两座之后或许就变成了“同向”、“目的地较近”。因此,我认为横向电梯更明智的运行策略是一个方向转到底。当然对于第七次作业来说,根据可开关门的座还可以具体确定运行方向。总之,不能人为粗暴地规定“上下”和“远近”,因为在环形情况下,这俩是会变的。

其它细节设计

同步块与锁

有一个原则:要获取谁的锁,就把同步块写在谁的类里,而不是调用者。即同步块只出现在共享对象里

在这个原则的基础上,虽然可以分别加读写锁,但是考虑到强测数据实在太少,竞争很少,并且计算机执行速度足够快,即使偶有排队,其等待时间也可忽略不计,故还是将synchronized加在方法上,这样简单、可靠,并且运行效率与其它方式几乎一样。

减少电梯sleep时间

有这样一种情况:电梯现在静止,但是现在要求它移动或开门,于是它又sleep了0.4s或者0.2s然后继续运行。可以考虑减少这种情况的sleep时间。

假设电梯上次关门/到达当前楼层的时刻为x;现在决定要移动/开门,时刻为y。
举个例子:电梯现在需要移动一层,sleep0.4s,那么我们可以判断:假如y-x>0.4s,那么就跳过sleep,电梯直接移动一层;假如y-x<0.4s,那么就sleep 0.4s-(y-x),补齐差的一点时间。开门类似。

这种方法从效果上看,好像是电梯“预知”了下一个动作是什么,提前开始移动/开门,以缩短等待时间。

记录时间需调用系统有关函数。

Bug分析

第七次作业

在本次作业互测环节被找到一个bug,是与线程结束有关。

在第七次作业种,我结束线程的方式是:有一个全局调用的类Output,每加入一个乘客,会把此类的一个staitc变量totIn加一;当输入结束后,输入线程会将此类的一个static变量inputOver置为真,表示输入结束了。而电梯线程识别到一个乘客到达最终目的地时,会将此类的static变量totOut加一。
对于每个电梯,结束线程的标志是:Output类的inputOver为真,且totIn等于totOut。

之所以要在全部输入结束后还判断是否所有人都到达最终目的地,是因为:
1,如果在全部输入结束前判断是否所有人到达,可能存在这样一种情况:某个时刻totIn等于totOut,即目前所有人都到了最终目的地,于是所有电梯线程结束;但是过了一会又有人来等电梯,此时totIn不等于totOut,但已经没有电梯可以坐了。
2,如果不判断所有人到达最终目的地,而是只看电梯自己对应的座/层,可能出现这种情况:某座/层的所有电梯发现对在的座/层没有人了,并且输入也结束,于是它结束线程;但是不久一位乘客换乘到这些电梯所在的座/层,此时没有电梯运行。

但是这样导致所有电梯线程需要以这样的情况结束:最后一名乘客到达最终目的地,调用Output的一个方法将totOut加一,然后发现totIn等于totOut并且输入也结束,进而通知各个等待队列全部结束。接下来等待队列等待队列唤醒所有电梯,并且让电梯们发现任务完成,然后结束线程。
如此一来,bug便诞生了:假如最后一条指令是增加电梯,并且此时所有乘客已经到达最终目的地。此时输入线程把Output的inputOver置为真,但是再也没有乘客到达目的地来调用上述方法,也就永远不会发现输入结束并且所有人到达,进而不会通知等待队列,等待队列不会唤醒电梯,程序不会结束。

被找出bug后,我的解决办法是:在程序之初便给Output的totIn加一;在输入结束后,又调用上述totOut加一的方法,这样人为地保证“最后必有一个乘客”来调用方法并发现任务结束。

如何发现bug

数据生成和正误判断

第五、六次作业使用郭鸿宇同学的数据生成代码,第七次作业自己写了一个。正误检查则使用自己写的代码

关于数据生成,仅展现我自己写的思路。基本思路大同小异,无非随机数拼凑。但是要找出某些bug,需要特别的数据。举例:可以限制出发座/层为固定的一两个值,以生成密集型请求;可以限制输入时间间隔极短以测试并发和调度效果;可以限制电梯数量较少以测试调度……当然,更特别的bug仍需要手动设计或依靠大量生成的数据测试。

关于正误检查,还是按照题目要求的主流标准判断:时间戳合规、相邻层/座移动、开关门成对、相关ID正确、人数限制、开关门限制、是否到达等。过于低级的错误未予考虑。

在数据生成和正误检查写好后,就只剩大量测试了。但是此次我并没有对性能进行有关的检查,原因是难以得知基准策略的运行情况。

测试有效性

三次作业中,课下找到很多自己的bug,几乎都在强测前修复。而唯一一个在第七次作业互测测出的bug,其实在强测结束后也自行测出,可惜为时已晚。我认为这与数据随机性有关,如果我课下自测的数据有一次生成了该bug对应的情况则可发现。

而对同房他人的bug,如果是需要大量提交测出的bug,我一般忽视(第五次作业最多);其它的bug大都被测出。

综上,大量随机数据测试的方式是比较有效的,尤其是当随机数据特征可调时

而对于线程安全类bug,个人人为最好是理解代码的逻辑,从中找出漏洞。但是这样太费时,故没有这么做。

思考/心得

在策略上的次次妥协

首先考虑电梯的运行方式。是由一个控制器,洞察着全局,操控每个电梯的每一步;还是分配管分配乘客,电梯怎么运行自己决定?个人认为前者可以做得更好,但要做好需复杂的理论知识,否则只是画虎类猫。于是决定采取后者,调度管的是乘客的分配,电梯怎么运行是电梯的事

初步思考调度时,有种“语不惊人死不休”的气势,想着把各种因素加进来考虑,形成一种全局最优的方案。但稍微深入一点便发现,这东西如果要研究,甚至可以写一篇论文。而我能力与时间都有限,于是改为“近似”地寻找全局最优方法。

但是渐渐又发现,有所“取舍”的方法,往往都有很大的缺陷。根本在于,整个运行过程是动态的,而如果采取的策略不够完善,最终都能找到一种情形,在该情形下,策略陷入了局部最优。“局部最优”,确实会让人联想到什么,但是我写不出来。

在绕几个弯就可想到的方法中,自由竞争确实有它的优越性:可以一口气接走顺路的人是最大的优点,还可以避免其它分配方式的扎堆现象……从测试结果看,它确实有效。

归根到底,我相信确实可以找到一个能动态地找出最优解的算法,但其工作量不少或所需理论知识不少。对于平常作业,难以抽这么多时间大动干戈。而那些有所“取舍”的策略,由于某些情况下的短板,导致性能反不如自由竞争,这就导致“反向优化”时而出现。如此一来,如果重开本单元作业,相信大片同学会选择look策略(改)+ 自由竞争(改)。

在第七次作业涉及换乘的问题上,我起初也有过雄心壮志。因为它看起来与一些算法知识挂勾。但还是与上面情况类似:做周到不现实,做一部分可能成“负优化”,于是选择简朴

反思

应当多掌握自动化评测的技巧,实现更方便的评测。

此外,考虑方法时,不能就着当前方法的优点去想,还要结合其短板考虑。特殊情况虽然需要考虑,但更应重视大多数情况的解决,不能顾小失大。

本单元作业虽然线程安全的问题解决得比较好,但是解决方式仍比较单一,应了解、尝试应用其它方式。

posted @ 2022-05-01 02:05  20373715WYJ  阅读(65)  评论(3编辑  收藏  举报