面写对象第二单元总结

于恩泽-22371494 学生 2024-04-19 15:13:39

第二单元

目录

  • 第二单元
  • 架构总览
  • 迭代分析
  • 性能平衡与可扩展性分析
  • 同步块设置和锁的选择
  • 主调度器设计
  • 架构批判
  • BUG分析
  • 互测BUG
  • 测试方法
  • 与第一单元区别
  • 心得体会
  • 致谢

谈到我的第二单元的坎坷命运,我想它就像KPZ 70一样,为追求性能而生,最后留下诸多遗憾黯然离场。

架构总览

首先本次作业按功能划分主要有两部分,一部分是分配,一部分是纵向运行。我主要采用了影子+LOOK的策略,最终实现了hw5-97.1637/hw6-98.7782/hw7-99.4716的性能。

img

我的架构分为线程架构和数据结构。

  • 线程架构主要是输入线程(InputThread)、分配线程(MasterScheduler)、纵向运行控制线程(SlaveThread)。

    • 输入线程将所有请求放到MultiRequestQueue中由分配线程获取
    • 分配线程会使用影子电梯策略计算得出当前的局部最优解,通过RequesetQueue发给纵向控制线程。
    • 纵向运行线程通过纵向控制器(SlaveScheduler)来控制电梯运行、上人等行为。
    • 具体见下方协作设计图

img

  • 数据结构

    • 请求存储主要是一个总队列、六个子队列,总队列中存放多种请求,包括reset,子队列只有人的请求。
    • 电梯本身作为纵向控制器(SlaveScheduler)的一个组成部分,纵向控制器有各种方法来控制队列与电梯的交互
    • 纵向控制器总揽各种纵向运行,甚至包括影子电梯的模拟。每次进行模拟时就会在控制器里复制出一份当前状态并且锁定当前状态进行模拟运行。
  • 特殊设计

    hw7中做了一点特殊的架构

    • 将两部电梯真正塞进一个电梯井:用一个纵向控制器控制两部电梯,纵向控制器也变成了共享对象,被AB电梯的运行线程共享。

    • 也即两部电梯共用一个小的请求队列,但是每个电梯只能看到属于自己管辖范围的那一部分请求,所以我的isEmpty都换成了isPartEmpty,都做了屏蔽处理。

    • 这样的架构有个好处是做影子电梯特别方便,不会存在一部电梯模拟不了的情况。

    • 做到了题目要求的“一个电梯井中的两部电梯”,符合现实。

迭代分析

两次迭代中主要的不变集中在纵向运行策略,LOOK策略基本不需要修改,除了第三次需要主动移出换乘楼层。

这也验证了模块化设计的优越性,纵向一旦设计好就再也不需要动。

第二次作业主要增加了分配策略和reset,reset没有设计好导致从此处开始锁出现了混乱。

第三次作业主要增加了换乘,在这次进行了较大幅度的改动,将纵向运行策略整体拿出设计成了一个简洁的基本只有run的类,方便起新电梯,或许从一开始就应该如此,此时才拿出有点为时已晚,又造成了一点混乱。针对双轿厢电梯修改了一些方法,前面已经提过,不再赘述。

性能平衡与可扩展性分析

这次的性能设计并没有造成很多架构的混乱

  • 影子电梯设计成了单独的类和独立性很强的方法调用。添加的功能也没有太影响性能设计,因为所谓"影子电梯",基本只需要复制粘贴真电梯的方法即可。

  • 还实现了“量子电梯”,也就是所谓弹射起步+合理睡眠,只需要在时间限制处更新一下记录的lastTime即可保证sleep的正确性。

可扩展性不错,下面来几次虚空迭代:

  • 假如出现新的电梯种类,比如特快电梯等,只需要继承原来的电梯,并且让影子电梯也做一下继承即可。
  • 假如出现直达电梯,那么意味着影子电梯很可能无法模拟完整过程了。可以通过一些像dfs之类的方法进行拆分,然后进行联合模拟;也可以直接做近似估计,比如送不到终点直接加一个大概的值。
  • 假如要增减电梯,那太好了,因为架构中设计的是reset完成时通过方法启动新电梯,只需要复用这里的代码即可。

