2024 BUAA OO UNIT 2

林舒雅-22371132 学生 2024-04-19 12:05:41

HW8-BLOG

一个动荡的多线程般不稳定的阶段
You're on your own, kid
You always have been

目录

  • HW8-BLOG
  • 一、同步块和锁
  • 1.1.共享资源的读写
  • 1.2.原子操作和等待
  • 二、整体设计分析
  • 2.1.自由竞争-HW5的简单多线程电梯
  • 2.1.1.生产者-消费者模型
  • 2.2.2.调度算法
  • 2.2.3.策略类的设置
  • 2.2.影子电梯-HW6/7的复杂多线程电梯
  • 2.2.1.电梯的调度策略 | Strategy
  • 2.2.1.1.重置/换乘的特殊处理
  • 2.2.1.2.双轿厢电梯的互斥处理
  • 2.2.2.调度器 | Scheduler
  • 2.2.2.1.影子电梯:策略矩阵最优
  • 2.2.2.2.缓冲队列:破解围城必阙
  • 2.2.2.3.模拟混合估计:换乘何去何从
  • 2.2.3.稳定和变化
  • 三、双轿厢电梯
  • 3.1.第二类重置:创建伊始
  • 3.2.正常参与请求分配
  • 3.3.互斥处理
  • 四、电梯的更多优化
  • 4.1.量子电梯
  • 4.2.弹射起步
  • 五、Bug分析和解决方法
  • 5.1.超时
  • 5.2.空指针
  • 六、心得体会
  • 6.1.线程安全
  • 6.2.层次化设计
  • 6.3.总结
  • 七、致谢

一、同步块和锁

1.1.共享资源的读写

对方法上同步锁,需要注意的是在上同步锁的方法中调用另一个对象的同步锁方法,可能出现死锁,设计时需要仔细排查,避免出现锁的十字交叉。

1.2.原子操作和等待

在测试过程中,我总能发现超时的现象,而且频率并不太高,难以发现bug所在。在多线程评测的环境下,跑600组数据出3个超时。最初以为是死锁的问题,但在大批量的跑单一数据并打印信息后,发现是原子操作和等待的问题。

先给出结论:是否应等待的判断和wait()必须形成原子操作。

调度器和电梯都是实现Runnable接口的,run()方法并不适合上锁,这就意味着run()方法是线程不安全的。而我的wait操作都发生在run()方法中。于是就出现了电梯或者调度器得到了等待的建议,但还未真正wait()时,requestTable就发生了新的变化。而电梯或者调度器无法知道,自顾自地等待了。如果此后恰好没有任何事件能够唤醒线程,就超时了。这样超时的现象在缓冲队列倒入刚启动的双轿厢电梯时概率更高一些,特别是缓冲队列中只有一个请求时。

img

二、整体设计分析

2.1.自由竞争-HW5的简单多线程电梯

顶层调度主要采用自由竞争,和之后的作业相差甚远。设计架构简单,除顶层调度外的部分是HW6/7的底层,因此只在此处做简要介绍。

2.1.1.生产者-消费者模型

img

2.2.2.调度算法

采用LOOK算法

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。

  • 到达某楼层时,

    首先判断是否需要开门

    • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
    • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。
  • 接下来,进一步判断电梯里是否有人

    。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯)——

    • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。

    • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。

    • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)。

    • 如果请求队列为空,且输入线程已经结束,则电梯线程结束。

2.2.3.策略类的设置

电梯类和策略类分离。电梯类只负责移动、开关门和反向。通过查询策略类发出的建议进行动作。

2.2.影子电梯-HW6/7的复杂多线程电梯

HW6/7采用了两级调度器进行调度:一级调度器即中央调度器,Scheduler:使用影子电梯+缓冲队列模拟局部最优解,将请求分配给最佳电梯;二级调度器即电梯策略类,Strategy,使用LOOK算法进行实时调度,直接控制电梯状态转移。

保持高内聚/低耦合,应该时时刻刻明白方法/类的职责。不断记录,不断反思。如果思维清晰,就能不断地向高内聚/低耦合靠拢。UML类图如下:

img

时序图如下:

img

2.2.1.电梯的调度策略 | Strategy

2.2.1.1.重置/换乘的特殊处理

延用HW5的LOOK算法,针对新要求做了几处特殊处理:

  • 最高优先级,判断该requestTable是否收到Reset请求——
    • 是:elevator进行reset,
  • 次优先,判断本楼层是否有乘客需要换乘——
    • 是:elevator进行transfer

未完成的请求/换乘乘客扔回Scheduler(一级调度器)进行重新模拟分配。

2.2.1.2.双轿厢电梯的互斥处理

见3.3.互斥处理,专门介绍双轿厢电梯。

2.2.2.调度器 | Scheduler

2.2.2.1.影子电梯:策略矩阵最优

影子电梯提要:作为电梯的影子,力求快速模拟局部最优解,只需将电梯类中的sleep方法改为time += plusTime

设当前电梯数量为n,那么可知一共有n种模拟方案:

  • 1号电梯新增当前请求,其他电梯维持现状
  • 2号电梯新增当前请求,其他电梯维持现状
  • ......
  • n号电梯新增当前请求,其他电梯维持现状

因此我们需要计算如下的策略用时矩阵:

  • 计算各策略中每部电梯用时,取最长为该策略用时

  • 选取用时最短的策略进行请求分配

    如下图,假设n=6时,有策略矩阵,应选择方案1。

img

2.2.2.2.缓冲队列:破解围城必阙

