2022年北航面向对象程序设计第二单元总结

2022年北航面向对象程序设计第二单元总结

今年的第二单元的各个拓展还是很好猜测到的,我在第二单元三周的架构都可以满足最终的课设要求,由于我的第一周的架构的线程安全问题解决的困难性很高,最终选用的架构是第二周所写的非常简单的look+自由竞争策略(原因为什么后面再说),第三周相对于第二周的添加也就只有一个更改的电梯构造函数然后de了下bug,所以这里第二周和第三周的project我就合并在一起写了,然后再讲第3周才de出来的第1周的bug架构

作业内容概述

(因为自己向学长问题目的时候细节总是问不全,所以我在这直接从指导书上扒了点儿

电梯系统说明

本月作业为模拟一个多线程环形实时电梯系统。

系统基于一个类似北京航空航天大学新主楼的大楼,大楼有 A,B,C,D,E 五个座,每个楼座可以对应多台电梯,可以在楼座内 1−10 层之间运行。每个乘客的起始位置和目的位置可以在楼层和楼座上都不同。电梯可对纵向电梯的运行速度、可容纳人数,横向电梯的运行速度、可容纳人数、可开关门信息进行定制

系统从标准输入中输入请求信息,程序进行接收和处理,模拟电梯运行,将必要的运行信息通过输出接口进行输出。

具体而言,电梯系统具有的功能为:

  • 1、楼座对应的电梯:上下行,开关门,以及模拟乘客的进出。
  • 2、楼层对应的电梯:左右行,开关门,以及模拟乘客的进出。

电梯系统可以采用任意的调度策略,即在任意时刻,系统选择上下(左右)行动,是否在某层开关门,都可自定义,只要保证在电梯系统时间不超过系统时间上限的前提下将所有的乘客送至目的地即可。

电梯每纵向(横向)运行一层(座)、开关门的时间为固定值,仅在开关门窗口时间内允许乘客进出

电梯系统默认初始在A,B,C,D,E 五个楼座的 1 层中有一个容量为 8 人,运行速度为 0.6 s/层的纵向电梯。

电梯系统默认初始在A 座的 1 层中有一个容量为 8 人,运行速度为 0.6 s/座,可以在全部楼座停留的横向电梯。

电梯参数说明

  • 1、可到达楼层:1-10 层
  • 2、纵向电梯初始位置:各座 1 层
  • 3、横向电梯初始位置:A 座各层
  • 4、数量:最初 5 部纵向电梯、1 部横向电梯,可增加
  • 5、编号:最初 5 部电梯中 A 座为 1 号,B 座为 2 号,依次类推,最初的横向电梯为 6 号;新增的电梯编号自定义,但不可与之前的重复
  • 6、移动一层(座)花费的时间:可定制
  • 7、开门花费的时间:0.2s
  • 8、关门花费的时间:0.2s
  • 9、限乘人数:可定制
  • 10、横向电梯可停靠楼座:可定制

电梯请求说明

本次作业的电梯,为一种比较特殊的电梯,学名叫做目的选择电梯

大概意思是,在电梯的每个入口,都有一个输入装置,让每个乘客输入自己的目的位置。电梯基于这样的一个目的地选择系统进行调度,将乘客运送到指定的目标位置。

所以,一个电梯请求包含这个人的出发楼座、出发楼层、目的楼座和目的楼层,以及这个人的id(保证人员 id 唯一),请求内容将作为一个整体送入电梯系统,在整个运行过程中请求内容不会发生改变。

Alpha版本(观察者模式 look+自由竞争)

设计思路

这一版本主要基于对电梯自我设置的考虑,用观察者模式,采用look+自由竞争的策略,以保证每个电梯的运载量为核心目标来设计(感觉也是采用最多的方法),这种设计方式的性能下限极高(听了好几个小伙伴说了他们的性能分99.8+,and还有3次作业99.3+的 %%%),而且非常地好写,实测无优化性能95分的代码只需要553行(听说往届还有400行KO的),所以非常建议首先选择。

(以及先放上一篇文告诫开始就打算采用复杂多线程设计模式的同学OO 第二单元总结:调度祭天,法力无边 - 葡萄味柠檬茶 - 博客园 (cnblogs.com) and 提醒一下如果是第一次写多线程类的java程序一定要给自己留出充分的理解线程安全的空间 异想天开可能会吃亏 以及本策略当成一个复杂模式的性能对照也很不错)

look+自由竞争的策略的执行策略是直接放在电梯类中的,look策略可以通过两句话直接描述

电梯向某一方向运行时,若当前层有请求且请求的运行方向与当前相同,则开门接收乘客;
若电梯内没有乘客且前方没有新请求则转向。

这是一种非常易于实现的算法,以及在完成调度过程的过程中会发现会频繁反复上面的两句话,推荐在测试其他思路的时候也可以将调度思路写出来进行对比

这种方法下的调度策略最好使用建议性接口(BUAA_OO_2021_第二单元总结 - 春日野草 - 博客园 (cnblogs.com)),因为很多不经意的优化会损害look策略本身比较优秀的性能

UML图

UML类协作图

线程安全设计

线程安全的设计想法早在失败的尝试时就已经发现好的解决策略了——设计线程安全类

开始使用的是synchronized通过初始化锁对象的方式实现线程安全的维护,但这一方案的线程安全当工程很大的时候便非常地难维护(指最初的2400行代码/(ㄒoㄒ)/~~),但将共享数据全部放在同一个类中的存取设置便可以很好地设置waitnotify条件,这也很符合OO的设计思想,将对共享对象的更改和获取操作封装进该对象(共享对象)中

以及为了更好地维护线程安全,我推荐单托盘神教的策略,将横纵向的请求全部放置在一个类(RequestQueue)中,然后仅有InputThread放入到RequestQueueElevatorRequestQueue取这一个操作

以及为了更好地维护wait条件,我推荐以下更稳(wu)妥(chi)的方法

public synchronized void waitForNew() {
	try {
		wait();
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}

通过这种方式,我在三次作业中仅使用了1个wait+5个notifyAll便完成了线程安全的设计(如果将横纵向的请求队列合并可以仅使用3个notifyAll

但在我实际提交的代码中为了更好地同步,我将横纵上的getRequest分开wait(因为以及非常安全了(x )

public synchronized void addPortraitRequest(MyRequest personRequest) {
    this.portraitRequests.add(personRequest);
    this.finishFlags.put(personRequest, false);
    notifyAll();
}
public synchronized MyRequest getPortraitFreeRequest(MyRequest personRequest) {
    if (portraitRequests.isEmpty()) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    if (portraitRequests.isEmpty()) {
        return null;
    }
    MyRequest removePersonRequest = null;
    for (int i = 0; i < portraitRequests.size(); i++) {
        if (portraitRequests.get(i).equals(personRequest)) {
            removePersonRequest = portraitRequests.remove(i);
        }
    }
    notifyAll();
    return removePersonRequest;
}

最后通过所有请求都已得到满足作为终止标志的设置条件

public synchronized boolean elevatorEnd() throws NullPointerException {
    try {
        for (MyRequest myRequest : finishFlags.keySet()) {
            if (finishFlags.get(myRequest) == null || !finishFlags.get(myRequest)) {
                return false;
            }
        }
        return true;
    } catch (NullPointerException e) {
        e.printStackTrace();
    }
    return false;
}
public synchronized void finishRequest(MyRequest myRequest) {
    finishFlags.put(myRequest, true);
    notifyAll();
}

然后在电梯中仅在无可运行的标准时调用一次RequestQueuewaitForNew

在这种方式下的线程安全保护也非常好理解和规范,就不详细解释了

运行策略

斗胆丢份纵向电梯的look策略的概念代码(这套代码是做基准测试时用的,性能相当于无其他优化的最佳look

@Override
public void run() {
    while (true) {
        if (requestQueue.isEnd() && persons.isEmpty() &&
                requestQueue.elevatorEnd()) {
            //requestQueue.portraitRequestsIsEmpty() 原条件 现可能用不到
            //super.dealtOver();
            return;
        }
        if ((persons.size() < super.getMaxCapacity() ||
                (persons.size() == super.getMaxCapacity() && getOut() != null))
                && needInOrOut()) {
            open();
            MyRequest person = getOut();
            while (person != null) {
                super.myElevatorOut(person.getPersonId(), building, currentFloor);
                if (person.getToBuilding() != building) {
                    person.setFromFloor(currentFloor);
                    requestQueue.addHorizontalRequest(person);
                } else {
                    requestQueue.finishRequest(person);
                }
                persons.remove(person);
                person = getOut();
            }
            while (persons.size() < super.getMaxCapacity()) {
                MyRequest personRequest = getIn();
                if (personRequest == null) {
                    break;
                }
                super.myElevatorIn(personRequest.getPersonId(), 
                                   building, currentFloor);
                persons.add(personRequest);
            }
            close();
        } else {
            if (persons.isEmpty() && getForwardRequest() == null) {
                if (getForwardBackRequest() != null) {
                    move();
                }
                else if (getBackwardRequest() == null) {
                    requestQueue.waitForNew();
                } else {
                    moveDirection = !moveDirection;
                }
            } else {
                move();
            }
        }
    }
}

@Override
public boolean needInOrOut() {
    CopyOnWriteArrayList<MyRequest> personRequests = 
        requestQueue.getPortraitRequests();
    for (MyRequest personRequest : personRequests) {
        if (personRequest.getFromBuilding() == building) {
            if (moveDirection && personRequest.getFromFloor() == currentFloor &&
                    personRequest.getSendFloor() > personRequest.getFromFloor()) {
                return true;
            } else if (!moveDirection && personRequest.getFromFloor() == 
                       currentFloor &&
                    personRequest.getSendFloor() < personRequest.getFromFloor()) {
                return true;
            }
        }
    }
    for (MyRequest personRequest : persons) {
        if (personRequest.getSendFloor() == currentFloor) {
            return true;
        }
    }
    return false;
}

private MyRequest getIn() {
    MyRequest personInRequest = null;
    CopyOnWriteArrayList<MyRequest> personRequests = 
        requestQueue.getPortraitRequests();
    for (MyRequest personRequest : personRequests) {
        if (personRequest.getFromBuilding() == building && 
            personRequest.getFromFloor() == currentFloor) {
            if (moveDirection && personRequest.getSendFloor() > 
                personRequest.getFromFloor()) {
                personInRequest = 
                    requestQueue.getPortraitFreeRequest(personRequest);
                break;
            } else if (!moveDirection && personRequest.getSendFloor() <
                    personRequest.getFromFloor()) {
                personInRequest = 
                    requestQueue.getPortraitFreeRequest(personRequest);
                break;
            }
        }
    }
    return personInRequest;
}

private MyRequest getOut() {
    MyRequest person = null;
    for (MyRequest personRequest : persons) {
        if (currentFloor == personRequest.getSendFloor()) {
            person = personRequest;
            break;
        }
    }
    return person;
}

横向电梯的look可控性不强,上面线程安全中做结束判断的NullPointerException就是横向的look策略导致的,虽然没什么影响(线下跑过了6000+组超强测数据验证),但还是觉得横向的ALS策略更稳妥

至于总的换乘策略,这一版本是直接在输入时进行了规划的,后面优化的部分会再次提这一部分

总之这种方法是真的方便简单+写起来非常舒服,总结起来就是

调度祭天 法力无边

look+自由竞争,能摸大鱼

原来是《别人的电梯月和我的电梯月》,用了是《我的电梯月和别人的电梯月》

Beta版本(Master-Worker模式)

设计思路

这一版本的设计思路来源于对于调度的整体过程的可控性,为了更好地保持所有的调度目标参数的可见性,尽可能地将所有的调度参数发派给能够进行最优调度策略的类中进行策略规划,然后发派给电梯执行,并收取电梯执行的情况进行进一步分配

这种调度策略可以得到最佳的调度策略,并且可以很方便地加入预测算法(如果数据真的有规律的话(x )

相关的cai坑调研发在了github

https://github.com/oyanghd/Investigation-on-OO-elevator-scheduling-algorithm-of-Beihang-University.git

以及在此再推荐一下yzr学长的一篇Dijsktra算法实现的电梯调度规划算法(https://blog.coekjan.cn/2021/04/21/Destination-Control-Elevator-Simulation/),或许学长与我最初的想法类似,我写完后发现跟学长的架构比较像,虽然今年的作业加入了多座之间的横向电梯,但我实际上真正用算法规划的部分也只有纵向电梯的运转,因为肝不动(指一摸起来就不想肝了)

对于这个架构我做了太多太多数不清的尝试了,但最终还是没能搬上第三次作业使用,线程安全的维护难度太高了,或许优化便利的架构与维护线程安全便利的架构是互斥的,开个玩笑,其实还是不够卷,第三次这周是奔赴去了我心心念念的OS(x

对于优化的部分为了便于总结和对比两种架构的优化方案,我放在了优化设计的章节来统一讲

UML图

UML类协作图

线程安全设计

前期的做法是无托盘设计,通过主线程初始化锁对象来在调度线程内直接通过synchronized进行同步块操作,但这种做法非常麻烦,因为类内在定义方法的时候想使用的锁其实大多都不是本类的this,但为了共用锁便只能使用该类的锁,但在其他类中便需要使用其他的锁,因此就会出现两个synchronized嵌套的情况,然后容易出什么问题就不用我多说了吧(x

所以这时就想到了曾经在超算时使用的Cache的共享数据池,于是就有了这一托盘式设计,没想到周四上机就看到实验代码便是这么一种设计,后面也发现这种想法确实也是使用地比较广泛的,以及亲测非常好用

一级RequestQueue的工作其实只是过滤请求,出于分段结束的考虑,便设计了这一托盘

二级RequestQueue的任务是请求发派和请求预告,即分请求在当前楼层能否转运将其置于当前层横向请求队列还是将其置于当前座的纵向请求队列,并在存在横向电梯请求队列为空且可达时分配一个提前开始移动到转运楼座

三级的DispatchData的设计目标是由调度线程向电梯发派执行任务,和存储该电梯未来需要接送的请求

其中一级RequestQueue仅有一个,二级的RequestQueue每一个楼座每一个楼层各有一个,三级的DispatchData是每个电梯各有一个

(摆上DispatchData线程安全处理的代码,因为实在太长了,就省略了一部分无线程安全问题的部分,RequestQueue的代码跟前面的基本类似,便不在这里展示了)

DispatchData

public class DispatchData {
    private int executeFlag; // 0 is move 1 is open 2 is close 3 is in 4 is out
    private int toFloor;
    private ArrayList<Request> requests;
    private boolean isEnd;

    public DispatchData() {
        executeFlag = -1;
        toFloor = 1;
        requests = new ArrayList<>();
        isEnd = false;
    }

    public synchronized Request getInFloorRequest(int floor) {
        for (Request request : requests) {
            if (request.getFromFloor() == floor) {
                requests.remove(request);
                return request;
            }
        }
        return null;
    }

    public synchronized int buildingGetExecuteFlag() throws InterruptedException {
        if (executeFlag != -1) {
            wait();
        }
        return executeFlag;
    }

    public synchronized int elevatorGetExecuteFlag() throws InterruptedException {
        if (executeFlag == -1) {
            wait();
        }
        return executeFlag;
    }

    public synchronized void setExecuteFlag(int executeFlag) {
        this.executeFlag = executeFlag;
        notifyAll();
    }
    
    ··· ···

    public synchronized boolean isEnd() {
        return isEnd;
    }

    public synchronized void setEnd(boolean end) {
        isEnd = end;
        notifyAll();
    }
}

这种方法最大的特点就是基本用不上方法外的synchronized,以及在出现线程安全问题时解决思路会非常地明确,相比于前一种设计方式,这一种设计方式实现了动态的换乘分配(前一种方式好像也有同学实现了),具体方式在下方的优化设计中说明

运行策略

调度完全依赖于BuildingProcessFloorProcess的调度,电梯仅做任务的执行器,这种调度方式的好处在于电梯的运行状态完全分立,且各个影响调度策略的输入在这一类全部显式声明,非常利于加入各种优化

策略的主要依赖为strategy方法的设计,但实在难以展示这个(因为受限于CodeStyle的要求,总共521行的代码拆的东零西落,改的实在丑出天际),所以就讲一下实现过程,然后以BuildingProcess为例摆一下主要调度器的run方法和电梯的rungetIn getOut方法

BuildingProcess

@Override
public void run() {
    while (true) {
        if (requestQueue.isEnd() && requestQueue.isEmpty()) {
            Elevator elevator = elevators.get(0);
            DispatchData dispatchData = dispatchDatas.get(0);
            if (elevator.getPersons().isEmpty() && 
            dispatchData.getRequests().isEmpty()
                    && elevator.getState() == 0) {
                for (DispatchData dispatchData1 : dispatchDatas) {
                    dispatchData1.setEnd(true);
                }
                return;
            }
        }
        for (int i = 0; i < elevators.size(); i++) {
            DispatchData dispatchData = dispatchDatas.get(i);
            Elevator elevator = elevators.get(i);
            try {
                if (!requestQueue.isEmpty() || !dispatchData.getRequests().isEmpty()
                        || !elevator.getPersons().isEmpty()) {
                    if (dispatchData.getExecuteFlag1() == -1) {
                        int executeFlag = strategy();
                        dispatchData.setExecuteFlag(executeFlag);
                    }
                } else {
                    if (requestQueue.isEnd() && requestQueue.isEmpty() &&
                            elevator.getPersons().isEmpty() && 
                            dispatchData.getRequests().isEmpty()
                            && elevator.getState() == 0) {
                        for (DispatchData dispatchData1 : dispatchDatas) {
                            dispatchData1.setEnd(true);
                        }
                        return;
                    }
                    else if (requestQueue.isEnd() && requestQueue.isEmpty() &&
                            elevator.getPersons().isEmpty() && 
                            dispatchData.getRequests().isEmpty()
                            && elevator.getState() != 0) {
                        int executeFlag = strategy();
                        dispatchData.setExecuteFlag(executeFlag);
                    }
                    else {
                        requestQueue.waitForNew();
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Elevator(这里修了一下代码风格,交上去的代码风格优化摆在这里感觉空旷旷的)

@Override
public void run() {
    while (true) {
        if (dispatchData.isEnd() && persons.isEmpty() && state == 0) {
            return;
        }
        int flag = -1;
        try {
            flag = dispatchData.getExecuteFlag2();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (flag == -1) {
            continue;
        }
        synchronized (this) {
            switch (flag) {
                case 0: arrive(); break;
                case 1: open(); break;
                case 2: close(); break;
                case 3:
                    if (currentAmount >= maxCapacity)
                        break;
                    myElevatorIn();
                    break;
                case 4: myElevatorOut(); break;
                default: break;
            }
        }
        dispatchData.setExecuteFlag(-1);
    }
}

public synchronized double myElevatorIn() {
    //inOrOut true is in false is out
    Request inRequest = dispatchData.getInFloorRequest(wz);
    boolean flagIn = inRequest != null;
    double t = 0;
    while (flagIn) {
        t = Print.in(inRequest.getPersonId(), building, wz, elevatorId);
        persons.put(inRequest.getPersonId(), inRequest.getToFloor());
        dispatchData.removeRequest(inRequest);
        this.currentAmount++;
        inRequest = dispatchData.getInFloorRequest(wz);
        flagIn = inRequest != null;
    }
    return t;
}

public synchronized double myElevatorOut() {
    int outRequest = getOutFloorRequest();
    boolean flagOut = outRequest != -1;
    double t = 0;
    while (flagOut) {
        t = Print.out(outRequest, building, wz, elevatorId);
        persons.remove(outRequest);
        this.currentAmount--;
        outRequest = getOutFloorRequest();
        flagOut = outRequest != -1;
    }
    return t;
}

对于BuildingProcessFloorProcessstrategy方法因为过度地想完成一版能解决突然加电梯后的线程安全问题,并没有写文献推荐中使用的dp方法(最开始写的是那一方法,但因为频繁抽出中间的某一请求导致线程安全问题太多,只好换线程安全问题较小的方法,现在有点懒得改回去了),而是使用了栈分配的策略,循环每一个电梯和DispatchData,使用一个变量来记录接人和下人的过程,来判断某一电梯能接人的情况,然后全部置入DispatchDatarequests中,然后电梯只需要连续运行到每一层判断是否需要接人和下人就可以了

这种方法下可以对每一个电梯单独操作,并可以得到每个电梯完成每个任务的时间,首先通过当前楼层对电梯排序,第一个电梯在得到其预分配的请求队列后,后面的电梯开始遍历前方电梯的请求队列和外部请求队列,优先以栈运行的方式接受所有请求,而后倒序遍历其他电梯的请求队列,来判断自身在加入工作后是否可以使总工作时间缩短,如果不是缩短便continue判断下一个电梯,如果缩短便将该请求放入自己的预分配请求队列,再倒序遍历下一个请求

在所有电梯都参与了分配后,将这一个楼座的预分配请求队列置于到该楼座每一个电梯的DispatchData中,然后在下一次分配时将其中剩余的未处理的部分收取回来重新分配

相较于上一种方式,对于横向电梯的楼座的处理几乎完全等同于纵向电梯,仅更改环形运行策略便可

环形运行策略这里采用如下方式来计算环形距离(其实可以直接打表,只是感觉这样写比较优雅)

private int horizontalDistance(Character ch1, Character ch2) {
    if ((ch2 - ch1 - 3) % 5 >= 0) {
        return (ch2 - ch1 - 3) % 5 - 2;
    } else {
        return (ch2 - ch1 - 3) % 5 + 3;
    }
}

然后选取距离近的方向移动

这里加入了一个预到达请求的优化,就是当所有请求都分配后,寻找未分配请求的电梯,如果存在便循环其他的楼座或楼层(座的找层的,层的找座的,仅考虑下一次的换乘),寻找是否其换乘楼座和楼层是否为当前楼座或楼层,是的话便将这一请求当作一个虚拟请求加入请求队列,诱导电梯向该方向运行,这样电梯只需要不接送虚拟请求便可达到预调度的优化

总的调度策略大致可以描述如下

每一个楼座和楼层循环当前楼座和楼层的请求队列,优先使用栈解析收取完当前楼座或楼层的请求;
后续电梯逐个遍历前方电梯已分配请求,判断每个请求收取后的执行时间系统总调度时间是否可以缩短,
    如果可以缩短总调度时间则收取并继续判断
    如果不能缩短则break请求判断continue判断下一个电梯
判断是否有空请求电梯,
    如果有空请求电梯则遍历其他所有楼座/其他所有楼层的电梯(座的判断层,层的判断座),
    	如果存在换乘到当前楼座/楼层的请求,
    		将请求虚拟出一个预请求(直接复制然后set虚拟标志),加入到该楼座或楼层的请求队列
下一个执行时刻收录所有DispatchData中的实Request,重新加入到请求队列中重新分配,并删除虚拟Request
(下一时刻指由该调度器管理的所有电梯拿走了自己的executeFlag等待下一次任务分配的状态)

这一套策略的优化真的做了非常多非常多,实际上我没听到过其他同学使用过其他比look快的策略,在我的已知中,这是唯一一个比look策略性能更优的调度策略了,无奈本周OS压力太大,没能真正搬上HW7测试。

这一套策略在HW5尝试失败后我便换了look+自由竞争的观察者模式,并一直在de这一策略的bug,但实际上这一套策略的bug我第三周才de出来在第一周的内容下才完全安全的版本,中途加电梯后的线程安全问题至今还没de出来,这还是在我当前的调度策略并没有完美地实现预期的dp分配情况下,最终在第一周1600+行的代码开发到2200+行的CodeStyle合适的版本,最后部分线程安全的代码+策略修改的代码一直飙到3240行,属实蚌埠住了,不过感觉这一思路有一个指导思想应该就可以实现了吧,我最开始使用这一策略的时候完全基于自己的调研了,以及做了很多的多线程的线程安全测试耽误了开发这一策略的时间,也算是为了后面的同学有机会尝试这种策略吧,我下面分享一下自己尝试过的一些多线程开发的实用方式和工具,为大家缩短这方面的测试时间以及避避坑~

线程安全控制

这里分享一下自己测试过的一些维护线程安全的工具或容器,还有不同模式下线程安全的设计

工具/容器

对于工具或容器,使用方式还有使用技巧这里仅大致展示,或许百度比我的解释更清楚,我觉得更重要的是划清这些工具和容器能够帮我们解决的线程安全问题的界限,以及什么样的问题通过更换容器无法解决

synchronized + wait + notify

我的推荐是除了类方法外,不使用synchronized来进行同步控制,实际上使用synchronized关键字可以很好地让代码达到更好地同步,但是在我们的OO作业中,线程安全的重要性远比性能重要,而且实际运行时这部分对应的CPU时间并不长,这部分的优化意义不大,但对程序设计考虑上的影响会很大,因此仅推荐类方法上加入synchronized控制同步

这一关键字的作用是通过synchronized某一对象来把这一对象当做锁对象来使用,当多线程访问到这一目标时,执行到synchronized处时会获取这一对象的锁,其他对象访问时会阻塞在这一行代码处,知道拿到这一对象的锁执行完互斥代码块后释放该锁。类方法中的synchronized关键字默认的锁对象为this,这种执行情况下会对应同一时刻仅有一个线程可以调用public方法,这种方法进行读写的控制还是非常有效的

waitnotify是生产者消费者模型(本次电梯的最基础的模型)需要掌握的阻塞方式,如果没有恰当的wait会造成busywaiting(轮询)的问题,可以从控制台看到一个非常高的CPU占用率,这种情况在评测中是必定CTLE的。设置条件一般为没有可以处理的请求便进入wait,然后在可以将上述状态改变的位置加入notify(在生产者消费者模型下通常为notifyAll

这部分前面已经说的差不多了,就不详细举例了,我个人认为在生产者消费者模型中设置线程安全类更有利于我们的开发的线程安全控制,后面几种的工具测试也基于这一模型下的同步情况的应用测试(不然场景太多了没办法做枚举和比对)

ReentrantLock

这一锁方式为我最开始在Master模式写的用于顶替多synchronized的一种比较简洁的方式,这样便无需通过将RequestQueueDispatchData中的项目通过锁对象的方式传递到下一层再通过synchronized加锁了,但这也不是一个好方法,还是推荐设置线程安全类,设置线程安全类后基本无需类方法外的synchronized,同样也不需要ReentrantLock,所以这种写法在我的模式中后面被淘汰了

对于线程安全类中的同步块,相比于上面提到的synchronized同步,使用ReentrantLock更为简洁且有效

ReentrantLock相比synchronized加锁的特点:

  • 提供了无条件的,可轮询的,定时的以及可中断的锁获取操作
  • 加锁和解锁都是显式的

在这种加锁模式下,同一对象可以多次获取一锁对象的锁,且加锁解锁都是显式的lockunlock,看上去更直观,且更为灵活(但也代表了用不好更容易出bug),ReentrantLock的控锁颗粒度和灵活性肯定都高于synchronized形式,但要注意恰当使用才行

//声明和使用
Lock lock = new ReentrantLock(); //非公平锁
//Lock lock = new ReentrantLock(true); //公平锁
try {
    lock.lock(); // 加锁
} finally {
    lock.unlock(); // 释放锁
}

在生产者消费者模型中最好使用非公平锁,ReentrantLock底层实现中为一个FIFO模型,使用公平锁会先进入等待队列的对象会先得到锁,而生产者消费者模型应当是随机得到锁

推荐两篇博客ReentrantLock 详解 - 知乎 (zhihu.com)ReentrantLock详解_SunStaday的博客-CSDN博客_reentrantlock

ReentrantLock 详解 - 知乎 (zhihu.com)里有一个很好的生产者消费者的例子来理解lock的作用(但还是推荐线程安全类)

两篇博客中都有提到使用lock控制同步块

ReentrantReadWriteLock

我姑且先将这种写法归结到dl写法(x,因为这种写法也是为了提高同步效率,满足仅有一个进程在写多个进程可以读的目的,使用读写锁可以很容易地分离读写操作,相比于普通的ReentrantLockReentrantReadWriteLock可以更方便地起到读写分离的目的

可以通过一下形式实现简单的读写接口,然后通过其他类使用readwriteRequestQueue进行成员变量的更改

public class RequestQueue {

    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    
    public void read() {
        try {
            lock.readLock().lock();
            System.out.println(Thread.currentThread().getName() + " start");
            Thread.sleep(10000);
            System.out.println(Thread.currentThread().getName() + " end");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.readLock().unlock();
        }
    }
    
    public void write() {
        try {
            lock.writeLock().lock();
            System.out.println(Thread.currentThread().getName() + " start");
            Thread.sleep(10000);
            System.out.println(Thread.currentThread().getName() + " end");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.writeLock().unlock();
        }
    }
}

(读写锁的测试比较多,就不一一拿出来讲了)

总结起来可以发现:

在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

仔细想想,这个设计是合理的:因为当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

(引用自读写锁——ReentrantReadWriteLock原理详解 - 云+社区 - 腾讯云 (tencent.com)

以及推荐一篇适用性讲解和测试的blog(Java并发编程--ReentrantReadWriteLock - 在周末 - 博客园 (cnblogs.com)

推荐一篇做读写的四种组合的测试blog(ReentrantReadWriteLock用法 - 简书 (jianshu.com)

Atomic

java.util.concurrent.atomic 的包里有AtomicBoolean, AtomicInteger, AtomicLong, AtomicLongArray, AtomicReference等原子类,这些类可以从设定上理解成是一系列线程安全的变量类这里就以最常用的AtomicInteger为例讲解

AtomicInteger atomicInteger = new AtomicInteger();
通过atomicInteger.get()获取值
通过atomicInteger.set(value)修改值
可以通过atomicInteger.compareAndSet(expectedValue,newValue)来比较是否需要修改再进行修改值(以及这个方法有返回值,与expectedValue相同会返回true,不同会返回false
可以通过atomicInteger.getAndDecrement()和atomicInteger.decrementAndGet()完成自加后取值和取值后自加的操作
可以通过atomicInteger.getAndAdd(value)和atomicInteger.addAndGet(value)完成加后取值和取值后加的操作

这一方式实现的整数值可以直接维护单一整数变量的多线程修改维护,比如我在Master模式中的DispatchData中的executeFlag就尝试了AtomicInteger(最终没使用是希望维护一下线程安全问题的统一处理,因为我不想在类外修改这一变量,在内部单独设立一个函数修改不加synchronized怕后续拓展出问题,加synchronized又会影响这一频繁操作导致的并发性降低),但在look策略中的finishRequest的操作就可以通过这一方式进行addfinishRequest进行计数来确定程序的执行结束,这种写法甚至还能再省一个notifyAll和判断中止部分的代码,实现起来更优雅

这一部分的操作比较简单,就只推荐一个比较全面的博客吧(其实他说的我也差不多讲了Java AtomicInteger的用法 - 简书 (jianshu.com)

Vector HashTable

这个是两个比较常用的线程安全类了,类似这样的java还有很多,但常用的就是Vector代替ArrayListSetHashTable代替Map,其实Collections中的synchronizedCollection(Collection c)方法可将一个集合变为线程安全,其内部通过synchronized关键字加锁同步,可以更方便的自由设计我们的线程安全结构

以及java还有Concurrentxxx数据结构,最常用的是ConcurrentHashMap,当然还有ConcurrentSkipListSetConcurrentSkipListMap等等,这些类使用ReentrantLock来提供更高的并发性和伸缩性,这种分段锁的机制可以实现更大程度的共享

但我个人感觉这些类的使用并不能从更换数据结构的方式来解决某些线程安全问题,就是使用这些结构只能解决一部分来自非安全数据结构->线程安全数据结构的问题,并不能解决一些设计上边存在问题的问题\doge,换句话说就是这些是在多线程程序的共享数据中必须要用的部分,但不能通过使用他们便不关注数据安全问题了

这里的应该是多线程基础,也没什么博客推荐啦,如果不理解的话,大家可以自己查查这里

BlockingQueue

可以从名字理解这一数据结构——阻塞队列,大体上可以有ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue三个数据结构实现的阻塞队列,在本单元第一次作业中可以尝试使用LinkedBlockingQueue,可以直接解决RequestQueue的线程安全问题,但这种实现会有一个性能问题,这也是队列的特点吧,就是每次只能取队首的元素,只能是取完了再加回去,这样子取出自己需要的元素在这一数据结构下十分繁琐,而使用PriorityBlockingQueue时所实现的compareTo是在Request类中就要实现的(仅与Request相比较),这在我们的整体优化下都是不能满足要求的方式,但可以参考BlockingQueue的底层结构来完成我们的加锁操作(插入删除共用一把锁),可以参考dhy学长的博客(BUAA OO 第二单元(电梯) 总结博客 - Dong_HY - 博客园 (cnblogs.com)),这一方法我的感觉是实现起来比较容易,线程安全性也非常高,唯一的缺点在于不够灵活

Semaphore

Semaphore就如同它的名字,就是一种用来保护一个或者多个共享资源的访问的计数器。在OS中也有类似的概念(我们今年OS的lab3-1,就是写这篇博客这一周刚做的Extra就是信号量,50分,悲),当一个线程要访问一个资源就必须先获得信号量。如果信号量内部计数器大于0,信号量减1,然后允许共享这个资源;否则,如果信号量的计数器等于0,信号量将会把线程置入休眠直至计数器大于0。这样一听就可以发现Semaphore对于线程安全的控制能力也非常强,但要注意的是当信号量使用完成时一定要释放,并且要注意释放和获取的逻辑,否则很容易出现死锁

截一段我使用信号量做测试时找的一个代码

final Semaphore semaphore = new Semaphore(2);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
    final int index = i; 
    executorService.execute(new Runnable() {
        public void run() {
            try {
                semaphore.acquire();
                System.out.println("线程:" + Thread.currentThread().getName() + 
                                   "获得许可:" + index);
                TimeUnit.SECONDS.sleep(1);
                semaphore.release();
                System.out.println("允许TASK个数:" + semaphore.availablePermits());  
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
}
executorService.shutdown();

使用信号量来控制多线程程序,可以获取到相比于锁更高的性能(我还是把它归到dl们的实现中,如果我有时间改进这个,我会更希望能de出来我的Master模式的新加电梯的bug😂),且过程可控(比如两个线程进行读写操作的时候还是可以在内部设置执行flag进行两线程操作的控制的(注意避免轮询))

这里推荐一篇我觉得写的非常全面且简洁的blog(JAVA Semaphore详解 - 简单爱_wxg - 博客园 (cnblogs.com)

CopyOnWriteArrayList

我愿称之为神,最开始我写线程安全类的时候都碰到个bug,就只是简单地换了CopyOnWriteArrayList,就完全没了bug,开始以为的自己的ArrayList都能在自己的稳定设计下无bug的梦想失败了,后来测试发现就是多线程安全类的问题,但换用Vector还是会出现我那个无影响的空指针乱报,但CopyOnWriteArrayList直接一步到位帮我解决了全部的bug,修bug的方式也非常地清爽,直接搜索ArrayList,替换成CopyOnWriteArrayList就完成了,摸了个大鱼,于是就在提交了之后仔细研究了一下CopyOnWriteArrayList原理

可以看到CopyOnWriteArrayList也是通过ReentrantLock支持并发操作

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock(); //上锁,只允许一个线程进入
    try {
        Object[] elements = getArray(); // 获得当前数组对象
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);//拷贝到一个新的数组中
        newElements[len] = e;//插入数据元素
        setArray(newElements);//将新的数组对象设置回去
        return true;
    } finally {
        lock.unlock();//释放锁
    }
}

比较有趣的是CopyOnWriteArrayList的拷贝过程,可以让每一个读取了其的数据结构达到安全遍历的目的,然后就可以放心地remove啦~

CopyOnWriteArrayList相似的还有CopyOnWriteSet,可以使用这个来替换Set达到更好的替换目的

这里推荐两篇博客:

CopyOnWriteArrayList的讲解(Java CopyOnWriteArrayList详解 - 简书 (jianshu.com)

另外JDK中是没有CopyOnWriteMap类的东西的,但可以参考这篇blog(Java并发编程:并发容器之CopyOnWriteArrayList(转载) - Matrix海子 - 博客园 (cnblogs.com))来自己完成一个CopyOnWriteMap

工具总结

我最推荐使用的是AtomicCopyOnWrite,其他建议大家根据自己的实际情况选用,and我所列出的也并不全面,其实如果单论第一次作业的话,使用java.util.concurrentExchanger类可以达到更简单的数据交换目的,其他就由读者们发挥了

设计模式

生产者消费者模式

是本单元作业必定会采用的一种设计模式,太基础了,不讲了

Master-Worker模式

画一个简单的该模式的概念示意图

类的一览表

类名 说明
Main 创建各个组件的类
Channel 接收调度请求和发派到工人线程的类
Master 发出工作请求和调度
Worker 工人线程类
Request 工作请求类

这一设计模式主要会在调度器统一分配的架构中使用,实际的操作和例程感觉前面讲的也差不多了,在这里就抽象出来一个设计模式层次吧,多的就不说了

Future模式

类UML图

类和接口的一览表

类名 说明
Main 向Host发出请求并获取数据的类
Host 向请求返回FutureData的实例的类
Data 表示访问数据的方法的接口,由FutureData和RealData实现该接口
FutureData 表示RealData的“提货单”类。其他线程会创建RealData实例
RealData 表示实际数据的类。(此类数据处理需要一个延时)

类协作图

这一模式在本单元作业中的应用有两个方面,一个是第三次作业中的会涉及总的请求队列为空,输入也已经停止,但是电梯内有需要换乘的请求,可能会从电梯内产生新的请求,在这种情况下是不能直接停止线程的,而是要为电梯内的请求单独设立Flag,这就是一种Future模式;另一个应用是我的预请求分配优化,这一优化的过程就是产生一个虚拟化的请求诱导空闲电梯的移动,这与Future的提货单方式更加吻合,可以完全地利用Future模式的特点设计线程安全架构

设计模式总结

读写锁设计模式就不单列出来讲了,流水线模式个人感觉在请求多级分割下也很难完美发挥look策略的性能(Master模式根本用不了)感觉有点鸡肋,就也不讲了,上面提到的三种模式可以按需参考,另外给大家提供我在本单元的参考书籍(链接:https://pan.baidu.com/s/1EF8qfj-guYy8jrkKme9nMg 提取码:6666 ,太大了放不进gihub),and 如果这里真的理解有问题我推荐还是参考书籍上写的,比我讲的要详细很多

优化设计

量子电梯

这一个优化主要为Hack评测机制(x

因为评测姬的考量为输出字符串和输出字符串的时间戳,所以可以在程序的运行过程中通过System.currentMillis()来在关门的时刻记录系统时间,并通过System.currentMillis()在下一次调度运行的时候再次获取当前时间戳,若满足条件便直接输出Arrive下一层信息(我猜可能是原来这个优化太过bug而导致今年在每一层都要输出一次Arrive,悲)

优化分析:这个优化点的优化主要可以为空闲电梯的运转提高了移动一层的调度速度,在请求数较密集时的优化效果不太明显,但在请求数较松散时的优化效果十分显著,用50s内的100个输入样例的运行时间测试中的最大优化可达12.4秒,测试的最小优化为1.6秒(评测有波动,小的不是很准确)

对于这一优化可以看一看这位学长的生动描述(*^▽^*) (BUAA_OO_2021_第二单元总结 - 春日野草 - 博客园 (cnblogs.com)

以及我采用System.currentMillis()的时候没发现神魔问题,但听同学说可能会有代码中连续调用两次System.currentMillis()导致前后使用的时间不匹配导致关门关早了的情况,大致如下所示

lastTime = System.currentMillis();
if (TimeCommit - System.currentMillis()) {
	//do
}

这种情况直接将下面的System.currentMillis()换成lastTime就可以了

但Maybe使用System.currentMillis()还可能有其他bug,详情可以看看这位学长的blog(BUAA OO 第二单元(电梯) 总结博客 - Dong_HY - 博客园 (cnblogs.com)

我个人是没碰到过上述问题的

对于量子电梯的优化,由于我的两种架构实现起来十分简单,因此

优化推荐度:★★★★★

开关门时间戳

开关门时间戳也可以通过System.currentMillis()的方式,通过System.currentMillis()的方式记录开门时间戳,在关门时通过System.currentMillis()再次获取时间,以\((开门时间+关门时间)-(当前时间-开门记录的时间)\)作为wait的标准进行wait,这个可以刨除掉程序运行过程导致的开关门时间差

优化分析:这一优化基本仅与数据量有关(更准确的就是仅与开关门的次数有关),用50s内的100个输入样例的运行时间测试中可以稳定优化0.5s上下

还是同上,由于我的两种架构实现起来都十分简单,因此

优化推荐度:★★★★☆

进入人优化

这一部分主要是基于上面开关门时间戳的优化下开展的,这个优化是保证可以在关门的wait阶段上下人,充分利用反常识设定的一个优化

如果不进行上述开关门时间戳的优化的画,本优化有非常简单的实现方式

就是open getOut getIn close过程(注意,先下后上是基本的优化,顺序不能倒),直接在opensleep关门+开门的时间,然后再getOut getIn close,从宏观上看是与上述优化效果相同的

但如果进行了上述开关门的优化,可以使用如下方式达到这一优化

就是电梯加入一个state成员变量,来记录是否为close的状态,在close状态可以打断然后上下人,再同样地利用时间戳来wait,这样开关门时间差还是一样的,且达到了关门阶段上下人的目的

优化分析:这一优化主要来自于关门阶段的请求加入,比较吃数据情况,多数情况下是测不到这个优化的,但用50s内的100个输入样例的运行时间测试中优化效果最高也达到过8s上下(可能在这次测试中频发了远距离的运送)

虽然同样是我的两种架构都可以实现,但由于效果不明显,且在后者已优化方案下有一定的代码更改,因此

优化推荐度:★★★☆☆

动态换乘分配

这一优化的来源是新电梯请求的动态加入导致的预分配换乘位置非最佳位置,我们以大多数同学选择的一次换乘为例讲解这一方案的实现策略(虽然电梯里的人也出来可能会有一定的优化,但是这样优化就变得过于复杂,我觉得意义不大,因此仅考虑电梯为接到的人)

优化的实现便是在加入电梯请求时,对所有的未接送(不在电梯中)的请求重新遍历来重新分配换乘电梯,再进行后续调度(提示:搭配量子电梯食用效果更佳)

优化分析:这一优化的意义非常大,在所有请求全部投放后再加入电梯这种情况,如果没有该优化所有成员的换乘便全会挤在1楼开展,而这时加入一台换乘电梯便几乎是成倍的提速,因此效果不用我多说了吧,据同学说没有这一优化强测也可能不RTLE,但我感觉这一个点还是挺好Hack到的

这一优化实现起来不如说起来那么容易,在很多架构中会产生不小的线程安全问题,因此

优化推荐度:★★★★☆

预请求分配

这一优化前面其实已经提过很多次了,其实我最开始并没有打算做这一个优化,但是后来自己没事测试的时候还是忍不住测试了一下,效果比我想象的要好很多(主要是Master-Worker架构)。这一优化来源主要还是基于空闲电梯的调度的,理论上讲,横向电梯有了量子电梯对空闲电梯瞬移一层的优化,这一优化在横向空闲电梯的移动优化期望仅为0.4*移动时间,可能测试优化结果主要集中在纵向电梯上,还是用50s内的100个输入样例(基本均含换乘)的运行时间测试中优化效果可以稳定在3-6s,出现了比预想结果好很多的优化,感觉还是很有意义的

优化分析:这一优化的意义可能在强测时是一抢分法宝,但对于look策略可能会很难实现(我的look是直接在唯一的共享数据池模拟了一下,有bug不说,跑出来的点效果还不如不优化,因此这里不推荐),优化效果前面也提过了,如果是Master-Worker架构的话推荐尝试

由于Master-Worker架构实现起来并不麻烦,且效果较好,因此

Master-Worker架构优化推荐度:★★★★☆

由于look+自由竞争架构实现起来问题很多,且个人尝试效果非常差,因此

look+自由竞争架构优化推荐度:★☆☆☆☆

多级换乘调度

这一优化的考虑是基于出发点和目标点由于电梯的可达性设置可能出现的多换乘最优解的一个优化处理,这一优化方案的出发点很好想,但是实现起来非常地困难,look策略我并没有想出一个简洁且合适的方案来进行多次换乘,对于Master-Worker架构我的实现方式为BuildingProcessFloorProcess在调度中向上一级托盘反馈当前该调度器所管理的电梯会停靠的楼层以及换乘成员,以这一预期会停靠的位置判断能否进行二级换乘(由楼座->层换乘->过渡楼座->层换乘->目标楼座)(再高级的换乘原谅我太菜,实在写不动了),但是最终的效果也是非常一般(就这样都加了300+行代码o(╥﹏╥)o),然后仅有五分之一的测试点会快于原不优化,但其他的测试点有的甚至会比原来的版本慢20-30s,虽然很大程度上是我这一盲目调度实现方式的问题,但这一优化的投入产出比实在不高

优化分析:可能这一优化的意义很大,但本人能力有限无法为大家进行测试了,希望未来能有机会补补坑吧

由于这一优化的实现方式过于复杂,为了这一测试花的心血实在太多(实在一言难尽),以及收获远不及预期,因此

Master-Worker架构优化推荐度:★★☆☆☆

look+自由竞争架构优化推荐度:☆☆☆☆☆

优化总结

最痛苦的莫过于最大的优化版本没交上,look版本优化在截止当天才慢慢悠悠开始然后截止前没做完,强测还小寄了。。。但一番下来感觉收获还是不小的吧,相比于刚开始写多线程的自己感觉这一个月收获的太多太多了,最后的结果不如意只能怪自己这个月太摸了,但跟各个同学讨论优化方案的日子还是非常快乐的,and 希望自己做的东西能有意义吧,就在此多墨迹了几句

评测机思路

对于评测姬的构造,我给出我这个月来与各位写评测姬的dl交流的和自己的一些思考来写吧(PS:我最终的评测姬是用的各位dl的缝缝补补结合版,实际Hack效果不如我第一单元使用的Hack效率高,但用来做测试却很方便,其实就是摆了

本单元多线程的评测姬与第一单元的评测姬构造差异很大,但主要体现也只是在CheckAns的设计上的问题,因此在下面的讲解中主要分CheckAns的设计和顶层测试模块的设计

以下为我总结的两种评测姬设计方向(也是看了很多学长的blog + 观摩了身边同学的评测姬,再加入了点自己做评测姬的需求和经验)所提出来的,我觉得两种设计模式都很有意义

实时模拟

设计思路

实时模拟这一策略主要出于OO的设计思想,以实例化对象的方式模拟进行验证。从编译的角度来看,一个程序语言至少要有它的语言规范、运行器(解释器)和编译器(and 链接器),对照于电梯模拟的请求输入格式,就可以理解为程序语言的语言规范,电梯的运行调度就可以理解成编译器,因此缺少解释器的工作我们可以声明一个游离于我们项目之外的电梯类作为我们输出时间戳下模拟调度的电梯作为方案中的运行器(解释器),来模拟运转电梯

实现方案

对于CheckAns的设计

InputThread中请求的加入在项目外模拟一个真正大楼的进程,模拟电梯的添加和Person对象的添加

将线程安全的输出接口的字符串,加入一个输出接口的转移,输入到模拟的大楼中

在每一次的输入和调度系统的输出,在模拟的Check过程中执行可行性判别操作,时间戳满足要求,电梯位置合理,乘客的位置合理便可执行对应的操作,否则则输出报错信息

对于主测试线程的设计

仍旧是使用Python的subprocess进行数据投喂,但是此处的操作为命令行运行官方包的定时投喂的可执行文件

此外由于之前出现过很多次房间同学的程序无法正常结束,因此加入了计时器,对于超过设定的MaxExecuteTime的程序直接kill掉

可以拓展使用多线程的方式进行复制后评测

输出检查

设计思路

这一设计思路来源主要是依据指导书中的评判标准进行逐一检查

引——指导书的正确性判断标准(有删改)

程序被认定为正确当且仅当以下 3 个条件同时满足:

  • \(1、\)代码实现中不存在轮询

  • \(2、\)电梯的性能符合基本要求,不超时

  • \(3、\)输出结果符合逻辑,具体来说,有以下几点:

    • \((1)、\)电梯运行逻辑合理:

      • \(a、\)电梯到达一层后就可以输出开门信息,然后花 0.2s 开门,开门结束
      • \(b、\)电梯开门结束后,某一刻电梯花 0.2s 关门,然后输出关门信息,就可以离开楼层
      • \(c、\)电梯在两楼层间若发生移动,应满足移动时间要求
      • \(d、\)电梯只在可到达楼层运行
    • \((2)、\)人员进出逻辑合理:

      • \(a、\)只有已经在电梯里的人可以出电梯,也只有不在电梯里的人可以进入电梯

      • \(b、\)如果电梯在楼座 A 楼层 2,那么乘客显然也只能在楼座 A 楼层 2 进出

      • \(c、\)反常识设定:开门的 0.2s 期间或关门的 0.2s 期间乘客仍然可以进出,即在整个开关门窗口时间内乘客都可以进出

      • \(d、\)乘客进出信息在电梯开关门窗口时间之内

        错误示例:
        [   4.6280]OUT-1-A-1 ← 这个人没开门就出来了
        [   4.8440]OPEN-A-1
        [   5.0470]CLOSE-A-1
        
      • \(e、\) 电梯系统结束运行时,所有的乘客都到达了目标楼层,且没有被困在电梯里

    • \((3)、\)电梯载客量合理:

      • \(a、\)电梯任何时候,内部的人数都必须小于等于轿厢容量限制
      • \(b、\)也就是说,即便在 OPEN、CLOSE 中间,也不允许出现超过容量限制的中间情况
    • \((4)、\)时间戳合理:即输出的时间戳应该是非减的

实现方案

对于轮询我没有测(x,所以最终强测轮询了,性能发现满足互测要求的数据如果不出现死循环基本不可能超时,因此仅自己测试的时候进行此测试

估计也可以猜得到了,我所说的评测姬的构造主要针对输出结果的逻辑(但强烈建议塞到Linux上测一测CPU时间)

本架构完全通过Python实现,包括GenData部分CheckAns部分和主测试投入部分

对于CheckAns的设计

这个方案的思路就是转述指导书+实现策略

总时间戳

  1. 满足总时间戳递增

循环输出文件,判断时间戳非减

输入对比

  1. 电梯:比对输入时间戳与开始运行的时间戳,满足递增关系

  2. 乘客:比对该乘客ID的时间戳与该时间戳满足递增关系

读取输入文件输出文件,比对相同的电梯ID和乘客ID对应的第一个数据的时间戳,全部满足则此处通过

开关门

  1. 开关门满足应有的开关门时间戳的延时

  2. 不能先输出关门后输出开门

  3. 不能未关门便移动

  4. 不能在不可达的位置开门

对于Open先判断是否当前位置可达(横向电梯需要判断,纵向不用)

对于每一个Open向下寻找最近的同电梯ID的Close,中间如果出现同电梯ID且非In Out则判断错误

找到后判断时间戳差是否大于开关门时间(0.4s)

遍历每个Close,前面不能没有对应电梯ID的Open

全部满足则通过开关门测试

乘客进出

  1. 开门后才可以进出乘客,关门后不能进出乘客

  2. 出电梯的人需要在电梯内,进电梯的人要在电梯外

乘客进出段前后均可找到匹配电梯ID的Open和Close

每个乘客设计一个In/Out状态,在In/Out时进行转换,转换与输出不匹配则输出错误

全部通过则通过进出测试

电梯不超载

  1. 每个电梯的运转过程中均不出现超载情况,包括open close时间段内

对每一个电梯开一个模拟栈变量记录当前电梯人数,对于该电梯的In进行该栈变量的++,对于该电梯的Out进行该栈变量的--,总过程应当满足每一个栈变量均满足大于等于0小于等于MaxCapacity

请求满足

  1. 结束时所有请求均送达指定位置

对每个Request跟踪其InOut位置,更新到最后的Out且满足时设立当前Request的finishFlagtrue,总过程应当满足每一个finishFlag均为true

对于主测试线程的设计

仍旧使用subprocess调用官方数据投喂包的方式进行数据投喂,对于超过设定的MaxExecuteTime的程序直接kill掉

评测机设计总结

每一次成功的Hack的背后都有一个性能优秀的多线程评测机(x

对于评测姬的研究真的是门大学问,之前与ch学长交流了一下评测机的设计策略(可惜那会儿我还太菜,完全没问到点子上,又懒,还没尝试过多少就去问学长...),以及在此需要感谢一下ywxgg的评测姬、两位不知名学长的评测姬,and ygg的数据生成器,还有chgg对评测姬架构的讨论

但ywxgg没有发他的评测构造,ygg也没发他的数据生成器,所以在这里就只能摆一下chgg的blog了(面向对象设计与构造第二单元博客作业 - Chenrt - 博客园 (cnblogs.com)),我愿称chgg的blog为北航第二单元评测姬最值得借鉴的blog,感觉从测试强度上要比我的评测姬高了很多很多,chgg绝对是开发+钻研道路上yyds

Hack策略

第一周寄了,第二周摆了,只有第三周认真参加了互测,这里就简单说下第三周的战况吧

(第三周强测也是小寄,被分到了B房,感觉房间里的人都没有评测机,一天下来基本就只有我在交....)

保守了一点最后只交了9发,怕被举报倒扣分(我其实没有Hack同质bug,但是Caster的程序涵盖了全房所有的bug,他的程序经常停不下来,总是误伤他)

我发现的bug来源主要就是线程异常结束

通过观察原码和课下偶然发现的同房同学交流发现,输出错误的bug是Sarber横向电梯理解错题目以为同一层只能有一个横向电梯,另一个同学的输出错误是横向电梯因为线程安全问题导致在一个点停着不动,他通过wait限时的方式送走了大部分的人但最终还是“吃人”了

Berserker的错误也可以发觉到是判断开门时加入了可达性的判断,但接人的时候并未加入可达性判断便接入了,结果导致在不能开门的地方开了门,此外他还有异常的问题和无法停止的问题

Assassin的错误我也不确定,他的错误是在Hack其他人的时候测出了他的CTLE

Rider的程序没有发现什么bug,我猜应该也是CTLE的问题,但我Hack不到他

Archer和Lancer的问题都是在输入较密集的时候线程容易停不下来,此外Archer还有空指针和访问越界的异常问题

至于总体的Hack策略,只能说有了评测姬的陪伴,第二单元的互斥上的考虑都给省了,加入了超时killed后可以稳定发现同学们的bug,只需要控制一下提交的不重复就够了

再进一步说策略的话就是自己手搓了几组边界数据,尝试Hack了一下look和ALS调度,自己本地测都没成功,遂放弃,然后又手搓了几组密集调度Hack超时(不加电梯全挤1楼,然后最后加一个电梯保证自己时间戳正常),但只Hack中了Assassin(是不是Assassin我也记不清了,只中了那个最慢的,他明显慢很多),只能说,互测的时间下限压的太低了,感觉对我们的超时数据十分地不友好(x

总的来看,还是需要评测姬的辅助吧,已经需要有一个自己趁手的数据生成器,不然很难在多线程的互测中拿到比较满意的成果(毕竟冷却实在太长了,我交了9发都基本坐了一整个下午,晚上8点才去吃饭)

心得体会

电梯月总算是过来了,唯一可惜的是,至今没能体验过一把迭代开发,要么就是直接写完了后面的,要么就是重构(x,可能也是自己的心理问题,总是想一下就做完后面的。不过总的来看电梯月在多线程上的收获还是非常满意的,真的有觉得自己的多线程程序设计能力有了很明显的提高,实在没想到电梯月最痛苦的竟然是第一周,也没想到电梯月就这么摸过来了,听往届的学长们说电梯月是最难熬的一个月,但感觉自从有了look+自由竞争的bug策略保底,感觉自己真就放开了摸了,没体会到困难就过去了心理还有一点空落落的,感觉没过足多线程设计的瘾,希望OO的快乐不会就此中止吧。

在此再感谢一下ywxgg和ygg的无私搭救,感谢wl这两个月的关照,以及感谢chgg对评测姬的交流,电梯要说再见啦~

posted @ 2022-05-02 19:36  Oh_so_many_sheep  阅读(592)  评论(1编辑  收藏  举报