OO第二单元作业总结

OO第二单元作业总结

程序架构、同步块与锁

第五次作业

作为多线程的入门作业,第五次作业的难度较低,类的数量和代码行数也都是三次作业中最少的,如下图所示。

本次作业的架构较为简单,整体由四部分组成:主线程、输入线程、共享队列以及电梯线程。具体来说,本次作业中共有6个类,作用分别如下:

  • MainClass是主线程,用于启动Input线程和Elevator线程
  • Input是输入线程,用于从标准输入中获得请求并将请求送到共享队列Channel
  • Channel是共享队列,成为InputElevator的共享对象
  • Elevator是电梯线程,主要负责电梯的升降、电梯开关门、人的进出
  • DirectionEnumPatternEnum是两个Enum,将电梯状态和电梯模式设置为Enum的目的在于方便扩展

从顺序图中可以看出,Channel作为InputElevator的共享对象,进行信息的传递。因此,在本次作业中,我的加锁位置如下:

  • Channel中的所有方法加锁,使其变为线程安全类
  • 在所有notifyAll()wait()外加锁
  • Elevator线程中对所有涉及到遍历waitQueue的地方加上锁

在本次作业中,我的加锁策略是:尽量打造线程安全类,从而在线程中尽量不出现synchronized关键字。

第六次作业

第六次作业的主要难点在于如何将请求分给不同的电梯,这就迫切地需要调度器的加入了。总体来说,调度策略可大致分为两种:一种是自由竞争类型,另一种则是包分配类型。在本次作业中,我选择了后者。

相较于上一次作业,在本次作业中我加入了Scheduler线程,并引入ElevatorStatus类:

  • Scheduler线程是调度器线程,负责将请求分配给不同的电梯,调度策略详见调度器设计
  • ElevatorStatus是一个电梯状态类,其用途是在特定时刻克隆电梯的当前状态,并将其交给调度器线程用于确定当前请求应分给哪一电梯

本次作业在整体设计上沿用了上一次作业的架构,即“输入-共享队列-输出”,只不过本次作业由于加入了调度器而产生了二次调度的情况。

对于锁的使用,由于本次作业中使用ElevatorStatus来在指定时间点提取电梯信息,因此共享变量除了经典的waitQueueprocessQueue之外还有elevator,需要对此加锁。相较于第一次作业,本次作业的加锁情况几乎没有变化,只是增加了在ElevatorStatus类中使用elevator锁这一处,并且几乎没有锁的嵌套。除此之外,由于本次作业加入了多部电梯,输出可能会存在时间戳递减的情况,因此要将官方包中的输出方法封装为静态方法,并使用synchronized关键字锁住整个Elevator类,从而实现时间戳的不单调递减。

总体来看,ElevatorStatus类的设计是一个败笔。一方面是因为Elevator本身是一个线程,而共享变量是Elevator类中的变量servedPersonList,因此并不应该将整个线程锁起来,而是“精确地”锁住servedPersonList这一变量。另一方面,这个ElevatorStatus类的设计初衷是使Scheduler能够随心所欲地获取到电梯的状态,只要在ElevatorStatus类中加入相应的获取方法就可以了,但是在实践过程中,我发现这并不能够随心所欲,由于锁住Elevator的zz设计,几乎每添加一个电梯状态我都要想好久到底有没有出现死锁问题,这很不OO。另外,我的调度器采用了一种很傻瓜的策略,导致最终也仅仅用了电梯类中processQueue.size()servedPersonList.size()两个信息,这实在没必要新添加一个类,这是增加了复杂度而不是减小了复杂度。

现在来看,想要修改ElevatorStatus类有两种方法,一种是删掉这个类,将调度器需要的很少量的信息直接传出;另一种是将servedPersonList这一成员变量独立出来,使之成为一个线程安全类,这样也能解决ElevatorStatus类出现的傻瓜问题。

第七次作业

本次作业主要聚焦于换乘问题上。换乘还是不换乘,这是个问题。

相较于上一次作业,本次作业为解决换乘问题,设计了MyPersonRequest类对官方包中的请求类进行了封装,并加入了一系列标志位用以判断是否进行换乘,整体架构并无太大变化。

本次作业相较于上一次作业,增加了将请求从Elevator扔回waitQueue的信息流,这进一步导致复杂度的上升和锁的嵌套。

由于本次作业要求换乘,于是请求信息出现回流,加锁顺序就变得混乱起来,多层锁的嵌套最终引起了严重的死锁问题。解决这一问题的方法我已经在上面提出,就是将servedPersonList独立出来成为一个共享变量,精确地锁住servedPersonList,从而在更细粒度上进行加锁和去锁。与此同时,本次作业延续了上次作业中的锁住waitQueueprocessQueue以及锁住静态输出方法的加锁方法。

