OO_U2总结

OO_U2总结

一、简介

  • 本单元三个任务为电梯调度与迭代升级。第一个任务是简单的纵向电梯,每栋楼一座纵向电梯,载人数固定,速度固定,可以简单分解为五个电梯线程与一个生产者线程。第二次任务新增横向电梯,并且保证没有换乘且必定有电梯存在才会有请求。第三次任务为换乘处理,且可能出现多次换乘,调度器的必要性更加凸显出来。
  • 对于性能分,尽管分享课上大家进行了相当长的交流,对于算法进行了细致的研究,但实际效果并不总是很理想,我个人认为这是由于多线程的不确定性造成的,时间差稍有波动便可能导致一系列的影响,但是算法本身是合理且优秀的。(收益有限我直接走自由竞争了)

二、设计与架构

1、第一次作业

  • 类图
    image
  • 第一次作业没有使用调度器,TaskProducer作为生产者,电梯Elevator作为消费者。由于每栋楼一个纵向电梯,所以给每个电梯直接分配一个TaskPool任务池,用于与生产者交互,这样生产者与消费者之间的交互为一一对应的,没有竞争。生产者生成任务立刻分析并投入对应的任务池中,也就是起到了了调度器的职责。电梯自身分析请求并决定行动方向,不依赖于外界调度,这也确定了我后续的设计。

2、第二次作业

  • 类图
    image
  • 第二次作业中要考虑横向电梯,我的思路是,不使用继承,而是用接口,状态机来实现接口,交互时使用电梯内部存放的状态机中实现的接口方法,电梯状态改变则状态机更换。这样做的好处是易于迭代,出现新的状态只需要去在新的状态类中实现接口,出现新功能则增加新接口,而不需要对原本的状态进行调整,减少了聚合度。
  • 其次,本次我引入了调度器TaskController,调度策略为自由竞争策略,即调度器只是从总任务池中提取自己能负责的任务,并发配给自己负责的任务池,与纵向同一栋楼的所有电梯或者横向同一层楼的所有电梯交互,电梯的运行由电梯自身的状态决定,与调度器无关(感觉我的调度器不符合调度器这个名字)。调度器的出现一是为了保证电梯无脑自由竞争时不会出错,二是为了后续换乘做准备。

3、第三次作业

  • 类图
    image
  • 时序图
    image
  • 第三次作业中需要换乘,我加入了全局的静态路径图类Path与乘客的任务类Task。在Path中时刻记录最新的电梯可达性情况,并提供计算可达路径的方法,每次可以计算当前最短路径。
  • 在乘客Passenger类中使用Task将任务按顺序分解并存于ArrayList中,每次只对调度器提供第一个任务,在任务结束时判断是否当前乘客的全部任务完成,未完成则由电梯投递回主任务池并移除第一个任务,供调度器判断并继续分配剩余任务。最后设置计数taskNumber来辅助判断线程何时终止。

三、锁与线程安全

1、生产者消费者模式

  • 生产者消费者模式解决了请求与等待过程中的CPU轮询占用问题,简单划分为三个类:生产者、消费者、缓冲区。缓冲区作为共享资源,可以被生产者与消费者访问,生产者与消费者为不同线程,按照所需规则wait与notifyall,锁只需要加在缓冲区的get与set方法中。
  • 本次电梯作业,我只在任务池的getPassenger方法中使用了wait,由于任务池没有大小限制,所以setPassenger不需要等待。wait置于while循环中,等待的判定条件为:状态机内部空+状态机认为任务池的任务列表中没有可以执行的任务+全部任务没有结束。wait置在while中而不是if中是为了防止自由竞争下的轮询,每次notifyall会唤醒所有线程,如果所有线程都无脑向下执行会不可避免地造成CPU资源的浪费。但是同样,电梯的复杂情况要求其不能死在while里面,因此总结为必须符合特定条件再wait。
  • 生产者消费者模型中本身set与get结束时都会调用notifyall,这本意为生产者消费者之间的互相唤醒。在电梯中,set不需要wait,可是get结束仍需要notifyall,这是由于一个电梯受限于其可达性、载容量等因素,get往往不会全部拿空,再加上自由竞争下其他电梯也需要去行动,因此此处set中的notifyall是为了唤醒同样在set中等待的其他电梯,这也体现了wait置于while中的重要性,频繁唤醒不可避免但while拒绝执行无意义唤醒后的行为。

