OO第二单元总结

OO第二单元总结

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

1.1锁与同步块中处理语句之间的关系

  • 需要设置同步块和锁的原因是:多个线程访问/操作同一共享对象,需要锁来保证一个线程对该共享对象的访问/操作不会被其他线程打断。

  • 锁与同步块中处理语句之间的关系:锁将它锁住的语句快(被锁住的这一块语句也称为同步块)包装成原子操作,即执行过程中不会被中断。这就保证了前一线程对共享对象的所有访问/操作动作都一定完成了才允许下一个线程进入,即实现了“同步”。

1.2 三次作业中同步块的设置和锁的选择

  • 本单元我的作业中一共有三个地方需要设置同步块和锁:生产者-消费者模式中的共享对象、输出方法、不同线程读/写同一变量。

    • 生产者-消费者模式中,我将共享对象都设置为了阻塞队列,由于阻塞队列是包装好的自带synchronized同步块和wait/notify的线程安全类,因此我并未显式地设置synchronized同步块或锁。

      • 阻塞队列的优点:它是包装好的wait/notify一定正确的、用起来比较简单的线程安全类,不会出现我乱notify导致的唤醒了不该被唤醒的电梯而造成轮询/notify不及时等非常难debug的情况。
      • 阻塞队列的缺点:使用方法非常受限,且除了阻塞队列自己的基本方法以外,要想将它和其他方法结合使用就非常容易出各种各样的线程安全问题(例如遍历阻塞队列只能使用迭代器,而如果此时有别的线程在改变阻塞队列就会导致迭代器报错,隐藏太深本地还没测出来)。
    • 输出方法中,由于不同方法要调用同一个输出线程的对象,而输出线程需要完成“获得时间戳-打印输出信息”两步、但线程并不安全,故可能会出现“A获得时间戳-B获得时间戳-B打印输出信息-A打印输出信息”的情况,造成时间戳不递增。因此,我们需要把调用输出输出对象的方法(此方法包含“获得时间戳-打印输出信息”两步)包装成原子操作,才能保证线程安全。因此,我单独设置了一个Output类,里面含synchronized的print方法(print方法调用输出包)。这样我输出时调用同一个Output的对象同步方法print来输出,即保证了输出一定是排着队的,不会出现时间戳不递增的情况,保证了线程安全。

      • synchronized锁优点:可以锁住一定的语句块而不是锁住一整个方法,提高了执行效率。
      • synchronized锁缺点:①synchronized这种“锁”是进入自动加锁、退出自动释放锁的隐式锁,加锁、解锁的动作都未显式写在代码里,容易导致因为程序员没想清楚而造成死锁的情况。②读锁和写锁不能分开只能是同一把锁,而只读不写的情况还被锁住会降低执行效率。
    • 不同线程读/写同一变量中,我对每个可能被多个线程访问/修改的变量都设置了它自己的ReentrantLock锁,对该变量的所有方法进行了lock和unlock。这样读/写时就能排着队读/写该变量,不会出现“读了正要被写但还未被写的变量值相当于读出了错误值”等线程安全问题;也由于变量间锁是相互独立的,不会出现一个变量正在被访问/修改而另一个变量也被阻塞的情况,提高了效率。

      • ReentrantLock锁优点:①ReentrantLock这种“锁”是手动加锁、手动释放锁的显式锁,加锁、解锁的动作都显式写在代码里,程序员不容易出错,也更容易debug。②读锁和写锁可以设置不同的两把ReentrantLock,提高线程地运行效率。
      • ReentrantLock锁缺点:使用不当会导致一个线程拿着一把锁一直不放。

二、调度器设计

3.1 调度器设计

