多线程、锁与设计——BUAA_OO_Unit2

多线程、锁与设计

1. 前言

We choose to go to the moon. We choose to go to the moon in this decade and do the other things, not because they are easy, but because they are hard, because that goal will serve to organize and measure the best of our energies and skills, because that challenge is one that we are willing to accept …

——John F. Kennedy

第二单元的主题是多线程。新的编程模式给设计与编码带来了极大的挑战;死锁、轮询、程序无法终止等问题轮番出现;多线程的不可再现性使得增加一个输出就可能导致bug消失,线程多等待几毫秒就可能导致输出大变。

第二单元的场景是电梯。多个电梯的调度和单个电梯的运行是没有最优解的。对于任何一种策略,当然可以构造一种请求序列,让其性能表现极好;也必可以构造一种请求序列,让其性能表现极差。表达式的优化是有尽头的,因为在一开始就能掌握所有信息;但是电梯的优化是没有尽头的,因为永远无法掌握未来的信息。

是的,第二单元很难。能用单线程模拟的双轿厢电梯,为什么选择用两个线程与一个锁实现换乘?随机分配与影子电梯或许只有几分的差距,为什么依然再写比其他部分总和行数还多的代码优化性能?

我们选择前往月球,不是因为它简单,而是因为它很难。

我们选择攀登昆仑,是因为它,就在那儿。

2. 架构设计与调度策略

重点关注调度器

1. 第一次作业

在这里插入图片描述
第一次作业因为给出了乘客需要搭乘的电梯,实际上并不需要调度策略,只需要运行策略。但是作业中已经明示了之后需要调度乘客到不同的电梯。所以我还是实现了调度器的功能。并且实现了调度器和调度策略的分离。虽然在程序运行的过程中切换调度策略除非题目有要求之外并无必要,但是这样可以在修复bug中只用修改一行就能切换调度策略,事实证明这是非常正确的。请求通过调度器给到电梯的运行计划器存储。

我将电梯实现为了一种状态机电梯本身只负责计时、接受乘客进入和处理乘客输出,各个状态之间的变化逻辑全部交给运行计划器负责。此处我将电梯的运行计划器也实现了一个借口,考虑的也是快速切换电梯的运行策略,只不过电梯的运行策略与电梯状态机的设计是深度绑定的,我的状态机就是适应LOOK运行策略的,所以这个抽象层次在之后就删除了。
在这里插入图片描述
第一次作业有同学参照实验的代码写了生产者-消费者模式。但是我个人认为整个电梯系统并不是一个完全的严格的生产者-消费者模式。因为作为生产者的产品,乘客请求的生产是由输入决定的,其不能被控制。产生了一个新的请求就必须进行处理。当然,可能因为暂时拿不到访问共享数据的锁而等待,但是不会有认为设计的等待。

结束电梯上使用经典的二阶段结束设计模式。给电梯设置结束标志。电梯的运行计划器的次态逻辑函数检测到结束标志时将在合适时给出次态为结束态,电梯线程以此结束。

2. 第二次作业

在这里插入图片描述
第二次作业的核心之一就是实现调度策略。今年的作业相比往年的作业不再允许使用自由竞争策略(虽然有同学在研讨课上可以通过一些投机取巧的办法近似实现自由竞争策略),在纠结了是随机调参还是影子电梯之后,我决定使用影子电梯。此时将电梯设计为状态机的优势就体现出来了。影子电梯的核心是用合适的方法获得电梯的克隆,让电梯运行到结束获得时间等数据。那么只要设置克隆的结束标志,将计时器的sleep改为增加某个变量的值,关闭克隆在运行时的输出就可以了,次态逻辑函数并不需要进行额外修改。

影子电梯面临两个主要问题。一是克隆,如果使用了容器,一定要进行深克隆,否则将会修改实际电梯的内容。官方包提供的乘客请求设计为了不可变对象,可以直接共享使用。二是线程安全,见3.同步块与锁-2)状态锁。
在这里插入图片描述
第二次作业的另外一个重要任务是实现电梯重置。类似于二结束,我设计了一个二阶段重置,先设置重置标志位,次态函数在合适的情况下将次态设为重置态。进入次态逻辑的电梯会将所有未完成的请求通过创建ResetRequestsProducer的方式抛出,这些请求会再次通过Dispatcher分配给各个电梯。