总结

对于多线程的线程安全问题,往往使用synchronized关键字和wait-notify就可以解决。然而,在哪里使用synchronized、如何避免synchronized的嵌套则成为了新的问题。对于第一个问题,鉴于矛盾的特殊性,应该具体问题具体分析。对于第二个问题,我能想到的最好的解决方法就是做到“精确加锁”。什么叫精确加锁?一是临界区中的代码行数一定要少,代码能不放在临界区就不放在临界区,只有当确实需要成为原子操作的时候,才将多条语句放在一个临界区中。这一规范能够极大程度上避免不必要的锁的嵌套。二是操作哪一变量就将哪一变量加锁,这样不仅能够提升代码的可读性,也能避免过大范围的锁导致性能的下降。

调度器设计

第五次作业

在第五次作业中,由于只有一个电梯,所以实际上我只实现了电梯的调度策略,而没有实现调度器的调度策略(也无必要)。对于电梯的调度策略,我采用了类似LOOK的算法,并且没有区分模式(事实证明差别不大,甚至还有小优)。

具体来说,电梯的调度策略可以写成下述伪代码。

while (true) {
	如果 输入结束 && 电梯外无请求 && 电梯内无请求 则 跳出循环
    如果 现在 电梯运行方向 UP or DOWN
    	如果本层有要进出电梯的人,开关门并进出人
    	如果 (电梯外有人在本方向有请求 && 电梯未满) || 电梯内有人请求前往本方向
        	继续沿本方向运行
        否则如果 电梯内无人 && 电梯外无请求
            电梯停止
        否则
            电梯转向
    否则
	如果 输入未结束 && 电梯外无请求 && 电梯内无请求 则
	    wait
	当 请求再次来临时 电梯继续运行,默认UP
}

第六次作业

本次作业加入了多部电梯。针对这一新增需求,我设计了“平均包分配”的调度器调度算法,电梯调度算法则沿用了上一次作业没有变化。

调度器与输入线程之间的交互由waitQueue实现,输入线程读取输入的请求,并将其放在waitQueue中,调度器从waitQueue中读取请求;调度器与电梯线程之间的交互由processQueue实现,调度器读取waitQueue中的请求,并将其放入processQueue,电梯则从processQueue中读取请求来运行。

这一调度器算法的主要思想写作伪代码如下

获取 电梯子队列人数 和 电梯内人数
电梯人员总数 = 电梯子队列人数 + 电梯内人数
遍历多部电梯 选择电梯人员总数最少的电梯 将请求放入它的子队列中
如果 存在电梯人员总数相同的电梯 则 选择电梯子队列人数最少的电梯 将请求放入它的子队列中

这一调度策略看似很傻瓜,但是在强测中的表现意外地好。分析一下,这一策略在初始时会将请求平均地分给每一部电梯,而在电梯们都动起来之后,这一策略会选择比较“清闲”的电梯进行分配,从而使每部电梯都在运动,性能自然不会差。

第七次作业

本次作业将电梯分为不同型号。调度器的调度策略的大部分以及电梯的调度策略与上一次作业相同,为实现换乘,我在MyPersonRequest类中封装了换乘序列transferList,这一换乘序列在Input获取到请求之后就已经静态分配好了,分配算法如下。

对于 出发地 和 目的地
	如果 楼层是1-3或18-20 则
		出发地/目的地 类型是 C
	否则 如果 楼层是奇数 则
		出发地/目的地 类型是 B
    否则
    	出发地/目的地 类型是 A
如果 (出发地,目的地) = (A,A) 则 换乘序列是A
如果 (出发地,目的地) = (A,B) 则 换乘序列是AB
如果 (出发地,目的地) = (A,C) 则 换乘序列是AC
如果 (出发地,目的地) = (B,A) 则 换乘序列是BA
如果 (出发地,目的地) = (B,B) 则 换乘序列是B
如果 (出发地,目的地) = (B,C) 则 换乘序列是BC
如果 (出发地,目的地) = (C,A) 则 换乘序列是CA
如果 (出发地,目的地) = (C,B) 则 换乘序列是CB
如果 (出发地,目的地) = (C,C) 则 换乘序列是C

这一调度策略的缺陷,也是静态分配的局限性,在于它不能够根据动态的输入来实时改变调度方法,因此性能分很低。

