OO第二单元作业总结
OO第二单元作业总结
程序架构、同步块与锁
第五次作业
作为多线程的入门作业,第五次作业的难度较低,类的数量和代码行数也都是三次作业中最少的,如下图所示。
本次作业的架构较为简单,整体由四部分组成:主线程、输入线程、共享队列以及电梯线程。具体来说,本次作业中共有6个类,作用分别如下:
MainClass
是主线程,用于启动Input
线程和Elevator
线程Input
是输入线程,用于从标准输入中获得请求并将请求送到共享队列Channel
Channel
是共享队列,成为Input
和Elevator
的共享对象Elevator
是电梯线程,主要负责电梯的升降、电梯开关门、人的进出DirectionEnum
和PatternEnum
是两个Enum
,将电梯状态和电梯模式设置为Enum
的目的在于方便扩展
从顺序图中可以看出,Channel
作为Input
和Elevator
的共享对象,进行信息的传递。因此,在本次作业中,我的加锁位置如下:
- 对
Channel
中的所有方法加锁,使其变为线程安全类 - 在所有
notifyAll()
和wait()
外加锁 - 在
Elevator
线程中对所有涉及到遍历waitQueue
的地方加上锁
在本次作业中,我的加锁策略是:尽量打造线程安全类,从而在线程中尽量不出现synchronized
关键字。
第六次作业
第六次作业的主要难点在于如何将请求分给不同的电梯,这就迫切地需要调度器的加入了。总体来说,调度策略可大致分为两种:一种是自由竞争类型,另一种则是包分配类型。在本次作业中,我选择了后者。
相较于上一次作业,在本次作业中我加入了Scheduler
线程,并引入ElevatorStatus
类:
Scheduler
线程是调度器线程,负责将请求分配给不同的电梯,调度策略详见调度器设计ElevatorStatus
是一个电梯状态类,其用途是在特定时刻克隆电梯的当前状态,并将其交给调度器线程用于确定当前请求应分给哪一电梯
本次作业在整体设计上沿用了上一次作业的架构,即“输入-共享队列-输出”,只不过本次作业由于加入了调度器而产生了二次调度的情况。
对于锁的使用,由于本次作业中使用ElevatorStatus
来在指定时间点提取电梯信息,因此共享变量除了经典的waitQueue
和processQueue
之外还有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
,从而在更细粒度上进行加锁和去锁。与此同时,本次作业延续了上次作业中的锁住waitQueue
和processQueue
以及锁住静态输出方法的加锁方法。
总结
对于多线程的线程安全问题,往往使用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的方法。
心得体会
-
线程安全
线程安全问题不仅是本单元最关心的问题,更是多线程程序中最核心的问题。如何解决线程安全问题呢?一个是善用
synchronized
和wait-notify
,synchronized
将需要设置为同步块的语句锁在一起,解决竞争的问题,wait-notify
则是将不需要运行的线程休眠,解决轮询的问题。另一个是不要滥用synchronized
和wait-notify
。前者的滥用会导致锁的嵌套混乱,大概率导致死锁,解决方法就是前面提到的”精确加锁“。后者的滥用则可能会导致所有的线程都进入休眠状态,导致RTLE。解决方法就是在设计阶段就将UML顺序图画好,将bug扼杀在摇篮里。 -
层次化设计
在本单元作业中我没有进行重构(时间也不允许),我从第五次作业开始就使用了经典的“输入-调度器-电梯”模型,“生产者-消费者模型”和“单例模式”陪伴我从第五次作业到第七次作业。这充分体现了OO的优势:架构一旦设计好,在此之上的迭代就变得极为容易。但是,在细节上,每一个类的实现仍然很扁平,这带来了大量的重复代码以及混乱的嵌套锁,这些都是我在接下来的学习中需要注意的。