BUAA 面向对象 第二单元 总结

“纵使困顿难行,亦当砥砺奋进”

前言

(电梯好似喵) 第二单元的主题是多线程电梯调度,对于第一次编写多线程的本苯人来说确实是个挑战。多线程并发问题,导致调试常常无法复现bug,ctle和rtle的bug也很难轻易解决。(所幸有IDEA分析器功能帮助定位) 本次总结将从以下部分展开:

  • 同步块设置和锁的选择

  • 调度器设计

  • 架构

  • 双轿厢的实现

  • Debug方法

  • 心得体会

    好,oo总结,启动!

同步块设置和锁的选择

​在第一次研讨课上,我们得出一个结论——“总结起来就是要在合适的地方加锁,不要滥用加锁,锁的大小要尽可能小,确定好哪段代码是临界区,将线程安全类与普通类分离开来”。个人理解,Synchronized修饰的方法,本质上是让这个语句块成为“原子语句”,即执行的过程中,不允许其它语句插入(这也是笔者debug时候模拟假设方法的基本原则,暂且按下不表)。所以对涉及read和write进行加锁便尤为重要。

​ 除了一般的synchrnized和runnable接口之外,还有一下机制锁:

  1. ReentrantLock:

    ReentrantLock允许一个线程多次获得锁,而不会发生死锁。它通过计数器来跟踪锁的持有次数,并且只有当计数器为0时,锁才会被完全释放。线程可以通过调用lockInterruptibly()方法在等待锁的过程中被中断。使用ReentrantLock时,通常需要手动释放锁,因此必须在finally块中释放锁,以确保在任何情况下锁都会被释放。这可以避免潜在的死锁问题。总的来ReentrantLock提供了更多的灵活性和控制,但相应地也需要更多的代码来管理锁的获取和释放。它通常在需要更复杂的锁定策略或更高性能的情况下使用。通常和Condition配合使用

  2. ReadWriteLock

    使用读写锁的场景通常是当共享资源的读操作频繁、写操作较少时,通过允许多个线程同时持有读锁,可以提高并发性能。而当有写操作需要修改共享资源时,会独占地获取写锁,以确保数据的一致性。读写锁分为读锁和写锁两种类型,允许多个线程同时持有读锁,但只允许一个线程持有写锁。

  3. Semaphore

​ 信号量,可以用来限制同时访问某个资源的线程数量,以及控制线程之间的交互。通过acquire()release()方法控制锁。会在下面双轿厢电梯实现具体展开。

HW5

​ HW5主要考察的知识点是简单的线程协同控制,对于其他诸如线程安全问题等没有太多要求。因此整体比较简单,我们只需要考虑的是维护单个电梯的生产者-消费者模型。所以在锁的选择上,使用内置锁即可,即 Java 提供的最基本的锁机制,使用 synchronized 关键字来实现。

​ 设置同步块的时候,需要分析不同线程的状态:

  • Read and Read

    ​ 无需加锁,不会造成线程安全问题

  • Write and Write

    ​ 一定需要加锁

  • Read and Write

    ​ 具体情况具体分析,比如总请求表WaitQueue的getEnd()setEnd()需要加锁,而需要读取电梯当前楼层状态不需要加锁。

HW6

​ HW6中由于增加了换乘需求,所以不同电梯的线程产生了交互,因此产生了线程安全问题,总体原则还是按照HW5进行,同样以内置锁的方法实现。在实现过程中,如果某一乘客凭空消失,那么就是线程安全出现了问题,就需要检查是否有地方没有同步。

HW7

​ HW7新添了双轿厢~~(双焦香)~~ 电梯,实现方法是另开两个线程,并关闭当前主线程。针对双轿厢电梯,为解决“不撞车”问题,笔者使用了semaphore(信号量)进行是否有轿厢进入换乘楼层的维护(此处和os联动),具体实现方法会在下文详细展开,目前按下不表。

调度器实现

​ ~~笔者原本以为调度器的实现原则就是尽可能快的将所有乘客送到目的地,但直到HW7,才想起来有耗电量这个评估指标。~~不同的调度方法都有其合理性,笔者将介绍自己在HW6和HW7的调度策略。(别问为啥没有HW5,因为HW5已经指定好了)

​ 对于HW6,笔者不死心于使用Random方法,想着通过影子电梯(模拟电梯)模拟目标乘客从起点到终点消耗的时间。然后再考虑该电梯目前可承载乘客量、运行速度等指标。HW6中影子电梯的实现方法相对简单,就是将sleep变成time的增加,最终将time返回。

