BUAA OO 第二单元总结

BUAA OO 第二单元总结

Part 1 设计策略

这三次做业采用了主线程获取请求,多级调度器逐级分派,电梯模拟运行的策略。具体来讲,主线程实例化ElevatorInput类,经过阻塞读取方式得到请求Request,以后将请求分配给调度器Scheduler,调度器负责处理请求(既能够本身处理,也能够分配给其余子调度器处理),每个电梯与一个ElevatorScheduler绑定,用于具体的调度、睡眠控制、打印输出。java

本次做业的难点主要有如下几点:python

  • 如何控制调度器、电梯线程的终止:简单的生产者-消费者模型中,生产者不断生产,消费者不断消费,不存在线程终止现象;现实中的电梯,一天24小时运行,没有异常状况也不会终止。可是更多的多线程问题是须要考虑线程终止的。这三次做业也是如此:主线程将全部请求都发送给调度器后,告知调度器准备结束,调度器处理完本身队列中剩余请求后,结束线程。git

    这种通常性的线程生命周期能够用这个比喻来讲明:假如你是一个员工,在上班的时候你可能在干活,也可能闲着,但即便闲着,也不能回家;到了下班时间后,若是你的任务尚未完成,那就继续工做(加班),若是完成了,就能够回家了。因而,咱们能够抽象出两个因素:是否空闲(idle),是否到达下班时间(在电梯的问题中是输入是否结束,可用inputEnd变量表示)。是否空闲是能够本身判断的,而是否到达下班时间则须要外界的通知。如下讨论这种通知输入结束机制。github

    一种比较容易想到的方法是采用interrupt机制算法

    while (true) {
        Request request = elevatorInput.nextRequest();
        if (request == null) {
            scheduler.interrupt();
        } else {
            // pass
        }
    }

    可是这种方法并不能实现精确控制:咱们但愿的是,若是调度器在等待下一个输入(wait()函数中),就打断;而若是它在执行别的任务,好比sleep(100),或者是其余的同步任务,就不打断。虽然在这一单元的做业中没有出现这种状况,可是多作这种考虑也是合情合理的。编程

    另外一种方法是专门设定一个setInputEnd()方法,由主线程调用,告知调度器输入结束。安全

    class Scheduler {
        boolean inputEnd = false;
        Queue<Request> requestQueue;
        public synchronized void setInputEnd() {
            this.inputEnd = true;
            notifyAll();
        }
        public synchronized void addRequest() {}
        public synchronized void getRequest() {
            while (requestQueue.isEmpty()) {
                wait();
            }
            return requestQueue.poll();
        }
    }

    我在做业中采用的是这种方法,可是后来发现,其实还能够用一种更加简洁的方法解决:创建一个TerminationRequest类。多线程

    interface Request {}
    class PersonRequest implements Request {}
    class ElevatorRequest implements Request {}
    class TerminationRequest implements Request {}

    这样,经过归一化处理,能够用一个队列来统一管理(更通常的来讲,是把全部线程关心的状态改变的通知都放到统一的容器中管理,对外用一样的接口,对内采起不一样的处理策略)。同时,这种归一化也方便了线程安全容器的使用。架构

  • 区分两种获取请求的模式:在简单的生产者-消费者模式中,消费者在没有商品的状况下老是会在共享区中的wait函数中等待,可是在实际生活的不少状况下,这种状况是不可接受的——消费者可能还有其余事情要完成,使用wait函数等待并释放CPU资源当然是一种进步,但这种方案同时也制约了消费者进行其余活动的自由。回到本单元做业,每个电梯在行为上都须要实现:获取新的请求,决定电梯的行为(开门、关门、上行、下行等)。可是这两种行为并非时时刻刻都须要进行的:若是电梯局部队列为空,电梯内部没有乘客,则电梯处于空闲状态,此时不须要频繁决定电梯的行为,只须要等待下一个请求的到来。所以,当电梯空闲时,应当阻塞地读取请求,即在请求处等待下一个请求;而当电梯忙碌时,则只须要查看并更新请求便可,没有新请求也不阻塞app

    这两种不一样的模式,能够本身解决,如:

    class ElevatorScheduler {
        private Queue<Request> queue;
        private Queue<Request> localQueue;
        private synchronized Request getRequest(boolean quickRetreat) {
            if (quickRetreat) {
                return queue.poll(); // if the queue is empty, return null
            } else {
                while (queue.isEmpty()) {
                    wait();
                }
                return queue.poll();
            }
        }
    }

    也能够采用Java内置的BlockingQueue来解决:

    private BlockingQueue<Request> queue;
    private void update() {
        if (idle) {
            localQueue.add(queue.take());
        }
        queue.drainTo(localQueue);
    }
  • 灵活的分配器:第一次做业只有一个电梯;第二次做业有多个电梯,但只有一种型号;第三次做业有不一样型号的电梯,每一种电梯型号下的电梯数是不一样的。能够这样认为,第一次做业的电梯只须要一级调度器(直接指挥电梯的调度器),第二次做业的电梯是两级调度(一级负责电梯见的负载均衡,另外一级负责直接指挥电梯),第三次做业的电梯是三级调度(一级总调度器负责换乘相关管理,一级负责同一个类型的电梯的负载均衡,一级负责直接指挥电梯)。以下图:

    为了使得分配更加灵活,给这些Scheduler设计一个统一的接口RequestReceiver便可,至于内部的处理,或分配或自行指挥电梯,请求提供者都没必要关心。

    interface RequestReceiver {
        void addRequest (Request r);
    }
    class CentralScheduler implements RequestReceiver {}
    class Scheduler implements RequestReceiver {}
    class ElevatorScheduler implements RequestReceiver {}
  • 反馈和闭环控制:在实际多线程编程中,反馈和闭环控制也是十分常见的。本单元做业也不例外:换乘须要进行请求的反馈,即电梯运行一部分请求后,由另外一个电梯继续完成另外一部分请求。既然电梯是逐级控制的,电梯处理完本身应该处理的那一部分请求后,须要将请求反馈给上级调度器,由上级调度器进行二次分配。另外一方面,调度算法在进行调度时,也须要考虑各电梯的负载均衡问题,于是电梯也要上报自身的负载状况。

    这几回做业中,能够经过相应线程类提供反馈接口,进行逐级反馈状态:

    interface FeedbackReceiver {
        void offerFeedback (Feedback fb);
        void offerRequestFeedback (Collection<PersonRequest> requests);
    }
    class CentralScheduler implements RequestReceiver, FeedbackReceiver {}
    class Scheduler implements RequestReceiver, FeedbackReceiver {}

    在反馈反向传播的时候,每一级Scheduler也能够对反馈进行处理,好比做业3中的负载,每一类电梯的负载能够取这一类全部电梯中负载最小的电梯的负载。

  • 楼层映射:这个问题其实并无什么面向对象的困难,主要是一个小技巧。每一个电梯调度器(直接指挥电梯进行运动的调度器,它实现了调度算法)有一个映射,实现楼层到楼层下标的快速转换。

    与其经过数学方法实现(分段函数):

    int flr_to_ind (int flr) {
        if (/* some conditions */) {
            // do something
        } else if (/* ... */) {
            // do something
        } else {
            // pass
        }
    }

    不如用Java自带的方法:

    List<Integer> flrs = Arrays.asList(-3, -2, -1, 1, 2, 3);
    index = flrs.indexOf(flr);
    flr = flrs.get(index);

