面向对象第二单元总结

一、同步块的设置与锁的选择

本单元是基于电梯模型来理解多线程这一概念。在三次作业中,都采取了生产者消费者模型。因此,整个程序的关键在于读取请求、分配请求以及执行请求。基于此出发,我建立了两个基本类:

  1. Request
    此类基于官方标准输入包,进行了修改,即包含一些基本信息,也实现了一些独特的方法,比如判断乘客所要移动的方向等等。
  2. WaitQueue
    此类是用于存放乘客的Request请求的,它将乘客的请求存于类中的ArrayList中暂存,当满足乘坐条件时就从此类中取出请求给予电梯。

同时,我创建了两个线程类:

  1. InputThread
    这个进程用于读取输入的请求,每读到一个请求,就将请求存放在WaitQueue中,等待调度。
  2. Elevator
    这个就是电梯的进程。每个电梯需要负责运行、开关门、上下乘客等功能,而它需要从WaitQueue中读取相应的请求,存放在自己类中的List中,作为现在的执行请求队列。

三次作业的基本共性就是如此。通过分析可以看出,WaitQueue类是主要的线程安全注意点。InputThread线程会往其中写,而Elevator线程会向其中取,两个线程的安全的关键就是对于WaitQueue的维护。

  • 第一次作业
    第一次作业的要求较为简单,只需要单部电梯运行即可,InputThread和Elevator共享一个等待队列WaitQueue,锁的选择就是WaitQueue。InputThread中的同步块设置如下:
    image
    添加请求时需要上锁,因为这是往请求队列中写数据,是可能引发线程安全问题的操作。Elevator的锁设置如下:
    image
    inList是电梯的等待队列。电梯刚运行时,需要对其进行判断:若满足退出条件,就唤醒其他线程并退出;要不,若执行队列为空,由于是刚运行,请求队列默认为空,就需要wait请求队列的请求到达。当第一个请求输入完成后,就开始分配请求并运行电梯。根据题目要求,分为NightMorningRandom三种运行类型,那么就分别写三种模式的运行方法。

    • Night
    1. 如果满足退出条件,就退出
    2. 由于Night是从高楼层到统一楼层,因此有一个请求即可运行,运行采用goTo方法,方法中也设置了同步块,当满足条件后也会退出。
      image
    • Morning
    1. 如果满足退出条件,就退出
    2. 由于Morning是集中在1楼到各处楼层,因此这里规定:就等待下一个请求,除非电梯已满或者请求队列已经关闭
    3. 运行电梯image
    • Random
    1. 如果满足退出条件,就退出
    2. 如果执行队列为空,就等待请求队列再执行
    3. 否则就执行
      image
  • 第二次作业
    第二次作业引入了三个电梯,并且加入了增添电梯的指令。在大体上思路与第一次作业没什么差别。在InputThread增添的请求,会放在总体的请求队列上,再经过新建的Channel线程分配到每个电梯的请求队列上。Channel是一个管理电梯类和分配请求的线程,其中有包含电梯的队列。在这里,将电梯线程的启动放在了Channel类中。
    在Channel中,若满足退出条件,就退出;否则,若总请求队列为空,就等待。在给电梯加请求之后,就唤醒电梯的请求队列。部分代码如下:
    image
    其中,对于elevator的锁,实际上就是对该电梯的请求队列的锁(因为只是保护电梯内的请求队列)。加入新电梯,对电梯队列也设置了锁。
    image
    在加入新请求时,也对电梯上了锁image
    实际上,对于电梯的锁,就跟第一次作业中对于电梯的请求队列上锁是一个性质,都是为了保护电梯的请求队列。

  • 第三次作业
    第三次作业将电梯也进行了分类,但对于锁的改动并不大,只是在调度时候的改动,所以对于同步块和锁的设置可以参考第二次作业。