​ 对于HW7,笔者在HW6强测性能表现不错的情况下,仍然执着于使用影子电梯,但在周四下午发现了TLE卡死,最终问题定位到影子电梯获得时间这里,而且在修好之后,发现在大量并发情况下,会不断使用前几台电梯,导致性能远弱于Random(鉴定为笔者太菜)。遂弃暗投明,改用Random。

​ 改完Random之后,笔者在思考自己的影子电梯性能差的原因,认为是双轿厢的单一轿厢的“可容纳人数”,不能和单轿厢进行比较。(此时陷入深深的怀疑人生) 不过在性能上,Random的耗电量相比于影子电梯确实会下降许多。

架构

​ HW5中,笔者在Input中直接分配给电梯,导致可拓展性极差。于是在HW6中连夜换为三线程模式,即输入线程-调度器线程-电梯线程的生产者-消费者模型。下图是最终的UML类图和协作图:

UML类图:

在这里插入图片描述

协作图:

在这里插入图片描述

HW5架构 :
  • 输入线程将请求传入Distributor读取并模拟加到电梯中,电梯采取ALS策略
  • 电梯有outRequests和inRequests,分别存放电梯外和电梯内的请求

​ 由于笔者不加思考地上手就写,只开了两个线程,导致代码在HW6中出现reset时加入的请求消失。(使得清明节通宵重构)

HW6架构:
  • Reset请求直接从InputThread加入ElevatorWaitingQueue
  • Reset时,让ElevatorWaitingQueue睡眠
  • 将Reset清除的乘客送入Scheduler再分配

​ 问题在于,让Scheduler过于繁忙,这个问题在HW7中分配时间过长,导致性能低下。

HW7架构: 如上图

​ 问题在于,改为random后,没及时调整synchronized方法的范围,导致运行时间过长(不rtle的情况下),使得性能分极低(具体体现在耗电量上,而非运载乘客时间)。

​ 下面将介绍双轿厢实现方法

双轿厢实现方法

​ 笔者的双轿厢是通过关闭当前Elevator进程,然后另外打开两个Car进程,开辟两个子ElevatorWaitingQueue,将四个对象存入主表进行。每个轿厢的运行本质上和HW5一致,但唯独需要解决两个轿厢的碰撞问题。笔者的解决方案是:通过维护信号量Semaphore,当一台轿厢即将进入换乘层时,使用semaphore.acquire(),然后在轿厢处理完换乘层的请求后立即离开换乘层**(这导致了耗电量的增加)**,并使用semaphore.acquire() 。下面将详细对实现方法进行阐述:

轿厢启动:

​ 首先在ElevatorWaitingQueue中,笔者进行如下内部变量的迭代:

private boolean isDouble = false;
private Elevator elevator;  //单电梯
private int transfer = 11;
private ArrayList<Car> cars = new ArrayList<>();
private ArrayList<ElevatorWaitingQueue> subQueue = new ArrayList<>();
private Semaphore semaphore = new Semaphore(1);

​ 在分配请求时,使用如下方法,根据isDouble变量和乘客目标方向以达到向A轿厢、B轿厢或原单轿厢的请求分配(这并不OO):

public synchronized void addRequest(Person person) {
    if (isDouble) {
        if ((person.getFromFloor() < transfer && person.getDirection() == 1) ||
                (person.getFromFloor() <= transfer && person.getDirection() == -1)) {
            synchronized (subQueue.get(0)) {
                subQueue.get(0).getPersonInRequests().get(person.getFromFloor()).add(person);
                TimableOutput.println("RECEIVE-" + person.getPersonID() +
                        "-" + elevator.getElevatorID() + "-A");
                subQueue.get(0).notifyAll();
            }
        } else {
            synchronized (subQueue.get(1)) {
                subQueue.get(1).getPersonInRequests().get(person.getFromFloor()).add(person);
                TimableOutput.println("RECEIVE-" + person.getPersonID() +
                        "-" + elevator.getElevatorID() + "-B");
                subQueue.get(1).notifyAll();
            }
        }
    } else {
        synchronized (personInRequests) {
            personInRequests.get(person.getFromFloor()).add(person);
            TimableOutput.println("RECEIVE-" + person.getPersonID() +
                    "-" + elevator.getElevatorID());
            personInRequests.notifyAll();
        }
    }
    this.notifyAll();
}