本单元三次作业中,调度器的作用都是:①从请求队列中获得当前乘客;②获得当前乘客的当前请求,给当前请求分配一个合适的电梯;③将当前乘客放到该电梯的候乘表里。
  • 完成步骤①,调度器需要和输入线程通信,输入线程-调度器本质是生产者-消费者模型,输入线程是生产者,将读到的请求放入阻塞队列PersonRequestWaitQueue(即请求队列);调度器是消费者,从阻塞队列PersonRequestWaitQueue(即请求队列)中取出乘客
  • 完成步骤②,需要先分析出当前乘客的当前请求;再把当前请求扔给一个调度策略方法(在我的作业中是schedulePassenger方法),该方法拿到一个请求,产生该请求应该去到的电梯
    • 获得当前乘客的当前请求:由于第一次、第二次作业不需要换乘,从PersonRequestWaitQueue(即请求队列)中取出的请求就是当前请求,直接把它传给调度策略方法就可以;但第三次作业中乘客可能换乘,从PersonRequestWaitQueue(即请求队列)中取出乘客的请求需要拆成几步,需要先解析出当前请求再把当前请求传给调度策略方法。我采取的方法是每个乘客自己有一个”解析当前请求“的方法,该方法记录此乘客的workingstage并根据workingstage产生下一步要做什么,即产生“当前请求”。调度器只需要调用当前乘客的”解析当前请求“方法,即可知道此乘客现在要做什么,然后根据他的当前请求分配电梯。
    • 调度策略方法:我采取的调度策略是类平均分配:记录当前所有Building类、Floor类电梯的分配到的总乘客数的最大值和每个电梯分配到的总乘客数;找到当前乘客目的地可达电梯中乘客人数小于最大值的第一个电梯;如没有,就max++再找第一个总乘客数<max的的电梯。这样可以达到近似指导书中均衡分配策略的效果。
  • 完成步骤③,调度器需要和每个电梯线程通信,调度器-单个电梯本质是生产者-消费者模型,调度器是生产者,往阻塞队列PassengerTable(即候乘表)中放入乘客;单个电梯是消费者,从阻塞队列PassengerTable(即候乘表)中取出乘客。

3.2 调度器通信

调度器需要和两个线程进行通信,一个是输入线程、一个是电梯线程,都是采用的生产者-消费者模型。具体已在“3.1 调度器设计”的“步骤①”、“步骤③”中写过,不再赘述。

3.3 调度器调度策略

本单元作业我采取的调度策略是类平均分配。具体已在“3.1 调度器设计”的”步骤②“中写过,不再赘述。

3.4 调度器中的设计模式

调度器设计中一共包含了三种设计模式:生产者-消费者模式、master-slave模式、流水线模式。
  • 在调度器和其他线程通信都是采用生产者-消费者模型输入线程-调度器的通信中,调度器是消费者;在调度器-单个电梯的通信中,调度器是生产者
  • 同时,调度器由于要同时管理多个电梯,调度器-多个电梯又是master-slave模型调度器是master,可以”看到”所有slave并给它们分配乘客;多个电梯每个都是slave,它只能看到它的上级“master”、不能看到其他的”slave“,运行结束时向调度器汇报。
  • 同时,由于第三次作业中乘客需要换乘,当前下电梯的乘客可能并未到达目的地,还需放回,因此调度器-单个电梯又是流水线模式中的Controller-Worker调度器是Controller,是流水线中的”传送带“,流水线上每个worker干完了一步就放回传送带,由它送往下一步;电梯是Worker,worker干完了它这一步(即运输完一个乘客的当前请求)后判断一下此乘客是否已经到达最终目的地,如果没有到达就再将它放回传送带上,即放回调度器的请求队列。

三、三次作业架构设计的可扩展性

3.1UML类图

3.1.1第一次作业
  • 橙粉色方块是MainClass;蓝色方块表示容器(即共享对象);粉紫色方块表示Runnable接口的实现(即线程);淡黄色方块表示容器或线程运行需要用到的附属类。
  • 架构:
    • ①MainClass:new出所有容器和线程对象,并把容器扔给需要它的线程。
    • ②容器(即共享对象):作为生产者-消费者模式中的缓冲区,生产者往里放产品、消费者从中拿产品,实现线程间的交互。
      • RequestWaitQueue:InputThread-Scheduler的缓冲区,Request是缓冲区内产品。InputThread是生产者,输入读取到Request并放入缓冲区队列;Scheduler是消费者,从缓冲区队列中拿到Request并完成电梯调度。
      • PassengerTable:候乘表类,Scheduler-Elevator的缓冲区,乘客(PersonRequest)是缓冲区内的铲平。Scheduler是生产者,将乘客放入相应电梯的候乘表里;Elevator是消费者,从候乘表里拿到乘客并将其运送到目的地。
    • ③Runnable接口的实现(即线程):分别运行。
      • InputThread:从输入读取到Request并放入缓冲区队列。
      • Scheduler:从缓冲区队列中拿到Request并放入相应电梯的候乘表里。
      • Elevator:从候乘表里拿到乘客并将其运送到目的地。其中,需要ElevatorState类来记录电梯当前状态(运行方向、所在楼层、所在楼座、乘客名单、乘客人数等),需要Output类来输出;如果需要换乘,则乘客本次运输完成后再次放回Scheduler的乘客等待队列中(即放回流水线传送带)进行下一次运输,直至乘客达到目的地。