调度器与输入线程和电梯线程之间的交互基本不变,在本次作业中新增了电梯线程反向与调度器线程的交互,具体来说,电梯线程将servedPersonList中的请求返回给waitQueue,而调度器线程则从waitQueue中获取请求,从而实现换乘的策略。

第三次作业架构的可扩展性

UML类图和UML顺序图

从功能性的角度来说,第三次作业很好地实现了换乘机制。三次作业采用“生产者-消费者模式”以及“单例模式”一以贯之,将架构的耦合性降低到可以接受的程度。总体来看,此后可能还会对请求进行更复杂的迭代,出现乘客自动爬楼、乘客中途改变目的地这样的迭代,只要在MyPersonRequest中加入相应的标志位即可,改动很小。

在性能上可扩展性方面,我认为第三次作业的性能上的可扩展性稍有欠缺,最根本的原因就是锁住了Elevator,使得锁的嵌套很混乱,解决方法当然前面已经说过了,将servedPersonList独立出来,这样会好一点。除此之外,我认为第三次作业的设计框架还是很好的。

bug分析

  • Homework 5
    • 在本次作业中,我犯了一个致命的错误,在电梯进出人时没有很好地判断是否电梯已满,进而导致电梯超载

    • 这一bug的位置在:ElevatorThread.letPersonRequestIn()

    • 方法度量分析:

      这一方法出现在含有方法最多的类Elevator中,因此即使复杂度不高,仍然出现了错误

Method Cogc ev(G) iv(G) v(G)
ElevatorThread.letPersonRequestIn() 7 3 5 6
  • Homework 6

    • 本次作业未发现bug
  • Homework 7

    • 本次作业中发生了严重的死锁问题,具体来说,processQueue锁和elevator锁在不同的地方的嵌套顺序相反,从而产生了死锁(经典的死锁方式T^T)

    • 这一bug的位置在:ElevatorThread.runRandom()

    • 方法度量分析:

      这一方法是整个项目中复杂度最高的方法

Method Cogc ev(G) iv(G) v(G)
ElevatorThread.runRandom() 45 3 18 21

发现别人的bug所采取的策略

  • 自己所采取的测试策略及有效性

    由于第二单元作业本身难度很大,并且由于多线程的不确定性,我的评测机并没有实现高并发的测试,而仅仅对输出的正确性进行了检测,这使我的评测结果并不理想。在实际的互测中,我仅仅在第五次作业中hack一位同学的关于正确性的bug,他的错误是没有检测电梯是否超载。

  • 自己采取的发现线程安全问题的策略

    为实现对线程安全问题的检测,我手动构造了一些极端数据并将这些数据重复运行多次(10次)。

  • 本单元测试策略与第一单元的不同之处

    在测试策略方面,本单元与上一单元有很大的不同。一方面,由于对多线程不熟悉,我难以搭建出一个高效的评测机,同时由于多线程的不确定性,这使我难以发现自己的bug。另一方面,多线程的时序对于执行结果影响很大,这对于bug的调试带来了很大的挑战。因此,相较于上一单元主要依赖评测机,在本单元中我主要依赖形式建模验证,这对于复杂度较小的程序来说比较有效,但是对于复杂度较大的程序来说则是力有未逮。此后,我将继续总结能够快速定位多线程bug的方法。

心得体会

  • 线程安全

    线程安全问题不仅是本单元最关心的问题,更是多线程程序中最核心的问题。如何解决线程安全问题呢?一个是善用synchronizedwait-notifysynchronized将需要设置为同步块的语句锁在一起,解决竞争的问题,wait-notify则是将不需要运行的线程休眠,解决轮询的问题。另一个是不要滥用synchronizedwait-notify。前者的滥用会导致锁的嵌套混乱,大概率导致死锁,解决方法就是前面提到的”精确加锁“。后者的滥用则可能会导致所有的线程都进入休眠状态,导致RTLE。解决方法就是在设计阶段就将UML顺序图画好,将bug扼杀在摇篮里。

  • 层次化设计

    在本单元作业中我没有进行重构(时间也不允许),我从第五次作业开始就使用了经典的“输入-调度器-电梯”模型,“生产者-消费者模型”和“单例模式”陪伴我从第五次作业到第七次作业。这充分体现了OO的优势:架构一旦设计好,在此之上的迭代就变得极为容易。但是,在细节上,每一个类的实现仍然很扁平,这带来了大量的重复代码以及混乱的嵌套锁,这些都是我在接下来的学习中需要注意的。

posted @ 2021-04-27 09:22  ArSpi  阅读(64)  评论(0编辑  收藏  举报