OO 第二单元总结

OO 第二单元总结

一、概述

本单元进行了一个电梯接送乘客、模拟电梯重置和“分裂”的场景,涉及多线程、线程安全、共享资源及其锁的使用,相比于上个单元,在架构和实现方式、调试难度、测试和bug复现、性能优化上都有显著的不同,或者说,都变得更开放、更自由、更难了。

这个单元我学的有点一塌糊涂,主要是赶上了冯如杯,没有太多精力和时间,不敢重构,测试也做的不够完全。多线程和锁的问题也让我从hw5觉得搞懂了,到hw6又似懂非懂,到hw7才彻底明确地理解。这一切使我这个单元的作业效果很烂,清明节第一天,好不容易把hw6的影子电梯调得不RE,结果强测性能很低(应该是影子电梯哪写挂了),hw7通宵debug,好不容易写完,也没来得及做足够的测试,甚至最后脑子短路修了一个bug push了没在OO平台提交,喜提强测大挂,互测爆炸。

回顾一下这个单元的三次作业,可以发现是逐渐深入,愈发复杂的趋势。

hw5是在请求中指定了电梯分配,主要为了让同学们对线程的基本操作有所了解,并解决电梯对于一个队列应当如何接送乘客的问题,为这个单元后面多电梯调度打好框架。

hw6中取消了请求中对电梯分配的指定,并新增了基础的重置操作,主要解决多电梯调度问题,以及一些常见的bug

hw7中又新增了一个双轿厢电梯重置请求,使每一个“电梯槽”里面也应该有两个线程,并且需要解决在换乘层上下两个电梯不会冲突的问题。

二、作业架构及线程交互

从hw5到hw7,我的架构演进主要以做加法为主,下面从类图和时序图两个维度进行依次介绍。

HW5 —— 拓展性强的架构

在hw5大框架的时候,我浏览了往届的后续题目,增加了本次作业本来无需存在的dispatcher,以及Elevator接口以快速拓展新类型的电梯(虽然这个接口最后基本没有用上),与此类似的还有Strategy接口,但因为从来没用就在此省略了。

1. 类图

在这里插入图片描述

上图展示了HW5作业的类图,架构参考了实验代码的架构,除了省略的Main线程外,共有3类线程:InputHdl、Dispatcher、NormElevator;需要被多个线程共享的对象有RequestTable一种,分别是1个主表和6个候乘表,另外还有为实现高内聚低耦合,单独列出的Strategy类,主要根据电梯状态和候乘表内容给出电梯下一步的行为,其运行策略选择主流的LOOK算法。

2. 时序图

其实在时序图里看出,我的线程创建是有一点扭曲的,全部在Main里面创建好虽然比较方便,但是考虑到后续交互并不是Main和其他线程或对象,而是这些线程和对象之间,因此需要在main里面调用函数“绑定”这些类或对象,造成了臃肿和潜在的不安全可能,而且为了正确性,InputHdl必须在后面都start好了以后再start,这个隐患留到了hw7才被发现。所以其实比较舒服的方式是在一级线程start以后创建下一级并start。

在这里插入图片描述

HW6 ——似是而非的影子电梯

hw6中取消了请求中对特定电梯的指定,同时新增了RESET请求,可以修改电梯的各项属性(速度、容量),面对这些改变,需要完善的主要就是hw5预留的dispatcher,以及考虑如何实现RESET请求。

0. 改动说明

关于分配策略,可谓五花八门,各有优劣。最简单的如随机、均摊,稍微复杂的如影子电梯、评价函数,以及往届很好用但今年被RECEIVE机制ban了的自由竞争,如果实现合理,这些理论上都不会出现正确性问题,主要是性能分的差异。

一开始觉得random虽然好写但是应该比较慢,评价函数涉及到复杂的调参,很考验本地数据以及分布,还有互测被人精心卡掉的可能,然后影子电梯好像也挺好写,于是就选择了影子电梯——通过深克隆电梯对象,模拟电梯跑到结束的过程,得到用时,通过六个用时谁最短判断把请求分给谁,属于一种局部最优方法,但是最基础的影子电梯没考虑到耗电量等其他指标,只有时间。