2、锁

  • 锁的设置根本原因在于不同线程间的相同容器间的读写共享。实现锁两种方式,第一种锁非静态的代码段和资源,即对象锁,访问同一对象时起作用;第二种是类锁,只要访问类便会触发。我认为锁特别需要注意死锁与失效。我的经验为:锁的使用需要时刻注意要保护的容器而不是对象或者类,不然非常容易出错。
  • 失效非常容易发生,首先对象锁和类锁不互相影响,混合使用可能会导致锁不住同一个容器。其次一个容器如果既在有锁的方法中使用了,又在没有锁的方法中使用了,并且未上锁的方法可以被多线程直接访问,则锁实际失效。最后,在锁住的方法中访问其他无自身锁的对象,锁不会为这些对象提供保护,比如锁中访问的其他对象被其他的线程在任意时刻访问修改,导致锁失效,而且就算保证这些对象不在其他地方被访问修改,也可能出现问题,这个在下一部分解释。
  • 死锁,如果获得锁的顺序存在问题,有可能导致死锁。访问多个不同对象时锁容易失效,但也不能所有对象全部无脑加锁,这样可能导致意料之外的死锁,并且每次获取释放锁占用的性能也不小。
  • 由此可见,锁必须时刻注意要保护的容器,对于不同的容器可以用lock上不同的锁,但也必须分析容器的调用情况,而不能仅依赖于对类或者对象、方法的使用判断。

3、notifyall、wait与锁结合带来的问题

  • 锁就是锁,notifyall、wait只是操作锁的方法。
  • 遇到synchronized或者lock,某一个线程确实拿到了锁,其他的线程到这里会因为拿不到锁被阻塞,阻塞是因为这里有个检查锁的指令挡着,如果没有检查锁的指令挡着,线程仍会继续并行执行。访问被该锁保护的类或者对象为检查锁的指令,直接获取锁也是检查锁的指令。
  • wait后立刻释放锁,被阻塞的线程拿到锁继续执行。notifyall会唤醒所有wait同一个锁的线程,并且只有一个线程可以拿到锁,所有wait的线程唤醒后都继续向下执行,直到遇到检查锁的指令导致除唯一有锁的线程外其他线程被阻塞。
    image
  • 问题出现了:使用notifyall时,wait与下一条检查锁的指令之间出现了一段不符合锁的特点的并行区间,锁没有失效因为这段区间必然没有需要检查锁的操作,即访问锁所保护的对象、类的操作。但是这也是上一部分中所说的,如果这里有访问其他对象的操作,当前的锁不会为其他对象负责,因此即使这对象不能在除此代码以外的任何地方被访问,在此处也可以被多线程访问导致线程安全问题,这里必须对“其他对象”的内部代码加锁来使其渡过这段“危险区”。

四、调度策略

1、电梯本身的ALS策略

  • 在第一次作业中,我的电梯便尽量按照ALS策略去做(尽管可能有点不一样?),并根据架构去调整代码,即如果同时违背基准策略与架构方式,则以维护架构方式为优先。
  • 电梯空载时,以等待队列中第一个人为主请求,并会去携带沿途同方向的请求,但是,当电梯进入任何一个人后,便以内部第一个人为主请求。这样是为了保证携带的人不会因为主请求而出现多次掉头的问题。
  • 没有使用算法,导致对 2->1、3->1、4->1、5->1 这类顺序进入的请求,总是上下反复跑才能接完,而不是去接最上层的再一口气携带下层的。

2、电梯间的自由竞争策略

  • 在第二次作业中引入自由竞争策略,在TaskProducer中先初始化15个任务池,对应5个纵向楼,10个横向楼,初始化5个纵向调度器10个横向调度器线程,初始化题目要求的电梯线程,初始化时绑定对应的调度器、任务池与电梯,最后初始化1个总任务池。
  • 每次向总任务池中投放任务,调度器线程分析并获取所需任务,再投放给电梯任务池,电梯自由竞争获取任务。
  • 加入新电梯时只需在TaskProducer中直接生成电梯线程并绑定对应的任务池即可,调度器不关心是否有新电梯加入。
  • 电梯会根据自身ALS策略标记第一个外部主请求,其余电梯不再考虑该请求。为了防止轮询,任务池为空的条件更改为是否没有状态机本身所需要的任务,而不是直接判断是否队列为空,由于任务池的复用,调度器和电梯均受益避免了轮询。

3、调度器的换乘策略

image

  • 第三次作业中使用的为接近静态的路径生成策略,调度器获取每个乘客的任务前会先判断任务有没有初始化,没有初始化则初始化全部任务,后续不再更改,也就是比乘客在TaskProducer中生成时立刻初始化路径的静态策略要稍微动态一点点。
  • 调度器没有任何变动,因为路径在Path中初始化时确定,调度器能接到则必然存在可达电梯。
  • 电梯依旧自由竞争,因此电梯的横向运动状态代码改变,需要判断可达性,状态机优势体现。
  • 电梯初始化时会在Path中注册,Path中生成路径以路径总长度最短为优先,不计入电梯运行时间的权重,因为电梯的自由竞争策略,即使计入权重也未必被指定电梯获取。
  • 计算总任务数来结束全部线程。电梯负责向主任务池直接投递请求,剩余交给调度器,即使乘客的个人任务全空也会向主任务池投递请求,只是为了能最后触发notifyall结束线程。

五、状态模式与可扩展性

1、状态模式