二、调度器设计

  • 第一次作业
    由于第一次作业是单个电梯操作,一个请求只能到达该电梯的请求队列中,不需要调度,我也没有设置调度器。InputThread线程与Elevator线程二者交互如上文所示。
  • 第二次作业
    第二次作业由于可以有多部电梯运行,若无调度器,就会造成某个电梯的请求队列过长或过短,造成资源的浪费进而可能超时。因此,引入了Channel类来进行调度。对于一个总请求队列,将里面的请求依次轮流放入各个电梯中,就相当于是一种平均分配。交互模式如下:
    1. 启动InputThread线程
    2. 在InputThread线程中,启动Channel线程,然后再进行循环读取操作。
    3. 在Channel线程中,首先启动各个电梯线程,然后进入循环分配请求过程。若总请求队列中为空且不满足退出条件,就进行等待总请求队列。
    4. 在各个Elevator线程中,由于电梯的请求队列也为空,也进行等待该电梯的队列。
    5. 当请求来临后,InputThread加入请求到总队列,然后唤醒Channel线程,进而Channel线程将请求分配到相应电梯,并将其唤醒,电梯开始运行。
  • 第三次作业
    第三次作业由于电梯有相应的分类,我选择按照请求的出发楼层和目的楼层将其分配到相应电梯,电梯的分类如下表格:
型号 到达楼层
A 1-20
B 奇数层
C 1-3,18-20
因此,可以如下分配:
  1. 当出发楼层是1-3或18-20,目的楼层是1-3或18-20的,分配到C型电梯
  2. 若1不满足,当出发楼层和目的楼层都为奇数,则分配到B型电梯
  3. 若1、2都不满足,则加入A型电梯

相应的交互模式与第二次作业相同。

三、第三次作业架构设计可拓展性

image

UML协作图:

image

可以看出,第三次作业各个类的耦合性是很强的,在Main进程中启动了InputThread进程,在InputThread进程中启动了Channel进程,在Channel进程中启动了三个电梯进程。如此一环扣一环,会对bug的检查带来一定的困扰。

对于拓展方面,电梯就只写了一个类,没有进行继承或者更加统一的抽象类来进行管理。电梯的总体方法是不变的,但我需要根据电梯的种类来分化不同电梯的性能。同时由于不同电梯聚合在一个类中,导致了代码行数十分长。对于请求的分配,是通过Channel类来进行静态分配,简单直观,每个电梯就按照自己的运行方式处理分配到的请求即可。总体而言,可拓展性一般。

四、程序Bug分析

  • 第一次作业
    首先,三次作业的策略都是Look算法,而Look算法的关键在于何时判断转向。简单来说,电梯转向规则如下:
  1. 电梯的执行请求中没有方向与电梯方向相同的请求
  2. 电梯的运行方向上没有在等待的同方向请求

第一次作业在运行上没有Bug。但是由于转向判断写漏了第二条,导致电梯运行速度大大减慢,进而一个点超时未过,其他点的性能分也极低。加上即可。

  • 第二次作业
    第二次作业的bug有以下两个方面:
  1. 电梯越界行为,即电梯运行超过了20层,到达了21层
  2. 电梯调度策略有问题导致运行时间过长

对于第一个bug,是因为自己的疏忽,需要在边界楼层特判转方向即可。不过由于这个地方错失了强测的许多点,非常可惜,也强化了对于边界条件的理解。

对于第二个bug,就是自己的调度策略的设计有问题。最开始的时候,我并没有想的是简单的平均分配,而是非常复杂地设计了一个调度策略:当一个新请求来临时,遍历所有电梯,根据以下规则生成临时电梯队列:

  1. 如果该电梯的请求队列为空,则将该电梯加入临时电梯队列,并停止遍历
  2. 如果电梯的运行方向和请求的方向相同,并且电梯还未运行到该请求的出发楼层,则将该电梯加入临时电梯队列

生成了临时电梯队列之后,再对这个队列进行遍历。如果该队列为空,则将该请求加入到1号电梯;否则,就选择队列中距离请求出发楼层最近的电梯,加入请求。

我以为这样能够较为优化调度器的选择,但事实上往往会造成以下情况:

  1. 临时电梯队列常常为空,导致请求堆积在1号电梯,运行速度过慢
  2. 距离请求最近的电梯,可能已经满员,它并不比其他距离请求较远的电梯更快,也可能造成请求堆积

