这是笔者第一次接触Java多线程,本单元的电梯调度作业把我领进了多线程作业的世界,也给我带来了很大的启发,比如说synchronized() 锁与同步块的使用、wait() 和 notifyAll() 的配合使用,也让我尝试实现了多线程的debug、性能的优化等等,可以说是一次充满挑战与收获的旅程。回首整个单元,看着自己的调度系统逐渐从单部电梯发展到多部不同类型的电梯,运转流程也更迅速更稳定,笔者由衷地觉得一路春光、不虚此行。下面,笔者将从整体架构分析、同步块与锁的设置、调度器设计、功能设计、性能改进、bug分析、心得体会这几个方面来对自己的程序进行分析。

架构分析

  这三次作业的整体架构较为相似,都设置了两个线程,分别是:输入线程(主线程)、电梯线程。三次作业均采用分布式调度,为每部电梯分配一个调度器,由调度器决定电梯的行进状态。由于第一次作业中笔者的设计较为规范,将输入线程、电梯线程与调度器都组织得较为有序,所以程序的功能延展性较好,在处理第二次、第三次作业的多部电梯时,只需增加一个 ElevatorSystem 类作为总调度系统,并根据特定规则将乘客分配到不同电梯的等待队列中,即可保证程序的有序运转,其他的类改动较少。

第一次作业类图:

 

  可以看到,MainClass是输入线程,每得到一个乘客请求,就将它放到Dispatcher的等待队列中,然后由Elevator线程自动从Dispatcher中获取需要被搭载的乘客,并根据乘客需求上下运行。由于作业需求中有三种到达模式:Morning、Night、Random,故笔者设计了三种调度器,它们有细微差别。

第一次作业代码行数:

第一次作业方法复杂度:

  第二次、第三次作业有多部电梯,但由于笔者采用的是分布式调度,故无需改变调度器,只需增加 ElevatorSystem 类来将乘客分配到特定电梯即可。

第二次作业 ElevatorSystem 类:

 

第三次作业 ElevatorSystem 类:

 

第三次作业类图:

 

  由于第三次作业的电梯在可达楼层上具有局限性,如果乘客不换乘的话,将会给A类电梯(可达楼层:1-20)带来过大的负载。因此,笔者将起点、终点楼层按奇偶性分类进行了换乘处理,进行了性能的优化。该部分将在后文详细阐述。

同步块与锁

  多线程最大的问题就是线程安全问题。如果多个线程同时操作一个共享对象,则会引发线程安全问题,产生错误结果。为了防止该现象,我们可以在方法名前加上synchronized关键字,或是给共享变量加上synchronized。

同步锁举例

  在 Random Dispatcher中,我们需要获取在电梯行进方向上的乘客,准备送入电梯线程。与此同时,输入线程也在向等待队列源源不断地加入乘客。如果不给等待队列加锁,则队列中同时被加入元素和移出元素,会引发线程安全问题。而如果我们对 requestQueue 加锁,就可以保证在每个时刻至多只有一个线程在操作等待队列,从而避免线程安全问题。下图为调度器获取可捎带乘客的 getLoadTasks 方法,其中用到了同步锁synchronized:

public List<PersonRequest> getLoadTasks(Instruction direction, int maxLoad) {
        List<PersonRequest> ret = new ArrayList<>();
        int curFloor = elevator.getCurFloor();
        synchronized (requestQueue) {
            for (PersonRequest personRequest : requestQueue.getPersonRequests()) {
                if (personRequest.getFromFloor() != curFloor) {
                    continue;
                }
                if (direction == Instruction.UP && personRequest.getToFloor() > curFloor) {
                    ret.add(personRequest);
                }
                if (direction == Instruction.DOWN && personRequest.getToFloor() < curFloor) {
                    ret.add(personRequest);
                }
                if (direction == Instruction.TAKE) {
                    ret.add(personRequest);
                }
            }
            int count = Math.min(ret.size(), maxLoad);
            ret = ret.subList(0, count);
            requestQueue.getPersonRequests().removeAll(ret);
        }
        return ret;
    }

