面向对象设计与构造第二单元总结

jiaoziqian 学生 2024-04-20 16:37:54

前言

本单元作业的主题是电梯调度,主要需要完成的是:单个电梯的纵向运行策略,以及多电梯协同调度策略,实现的最主要手段是多线程设计,需要最重点关注的点在于线程安全与性能的平衡。

总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句之间的关系

我在本单元的作业中,只使用了 synchronized 关键字(对象自带锁)这一种加锁方式来解决线程安全问题,一开始是因为只会这一种做法,到了第三次作业基本学会了读写锁的用法,但是觉得没有必要再引入一种新的锁改变架构了,就继续用这一种方法了。

加锁,是本单元任务的一个核心,能够保证线程安全,实现最基础的正确性,此外,合理地加锁还可以提高运行效率。因此,锁少了不行,锁多了也不行,因此加锁的“度”就成了我们进行多线程工程设计与实现时最需要细细体会的。

要想合理地上锁,首先要找到需要加锁的对象——共享对象。分析【线程类】与【共享对象类】是多线程设计的第一步,线程是可以运行的类,而共享对象类是可能被多个线程同时(在很短时间内先后)访问的类。涉及到共享对象中会被多线程共享的数据的操作,一定要加上锁,让这个类成为线程安全的类,不要让调用对象方法的类去加锁,避免之后迭代想不起来哪块需要锁。

这个“锁”,可以在方法上加,也可以在方法内部的部分语句块上加,前者更简单,而后者更灵活。

我在第一次作业中只采用了第一种方法,将共享对象的所有方法都加上 synchronized 关键字,采用这种做法,是因为第一次作业中共享对象很少,而且我写的时候对多线程尚不熟悉,只采用了这种简单但是易操作、保险的做法,只是损失了一些性能,但是问题不大。

从第二次作业开始,我逐渐尝试将锁从整个方法上剥离下来,只在必要的地方加锁,即只在对共享资源进行操作时加上锁。但是由于一开始没有把握好加锁的度,把两个本来应该锁在一起的块分开锁了,导致了一个很难复现的线程不安全 bug,这个在之后的 bug 部分详细记录。

最终,第三次作业结束之后,我的架构定型为两种加锁方式相结合,如果方法里全都,或者几乎全部是操作共享资源的指令,就整个方法加锁;如果只有不大的一部分要操作共享资源,则在这一部分语句块上加锁;如果方法只涉及原子操作(比如简单的取值或者赋值),或者不涉及共享资源的操作时不加锁;在不需要确切数字,只需要确定大概范围时(比如在检查电梯中现存的大致人数时)不加锁。整体上还是以保持代码的规整和正确性为先 ,没有做很多的性能争取。

此外,还有一点值得注意的是,为了避免容器对象的传递导致不同线程同时遍历或者修改,我在设计共享对象对外接口的过程中保证不直接传递内部容器,需要获取容器中部分特定内容时,新建一个容器作为返回值,把对内部容器的操作全部保持在类内部。

总体来说,同步块中的处理语句应该尽可能都是针对上锁的共享对象的,即尽可能地缩小临界区,提高并发程序的效率。

总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互;总结分析三次作业中的调度策略,并分析自己的调度策略是如何适应时间、电量等多个性能指标的

第一次作业

类图:

img

协作图:

img

设计说明

第一次作业相对较为简单,每个请求都指定了对应的电梯,但是为了之后的拓展还是做了【输入-分发-处理】三级流水线架构。

此时的分配器就是一个摆件,负责根据请求中的信息分配请求,但是把这个类设计出来,之后再加调度策略就很方便了。

为了快速获取请求的运动方向(始末楼层之间的大小关系),我给 personRequest 类套了个壳,后期如果还需要什么属性也可以添加,不用每次想获得什么都得现写,实现了代码复用(虽然到最后也只是需要获得方向这一个属性)。

本次作业主要需要设计的是单个电梯的运行策略,这很重要,是后面电梯协作的基础,因此我阅读了一些往届博客,并做了几种尝试,最终改到了经典的 look 算法(其实一开始用的是是 look 和 ALS 的结合,但是结合并不成功,带来了一些情况下极差的性能,最终决定修改了,详见后面的 bug 部分)。

从第一次作业开始,我就把电梯和电梯控制分开了,让电梯控制作为纯粹的线程,只负责控制,具体执行和状态存储都交给电梯,从而使线程类精简,且方便共享电梯状态。