而RESET的实现比较简单,虽然可以走两步再reset,但是考虑到线程之间时间差以及保留容错,我选择直接通过InputHdl传给ElevTable(很多人通过dispatcher就会出现reset被阻塞导致重置不及时的问题),关于电梯里的人以及已经分配好的请求,就都直接重新放进MajorTable,重新分配即可,另外,因为可能把请求扔回MajorTable,所以终止条件需要改变:记录reset数量,以及完成了的reset数量,一旦这两个相等,才能真正结束。

1. 类图

在这里插入图片描述

红色的线标出了相比HW5新增的关系,对应上一小节说到的影子电梯reset的实现.

2. 时序图

在这里插入图片描述

可见,增加的主要内容就是一个VirElevator以及Reset相关操作。

HW7 —— 因小失大的偷懒设计

0. 改动说明

本次作业只是增加了一个新类型的Reset:DoubleCarReset,把一个电梯按照换乘层分裂为A和B两个,带来的影响主要有:

  1. 涉及换乘问题:既可以是1-A和1-B的协作,也可以是1-A和2-B的协作
  2. 电梯双线程交互及防碰撞问题:什么时候电梯可以停?里面没人且ElevTable已经End吗?显然可能要接受来自另一半电梯的“长途需换乘乘客”,如果考虑了不同梯的换乘,则需要考虑可能最多其他11部电梯。

这次我偷了个懒,在reset的时候直接把原本的电梯改成下边那半,再另外创建上面一半即可,新增一个Trans类让两个电梯共享,防止同时进入换乘层(后来发现其实做的和OS的自旋锁是一个原理…),但这导致了两个电梯线程地位上不对等,使得后面会出现种种问题。

另外,由于上次影子电梯效果不好,这次双轿厢后更为复杂,果断放弃,投奔random,因此重置以后还把原来电梯里的人的请求放回了本电梯对应的Table(在hw6的random基础优化)

1. 类图

在这里插入图片描述

图中新增的ElevTable1和NormElevator1其实并不是新类,只是方便表达才如此安排。这种设计的好处就是只需要额外创建6个线程,坏处是非常不优雅,而且直接导致了后来我的多个bug,以及很长的debug时长。

2. 时序图

在这里插入图片描述

此处相比类图,能够比较直观地看出Trans类是如何保证换乘层只有一个电梯的,以及双轿厢如何停止。

三、调度器

1. 调度器线程

dispatcher从 MajorTable拿到request,再通过调度算法分给电梯所属的候乘表,其主要交互的线程是InputHdl和Elevator,都是通过生产者消费者模型,把东西放到托盘(Table)上。在hw6中因为使用了影子电梯算法,所以和他打交道的还多出来个他创建的VirElevator线程。

2. 调度器算法

在上面的作业架构里,其实阐述了我的dispatcher算法的“演进”,或者说“退化”。

hw5不涉及调度算法,正常按照要求分配

hw6使用影子电梯,但是写的有问题,导致性能分低(时间也没快起来,耗电量可能还会异常的大)

hw7使用random,本来还能看看性能如何,结果出bug了直接挂了

后来对比了hw6两个舍友的性能,都是电梯内使用LOOK,一个用Random分配,一个用影子电梯调度(应该是写对了的影子电梯),事实证明二者分数非常接近,都在97左右,但是影子电梯时间确实短——这可能意味着影子电梯为了时间短让电梯耗电量比较大,导致这部分性能分丢了,当然这只是个人推测,而random经过基础优化(reset的话让乘客下去再上来,别重新分配)性能其实也并不算差。

四、同步块与互斥锁

1. 锁与同步块

锁本身是为了保证多线程情况下,多个线程同时读/写一个对象导致出现经典的错误,两次实验分别使用的synchronized和读写锁都属于解决这一问题的好方法,保证同时只有一个线程能够修改/读取某值,其他线程需要等待锁被释放并获取到锁后,才能进入临界区。

显然,RequestTable的所有方法都有可能导致多线程情况下数据不同步问题(不是读就是修改),所以使用synchronized修饰其每一个方法(也可以使用读写锁,相比synchronized可以允许多个线程同时读,但是好像没什么必要,在生产者消费者模型也很少有两个消费者同时读取一个托盘(?))