​ 而在轿厢启动的时候,通过继承,对Car进行如下初始化,从而实现A、B轿厢。

public Elevator(Elevator elevator, DoubleCarResetRequest doubleCarResetRequest,
                ElevatorWaitingQueue elevatorWaitingQueue, char sign) {
    this.argument = new Argument(elevator.argument);
    transfer = doubleCarResetRequest.getTransferFloor();
    direction = 0;
    status = false;
    reset = false;
    this.elevatorWaitingQueue = elevatorWaitingQueue;
    scheduler = elevator.scheduler;
    position = sign == 'A' ? transfer - 1 : transfer + 1;
    end = position;
    isDouble = true;
    this.sign = sign;
    if (sign == 'A') {
        argument.changeMaxFloor(doubleCarResetRequest.getTransferFloor());
    } else {
        argument.changeMinFloor(doubleCarResetRequest.getTransferFloor());
    }
    controller = new CarController(this);
}

轿厢碰撞

​ 在上面迭代中,使用了private Semaphore semaphore = new Semaphore(1);来维护是否能够进入换乘层。

​ 对于进入换乘层时,尝试对信号量进行acquire:

public void down() throws InterruptedException {
    Argument argument = getArgument();
    if (getStatus()) {
        shut();     //门现在开着
    }
    if (getPosition() - 1 == getTransfer()) {   //马上要下到transfer
        try {
            semaphore.acquire(); // 获取许可
            sleep(argument.getMoveTime());
            changePosition(-1);                 //下到transfer
            TimableOutput.println("ARRIVE-" + getPosition() + "-" +
                    getElevatorID() + "-" + getSign());
            changeDirection();                  //此时direction必然是0?
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    } else {
        changePosition(-1);
        sleep(argument.getMoveTime());
        TimableOutput.println("ARRIVE-" + getPosition() + "-" +
                getElevatorID() + "-" + getSign());
        changeDirection();
    }
}

​ 而在run()方法中,当电梯在移动后处于换乘层时,进行如下处理(A轿厢只能在换乘层接下行的人,B轿厢只能在换乘层接上行的人):

if (getPosition() == getTransfer()) {
    if (getSign() == 'A') {     //A接下楼的
        inCertainDirection(-1);
        changeEnd(controller.getEnd());
        if (getDirection() == 0) {
            changeEnd(getTransfer() - 1);
            changeDirection();
        }
        down();
        semaphore.release();
    } else {                    //B接上楼的
        inCertainDirection(1);
        changeEnd(controller.getEnd());
        if (getDirection() == 0) {
            changeEnd(getTransfer() + 1);
            changeDirection();
        }
        up();
        semaphore.release();
    }
} else {
    if (controller.isIn()) {
        in();
    }
}

Debug方法

​ 在Debug中,主要问题是rtle和ctle,这两个笔者使用了分析器+输出特定字符串(通常不好用)进行debug。而对于线程不安全导致的错误输出,笔者使用“模拟插入”方法,避免使用调试而无法复现。(通过这些方法,三次作业的强测和互测都没有问题)

rtle 和 ctle问题

​ 在IDEA中,有分析器这个东西监视cpu运行时间,并在程序结束(自动/卡死手动)后,显示运行时间占比较长的方法,下图是一个ctle的例子:

在这里插入图片描述

​ 所以,为解决这个ctle问题,我会关注耗时过长方法前的方法(即未能及时wait()导致ctle)。这个方法有效解决了许多ctle和rtle问题。

​ 由于多线程并发,输出特定字符串可能打乱时序而无法复现,所以在没有线程安全问题的情况下,才能使用输出特定字符串方法,定位ctle。

WA问题

​ WA导致的问题,主要是线程不安全导致的(主要是某个人丢了) ,这时候需要检查sychronized之间有无可能被其它线程语句插入的问题。这时候,可以通过“追本溯源”,模拟最后一个RECEIVE请求,然后将addRequeset()方法逐一尝试插入,可以帮助解决问题。

心得体会

​ 线程安全作为最核心的因素,在设计架构的时候需要解决多个线程同时操作共享数据资源的问题。在OS同样进入进程和线程单元的时候,两门六系重课的交叉会带来独特的感受。通过实际操作多线程,体会到了多线程设计的复杂性。我们设计的架构必须要能充分支持跨线程的实时优化(不然再美丽的调度策略也会很慢)。这就像团队游戏一样,coworking >> 1 + 1 + 1 + 1 + 1 +1。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值