image

  • 除了服务于多线程交互与安全的生产者消费者模式,本次作业还采取了类似状态机模式的架构。
  • 电梯Elevator类主要存储属性与基本方法,如openDoor()、closeDoor()、peopleOut()、peopleIn()、elevatorSleep()以及属性的get与set,这些为迭代开发时不会受到影响的final部分。
  • 状态接口Status中定义状态机所需要的方法,如move()、checkStop()、admitIn()、checkDirection()等,再定义状态类来实现接口,如MovingUp、MovingDown、Waiting这些纵向状态类与MovingRight、MovingLeft、Stopping这些横向状态类,不同状态之间互不关联。Status中的方法在不同状态中往往需要不同的实现,如果不使用状态机,则其效果应当为大量的if-else判断。因此状态机本质上是为了解耦,大量的if-else会导致迭代时逻辑爆炸,容易出错,而使用状态机仅需要考虑状态转移,转移的过程化简了条件判断。
  • 电梯内部存储一个Status接口的引用,作为当前电梯的状态属性,记录当前状态,每次需要与缓冲区交互时,使用当前状态中的方法,每次状态更新时,只需更新状态的引用。这是实现接口的编程方式,相比于继承,更加灵活,不易出错。
  • 调度器使用状态机一是为了任务池的复用,二是为了横向与纵向调度可能出现的不一样做准备。

2、可扩展性分析

  • 仅分析第三次作业
  • 度量
    对于方法有
    image
    对于类有
    image
  • 相比于U1的作业,本次作业的复杂度降到相当低的水平,主要原因是使用了状态机模式,拆分了原有的条件判断,而复杂的部分如checkStop()、checkDirection()等基本上是出于对调度策略算法的设计。对于迭代的修改部分,第五到第六次作业中基本上只靠添加新的状态机以及状态转移方法就可以完成,第六次到第七次作业中只改变了部分状态类,其余的如Path等必须新加入的类并不影响原有的电梯与调度器,只是影响了Passenger类,可见迭代起来也比较方便,扩展性良好。

六、bug分析

1、在互测前踩过的坑

(1)电梯无效开关门

  • 第一次作业没有使用状态机,在if-else判断的时候考虑不周导致了浪费时间的无效开关门。

(2)自由竞争下的轮询

  • 对于进入wait的while循环判断条件,最开始是任务队列不为空,则不去等待,这明显是有错误的,如果有接不到的人便会导致不进入wait而轮询,如第三次作业中的可达性横向电梯。修改方式为更改判断条件,我是在状态机中完成判断的,只需要修改横向状态机的判断方法即可。

(3)标记位空指针异常

  • 我个人希望自由竞争但是不要共用同一个外部主请求,因此给乘客加了一个String类型标记位,用于不同电梯的识别,不过如果是null直接equals会出现异常。

(4)锁失效

  • 在前面的 notifyall、wait与锁结合带来的问题 的部分提到的,wait唤醒后的危险区资源失效,我的乘客任务Task在这里被初始化,解决方法是给Task中涉及到的方法加锁。

(5)进程结束方式

  • 虽然设置了计数器,但是我没有像上机中的那样让主线程等待到计数器归零去唤醒调度器再结束,而是各个线程自行识别,但是计数器归零后需要一个信号唤醒调度器。最后解决方案是由电梯唤醒调度器,电梯在换乘的时候本身要将乘客set到主任务池,顺便notifyall调度器,那么不管是不是有后续任务都向主任务池投放就好,其余交给调度器,保证调度器可以被唤醒即可。

(6)乘客任务未初始化

  • 我本意上希望乘客任务尽可能晚被初始化,将其放在wait外层的while条件中初始化,但是作为while的判断条件,我没有将乘客列表全遍历一遍,所以还得在后续真正的调度器get前初始化一下。

2、互测中发现的bug

  • 在第三次互测中,我被hack了,原因是横向电梯可达带来的问题。横向电梯初始化的时候不一定所在位置可以停靠,所以我希望它先移动到可停靠的位置,我改变了横向电梯的初始状态为右移,但是我的架构中,右移中的状态机对于没有被标记的左移请求无法携带,而标记只在Stopping状态下进行,导致第一个左移的人永远接不到而跑到超时,所幸只错了一个强测的点(然后成为了a房唯一能被刀中的人)
  • hack别人只在第二次作业成功了一次,但是并不知道咋刀中的,实在不会手搓数据(我大概是缺一个自动测试姬)

七、心得体会

  • 在本次作业之前没有接触过多线程编程,但是经过三周洗礼感觉多线程编程也不是那么难(?)。对线程安全、锁、共享资源以及等待唤醒有了深入的了解,对于设计模式中的生产者消费者模式有了深刻的认识,学会了用状态机来实现接口,对于解耦、优化与迭代开发感觉更加得心应手。
  • 这次的坑几乎全是自己踩自己找到分析,多线程不同人的架构不同面对的问题也往往不同,感觉形式验证的能力加强了。
  • 完成了U1的flag,官方思路确实好用。
  • 不过对于如何进行高强度测试,显然不得不自己写评测机了(下次一定)。
posted @ 2022-04-27 18:01  Maryin-c  阅读(19)  评论(0编辑  收藏  举报