因为有乘客请求被抛出,并且抛出的方式ResetRequestsProducer,所以仅仅输入结束了并不能作为结束条件。但是因为一次重置请求固定会创建一个ResetRequestsProducer,我将结束条件设定为了输入结束,并且结束的ResetRequestsProducer数量与重置请求的数量相等。

3. 第三次作业

在这里插入图片描述
第三次作业的核心是实现双轿厢电梯。双轿厢电梯需要换成在时间上是更长的,但是在能耗上有巨大的优势,所以调度策略要做到二者的平衡。简单的影子电梯只能获得一个电梯运行时间,能耗等数据,综合考虑这两个数据就会碰到调参的问题。但是考虑到,课程组是给了性能分的计算公式的,那么就可以使用一种更加强的方法——影子系统

影子系统就是将六个电梯同时获取其克隆,将请求给到某个克隆,通过模拟时钟的方式,让整个系统运行到结束。这样就可以得到六个系统的数据,带入课程组提供的性能分公式,直接算出性能分,选取性能分最优的情况。

这样有两个问题。一是双轿厢电梯的换乘问题,在换乘的时候因为没有到达其所在的楼层就走出电梯,所以该乘客确实可以被其他的电梯运到目的地楼层。如果在影子系统的模拟过程中又要,决定将乘客放到哪个电梯,那么就需要递归地进行影子系统运算,正确性的压力太大。但是,我考虑到双轿厢电梯的巨大能耗优势,加之课程组明示我们要利用双轿厢的这个优势,我直接规定模拟时由双轿厢电梯的请求一定在双轿厢电梯内部完成,不交给其他的电梯,但是实际电梯中,乘客换乘时还会在做一次影子电梯运算。二是模拟时钟确实非常消耗CPU,若电梯运行时间比较长,循环次数将会非常多,每一次运算的循环次数都有几千次,在中测时一个此时点的CPU使用时长已经来到了4.7秒,最后改交了随机分配的版本。
在这里插入图片描述
第三次作业的另外一个重要任务是实现双轿厢电梯。我将电梯拆分为了电梯和轿厢两次,对外调度器的表现上,仍然表现为一个电梯。与外界的交互也是以电梯整体的形式。但是在内部的逻辑上通过模式标志区分但轿厢电梯和双轿厢电梯。同时电梯还负责管理双轿厢电梯的换乘楼层的锁。

因为涉及到换乘,并且我允许双轿厢电梯换乘时换乘到不同的电梯,所以不像第二次作业的时候可以简单准确算出ResetRequestsProducer的数量,加之我需要实现影子系统算性能分。所以我实现了一个全局的监视器,监视当前的各个乘客请求的完成情况以作为结束条件。

4. 稳定与易变

在各次的迭代任务中,保持稳定的是数据的流动结构和请求的处理结构。

易变的是具体实现的次态函数以及结束条件。

1) 同步块

我使用同步块一般是在可能产生的冲突方法上加上synchronized。

如在Controller中,几乎所有的次态逻辑函数都会读取或者修改到goUpWaitList和goDownWaitList,这与接受调度器传来的乘客请求要修改这两个List是存在线程安全问题的,所以我使用了synchronized

// 来自调度器的请求
public synchronized void add2WaitList(PersonRequest request) {
  if (request.getToFloor() > request.getFromFloor()) {
    this.goUpWaitList.addLast(request);
  } else {
    this.goDownWaitList.addLast(request);
  }
  notifyAll();
}

// 次态逻辑函数
public synchronized CState prepareMoveNext() {
  if (this.shouldReset) {
    ......
  } else if (this.canReQuantum && car.passengers().size() < car.capacity()
             && this.havePassengersIn()) {
    ......
  } else {
    ......
  }
}

// 次态逻辑函数中使用的判断函数
private boolean havePassengersIn() {
  if (car.direction() == CDirection.UP) {
    return this.goUpWaitList.stream().anyMatch((r) -> r.getFromFloor() == car.floor());
  } else {
    ......
  }
}

2) 状态锁

我将我的电梯实现为了一种状态机,由于在电梯克隆的过程中需要读各种容器,此时实际电梯线程是不能修改这个容器的。构造方法,重写的clone方法是不能加上synchronized的。同时影子电梯在计算的过程当中,电梯应当不动以保证正确性。