因此,在bug修复过程中,我修改成平均分配策略,这种策略能够较好地解决请求堆积问题。

  • 第三次作业
    第三次作业的bug是出现了线程安全问题。同一份代码,多次提交,总是会出现有乘客没有上电梯的情况,而且没有上电梯的乘客可能不尽相同。

根据分析,可能是出现了线程安全问题。我分析了一下Random模式下的代码,发现这样的情况
image
当我在使用goToForRandom方法时(此方法是对Random模式下电梯设计的运行方法)对其加了个锁,并且没有唤醒操作。事实上,我在这个方法的内部已经对相应会产生线程安全的地方上了锁
image
关键在于getIn这个上电梯的函数,如果在外部对其上了锁,可能当请求到来时与取请求时产生冲突,就会产生有乘客上不了电梯的情况。因此,将这个多余的锁删去,即可满足要求。

五、分析别人bug所采用的策略

这个单元的作业我并没有去Hack他人,因为多线程的bug测试实在是过于魔幻。但结合自己的bug,主要就是一下三种:边界判断运行超时以及线程安全

对于前两个bug,事实上就是构造特殊数据的方法了。就比如其中一个强测案例,在180s的时候一下子给了许多条指令,从1-20层和20层-1层。这样对于边界检查和时间限制都有很大的考验。但是事实上,这样的数据对于运行超时的检查有点过于苛刻,它实际上是在考验电梯的性能,而不是它的正确性。当然,性能也应该是正确性的一部分,不然这个题目就没有实际运用的意义。而当互测时,这就显得没有多大的意义了。同时,我也不能保证能给出自己构造的特殊数据的正确答案。

而对于线程安全,最主要的是要分析整个程序的架构和同步块的设计。但是对于电梯这个题目,可能有一些想法大家是相同的,但是每个人写出来的代码都会令人费解,即使他们的思路相同。因此,在尝试了阅读几个同学的代码后,我就选择放弃了,理解真的使人头大。况且,线程安全的测试过于玄幻,一次并不能保证测的出来,可能需要自行构造评测机,目前我还没有那个能力,只能肉眼debug,因此对于这个bug的检查也没有多大办法。

六、心得体会

两个单元的OO作业都是如此。第一次作业就是在恶补新知识,只想着能够将程序写出来,能够过中强测。但这一单元吸取了第一单元的教训,在写之前也注意到了架构的选择,这使得之后两次作业的拓展显得较为简单。

这一单元最重要的便是对于多线程的理解。其实多线程的概念很好理解,就是并行;那么它的关键就在于对于共享数据的保护与处理以及各个线程的等待与唤醒

这三次作业,有两个需要思考的点:一是同步块的设计,二是调度策略的设计。对于同步块设计,需要注意的就是何时释放锁是否唤醒锁以及避免出现死锁。其实这就要求我们在脑子里有一副模式图,思路清晰,那么在分析bug的时候就更加有针对性。对于调度策略的设计,是非常令人头大。设计出好的策略并没有想象中那么简单,甚至会由于自己的思考不全面导致时间出奇的长。就如上文提到的第二次作业的调度设计,就令我在强测中惨败。第三次作业中,我也写过换乘的代码。但换乘如果要效率高,要考虑的细节非常之多,并且生成的新请求又可能引发新的线程安全问题,最终我选择了放弃。

不过有一点在第二次作业中体会很深,那就是一个好的架构能够为后续的开发提供很大的便利。第一次作业选择了生产者消费者模式,第二次作业和第三次作业,只需要加一个调度器来分别分配这些请求,生成新电梯以及满足不同电梯的运行特性即可。当使用少量代码就能实现功能时,内心确实感受到了架构的重要性。

总体而言,前期对于线程的理解是非常困难的,记得第一次作业看了好几天的博客,但就是迟迟下不了手。但是一步步走下来,看到自己的电梯能够满足需求一步步输出,内心还是挺满足,挺有成就感的 要是强测不翻车就更好了

posted @ 2021-04-25 20:31  果冻狂魔  阅读(88)  评论(1)    收藏  举报