Part 2 第三次做业的可扩展性

假如个人第三次做业真正实现了第一部分中所叙述的思想和方法,那么再进行扩展也不会很复杂了。但事实上个人第三次做业并无彻底实现这些方法和技巧——程序的主题部分是第五次做业时构建的,以后只作了些小修小补。可是毕竟结构是相似的,也能够作一些分析:

  • 实现紧急制动:从Request接口下增长一个紧急制动请求的实现,调度器将这一请求分派到对应电梯。电梯到达下一个停靠点时,经过反馈渠道反馈全部的未完成请求,由上层调度器二次分配,同时电梯线程结束运行。
  • 更复杂的电梯类型:构造电梯工厂,采用工厂模式,根据所提供的电梯型号生产相应电梯。在调度器方面,增长若干二级调度器,使每个电梯类型对应一个调度器。(固然若是类型增量不大,把这一调度器与主调度器合并也是可行的)
  • 更大的规模:增长调度级数,实现更细粒度的调度。

SOLID角度看:

  • Single Responsibility Principle:调度器总的说来比较符合这个原则,而电梯类符合度较低。在本人的设计中,电梯类既做为一个容器,管理电梯内的乘客,同时又负责输出、睡眠,并且请求的管理与ElevatorScheduler类的职责部分重叠,耦合太高。在一开始的设计中,电梯的职能被规定为负责输出和睡眠(由于这两方面相对固定,能够与易变的ElevatorScheduler分离,可是在以后的迭代开发中,逐渐职能扩充。
  • Open Close Principle:这三次做业在函数层面(即每一个类的方法层面)比较符合这一原则,将易变的类和不易变的类分开,迭代开发时主要替换一些函数,没必要大规模修改函数。但在类的层次对这一原则符合度较低。在最初的设计中,我本是打算每个主要的类先写成抽象类,再经过继承抽象类进行实现,但最后感受不太现实,就没有真正实施,而是直接把抽象类改为具体类……[捂脸](也许小型工程不太容易作到OCP吧,毕竟就那么几个类)
  • Liskov Substitution Principle:这三次做业关系比较少,可是基本上全部存在的继承关系都知足LSP原则了。
  • Interface Segregation Principle:其实第三次做业并无用到接口,可是若是按照第一部分的分析,每个调度器都实现RequestReceiverFeedBackReceiverRunnable方法,也算是有一点ISP的意思了。
  • Dependency Inversion Principle:毕竟没有接口,继承关系也比较少,第三次做业的具体实现其实没有体现这一原则,不过若是按照第一部分的分析,main函数线程只依赖RequestReceiver接口,也有一点DIP的意思了(虽然没有实现)。

Part 3 经典度量

考虑到三次做业结构一脉相承,每次迭代又没有什么重大改动,就只分析最后一次做业了。

UML图

这里只实现了二级分派结构,其中PersonTransfer是课程组提供的PersonRequest类的子类,表示须要换乘的乘客请求。二级分派结构能够解决这三次做业的问题,main函数获取请求,再由高级调度器分派给低级调度器,低级调度器与电梯类协做,实现look电梯调度算法。在算法的实现过程当中,须要管理楼层信息、管理用户请求信息,这些管理由building类和floor类处理,同时设置FloorNumberManager类,提供一些静态方法管理楼层映射、可达性查询等服务。

这种结构的主要问题是没有处理好电梯类Elevator和电梯调度器类ElevatorScheduler之间的关系。电梯调度器类只拥有一个电梯,负责这个电梯更细致的调度管理,如每个时间节点,决定电梯上行、下行、开门、关门等动做,主要实现了算法。但同时,电梯类不只负责输出、睡眠,还负责管理电梯内部人员,检查到达目的地的乘客,反馈电梯内部乘客信息等。在具体实现中,电梯类又将自身容器暴露给电梯管理类,使得两个类之间耦合度较高。

此外,电梯类Elevator并无成为一个独立的线程,因此在电梯睡眠时,实际上时是在ElevatorScheduler线程中睡眠,致使电梯睡眠和电梯调度算法运行没法并行,下降效率。

复杂度

能够看出,调度器是比较复杂的类,而调度器中负责算法的方法又是调度器中比较复杂的方法。可是除了调度器以外,电梯类也比较复杂,这是与设计初衷不符的。缘由在上文也提到过,主要是随着代码实现的推动,电梯类的职能不断扩充,与调度类有所交叠,没有很好处理这一问题。

协做图

Part 4 Bug分析

这一单元的做业主要容易出bug的地方包括:

  • 程序死锁,尤为是输出终止,准备线程结束的时候。
  • 伪多线程,主要是指程序实质上是在轮询,没有很好实现资源让出。
  • 需求理解误差。

我在三次做业的强测和互测中均没有发现bug,可是个人第一次做业(整个课程的第五次做业)有一个很是严重的错误(强测和互测,因为测试机制固定,都没有检测出来):若是全部输入结束后没有马上提供输入结束信号,程序将会进入死锁,没法终止。部分代码以下:

// methods of ElevatorScheduler
public synchronized void setInputEnd() {
    this.inputEnd = true;
    notifyAll();
}
private synchronized void update(boolean quickRetreat) {
    if ( (inputEnd || quickRetreat) && buffer.isEmpty() ) {
        return;
    }
    while (buffer.isEmpty()) {
        try {
            wait();
            /* when the thread is notified, it's still in the while loop */
        } catch (InterruptedException e) {
            return;
        }
    }
    // some updates here
}

可见,虽然程序退出了wait()函数,但仍是会再次进入wait()函数,致使死锁。一个简单的修复是在while循环上增长一个条件;设计上的修复我在第一部分已经提到过,线程检测到TerminationRequest后就再也不调用update函数,把这个问题在线程内部解决

另外一个问题是没有处理好CPU资源的让出,表如今轮询所致使的CPU_TLE。通常来讲,线程空闲时须要等待,可以使用wait()函数,一旦线程须要被唤醒,相应锁的notifyAll()函数必须被调用。我在第一次电梯做业的互测中用相似如下数据的数据点发现了两个A屋的solid bug:

[5.0]1-FROM-2-TO-3
[150.0]2-FROM-14-TO-5

Part 5 测试程序分析

本单元测试程序主要考虑本身的使用,包括三部分:

  1. 输入文件的时间映射器,将带时间的文件输入映射到时间轴上,实现定时输入。具体代码见:https://github.com/YushengZhao/BUAA_OO_elevator_input_mapper

  2. 电梯仿真器,模拟电梯的真实运行,在运行过程当中检查相关问题。主要思路是:将电梯请求和被测程序输出转化成若干电梯指令,按照时间排序,在仿真器上模拟运行,在运行过程当中记录参数、检查行为,最终能够给出性能报告。

  3. 请求指令生成器,能够定制若干段请求序列,每一段能够设置参数,可参考如下代码:

    def generator(size=10, timescale=10, id_start=1, time_shift=1):
        # generate one segment of requests
        pass
    
    def generate():
        periods	 = 	[12,19,8,4,13]
        sizes	 = 	[8,4,10,13,16]
        s = []
        id = 1
        time_shift = 0.3
        for i, period in enumerate(periods):
            s += generator(size=sizes[i],timescale=period,
                           id_start=id,time_shift=time_shift)
            id += (sizes[i]+1)
            time_shift += (period+0.1)

将这些组件链接起来就能够生成测评机了,可是考虑到自身实际需求,就没有具体实现了。

一个值得注意的地方:许多同窗采用python脚本进行时间映射,我在参考了以前一些学长的博客以后发现,这种方法容易产生时间误差,时间控制不是很精确,而将时间映射器内嵌到Java语言内部则能够实现更精确的控制。同时,这样也便于调试。

关于请求生成策略:实际应用中可能会出现不一样时段负载不一样的状况,我在测评机中按段生成请求,能够模拟这种状况。在进行几回到几十次测试后(总请求量1e2量级),通常没有什么显著问题;进行大量测试(1e3,1e4量级的测试)也许能够发现一些问题,但考虑到每个测试所消耗的时间成本,就没有过多测试了。真正实际应用中,大量的测试确定是必要的。

这一单元的调试和测试与上一单元相比,主要是多了时间因素,在测试时要考虑输入随时间分布的不一样特征,如在一个时间点大量输入,在一段时间内没有输入,等等。而在调试时,因为不能使用断点调试法,我广泛采用了日志记录的方法,增长一个可插拔的logger:

private static final boolean LOGGER = true;
private static final boolean ASSERTION = true;
public static void log(String msg) {
    if (LOGGER) {
        System.out.println(msg);
    }
}
public static void ast(boolean condition, String msg) { // ast == assert
    if (ASSERTION && !condition) {
        System.out.println("Assertion failed: "+msg);
    }
}

或者实现一个带level的不一样重要性的日志输出:

private static final int LEVEL = 5;
public static void log(int level, String msg) {
    if (level >= LEVEL) {
        System.out.println(msg);
    }
}

固然,Java也有相应自带的日志记录机制,不过考虑到这一单元做业并不须要复杂的日志,就没有使用了。

Part 6 心得体会

  1. 多线程方面,我在作电梯第一次做业时花了比较长的时间(甚至一度觉得本身就要止步于此了),当时有许多问题想不清楚,最后实现的代码也有许多逻辑混乱的地方。多线程之因此容易出现各类安全问题,归根结底仍是线程自身行为逻辑复杂,好比,简单的生产者-消费者模型,基本不会有人写出线程安全的问题,可是复杂一些的生产者-消费者模型(如消费者在没有产品时不调用wait函数,而是进行其余活动),就容易产生线程安全问题了。所以,根据需求创建简单通用的线程模型很是重要——简单的逻辑每每不容易出错。好比观察者模式,构建了一个线程发布消息,若干线程接收消息的模型;反过来,又有时也须要若干线程发送消息,某一个线程接收消息的状况,这时即可以采用消息队列:

    class Scheduler {
        private Queue<Message> messageQueue;
        public synchronized void addMsg() {/*some code here*/}
        public synchronized Message getMsg() {/*some code here*/}
    }
    interface Message {}
    class Feedback implements Message {}
    interface Request extends Message {}

    全部须要通知Scheduler的消息,都经过addMsg方法传入,不管其具体内容,以后再由其余函数分别处理。所以,当Scheduler处理完剩余任务后,即可以直接在getMsg方法内等待。

  2. 设计原则方面,我在完成第一次电梯做业时,并无太多的设计原则方面的意识,而因为后两次做业都沿用第一次做业的结构,全部后两次做业也没有体现多少设计原则,这是遗憾的。

  3. 代码重构方面,我从这一单元开始坚决了能不重构就不重构的态度。从计组到如今,在各类须要迭代开发的工程中,我老是感受以前写的不够好,想要重构,但又常常忽视了重构的风险以及不重构的可能性。实际开发中,重构确定是要尽可能避免的,在原来的代码(多是看似比较乱的代码)上修改其实才是常态。事实上,看似乱糟糟的代码其实可能并无想象中那么差。我在第二次电梯做业时曾经尝试了重构,可是等到真正动手写重构代码的时候才发现,若是重构,不少代码都是差很少的,原来不少设计上的考虑都是颇有道理的。

  4. 算法方面,这一单元的做业给算法留出了很大的空间。不过想拿高分并不须要很复杂的算法。最基本的look算法,在强测不出现错误的状况下,基本就能够达到95分以上了。后两次做业再加一些负载均衡的考虑,若是没有错误,基本也能拿到95分以上。尽管如此,讨论一些算法也是没有坏处的:

    • 将电梯的打印输出/睡眠与电梯运行逻辑解耦。这样作的目的是,能够在不真正运行电梯的前提下(运行一个虚拟电梯),估计电梯的总运行时间,进一步地,加入一个请求后的总运行时间。理论上,这样老是能够作到局部最优规划。并且这种估计是不依赖于特定算法的,灵活性比较强。
    • 用马尔可夫决策过程建模。这样作是考虑到如今有许多针对马尔可夫决策过程(Markov Decision Process)的算法可供使用。

    固然这些算法都是我没有实现的,毕竟课程的主要目的也不是算法。


其余:

  1. 有些问题其实并非OOP的问题,有些bug其实也不是由于线程安全。好比look算法,我就花了很多时间实现、调试。
  2. 不少时候模仿现实世界的实体和关系也是一种很好的方法。好比,给电梯安排一个Building,给每个Building安排若干Floor,并提供“向上的按钮”和“向下的按钮”接口,就像现实生活中的电梯同样。电梯不知道一个楼层有多少人,只知道某一楼层有没有人想上楼、有没有人想下楼。
  3. 第二点的想法虽然对咱们写做业颇有帮助,可是这是否是就是在参考真正给电梯写程序的编程人员的代码架构呢?其实课程的者几回做业,我都是或多或少参考了前一届同窗的博客,假如没有这种参考,我又能写出什么样的结构呢?
相关文章
相关标签/搜索