所以一个电梯有一把状态锁,在状态变化时会需要获取这个锁。生成影子电梯时也要获得这把锁,让电梯的状态不能变化。

3) 换乘锁

换乘楼层的冲突我也是通过锁解决的。在某个轿厢要前往某个楼层时要获取换乘楼层的锁,离开换乘楼层时就释放对应的锁。如果换乘楼层,会在换乘楼层前阻塞,直到另一个电梯离开换乘楼层。

public void acquireTransferLock(int toFloor) {
  if (this.mode == EMode.DOUBLE_CAR
      && toFloor == this.transferFloor) {
    transferLock.lock();
  }
}

public void tryReleaseTransferLock(int arrivedFloor) {
  if (this.mode == EMode.DOUBLE_CAR
      && arrivedFloor != this.transferFloor
      && this.transferLock.isHeldByCurrentThread()) {
    transferLock.unlock();
  }
}

4. Bug与Hack

在中测、强测中都没有出现正确性问题。第六次作业和第七次作业互测的场面蔚为壮观,有同学的发起hack次数甚至达到了280次,这两次作业都被hack了。不过感觉是大家知道了某个计策之后疯狂hack一个bug。

1. 红蓝线分析法

在提交之前修复的bug主要是死锁。整个工程中既有直接使用synchronized的锁,又有使用ReentrantLock的锁,不同的方法之间相互调用,很容易原本想着没有什么问题,但是埋下了死锁的隐患。我总结了一个死锁红蓝线分析法,可以通过对于一个线程有关于锁的操作的分析找到可能的死锁。

内容涉及大量的图示,也比较长,已经发在了讨论区中。

http://oo.buaa.edu.cn/assignment/512/discussion/1552

之所以叫红蓝线分析法,是因为笔袋当中黑笔、红笔、蓝笔和尺子是最为常见的。

2. “围师必阙“之计

在第二次作业的互测当中,很多同学都中了“围师必阙“之计,互测中有大量如下的测试点:

----- 先重置五个电梯 -----
[49.9]RESET-Elevator-2-3-0.6
[49.9]RESET-Elevator-3-3-0.6
[49.9]RESET-Elevator-4-3-0.6
[49.9]RESET-Elevator-5-3-0.6
[49.9]RESET-Elevator-6-3-0.6
----- 以下为大量的乘客请求 -----
[49.9]1-FROM-1-TO-11

我在收到让电梯重置的请求之后,就将对应的电梯移出可被分配的电梯,直到电梯重置结束才再次加入到可被分配的电梯中。也就是电梯在重置状态下,一个新的乘客请求输入后不会建立它的影子、给它分派乘客请求。

此时非重置状态的电梯只有一个,而又有大量的乘客请求输入时,调度器将会把所有的请求分派给不在重置状态的电梯。分派之后我也并没有设计取消分派,一个电梯完成这些请求就会导致超时。解决方案有两种

  1. 当重置的电梯达到某个数量的时候就暂时不进行分配,等待足够数量的电梯重置完成之后再分配(在bug修复中这样的修改行数很少,可以卡合并修复)
  2. 可以获取重置状态的电梯的影子,毕竟对于影子电梯来说,这只是一个持续一定时长的状态而已。分配给重置状态的电梯时,先存在这个电梯的缓冲区当中,直到电梯重置完成再RECEIVE

3. 结束条件

在第三次作业的互测当中,我的程序成为了地雷,基本上只要最后一条指令是双轿厢重置指令,我的程序就无法结束。原因在于设置轿厢的结束标志、电梯模式更新和启动电梯线程之间的时间差。

这是调度器设置轿厢的结束标志的函数,根据当前的电梯模式来决定对哪个轿厢设置结束标志

public synchronized void endElevator() {
  if (this.mode == EMode.SINGLE_CAR) {
    // 单轿厢模式的轿厢结束
    this.carSingle.endCar();
  } else if (this.mode == EMode.DOUBLE_CAR) {
    // 双轿厢模式的轿厢结束
    this.carA.endCar();
    this.carB.endCar();
  }
}

之所以这样是因为carA和carB的初始值为null,在接受到RESET请求之后才创建对象。