调度器设计

  笔者采用分布式调度,为每部电梯都分配一个调度器,该调度器决定了电梯的行进方向和可捎带的乘客。

  根据三种到达模式,笔者设计了三种不同的调度器。由于它们在总体功能上差异不大,都是获取主请求、决定电梯状态(行进方向)、获取可捎带乘客队列,所以笔者定义了一个Dispatcher抽象类,该类有 getCommand()、getLoadTasks()等方法,只需在三个子类中覆写这些方法即可。

  Random模式为一种最普通的调度器,只需保证在每一时刻都有一个明确的主请求,电梯的行进方向由主请求决定,然后获取当前可捎带乘客队列即可。

  Morning模式的独特之处就是在一层时可以等待两秒,我们将这段时间设置为MORNINGWAIT。MorningDispatcher的getCommand函数如下:

public Instruction getCommand() {
    PersonRequest mainPersonRequest = elevator.getMainTask();
    if (mainPersonRequest != null) {
        //电梯内有乘客,主任务为电梯乘客请求最早的任务
        return Instruction.valueof(
                getDirection(elevator.getCurFloor(), mainPersonRequest.getToFloor()));
    }
    else    //电梯内无乘客,主任务为侯乘乘客中请求最早的任务
    {
        synchronized (requestQueue) {
            mainPersonRequest = this.getMainTask();
            if (mainPersonRequest == null) {
                if (requestQueue.isEnd() && requestQueue.isEmpty()) {
                    return Instruction.STOP;
                }
                else {
                    return Instruction.WAIT;
                }
            }
            if (elevator.getCurFloor() == 1) {
                return Instruction.MORNINGWAIT;
            } else {
                return Instruction.DOWN;
            }
        }
    }
}

  然后,在Elevator线程中,当指令为MORNINGWAIT时,电梯可睡眠两秒再关门,以等待更多的乘客上电梯。代码如下:

if (command == Instruction.MORNINGWAIT) {
    sleep((long)(MORNING_WAIT_SPEED * 1000));
}
sleep((long)(CLOSE_SPEED * 1000));
closeDoorInfo();

  Night模式较为简单,我们甚至可以直接将等待队列中乘客的起始楼层从高到低排序,然后命令电梯到达当前请求中的最高起始楼层,尽可能多装几个乘客,然后送到一层。如此循环往复,直到把乘客全部送完为止。

电梯功能设计

  每个电梯都拥有一个调度器,调度器根据乘客需求给电梯指令,决定电梯的状态。调度器会给电梯如下指令:

public enum Instruction {
    WAIT, TAKE, UP, DOWN, MORNINGWAIT, STOP;
}

  电梯的职责就是:上楼、下楼、开门、关门、装载乘客、送出乘客。它的工作过程如下:

private void doAction(Instruction command) throws InterruptedException {
    if (command == Instruction.WAIT) {
        status = ElevatorStatus.WAITING;
        sleep(1000);
    }
    else {
        status = ElevatorStatus.RUNNING;
        if (command == Instruction.UP) {
            sleep((long) (MOVE_SPEED * 1000));
            curFloor = curFloor + 1;
            arriveFloorInfo();
        } else if (command == Instruction.DOWN) {
            sleep((long) (MOVE_SPEED * 1000));
            curFloor = curFloor - 1;
            arriveFloorInfo();
        } else if (command == Instruction.MORNINGWAIT) {
            status = ElevatorStatus.MORNINGWAITING;
        }
        List<PersonRequest> outPassengers = passengersOut();
        loads.removeAll(outPassengers);
        List<PersonRequest> inPassengers = dispatcher.getLoadTasks(
                command, MAX_OVERLOAD - loads.size());
        loads.addAll(inPassengers);
        if (outPassengers.size() > 0 || inPassengers.size() > 0) {
            openDoorInfo();
            passengersOutInfo(outPassengers);
            passengersInInfo(inPassengers);
            sleep((long)(OPEN_SPEED * 1000));
            if (command == Instruction.MORNINGWAIT) {
                sleep((long)(MORNING_WAIT_SPEED * 1000));
            }
            sleep((long)(CLOSE_SPEED * 1000));
            closeDoorInfo();
        }
    }
}

第三次作业Elevator类的Sequence Diagram如下:

  笔者觉得自己的电梯线程设计得比较清晰,功能延展性不错,如果需要的话,还能添加更多的功能。