优化方面,这次作业中我使用了学长们总结的“量子电梯”写法,即不关注电梯实际的运行情况,只关注输出,即等待的时候,电梯可能在本层,也可能在下一层,只要保证输出合法即可。

开始设计之前阅读了一些学长的博客,收获良多,放两个反复阅读的优秀博客链接,感谢前辈们的无私分享!

钟鼓楼

hyggge

第二次作业

类图:

img

协作图:

img

迭代分析:

这次作业一方面添加了重置指令,另一方面乘客不再指定电梯,需要考虑分配策略了。

首先,针对重置指令,我的做法是在流水线基础上增加一条转发旁路,由输入线程直接把指令送给电梯的请求管理器而不经过分发器,这样可以保证重置指令的优先级,而且不用让分发器多加判断,比较简单。

另外,对于指令在电梯之间的分配,最简单的就是均匀分配或者随机分配了。考虑到互测时有规律的分配可能被人针对,我的基础分配策略就是纯随机数,在当前可用的电梯里面选择。

在获取可用(不在重置中)的电梯时,如果所有电梯都在重置,不加特判可能导致最多 1.2s 的轮询,对此我的做法是如果所有电梯都在 reset 就先让线程睡一小段时间,也就是“等待+轮询”,降低对 CPU 的消耗。但是这样其实不是很优雅,本质上还是在轮询,这块网上似乎是有更好的改进思路,但是比较复杂我没看懂,就先将就着用了,希望之后再多学点处理方法,避免这种原始的处理。

其实还有一种比较好的方法是给请求队列加个 buffer,这样重置中的电梯也可以接受指令,只是等重置之后再输出,这种方法更加优雅,且适合与完整版影子电梯相搭配,但是可惜当时没有想到。

除此之外,我还尝试着写了一下影子电梯,基本思路就是将电梯在某一时刻的状态克隆下来,添加指令并模拟运行(用增加时间变量代替 sleep),看哪个电梯先结束。但是我写的比较粗糙,没有保证计算过程中电梯在不在移动等情况,只是模拟了部分的运行,因此效果不是很好(但是在不少情况下确实能比随机好一些,毕竟这也可能是随机的一种情况,不太好比较)。

还有一件事,为了实现合理的结束条件(所有乘客都要到位才能结束,不能一个电梯没事了就结束,否则只剩一驾电梯,如果突然要 reset,就不能让别人来继续送人了),我设置了人员计数器,采用单例模式,分发器分配一个人就计一个数,人从电梯里出来或者 receive 失效时减一个,计数器归零(且输入结束、请求处理完毕),所有电梯才能依次结束。

第三次作业

类图:

img

协作图:

img

迭代分析:

这次作业最重要的迭代任务就是添加“双轿厢”电梯。