3.1.2第二次作业
  • 各颜色方块的定义不变。
  • 增量开发:
    • ①增加了Watcher线程负责动态增加电梯。
    • ②将Elevator类分为ElevatorBuilding和ElevatorFloor(均继承自Elevator类),由于横向电梯和纵向电梯的运行逻辑一样,所以电梯运行逻辑部分还是放在Elevator类不变,只是将下一层的计算、距离的计算等策略类中不同的地方单独分到每类的方法中去。

3.1.3第三次作业
  • 各颜色方块的定义不变。
  • 增量开发:
    • ①原本用PersonRequest类代表乘客,现在将PersonRequest类单独制成了Passenger类代表乘客。类中除了记录乘客自己的PersonRequest以外,还添加方法分析他自己的换乘策略、生成下一步乘电梯的PersonRequest。同时,为了配合此类的改变,RequestWaitQueue原来可以同时处理Request(PersonRequest和ElevatorRequest同时继承自Request),现在必须拆分为PersonRequestWaitQueue和ElevatorRequestWaitQueue两个队列(Passenger和ElevatorRequest不能继承自同一个父类)。

3.2 UML协作图

  • 三次作业中我的线程间协作关系并没有大的变化,均如下图所示。

四、Bug分析

  • 第一次作业中,我的bug主要原因是策略问题导致的TLE

    • 问题①:将策略类和电梯运行拆开了,策略类没有考虑到开关门窗口期电梯sleep依然会有乘客来,导致电梯多跑了好几趟。(虽然此问题很简单改起来也简单,但非常严重,一个问题导致强测几乎所有的点无一幸免,教训深刻)。

    • 问题②:第一次作业我的设计是每层一张候乘表,我想当然地认为这样捎带时检索效率高所以程序性能会更好,但实际上维护两张表的代价远远大于检索提高的效率,程序的性能实际下降了;而且由于我的设计中维护两张表需要用一个主请求队列,导致先来的请求不能被最先接走、而再来接它一趟的代价可能奇高。

      至此我终于明白了一个设计哲理:最简单的就是最好的!!!

  • 第二次作业中,我没有出现bug。(主要是吸取了第一次的深刻教训好好做了测试,并且遵循新鲜感悟的设计哲理重构了一部分代码)。

  • 第三次作业中,我的bug主要原因是线程安全问题:在迭代器遍历阻塞队列的时候另一个线程修改了该阻塞队列。

  • 总结:本单元的作业,我的问题一方面是策略问题,一方面是线程安全问题。

    • 策略问题由于能够运行出正确结果、只是时间比较长,导致我没有发现。这个问题本来只要我用助教给的checktime程序多造几组数据就能测出来,但我当时只检查代码的逻辑觉得自己的ALS策略没问题,基本上只测了正确性、中测过了就没管了,但事实上运行起来还要考虑开始生成下一条策略的时刻并不是正确的产生策略的时刻会导致策略是错的。所以,判断线程的正确性不仅要考虑代码逻辑正确,还要考虑生成策略的时间的正确性。这是我以前从未考虑过的一个新维度
    • 线程安全问题产生的原因是:操作线程安全容器本身确实线程安全,但它和迭代器一起用的时候由于迭代器不安全会导致修改线程安全容器时报错。这个错误很隐蔽,且我本地无法复现,所以真的很难测出来。应该第一遍写的时候就注意把没搞清楚的用法先记下来,写完了再仔细想一想有没有bug。

五、Hack策略

  • 本次作业我主要是用我测试自己代码时造的强数据去hack别人。效果竟然还可以(指hack到人了),看来大家的错误有共通之处。

  • 第一次作业看群里讨论明找到了输出线程不安全的问题,后面没再发现别人的线程安全问题。

  • 本单元与第一单元的重要差别在于多线程安全很容易出问题,用数据轰炸有很大可能找到别人的bug(而第一单元数据轰炸没有这么大用,尤其是对A房同学)。但由于我不会写评测机,所以hack策略没啥变化。

六、心得体会

  • 线程安全方面,我写作业的时候没有遇到很大的困难,因为第一次作业就用了阻塞队列,所以后面没出过轮询、死锁或者线程安全等比较难debug的问题。

  • 层次化设计方面,我遇到的最大的困难可能是第一次作业的时候为了维护两张表debug的时候绞尽脑汁(线程安全停止、找到主请求等方方面面都有不少问题),结果居然发现性能更差了。后面完全悟出了“最简单的就是最好的”的哲理,没有再进行费力不讨好的复杂设计。另外,多做测试仍然是王道,即使不会自己写评测机,自己根据自己代码造一些简单的数据也能够把大部分设计错误测出来,所以不要摆烂不测。

posted @ 2022-04-29 18:28  Siazxyyy  阅读(19)  评论(0编辑  收藏  举报