输入线程(主线程)

 

  笔者设计的调度器是静态的,因此只有输入线程和电梯线程在交互,交互过程简单明了,主要操作就是输入线程将乘客请求放入缓冲区以及电梯线程从缓冲区获得乘客请求,这样的设计不易出现线程安全问题。

性能改进

  1. 相比于 ALS 算法,LOOK 算法在性能上更好。如果使用 LOOK 算法,电梯在最底层和最顶层之间运行。当 LOOK 算法发现电梯所移动的方向上不再有请求时立即改变运行方向,这样,电梯走的“回头路”较少。而 ALS 电梯需要根据主请求的变化不断调整行进方向,折返较多,时间开销较大。由于指导书上提供了 ALS 算法,笔者基于稳妥起见选择了 ALS, 未尝试 LOOK 算法。

  2. 第二次作业,笔者先采用平均分配(取模)的方式通过了中测,但性能不佳。之后,笔者在乘客的分配上进行了优化,为每位乘客分配同方向且距离最近的电梯,代码如下:

    public int searchByDirectionAndDistance(PersonRequest personRequest) {
        int minDistance = 19;
        int minDistanceId = -1;
        int currentDistance;
        int i;
    
        for (i = 0; i < elevators.size(); i++) {
            Elevator elevator = elevators.get(i);
            //找出人在电梯行进方向上且距离最近的
            if (elevator.getDispatcher().getCommand() == Instruction.UP &&
                    elevator.getCurFloor() < personRequest.getFromFloor() &&
                    Dispatcher.getDirection(personRequest.getFromFloor(),
                            personRequest.getToFloor()) == 1) {
                currentDistance = personRequest.getFromFloor() - elevator.getCurFloor();
                if (currentDistance < minDistance) {
                    minDistance = currentDistance;
                    minDistanceId = i;
                }
            } else if (elevator.getDispatcher().getCommand() == Instruction.DOWN &&
                    elevator.getCurFloor() > personRequest.getFromFloor() &&
                    Dispatcher.getDirection(personRequest.getFromFloor(),
                            personRequest.getToFloor()) == -1) {
                currentDistance = elevator.getCurFloor() - personRequest.getFromFloor();
                if (currentDistance < minDistance) {
                    minDistance = currentDistance;
                    minDistanceId = i;
                }
            }
        }
        return minDistanceId;
    } 
    //然而遗憾的是,优化后,强测wa了一个点,而优化前的版本却能通过强测。。。
  3. 第三次作业,笔者采用了如下换乘策略:

    先判断该乘客能否通过C类电梯直接到达,如果能,直接进入C类电梯,直达终点。

    如果该乘客不能通过C类电梯直达,就按照起点和终点的奇偶性分类换乘(每位乘客最多换乘一次):

    如果起点是奇数,终点是奇数,首选进入B类电梯,直达;如果所有B类电梯的等待人数过多,则进入A类电梯。

    如果起点是奇数,终点是偶数,首先进入B类电梯,走一半路程,换乘进入A类电梯,到达终点。

    如果起点是偶数,终点是奇数,首先进入A类电梯,走一半路程,换乘进入B类电梯,到达终点(注意换乘楼层必须是奇数!)。

    如果起点是偶数,终点是偶数,进入A类电梯直达终点。

    我们以起点为偶数,终点为奇数这一情况为例进行说明。

    public void handleEvenToOdd(PersonRequestAddTransInfo personRequestAddTransInfo) {
        int fromFloor = personRequestAddTransInfo.getPersonRequest().getFromFloor();
        int toFloor = personRequestAddTransInfo.getPersonRequest().getToFloor();
        if (Math.abs(toFloor - fromFloor) <= 1) {
            personRequestAddTransInfo.setNeedTransfer(false);
        } else {
            personRequestAddTransInfo.setNeedTransfer(true);
            personRequestAddTransInfo.setTransferFrom("A");
            personRequestAddTransInfo.setTransferTo("B");
        }
        elevatorAs.get((new Random()).nextInt(elevatorAs.size())).
                addPersonRequest(personRequestAddTransInfo);
    }
    //值得注意的是,当需要换乘的乘客第一次下电梯后,我们需要为他生成一个新的请求,放入该乘客即将换乘的电梯的等待队列。
    //新请求的起始楼层是换乘楼层,终点楼层仍为原来的终点楼层。

    分析自己的bug

      第一次作业

      在公测中未出现bug。在互测中被hack一次,原因是我有一处判断乘客的起始楼层与终点楼层之差的绝对值时写成了小于19,于是从1层到20层的乘客就没有送到。。。修复bug只需将<19更正为<=19即可。

      第二次作业

      因为优化后的版本在强测中 RTLE 了一个点,所以笔者在bug修复环节采用了最初版本(平均分配),全部通过。这次作业让我明白了,一些优化后的调度算法在处理一些极端情况时(比如在较晚的时间点投入很多乘客请求,在同一时间投入很多一样的乘客请求等),效果不如平均分配。这也告诉我,优化要小心,要考虑清楚极端情况,不要因为追求性能分而错失了正确性。

      第三次作业

      强测wa了两个点,两个点都错在从奇数楼层到偶数楼层的情况。而笔者在本地测试换乘策略时并未发现错误,所以面对这两个wa掉的点着实有些懵圈。在bug修复时,我改变了该情况的乘梯策略,由B换乘到A改为了直接搭乘A类电梯直达,成功修复了bug。

    第二次、第三次作业的互测都很安静,没有人hack成功。

    分析别人的bug

      经过一个单元三次互测的厮杀,笔者发现,如果没有肉眼可见的bug,要想在第二单元成功hack到别人真的很不容易,我就没有任何一次成功了。同学们的代码逻辑性都很强,思维也很严密,几乎不会出现死锁、线程没有正常结束等问题,如果手中没有一个很强的评测机,几乎不可能hack成功。对于笔者这种小白,互测纯属娱乐,只送头不拿刀。要想得分,还是得在课下好好检查自己的程序,争取得到一个满意的强测结果。

      相比于第一单元,第二单元所模拟的过程更加动态,每一遍的执行结果都取决于JVM的运转,每一遍都会有细微差别。第一单元互测中投放的数据点都是奇形怪状的,主要针对各种嵌套函数、连加连减的处理;第二单元的互测可以在较晚的时间投放很多乘客请求,也可以投放很多毫无规律的请求,撞撞大运,hack“死锁”。笔者在两单元的互测中都很少hack成功,因此也没有什么分析别人的bug的发言权。

    心得体会

      关于线程安全

      这一单元对于多线程的学习让我收获了很多。首先是对多线程本身,比如synchronized关键字,wait() 与 notifyAll() 的使用,如何避免死锁等等。多个线程尽量不要同时操作同一个变量,可以用同步锁synchronized()对共享变量加锁,避免它被多个地方同时修改。虽然如此,我们也不能滥用锁,否则会出现死锁,多个线程同时阻塞,程序卡住。wait() 与 notify() 可以搭配使用,wait() 把线程阻塞在预执行队列里,notify() 随机唤醒等待中的一个线程,notifyAll() 唤醒正在等待的全部线程。

      关于设计层次

      落笔前脑子里一定要想清楚,要不然又要大面积重构了。。。设计的时候多想想现实世界的规律,就能得到一个合理的层次。比如说,电梯只能知道里面有谁,却无法自己领悟该往哪儿走,这就需要调度器给电梯指令,告诉它该往哪儿走。

      另外,多个线程既要泾渭分明,也要协同合作。输入线程和电梯线程不能直接交互,而要通过一个缓冲区读取乘客请求,这样的设计较为清晰,不易出错。

      当我们的架构初具雏形后,我们就可以考虑性能的优化了。比如,尝试 LOOK 算法、设计换乘来避免一部电梯过载等等,都可以在一定程度上减少电梯的总运行时间。此外,笔者在研讨课上聆听了很多dalao对于调度器设计的思考,更加明白了集中式调度与分布式调度的优缺点。比如,集中式调度用“竞争抢人”的方式实现了更好的性能, 因为它能提前把各部电梯调度起来;而分布式调度写起来更为简单,直接通过特定规则将乘客指派到各个电梯,稳妥、不易出错。

      这一单元小小的遗憾就是在强测中做得还不够完美,后两次作业都因为优化时没有考虑全面而失去了正确性。希望下一单元的强测可以不再翻车!

     

 

 posted on 2021-04-27 21:39  SarahGaga  阅读(86)  评论(0编辑  收藏  举报