BUAA_OO_Unit2总结

目录

前言

本单元的三次电梯作业整体上都采用了生产者-消费者模式:输入线程作为生产者,读取的请求作为产品,电梯线程作为消费者,存储请求的队列作为多线程可见的共享数据,将生产者与消费者联系到一起。
在每一次作业中,我会依次分析当次作业的架构设计同步块的设置调度器设计与调度策略出现的bug以及一些值得注意的内容

hw5

架构设计:

UML类图如下:
hw5_UML类图

黄色框代表线程,绿色框代表共享数据,整体的架构如图:
hw5_架构图
在hw5中,每个乘客的请求都指定了电梯,所以不需要设置调度器。每台电梯对应一个待处理请求队列RequestTable,其中存储已分配给该电梯但还未开始处理的乘客请求;InputThread负责读取请求,并根据请求指定的电梯id将该请求传递给对应电梯的待处理请求队列;Elevator按照一定的运行策略处理RequestTable中的请求。

同步块的设置:

只有访问多个线程可见的共享数据时才需要设置同步块。在hw5中,共享数据是每个电梯的待处理请求队列RequestTable,该共享数据会被InputThread线程写,被Elevator线程读和写。因此,我将访问RequestTable对象的方法都放在RequesetTable类中,并使用synchronized关键字将其设置成同步块。

出现的bug:

在互测中,我出现了并发修改异常,归根结底是同步机制出了问题。Elevator在判断是否需要开关门、是否要继续移动时,需要遍历对应的RequestTable,为此,我将RequesetTable内部的属性——requestTable(用来存储请求的容器),通过方法getRequestTable()开放给了Elevator,这导致可能出现这样的情况:Elevator线程在遍历requestTable时InputThread线程正在向requestTable内添加新元素,导致出现并发修改异常。
我最终将这个遍历过程移进了RequestTable中,设置成方法供Elevator线程调用,并使用synchronized关键字设置为同步块,修复了这个bug。

hw6

架构设计:

UML类图如下:
hw6_UML类图

整体的架构如图:
在这里插入图片描述

在hw6中,乘客请求不再指定电梯,而是需要自己决定将乘客请求分发给哪部电梯。因此需要再新增一个调度线程并设计相应的调度策略,具体内容稍后展开来谈。此外,hw6新增了一种请求类型——reset请求(电梯重置请求),reset请求会指定特定的电梯,接受到reset请求的电梯需要在一定时间限制内完成重置,且将要重置时电梯内不能有乘客,重置时电梯不能运行或接受乘客请求。
InputThread线程接受输入,将读取的请求传递给共享数据WaitRequest;Dispatcher线程是调度线程,将WaitRequest中的请求按一定调度策略分发给各个电梯,即,将请求传递给各电梯的待处理请求队列RequestTable;Elevator按照一定的运行策略处理RequestTable中的请求。

同步块的设置:

Dispatcher在调度时需要WaitRequest的信息,Elevator的属性信息,比如Elevator的容量、速度、位置和有哪些乘客,以及Elevator的待处理请求队列RequestTable的信息。因此,hw6中的共享数据有:WaitRequest(InputThread,Dispatcher和Elevator三类线程均会访问它),RequestTable(Dispatcher和Elevator线程会访问它),Elevator的各种属性信息,如Passengers(Dispatcher和Elevator线程会访问它)。我将访问这些共享数据的方法全都设置在共享数据类内部,并设置成同步块,这样就保证了数据读写不会冲突。
在本次作业中,Elevator类既是线程类,又是共享数据类。面对这种情况,为了使程序结构更清晰,可以选择把Elevator类分设成两个类,一个ElevatorThread类作为线程类,另一个ElevatorData类作为共享数据类。不过不改也可以,本着尽量少调整架构的原则 (绝不是懒) ,我只将Passengers单独抽出去形成了一个共享数据类,其他属性依然保留在Elevator中。

锁的选择:

在调度时,访问Elevator类不同属性的方法应使用不同的ReentrantLock(使用synchronized锁住不同的对象也可以),这样避免了Elevator类不同属性共用同一把锁而无法同时访问Elevator类不同属性的问题。以下是Elevator类中的部分代码示例:

private final ReentrantLock resetLock = new ReentrantLock();
private final Condition resetCondition = resetLock.newCondition();
...
public int showResetState() {
    resetLock.lock();
    try {
        resetCondition.signalAll();
        return resetState;
    } finally {
        resetLock.unlock();
    }
}

public void setResetRequest(ResetRequest request) {
    resetLock.lock();
    try {
        resetCommand.setValue(request);
        resetState = 1;
        resetCondition.signalAll();
    } finally {
        resetLock.unlock();
    }
}
...

多个线程并发访问WaitRequest和RequestTable时,实际上主要访问了他们的一个属性——用来存储请求的容器,因此,我们可以直接用synchronized关键字修饰方法,将this对象作为锁,不会过分影响性能。

调度器设计与调度策略:

调度器设计的一大难点是何时结束调度线程。我的想法是,当不再需要调度器调度时,结束调度线程。问题此时转换成了何时不再需要调度器调度。当WaitRequest为空,且WaitRequest.isEnd为true(输入线程结束),且没有正在重置的电梯时(电梯重置会将Passenger中的请求放回到WaitRequest中),WaitRequest将不再有新增的请求,此时不再需要调度器,可以结束调度线程。
关于调度策略,我选择了打分策略(排除正在重置的电梯),即,根据请求信息、电梯状态、WaitRequest等因素给每部电梯打分,将请求分配给分数最高的电梯。我的打分标准如下:

private int evaluateFit(int reqCount, int speed, int limit, int elePos, int perPos,
                        Elevator.Dir eleDir, Elevator.Dir perDir, boolean isFull) {
    int fitness = -reqCount;
    if (perDir == eleDir && perPos == elePos) {
        fitness += 5 * gear;
    } else if (perDir == Elevator.Dir.UP && perPos > elePos && eleDir == Elevator.Dir.UP) {
        fitness += 3 * gear;
    } else if (perDir == Elevator.Dir.DOWN && perPos < elePos && eleDir == Elevator.Dir.DOWN) {
        fitness += 3 * gear;
    } else if (perDir == Elevator.Dir.UP && perPos < elePos && eleDir == Elevator.Dir.UP) {
        fitness += gear;
    } else if (perDir == Elevator.Dir.DOWN && perPos > elePos && eleDir == Elevator.Dir.DOWN) {
        fitness += gear;
    } else {
        fitness += 2 * gear;
    }
    if (isFull) {
        fitness -= gear;
    }
    fitness -= 2 * ((speed / 100) - 1);
    fitness += limit - 2;
    return fitness;
}

最终强测分数99.5,高于我的预期,说明这个打分标准还是有一定可取性的。有能力和精力的话,还可以尝试通过数学建模的方式优化各参数的系数。

出现的bug:

我在打分时排除了正在重置的电梯,这也导致了在互测中被极端数据以卡运行时间的方式hack成功。比如,在49.5秒时,重置5部电梯,只保留一部电梯可以正常工作,然后在49.9秒时涌入大量乘客请求,这样,这些乘客请求都会被分配给一部电梯,导致运行时间过长,超出120s的限制。
我选择的解决方式是:当只有一部电梯可用时,就先不分配,只有有两部或以上的电梯可用时才开始分配。

一些细节:

由于课程组安排了输出RECEIVE这一约束,而从reset请求被InputThread线程读入到相应电梯开始reset的过程中,电梯不能移动超过2层,因此当WaitRequest中有reset请求时,要优先将reset请求分配给指定电梯,防止电梯"move too many times!"。

hw7

架构设计:

UML类图如下:
hw7_UML类图

整体架构图同hw6,这里不再展示。
在hw7中,新增双轿厢电梯和对应的reset请求类型,通过新型reset请求,将原本的单轿厢电梯重置为双轿厢电梯,双轿厢电梯可以理解为一个井道里有两个可到达楼层范围受限的单轿厢电梯。因此,hw7中需要考虑的问题主要有三个:1. 怎样处理新型reset请求,将单轿厢电梯变为双轿厢电梯? 2. 怎样保证一个井道里的两个电梯在换成楼层不相撞? 3. 怎样调度?
关于问题1, 主有两种处理方法,第一种是直接设置12部电梯,只不过开始时只启用其中6部;第二种是在程序运行过程中,接受到相应的reset请求后再新增elevator线程。我当时第一反应是第二种解决方案,没有多想就采纳了,但后来感觉第一种其实更好写一点。
当某部电梯接受到新型reset请求变为双轿厢电梯后,在下一次调度时,由于我的打分策略会遍历所有电梯,所以我可以在遍历到改设成双轿厢的这部电梯时新增另一轿厢,调用该轿厢的run()方法,并将该轿厢加入调度范围。这里要注意并发修改问题,不能边遍历边增加,可以遍历完现有电梯后,将新增的电梯统一加入到调度范围。
问题2和问题3分别在同步块的设置调度策略部分展开来谈。