围城必阙:把敌人包围住的时候要留一个缺口。五部电梯重置,同时派发大量请求->一梯过载,n梯围观

除却不能接送当前请求的部分双轿厢电梯(指乘客的起点不在电梯运送区间),将剩余所有电梯纳入模拟范围。

只需在receive和分表添加请求前加上特判,如果最佳电梯正在被重置,则将请求加入缓冲队列。待重置结束,从缓冲队列中取出请求放入电梯的requestTable。

小Tip:从根源上破解围城必阙,其实是每次模拟的时候,把缓冲队列倒入影子电梯

2.2.2.3.模拟混合估计:换乘何去何从

对无需换乘的请求,请求无关其他电梯,时间在影子内就能计算完成,故模拟都能比较精准地进行。

但若想精准模拟换乘,这涉及多影子电梯之间的交互,我能想到的只有开线程运营影子。显然这弊大于利。

于是我对换乘乘客耗费的时间进行了估计,记录额外的最大总move时间,即乘客中目的地与换乘楼层的最大差值,加上必然发生的开关门耗时,同时取了一个适中的数字作为其他电梯移动接盘的用时。

2.2.3.稳定和变化

在三次作业中,有些部分的设计是一以贯之的:

  • InputHandler(课程组提供)
  • 电梯的状态机身份
  • 策略类的核心是LOOK算法
  • RequestTable的托盘身份
  • OutputHandler:单例模式,对方法上同步锁,保证线程安全

而不断变化的是调度器的设计、线程终止条件的安排,以及重置和换乘时电梯的行为。

三、双轿厢电梯

3.1.第二类重置:创建伊始

双轿厢电梯被视为两部相对独立的轿厢电梯,它们的联系仅仅为共享的盘子,在move时需要查询彼此对换乘楼层的标记情况。

因此普通电梯置为双轿厢电梯在行为上表现为:普通电梯终止,两部轿厢姊妹电梯创建。

轿厢电梯继承了原本的正常电梯类,使得HW6的设计得以保留。

3.2.正常参与请求分配

事实上,在请求分配过程中,使用到电梯真正ID的地方屈指可数,大可以用假代码代替其称呼,比如我用ArrayList存储requestTables,那么数组下标就是请求分配中的那个“电梯ID”。

查询电梯ID,无论是遍历也好还是专门使用HashMap存储映射关系,都没有关系,毕竟电梯最多只有12部,ID也是final量。

3.3.互斥处理

  • 当且仅当requestTable为空且本电梯为轿厢且本电梯当前楼层为换乘楼层时,执行MOVE操作。让电梯在WAIT / OVER前无脑离开换乘楼层,有效实现互斥。

  • 双轿厢电梯的move()在电梯父类的基础上重写——

    • 判断电梯下一步楼层是否为换乘楼层——

      • 是:判断姊妹电梯是否占据换乘楼层——

        • 是:等待,当姊妹电梯离开换乘楼层后被唤醒,执行父类move()

        • 否:占据换乘楼层,执行父类move()

      • 否:执行父类move()

    • 判断电梯本次移动前的楼层是否为换乘楼层——

      • 是:取消对换乘楼层的标记

img

那么如何安全实现对姊妹电梯当前楼层的查询?我在同一对姊妹电梯之间放置了一个共享盘子,记录了换乘楼层的标记情况等,上同步锁。

四、电梯的更多优化

4.1.量子电梯

仔细阅读课程组的输出接口说明,会发现TimableOutput.printlnlong类型的返回值。因此私以为将它和System.currentTimeMilis()结合才是优美的写法。如果全部采用System.currentTimeMilis(),应需要慎重对待记录时间点和时间化输出的时序先后,否则会使得sleep时间不足。

/* 示例 */
long lastResetBeginStamp = TimableOutput.println("RESET_BEGIN-" + id);
long currentStamp = System.currentTimeMillis();
if (currentStamp - lastResetBeginStamp < resetTime) {
    try {
        Thread.sleep(resetTime - currentStamp + lastResetBeginStamp);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

4.2.弹射起步

普通电梯在刚刚启动时并没有参照物,可以无需sleep 400ms,直接弹射至2层。

五、Bug分析和解决方法

5.1.超时

建议使用多线程评测机,Bug复现率较高。经过反复的测试,我解决了自己超时的问题,详见1.2原子操作和等待

5.2.空指针

可以说碰到了小概率事件,但确实是我设计不周。

我的线程启动顺序是:InputHandler先,Scheduler后。在高压评测环境下,发生了:InputHandler接到了第一个请求并传送给Scheduler,但是此时Scheduler还未启动。

解决方法很简单:调换顺序。

六、心得体会

6.1.线程安全

小心死锁。双重上锁是大家都警惕的。但容易忽略的是在同步锁方法中调用另一个对象的同步锁方法。

对等待的判断和等待操作一定要封装成原子性的。否则,随时开出超时。

6.2.层次化设计

多画图,多记录。设计是在记录的过程中不断完善的。层次化设计,一定要基于自己对每个事物的职责了解,才能顺利进行。

6.3.总结

多线程是复杂的。因其概率触发Bug的特性,很多时候测试AC并不能代表什么。这需要自己足够了解设计,对每个细节进行斟酌和考量,时序会影响很多事情。

多线程尤忌讳对小概率事件的期待。在高压测评环境里,一切皆有可能。

七、致谢

感谢每一位助教老师,感谢每一位写博客的学长学姐,感谢267

**爱来自267**´∀`)*´∀`)*´∀`)*´∀`)

最后感谢自己的努力和坚持。Take the moment and taste it.

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

301

社区成员

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

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