对于这个需求,我做的改动并不多,输入基本不变,只是在电梯处理 reset 的时候加一种可能性,如果要变成双轿厢则打开两个新的控制线程,关闭本控制线程(我觉得这样比较优雅,虽然会带来一些线程开关的开销,但是一方面语义上明确了出现两个地位平等的电梯,另一方面也是避免修改对于一个电梯应该是常量的运行范围。

我觉得双轿厢的核心,一者是不相撞,另一者是如何看待它们的分工与协作方式。针对第一点,我会在下一节仔细说明;针对第二点,我的想法是让两个轿厢共享一个请求管理器,但是只能“看见”自己运行范围内的指令

这种做法可以保证电梯对外(分配器)接口的稳定性——始终只展示一个请求管理器,电梯内部是什么样,不用告诉外界(除了要用影子电梯之类的策略)。

实现起来,我一开始是让电梯忽略自己视域之外的请求,但是处理不得当,导致了 CPU 时间爆炸的问题,这部分在后面 bug 节细说;此外,我让电梯查找有无自己范围内的请求时用的还是遍历的方法,但是后来我转念一想,其实可以借鉴 OS 中的 P、V 操作,维护两个轿厢的“请求量”,只有自己有请求的时候才去遍历并删除一个请求。

调度器修改:

由于我这次保证了电梯部分对调度器的接口稳定,所以随机策略完全不用改,但是影子电梯实在是模拟不出来了,我也设想了一下直接模拟全局的“影子系统”,但是感觉实现难度过大而且开销也极大,就望而却步了。因此第三次作业最终用的就是随机分配,性能不太好,但是也说不上差。

综合分析

我的设计草图(手绘,比较潦草):

img

图中体现了我设计的线程(黄色方块)、共享对象(蓝色圆圈)以及“数据通路”(黑色箭头),总体架构呈现出一个三级流水线的形态。

这个图基本上是在三次作业中一以贯之的,只是内部实现方式有部分修改。此外,从第二次作业开始,加入了【ResetRequest】这一“旁路”,主要是为了给它更高的优先级,而且传递目标固定,就不再由分配器分发给电梯,而是由输入线程直接加急送到电梯专属的请求管理器中。

PsnCnt 乘客计数器是从第二次作业开始有的设计,游离于流水线之外,让所有乘客都运送完毕之后再依次关闭电梯,这是为了保证中途从重置的电梯里面走出来的人还能继续坐别的电梯到达需要的楼层。

我感觉自己三次作业的架构整体来说很稳定,没做过架构上的大改,这主要是因为一开始就选择了拓展性较强的三级流水线式写法,而且各个共享对象和线程分的比较明确,避免了后期需要共享的时候再做拆分的窘境。

分析自己在第三次作业中是如何实现双轿厢的两个轿厢不碰撞的

我的做法是将【换乘楼层】作为两个轿厢的“共享资源”,采取类似 OS 课程中进程调度部分的思路,两个轿厢竞争一个楼层就相当于是两个进程竞争系统资源。

我在两个轿厢共享的请求管理器中添加了三个布尔属性,分别标示换乘楼层是否正在被占用,以及两个轿厢分别是否有进入该层的需求。

当一个轿厢即需要进入换乘楼层时,会先“举手”,即将请求管理器中的一个布尔值置 true,然后去检查换乘楼层是否已经被占用,或者另一个电梯是否已经先提出了进入该楼层的请求,如果是则等待楼层空出,否则直接进入。

当轿厢进入换乘楼层时会先将“共享楼层占用”置 true,然后再“放手”,取消自己的进入请求,避免出现指令之间的空窗导致线程不安全;当轿厢因为其他指令或者另一轿厢的进入请求离开换乘楼层时(移动之前),会放开对该层的占用,以便另一个轿厢尽快获得进入许可。

当轿厢在换乘楼层等待时,会被请求管理器的状态变化唤醒,此时如果另一个轿厢请求进入本层则立即离开。

这样实现可以避免轿厢的额外移动*,并且几乎没有轿厢间交互的耗时,因为当一个轿厢有进入换乘楼层的“打算”(到达换乘层相邻的一层并准备向换乘层移动)时就会“举手”,此时如果另一个轿厢正在换乘层等待,则会立即开始离开,二者可以同时移动,相当于首尾相连,但是并不会相撞。

*这句话是基于我一开始的另一种设想,即只在请求管理器中设置一个换乘楼层被占用的标示,并且从策略上保证轿厢尽可能少地占用换乘楼层,即不在该层等待,非必要不进入,并且一旦没有请求立即离开该层。这种想法实现起来很容易,但是会导致电梯大量无效移动,最终被否决。

分析自己程序出现过的 bug 以及自己面对多线程程序的 debug 方法

第一次作业

第一次作业由于加了绝对过剩的锁,所以没有出现正确性的问题,但是因为调度算法不合理,在互测中出现了运行超时的问题。当时在看了指导书和一些博客之后试图使用 look 算法,并为之后的“自由竞争”做好准备,但是由于没有完全理解 look 的运行机制,所以最终的产物是一种介于 look 和 ALS 之间的算法,开始会锁定一个“主请求”,电梯运动起来之后会进入 look 的运行模式,直到所有乘客都从电梯里出去。

开始锁定“主请求”是为了之后的自由竞争(虽然最后被禁止了)过程中让多部电梯共享请求队列时,避免电梯跑到某层的时候要接的乘客已经跟别的电梯跑了的资源浪费,因此电梯会锁定离自己最近的请求作为主请求,并将其从等待队列中暂时删除,到了其所在楼层再把人放回去,就像是在几楼之外把人“召唤”到电梯门上贴住一样。

由于这种“主请求思路”,我的电梯接到主请求之后会立即根据其目标方向改变运行方向,所以在互测中被类似于“10->11; 9->11; 8->11; ...”这种请求列表狠狠刀中——我只能一次运送一个人,把原本可以通过捎带一次解决的六个请求分了六次,电梯来回奔波,极大浪费时间。

现在审视这种策略,发现其有很多不成熟之处,比如锁定请求可能带来的请求延误——电梯到达主请求起始楼层已经不能再装人等,未必比电梯多运行带来的耗电轻,而且这样似乎也有些违背了自由竞争的初心,而且后续的迭代要求也明确禁止了自由竞争(至少也是禁止了明面上纯粹的自由竞争),因此也就没有在这一思路框架下进一步思考,而是将电梯的运行策略直接改成了纯粹的 look(似乎最终的实现还是跟部分同学不同,设置了“停止”状态,而不是只有上与下,我个人觉得这样更加合理,但是效果上影响不大)。

最终将电梯运行测略改为较纯粹的 look 之后避免了这种情况,可以更好地实现捎带。

但是(我的) look 策略也并非完美,比如当前几层的请求人数已经足以使电梯满员,但是前面仍有请求,电梯仍会向前。这种问题似乎也可以通过特判优化,但是特例太多,我肯定是无法穷尽的,也没有能力和兴趣用数学方法证明出一种最合理的优化思路,在 look 基础上优化始终显得投入产出不成正比,因此经过几次尝试之后就没有做不稳定的修改。

第二次作业

第二次作业成绩不太好,由于一个线程安全 bug 和一个分配策略的漏洞导致错了 4 个强测点,还被 hack 了两刀。因为清明出去玩没好好 debug 导致的

首先时线程安全问题,我在遍历请求管理器的内部容器(定义于父类中)时,由于将获取容器和遍历容器两个操作分开加锁,导致了不太好复现的 bug。这个问题其实最后加了几个输出也就找到了,但是写的时候陷入一种思维定势了,没想到还有这种可能,一直在别的地方找 bug,最后也没找到。

其次是调度策略,因为设定了只给不在重置中的电梯分配指令,导致中了著名的“围师必阙”之计,当五个电梯陷入重置状态时,所有的请求都会进入孤零零一个电梯里,让它承受太多,最终不堪重负,超时了。

因此,针对这种特殊情况,诞生出几种解决方案:一是 buffer 大法,给重置中的电梯分配指令,但是暂不输出分配结果,等到重置结束后统一输出,这样做确实很合理而且很优美,而且配合全套影子策略食用效果更佳,但是修改较为麻烦,我后期也放弃了影子电梯,因此没有实现;另一种方案是给每个电梯的请求数量加一个上限,超过上限后请求到来也暂时搁置,这样需要修改的很少,也可以满足性能要求,我采用的就是这种做法,此外,还有如果有一定数量的电梯同时陷入重置则暂停所有电梯接受请求的服务,等大家都准备好再来分配等等策略,但是采用价值不大,不如前两种方法优美简洁(可能也是因为我没想到也没听说过更好的方法)。

第三次作业

这次作业中,由于我架构相对稳定,而且对多线程设计也更为熟悉了,最终成果虽然性能不太好,但是正确性没出问题,也算是给电梯单元画上了一个差强人意的句号了。

在第三次作业的迭代开发和本地测试过程中,我也面临了一些 bug,最关键的一个就是加入双轿厢模式后,电梯控制线程可能出现 wait-notify 失效的问题,让 CPU 忙等,消耗大量 CPU 时间,最后发现是因为一个轿厢能看到不属于自己运行范围的请求,但是却无法到达,导致它不断尝试去获取请求,而不陷入 wait 中。最终的解决方案是加入一道特判,如果电梯看到的只有自己运行范围之外的指令,也会陷入一个 wait,相当于是打了一个补丁来面对特殊情况。

调试多线程 bug 始终是一个毁灭灵魂的问题,多线程运行的不确定性让人很难跟踪、判断问题来源,但是面对多线程,我们并非无计可施,比如最传统的调试方法——输出调试大法(在适当的位置加输出判断程序运行情况),就可以解决大部分问题,无论是死循环还是死等,乃至 wait 失效导致的 CPU 时间爆炸,都可以用输出方法定位,但是有时候 bug 难以浮现,可能就需要编写脚本自动化重复多次测试之类的方法来强行复现了,只要重复次数够多,小概率事件一定发生。

此外,我们还可以借助 Java 自带的 jconsole 来判断线程状态,诊断死锁,以及检查 CPU 运行时间是否过长等问题,这个插件的确很有帮助,能够帮我锁定一些不易观察的问题的出处,从而针对性修改。(我关于 jconsole 的了解来自YannaZhang 张杨学姐的博客,非常好博客,十分感谢学界的分享)

最终的调试方法就是【代码走查】了,对着静态的代码逐行分析、解释自己这样设计的原因,这大改就属于是多线程,乃至所有程序设计的最终 debug 方案了,但是这种方案能够生效的前提还是对自己代码的理解程度和对多线程运行原理的熟悉,否则绝对百思不得其解。其实检查代码也是多线程设计能力的体现,有检查代码的前提,也是编程习惯良好的一种体现吧,毕竟如果结构乱七八糟,实现也乱七八糟,自己都看不懂,甚至看不下去,也就失去了检查出问题的可能性了,因此设计和实现的时候还是多替未来的自己着想,别写的太神奇,至少让未来的自己能看懂(很多时候睡一觉前后就像两个人,思路都对不上,仿佛在模拟团队开发)。

心得体会

线程安全

主要的体会前文都有表现了,总结下来最重要的还是要在开始动手实践之前做充分的设计规划,分清楚线程和共享对象以及其他对象,划分清楚共享关系,然后在动手写代码的时候适当加好锁,不必为了性能把临界区拆的过于支离破碎,但是也不能乱加锁,导致多线程失去其优势和意义。

层次化设计

说实话我没太弄懂什么是层次化设计谈谈我的代码架构吧。

这次的整体架构就是“三级流水线”,从输入,到分配器,再到电梯控制,三步分别交给三个线程类,然后线程之间不直接交互,只通过共享对象来分享数据,保证线程之间的平等地位,避免了结构的混乱。

这种类之间的明确分工是很有必要的,线程类就只负责运行,尽量简化其 run 方法,让它只负责“发号施令”,任务的具体实现交给其他类来完成,这样可以省去很多不必要的麻烦。

类的逻辑层次方面,我只给请求管理器做了继承,细分成了分配器对应的“大托盘”和每个电梯一个的“小托盘”两个类,它们储存的内容大体相同,只是一些操作不会同时出现,因此可以用继承的方法来实现代码复用。

课上老师讲的思路似乎是在实现双轿厢时对电梯类进行继承,但是我没有这么做,因为我觉得需要改变的其实就是把电梯的始末楼层从常量变成变量,虽然这种直接针对已有类的修改似乎不太礼貌不太合乎开闭原则,但是我觉得针对我们的小工程还是很方面和适用的。

此外一个比较重要的点就是对电梯功能的分配,课程组建议的“策略类”我没有实现(主要是因为没看明白,而且觉得麻烦),我的做法是把“电梯控制”作为可运行的类,它“拥有一台电梯”,电梯控制类就是周期性地给电梯发送各种指令,但是具体执行以及条件判断全部交给电梯内部实现,这样有助于我们共享电梯内部数据(如果实现影子电梯就必须要做),而且比较符合单一职能原则,比较优雅。

我觉得我这种方法和“策略类”的区别就好像“是电梯控制器拥有一个电梯轿厢,还是电梯拥有一个控制策略”的“哲学问题”,但是对于实现效果来说可谓是殊途同归了。

其它想法

本单元的作业中,我第一次接触了“多线程”这一概念,从单元刚开始时的“进程”、“线程”傻傻分不清楚,到设计最后一次作业时较为熟练地应用多线程的基本操作来兼顾运行效率和线程安全,整体来说学习压力很大,但是同时感觉收获颇丰,尤其是 OO 多线程与 OS 进程管理在这一时间节点同时到来,给我了一个理论与实践相结合、操作系统的设计者与使用者两个角度观察“多线程”的机会,让这部分的知识能够更好的融会贯通。

感觉作业的要求和前几年发生了较大的变化,不少学长学姐们的“智慧结晶”不能使用了,更需要我们自己来创造新的架构和优化策略,总体来说还是很有趣的。

这单元中由于 OS 等课程压力略大,以及需要面对蓝桥杯、冯如杯等挑战,再加上最主要的,自己比较懒,就只做了很少的优化。第六次作业中写的影子电梯能用,但是只能用一点点,效果并不稳定,后来看到魏新明同学的博客感觉醍醐灌顶——我的影子电梯计算时电梯也是可以移动的,因此很可能上一个时刻的局部最优解到算完之后已经不再具有优越性(比如电梯开始移动等),而且因为没有给正在重置的电梯实现 buffer,即直接跳过重置中电梯,使我所谓的“影子电梯”也失去了很多可能性,虽然这些问题都可以解决,但是最终也没有着手去做。

总体来说,完成这单元的作业之后有一种如释重负的感觉,初步掌握多线程之后感觉自己能够掌握的项目又增加了一个维度——不仅是类与类之间先后的协作关系,更是能够完成同时的、线程间的同步交互,很有成就感。

我对这次作业的架构还是相对比较满意的,相较于第一单元,我觉得我的架构更“美”了,迭代过程中做的修改也相对较少,虽然还有很多问题可以进一步优化,但是的确让我感受到了自己能力的进步,期待未来更多的挑战与进步。

...全文
68 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