public synchronized void scheduleDoubleCarReset(DoubleCarResetRequest resetRequest) {
  if (this.mode == EMode.SINGLE_CAR) {
    // 设置重置后的运行模式
    this.resetMode = EMode.DOUBLE_CAR;
    ......
    // 构建A轿厢和B轿厢
    this.carA = new Car(...);
    this.carB = new Car(...);
    ......
  }
}

但是创建对象后,并没有启动线程,也没有立即修改电梯模式,保持了原本的单轿厢模式,直到单轿厢进入重置状态。

public void startReset() {
  // 改变电梯模式,使重置期间的影子电梯能正确运行
  this.mode = this.resetMode;
  ......
  if (this.resetMode == EMode.DOUBLE_CAR) {
    // 如果接下来为双轿厢模式,则启动线程
    new Thread(this.carA).start();
    new Thread(this.carB).start();
  }
  ......
}

那么就存在这样的一种可能的运行情况。最后一条指令是双轿厢重置指令,调度器在调度完成这条指令之后满足了结束条件,立即请求结束电梯。此时单轿厢还没有来得及进入重置状态,改变电梯的模式,故没有设置新的双轿厢的结束标志。单轿厢进入重置状态之后,启动了双轿厢的线程,然后再也没有其他的因素能够触发设置双轿厢的结束标志了。

但是我的强测并没有出现正确性错误的原因是强测的数据在输入结束的时候,还有乘客没有到达所需楼层,并不会触发设置轿厢的结束标志,给电梯模式更新留足了时间。

5. 后记与致谢

博客要求从“从线程安全和层次化设计两个方面来梳理自己在本单元三次作业中获得的心得体会”。我在这三次作业中一直把这两个方面结合在一起考虑。线程安全安全需要良好的层次化设计来保障,全面的层次化设计需要充分考虑线程安全。

  1. 明确行为,划分数据:类的方法对应着某种行为。设计时要明确一个行为属于哪一个层次,一个层次要有哪些行为。完成一定的行为需要哪些数据,这个数据应该属于哪一个层次。尽量让一层只掌握完成其行为所必须的所有数据,避免掌握不必要的数据造成线程不安全。

  2. 让数据流动:确定数据应该通过怎样的方法在层次之间流动。尽量让数据流动到需要的层次,而不是积压在一个层次让别的层次去调取,从而减少可能存在的访问冲突。

  3. 设计先行,定位共享数据:在正式编码的设计中应当对于每个层次的共享数据有充分的把握,事先确定采用的保护机制,在图表上分析比盯着代码查看的效率要高。

滚滚长江东逝水,浪花淘尽英雄。是非成败转头空。青山依旧在,几度夕阳红。
白发渔樵江渚上,惯看秋月春风。一壶浊酒喜相逢。古今多少事,都付笑谈中。

——杨慎《临江仙·滚滚长江东逝水》

我在第七次作业截止前两个小时终于写完了影子系统,但是因为CPU用时过高就临时换成了随机分配的版本。在BUG修复中,我又尝试提交了影子系统的版本,让我感到意外的是没有一个强测的点会CTLE,虽然最高的一个测试点的CPU使用时长已经来到了7.9/10。这个时候我释然了,影子系统不是一个神话,而是一个美好的故事。我并不遗憾没有所谓勇敢地赌一把不会出事。在那个条件下,保证正确性就是最重要的事情。

回过头想,实现影子电梯、量子电梯这些优化,在15分的性能分中不断内卷。把性能不断地推向极致的同时,似乎也离实际工程所需越来越远。99.99分的传奇,85分的惜败很快都会过去,被浪花淘尽。

等到这些纷纷扰扰过去,剩下的东西——多线程程序的基本思想、设计多线程程序的方法、解决多线程问题的工具等等才是这一趟昆仑之行的最大的收获吧。

跨越山海,终见曙光。

这一单元的作业同样得到了学长学姐的博客和同学的帮助,非常感谢优秀的学长学姐和同学

  1. 关于量子电梯和计时器的实现,感谢thysrael
  2. 关于架构设计,感谢musel
  3. 关于“围师必阙”之计及其破解之法,感谢魏新明、颜铭鑫
  4. 关于红蓝线分析法,感谢李从容的提问
  5. 关于测评机,感谢陈叙传

最后,感谢所有在讨论区分享的同学们。感谢助教和课程团队。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值