当然,这一切都建立在能够解决锁的基础上,可能会很棘手,最好还是remake下锁的设计。

同步块设置和锁的选择

首先,对于锁的选择,全部选择synchronized语句带来的锁,不使用其它锁,主要是因为synchronized需要手动控制的部分非常少。而且很贴心的实现了偏锁等功能,简直是懒人福音。

其次,同步块设置上,设计了两个线程安全的类,一个是Queue,一个是Elevator,对这两个类的任何单个方法调用都是安全的。

  • 在外部加很多锁主要是因为这两个线程安全的类操作还是太原子了,锁的区域不足,需要合并,就像下面那个代码块所展示的。

  • 另一个原因是设计缺陷:很多添加请求都通过调度器和纵向控制器的类进行,其中需要修改这两个玩意的属性,于是就需要锁,属于是设计缺陷,如果设计成生产者消费者或许会好很多。

  • 如果很不幸地设计成了这样,有一种方法可以尽可能保持逻辑清晰:加锁完全按照从外到内,从大到小的逻辑:比如在从纵向调度器返回请求时:

synchronized (masterScheduler) {
    synchronized (slaveScheduler) {
        popArr.addAll(slaveQueue.popAll());
        masterScheduler.addAll(popArr);
        //不能被断,因为pop之后已经暂时不能再被分配请求了,否则会有乘客走丢
    }
}

主调度器设计

hw5 无需调度,直接指定了电梯

后续采用影子电梯进行分配。设计了Ghost类,里面几乎和纵向调度器一样,可以进行影子模拟,在使用构造器构造时进行复制,在调用simulate方法时进行模拟。

synchronized (this) {
            synchronized (slaveQueue) {
                synchronized (elevatorA) {
                    synchronized (elevatorB) {
                        //连续数个锁定保证了经过模拟后,电梯还是那个状态的电梯
                        Ghost ghost = new Ghost(elevatorA, elevatorB,
                                slaveQueue, fromFloor, toFloor, occupied, isResetting);
                        return ghost.simulation();
                    }
                }
            }
        }

交互模式

  • 主调度器接到请求就直接调用纵向调度器的模拟方法进行模拟。
  • 模拟完成后选出最佳直接放到作为托盘Queue中。
  • 影子电梯保证了即使是在reset中也能正常计算。
  • 消息缓冲队列保证了reset结束时将接到的请求全部打出。

架构批判

这是在深思熟虑后追加的一块

hw6开始,锁就变得极为混乱,时常出现需要先锁调度器再锁队列的诡异情况,仿佛身首异处,以至于我想优雅但是无力回天。

经过长达数周的思考,我发现原因主要是在于有些东西,放错位置了,或者更具体的说,有些属性,应该直接放到共享的对象里。比如我把working(记录当前这个电梯是否处于工作状态)这个属性放到了主调度器里,导致电梯甚至需要拿到主调度器的锁来修改它。如果把它放到一个由单例模式生产的“托盘”里,就能规避掉这一切。

错位就是混乱的主要来源,或许大的架构是合理的,但是实施起来,几个属性的错位就可能造成巨大的混乱。

BUG分析

互测和强测均未被hack到(最金身的一集)。
在写和自测的过程中主要遇见了以下bug

  • 一种是偶发死锁,主要是一些设计缺陷导致不得不加很多锁导致发生了互锁,针对这种bug,有两种方法可以快速消除

    • 一种是用Jconsole直接检测,但是你必须复现出来才能用这个,可以写一个脚本让他一直跑,然后卡死的时候拿出来Jconsole监视这个进程。

    • 另一种是基于瞪眼法的画图法。因为互锁仅仅发生在有相反顺序的嵌套锁的时候,所以我们只需要画两条线,一条线代表一个线程,画出每条线上锁的关系,找到相反的锁,就能解决问题。

  • 另一种是因为锁加的不够导致应该连续的两个操作被中间打断造成奇怪的问题。认真分析不应该被打断的区域即可,比如:

synchronized (slaveQueue) {
    //这里之所以要锁起来,就是为了防止在checkEnd之后,wait之前被setEnd,这样就会永远卡死在wait。
            if (checkEnd(elevatorNum)) {
                return false;
            }
            if (slaveQueue.isPartEmpty(getMinFloor(elevatorNum),
                    getMaxFloor(elevatorNum)) && checkElevator(elevatorNum)) {
                    slaveQueue.wait();
            }
        }
  • 此外,还有一个bug是影子电梯偶发死循环(概率很低)。实在debug找不到问题,应该是拷贝的某一步不安全造成的,最后直接:
circleTime++;
        if (circleTime >= 200) {
            return (int) ((size * 4000) + (System.currentTimeMillis() % 2000));
        }
//影子电梯的模拟循环超过200次即视为发生死循环,直接根据当前size估算时间并且加个随机量
  • 测试方法:hw5,6均使用合作的评测机(LTC同学是一直在C的),使用评测机可以通过大量测试发现bug。hw7没空做了就用的hw6的评测机,居然神奇地de完了bug。

互测BUG

测试方法

用评测机测试,评测机都跑冒烟了。

  • 主要是线程不安全的bug,很多都无法复现,很恼人,建议官方给每个提交的数据跑10次。
  • 发现有同学hw6不明原因超载,而且大概率复现,可能是线程不安全导致电梯上人混乱。
  • 发现有同学在5个电梯都reset时会将请求全给一个电梯,或通过personId直接模6选择电梯,针对这样的情况,可以采用“围师必阙法”加特殊personId直接乱杀, hw6直接一刀四杀。
  • 基本只有hw6 hack到了不少人,剩下两次运气都不太好,分到了强的离奇的房,体验不好。

与第一单元区别

与第一单元的必然可复现不同,由于线程不安全的偶发性,这单元几乎只能通过大量的测试才能发现bug(评测机最唯一真神的一集)。

但是在debug的时候又复现不出来,于是想到了可以直接print#开头的调试信息,让评测机直接忽略#开头内容,来进行顺利的复现debug

心得体会

这是目前为止见过的最难的作业。

  • 在线程安全上
    多线程锁的混乱,bug的偶发与乱打补丁,都导致最后的代码变成了屎山。采取了一些策略梳理了一下锁,但是也只能说是略微改观。虽然没有bug,但是我再也不想看一眼,太混乱了。
    这是一次深刻的教训,设计的缺陷会导致代码不断打补丁最后变成屎山。
    在电梯部分懒得设计生产者消费者最后导致几乎每个对象都变成了共享对象。然后顺着这样的混乱延续里整整三次迭代。
    如果在设计时多用成熟设计模式,就能很好的降低线程安全的混乱程度,变成一次真正完美的作业,可惜没有如果。

  • 在层次化设计上
    我认为大体逻辑其实是很不错的,一个电梯井装两个电梯的架构我认为很符合现实,将分配器,纵向控制器,电梯本身分层还是比较合理的,功能也挺分离的,是个不错的架构,但是因为没有设计好生产者消费者导致层次化的架构被浪费了。

  • 总结:

    • 这次作业有得有失,得是性能很好也没有bug,失是锁混乱最后变成了屎山。
    • 几度想要放弃性能,放弃影子电梯,直接随机一个得了,但是总觉得心有不甘,最后还是坚持下来了。
    • ban掉自由竞争以及最后的双轿厢我认为很有新意,虽然挑战很大,但比之前狠狠借鉴前人要有意思的多。

这次作业挑战很大,但回过头来,也很有意思。

我并不追求完美,但我也不想再创造屎山。

致谢

感谢LTC同学在评测机搭建中付出的非常辛勤的努力

感谢课程组作出的创新

...全文
61 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