同步块的设置和锁的选择:

同步块设计的思路不变,依然是寻找共享数据和访问共享数据的线程。在本次作业中,调度时需要考虑是否为双轿厢电梯,如果是,换乘楼层是哪层等问题,因此Elevator新增的一些属性成为了新的共享数据,被Dispatcher线程和Elevator线程访问。我同样采用了ReentrantLock来实现同步块,以下是一些实例:

...
private final ReentrantLock twinTypeLock = new ReentrantLock();
private final Condition twinTypeCondition = twinTypeLock.newCondition();
private final ReentrantLock transferFloorLock = new ReentrantLock();
private final Condition transferFloorCondition = transferFloorLock.newCondition();
...
public int getTransferFloor() {
    transferFloorLock.lock();
    try {
        transferFloorCondition.signalAll();
        return transferFloor;
    } finally {
        transferFloorLock.unlock();
    }
}

public void setTransferFloor(int value) {
    transferFloorLock.lock();
    try {
        transferFloor = value;
        transferFloorCondition.signalAll();
    } finally {
        transferFloorLock.unlock();
    }
}
...

除此之外,为了保证一个井道里的两个电梯在换乘楼层不相撞,我还在Elevator的move()方法中采用ReentrantLock设置了一个同步块,一个井道里的电梯使用同一把锁,不同井道里的电梯使用不同的锁。当双轿厢电梯要移动到换乘楼层时,进入该同步块,部分代码示例如下:

private void move() {
      String str = new String((twinType == 0) ? "" :
              (twinType == 1) ? "-A" : "-B");
      try {
          sleep(moveTime);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
      if (direction == Dir.UP) {
          pos++;
      } else {
          pos--;
      }
      if (pos == transferFloor && twinType != 0) { //不用考虑在换乘楼层重置的问题,双轿厢电梯不会接受到重置指令
          floorLock.lock();
          try {
              ...
              floorLockCondition.signalAll();
          } finally {
              floorLock.unlock();
          }
      } else {
          TimableOutput.println("ARRIVE-" + pos + "-" + elevatorId + str);
      }
  }

为了保证安全性,我牺牲了一部分性能,让双轿厢电梯在到达换乘楼层执行完相关操作后直接改变方向,向远离换乘层方向移动一层。

调度器设计与调度策略:

我依然选择了打分策略,只不过双轿厢电梯可到达楼层有限,遇到来自于可到达楼层之外的请求时,不能将其分配给双轿厢电梯。此外,为了防止被hw6互测中出现的极端数据hack,我只在有至少2个井道有电梯可用时才分配请求,否则等待直到满足该条件。
同时,由于要换乘,双轿厢电梯可能导致更多次开关门,完成请求耗时更长,但双轿厢电梯的耗电量仅为正常电梯的1/4,而耗电量占性能分的0.4,最长等待时间占性能分的0.3,一番权衡之后,我选择了追求更低的耗电量,因此在打分时,给双轿厢电梯一个补偿分,使得双轿厢的优先级高于正常电梯。打分标准如下:

//转向次数粗略地代表了把乘客送至目的地的移动距离
private double evaluateFit(Elevator elevator, PersonRequest request) {
    ...
    double fitness = -1000000;//不可达时,给出该评分
    if (isQuailified(elevator, request, perDir)) {
        fitness = -1.1 * requestTable.getCount();
        if (perDir == Elevator.Dir.UP && perPos >= elePos && eleDir == Elevator.Dir.UP) {
            fitness += 3 * gear;
        } else if (perDir == Elevator.Dir.DOWN
                && perPos <= elePos && eleDir == Elevator.Dir.DOWN) {
            fitness += 3 * gear;
        } else if (perDir == Elevator.Dir.UP && perPos <= elePos && eleDir == Elevator.Dir.UP) {
            fitness += gear;
        } else if (perDir == Elevator.Dir.DOWN
                && perPos >= elePos && eleDir == Elevator.Dir.DOWN) {
            fitness += gear;
        } else {
            fitness += 2 * gear;
        }

        if (isFull) {
            fitness -= gear;
        }
        fitness -= 2 * (((double) speed / 100) - 1);
        fitness += limit - 2;
        if (twinType != 0) {
            fitness += 2.5 * gear;
        }
    }
    return fitness;
}

最终强测分数99.7,说明这个打分标准有一定可取性。

本次作业在强测和互测中均未出现bug。

一些细节:

我在hw6中判定调度线程结束的条件是:当WaitRequest为空,且WaitRequest.isEnd为true(输入线程结束),且没有正在重置的电梯(电梯重置会将Passenger中的请求放回到WaitRequest中)。这在本次作业中不再合适,因为双轿厢电梯在换乘楼层会清空乘客,未到达目的地的乘客此时仍需调度器调度。因此,调度线程结束条件新增了两条:所有电梯内没有乘客,所有电梯没有待处理的请求。

三次作业稳定的内容和易变的内容

稳定的内容:

  • 贯穿了三次作业的生产者-消费者模式。这种设计模式是多线程典型的设计模式,通过设置缓冲区,很好地满足了不同线程的需求;
  • 整体的架构。三次作业均采用了一个线程读取输入,一个线程负责调度(hw5完全可以增加一个调度线程),几个电梯线程负责处理分配给自己的请求的结构。这种结构对细节并不关注,有很强的可扩展性。
  • 同步块的设置。多线程必然要涉及同步问题。在考虑同步问题时,我觉得主要考虑两个问题:1.哪些数据是多线程可见的? 2. 多线程可见的数据具体是被哪些线程读,哪些线程写? 为了解决共享数据的读写冲突,我们可以将共享数据包装成一个线程安全类,对外提供线程安全的方法;也可以对小的代码块上锁,此时要多加考虑,避免无效锁或死锁。

易变的内容:

  • 请求类型
  • 电梯类型
  • 调度策略

由以上内容可以看到,稳定的往往是结构、设计模式、设计方法,这些东西具有更高的抽象层次;易变的则是具体的业务类型、工具类型、处理方法,这些东西是更具体的、低抽象层次的。

多线程debug的体会

  • 多线程的bug往往是由于共享数据读写可能发生冲突,或者是死锁导致的。因此,应该找齐有哪些共享数据,然后采用系统化、规范化的方式设置好同步块。
  • 逻辑错误也时常出现,我们不能将逻辑错误以为是同步互斥没做好导致的错误,这需要我们细细分析导致错误的数据,在脑海里模拟程序的执行流程,或者采用加输出语句、用IDE提供的工具等方式来debug。
  • 面对难以复现的错误,一定要保留好引发错误的数据及当时的输出,然后慢慢找bug…

Unit2心得体会

线程安全:

线程安全问题主要是多线程可见数据的读写冲突问题。上文已有相关看法,如下:

  • 考虑互斥同步问题时,我觉得主要考虑两个问题:1.哪些数据是多线程可见的? 2. 多线程可见的数据具体是被哪些线程读,哪些线程写? 为了解决共享数据的读写冲突,我们可以将共享数据包装成一个线程安全类,对外提供线程安全的方法;也可以对小的代码块上锁,此时要多加考虑,避免无效锁或死锁。
层次化设计:

层次化设计的方法主要包括:

  • 自顶向下:从整体到部分,从宏观到微观,逐步深入到问题的核心,找到问题的根源。
  • 分级处理:采用自下而上的分层处理方法,逐一解决每个子问题,并在此基础上逐步组合成设计方案。
  • 模块化:将设计问题分解成多个独立的模块,便于管理和维护。

层次化设计有助于简化设计,提高程序的可维护性、可扩展性和灵活性。在完成本单元三次作业的过程中,我进一步强化了自己层次化设计的思想,在很多地方践行了层次化设计的理念,比如,Elevator类只负责处理RequestTable中的请求,无需关注WaitRequest中的请求;Elevator只需要执行Strategy给出的指令,无需自己判断怎么运行等。这种设计为迭代开发带来了许多便利,减轻了工作量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值