以及在dispatcher的run方法中,要从MajorTable拿走后删除,如果像我一开始写先get再delete,则必须将其放在一个synchronized(MajorTable){ //...}的同步块里,否则可能中间又放进来一个,但是直接被delete了

相似的还有Elevator在获取下一步以及执行的时候:判断ElevTable是否为empty——确实empty——ElevTable.wait()——ElevTable.notifyAll()以后再重新判断。本来这样是合理的,但是假如在isEmpty()返回false之后,而ElevTable.wait()之前ElevTable被加入了一个请求(真的就会有这么巧!),那么这个电梯就会沉睡过去——这并不是死锁,但是却导致线程一直wait,无法停止。我写的时候一开始并没有意识到,但是后来仔细一想,这个问题和上面的一模一样,所以应该在getNext和后面的switch执行之间设立synchronized同步块!

2. 杜绝滥用notifyAll()

在本单元第一次实验中,几乎所有synchronized的地方都加了notifyAll,于是我后来也没多想,在任何synchronized的结尾都加了notifyAll——这本身不会导致正确性问题,但是实际上有些地方确实没必要浪费这额外的CPU消耗。

notifyAll主要是配合wait,用来唤醒wait当前对象的线程,而什么时候会wait呢,在我们这个场景下基本就是还未结束,但是现在又empty,调度器/电梯等着托盘上的东西,避免轮询或者忙等待才wait,所以假如我调用了isEmpty查询是否为空,这并不会带来什么改变,却notifyAll,让等待改变的线程都再获取一次,发现还是得wait,属实没有必要。

我觉得一开始我的问题在于没有分清wait-notify和synchronized与锁的释放:synchronized块结束后,会自动释放锁,想要拿到锁的线程会自动得到锁,这和notify无关;而wait也会释放锁,但是必须由notify唤醒,且如果没有notify唤醒导致一直wait,其表现和死锁一样都是无法结束,得查看线程状态才能明白。

五、令行禁止——防止撞车

在介绍hw7架构的时候,我有简单提到通过Trans类在两个轿厢之间共享,达到类似“自旋锁”的效果(但不是忙等待,因为我有wait-notify),只有一个走了释放了锁,另一个才能进来。当然Trans的两个方法都是使用了synchronized保证其原子性和同时单一访问性。

public synchronized void tryArrive() {
    while (has) {
        try {
            wait();
            notifyAll();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    has = true;
    notifyAll();
}

public synchronized void leave() {
    has = false;
    notifyAll();
}

六、Bug汇总与调试心得

Bug collections

光是上面的总结就已经涉及了很多bug,在此我做一个汇总(包含了强测前后的各种bug):

  1. 在main里先让InputHdl跑起来,导致后面还没bind/start就被扔了请求,NullPointerException
  2. 虚拟电梯线程不安全:深克隆的时候没有锁住电梯,导致克隆后其人数可能和内部请求数不同,电梯一直跑或越界,NullPointerException
  3. 电梯获取策略和执行wait之间没有用同步块绑定,导致中间出现改动后线程一睡不醒,RTLE
  4. 使用“弹射起步”小优化,但是OUTPUT放到释放Trans锁之后,导致从输出来看两个电梯碰撞(这也是我脑子短路改完忘了提交的那个bug。。。)。

Debug 心得

多线程debug非常的困难,其主要难点在于:

首先,不好调试和复现。断点有可能由多个线程触发,而这种断点本身也会打乱线程之间的正常运行时序(如果没有做相关约束的话),导致问题无法复现,print输出也是类似,有时候我产生RTLE(其实就是bug collections的第三点)不加任何print就可以99%复现,但是加的越多,就越难以复现,我觉得可能是printf延迟了某个线程,让我的 获取策略-执行wait 之间没能插入修改。

其次,比较抽象,难以捕捉。在某些情况下失去了print和断点调试后,就必须手动模拟这为什么发生,这如何发生的,以及定位问题的时候,我还被动地学会了使用jconsole、jstack和visualvm这些东西(虽然IDEA里面可能都有类似功能)。

最后是我hw7自己实现的问题,因为使用了random,所以bug复现很吃概率…这也…没什么办法。

七、感想与收获

这个单元收获很多,多线程编程确实是从0到1的过程,对于几种锁和同步块、wait-notify机制、生产者消费者模型以及多线程调试上面都可谓是,错的越多,教训越深,学到的就越多。

但是这个单元恰好赶上冯如杯、蓝桥杯以及我其他安排的高峰重叠期,对于OO贯彻探究到底的精神,也没有像我一位舍友敢于重构的精神,导致最后只能是一团烂。

我觉得其实可能像notifyAll这个地方有不少同学都没能完全理解,建议课程组其实可以稍微多一点说明(或者我烂我没看到…)

  • 17
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值