OO_Unit2_单元总结

OO_Unit2_单元总结

Part0 综述

本单元三次电梯作业依然是在逐次迭代的基础上进行开发的。其中第一次作业要求我们实现一个每座只有一个纵向电梯,无新增加电梯的请求并且乘客的请求仅限于同楼座不同楼层的电梯系统。第二次作业相较于第一次作业添加了横向电梯的概念,并且可以通过请求增加横向电梯或者纵向电梯,但是乘客的请求仍是需要满足在同楼座不同楼层或者同楼层不同楼座间移动的要求。第三次作业最大的变化就是乘客的请求不再要求同楼座或者同楼层满足其中之一,而是可以在不同楼座不同楼层之间进行移动,这表明初始的乘客请求此时已经不能仅通过一个电梯来完成了。至于其他的诸如电梯容量、速度、横向电梯的停靠楼座等变化并不需要逻辑上的思考,只需通过对电梯类的小小改动即可完成。

Part1 同步块与锁

设置同步块与锁的原因

在多线程编程中,同一个对象可能会被多个线程进行访问和操作。但是Java程序中语句往往不是原子操作,比如count += 100;这个操作就被分解为取出count值,做加法,将结果重新赋给count这三步原子操作,一条语句尚不是一个原子操作更不用说包含多条语句的一个方法了。而在原子操作之间线程的执行顺序是可以进行切换的,由此多个线程在访问/操作同一对象时往往会产生写写冲突或者读写冲突。

换句话说就是,JVM调度线程执行,这个调度过程是程序员不可见的。对于共享对象,哪个线程先进入哪个线程后进入我并不关心,因为这并不影响结果的正确性。但是我希望当某一线程在执行共享对象的方法时,其它的线程不要进入该共享对象因为操作非原子性的问题与当前的线程发生冲突。因此我们使用锁来对语句块进行同步。

锁将它锁住的语句快(被锁住的这一块语句也称为同步块)包装成原子操作,即执行过程中不会被中断。这就保证了前一线程对共享对象的所有访问/操作动作都一定完成了才允许下一个线程进入,即实现了“同步”。

同步块的设置

在本单元的作业架构为经典的生产者-消费者模式,inputHandler线程与dispatcher线程分别作为生产者和消费者共享一个大的等待队列requestTableAll,而dispatcher线程又与各个电梯线程分别作为生产者和消费者共享一个小的等待队列parallelTable。我的同步块设置就是在共享队列类RequestTable,RequestAll中,通过synchronized关键词将共享对象封装成为线程安全类,并且使用wait,notifyAll机制让调度器线程和电梯线程在恰当的时候进行等待和唤醒,避免轮询和死等。

要特别注意的是官方给出的输出类并不是线程安全的,所有的电梯线程在输出时都需要调用输出方法,这有可能会导致时间戳和输出内容不同步的情况,比如时间戳非严格单调递增的“时光倒流”(第一次作业就因为输出线程不安全被别人Hack)。所以我们需要将官方给出的输出方法封装成线程安全的,这点在讨论区也有相关的帖子。

//MyOutPut.java
import com.oocourse.TimableOutput;

public class MyOutput {
    public static synchronized void println(String s) {
        TimableOutput.println(s);
    }
}

Part2 调度器

说到调度器,我其实并不认为我实现的这个Dispatcher类可以被称为调度器。按照它的具体功能来说,“分配器”这个名字或许更贴切一些。

在等待队列的设置上,我并没有为每个电梯都设置一个该电梯专属的等待队列,而是将等待队列与某一楼层或者某一楼座进行绑定。即每一楼座的纵向等待队列与每一楼层的横向等待队列均只有一个,同一楼座的纵向电梯和同一楼层的横向电梯均会共享一个等待队列。这采取了往年学长博客中“自由竞争”的思想,当多个电梯都可满足该乘客的可达性要求时,就让这些电梯去竞争这些乘客,抢到了执行,没有抢到就原地暂停好了。

对于第三次作业中条件停靠的横向电梯,我最初的设想是为每个横向电梯都绑定一个等待队列(因为同楼层的横向电梯由于停靠信息的不同可以视为是不等价的)。但是后来又考虑到同一楼层停靠信息不同的两部电梯可能同时满足请求的可达性要求,如请求从A座到B座,一部电梯可停靠A、B座,另一部电梯可停靠A、B、C座。此时仍然满足“自由竞争”的条件。所以干脆为每个楼层只设置一个该楼层所有横向电梯都共享的等待队列,同时只让电梯“看到”那些它们可以满足其可达性的请求。

综上所述。第一、二次作业中,没有增加换乘的需求,当输入线程将请求加入到大的等待队列时,调度器只需要找出该请求需要被分配到哪个小的等待队列并将其从大的等待队列中取出加入小的等待队列中即可。这并不是一件难事,因为在这条请求的信息中已经明确包含了该请求需要被加入的小队列的信息(楼座or楼层)。在第三次作业中原始请求需要进行换乘,但是在该请求加入大的等待队列之前,就已经被静态分解为多个(至多三个)小的请求。而调度器不过是将分解后的不需要进行换乘的请求加入到小的等待队列中,这与第一二次作业中的情形完全一致。所以我的所谓“调度器”不过是一个机械的分配器,没有什么复杂的逻辑,在三次作业中的实现几乎完全相同。

Part3 程序架构

第一次作业

结构分析

上机实验的代码对我完成本次作业提供了极大的帮助,这次作业的架构也为接下来两次作业奠定了基础。

本次作业的架构就是两层的生产者-消费者结构。其中dispatcher作为大等待队列的消费者和每个小等待队列的生产者将这两层结构串联起来。

UML图

时序图

电梯内部逻辑

电梯内部的运输逻辑分为五个步骤getOut,checkDir,getIn,close,move.

其中最重要的部分就是checkDir方向检查,如果电梯运行的方向可以确定,那么其他的步骤都可以按部就班的顺利完成。如果达到了目标楼层就让电梯内的人出来,如果当前楼层有人符合乘电梯的条件那么就让这个人进入电梯,此时如果电梯门是开着的那么就让电梯关门,之后再让电梯照着之前确定好的方向移动就可以了。

由于我实现的电梯是一个类Look算法的可捎带电梯,所以当电梯中有人时电梯的运行方向是始终不会发生变化的。这很容易理解。所以我需要重点考虑的情况就是,当电梯内为空时,电梯的运行方向该如何选择。这也是为什么将方向检查checkDir放在getOut之后,因为getOut后是最早有可能出现电梯为空的时刻,此时我们需要就行方向检查。

如果此时电梯为空,且没有处于接人状态,那么我们首先将电梯设置为悬停状态。之后判断当前楼层是否有人要进入电梯,如果有那么让该请求进入并将电梯的方向设置为该请求的方向;如果当前楼层没有请求,那么电梯会寻找新的请求,将方向设置为接新请求的方向并设置电梯为接人状态,到达接人的楼层后再设置接人状态为false.由此不断循环,就解决了电梯为空时的方向判断问题。

第二次作业

结构分析

第二次作业相较于第一次作业,增加了也仅增加了横向电梯的设定。并且可以通过外部请求来增加电梯的数量。

其中,横向电梯的实现与纵向电梯类似,电梯内部逻辑不需要进行大的改动,基本拿过来就可以用。对于同一楼座和同一楼层出现多部电梯的情况采用“自由竞争”策略,这多部电梯共享一个等待队列,乘客谁先抢到就算谁的。

整体结构与第一次作业基本相同。

UML图

时序图

第三次作业

结构分析

第三次作业最大的难点在于换乘,参考基准策略,我为每个请求找到一个中转楼层,通过中转楼层进行横向移动。原始请求在被输入后会被静态分解为至多三个小请求,这若干个小请求会被放入一个队列之中,这个队列被存放进大等待队列RequestAll中。

if (inRequest instanceof PersonRequest) {
                    PersonRequest temp = (PersonRequest) inRequest;
                    MyPersonRequest parRequest =
                            new MyPersonRequest(temp.getFromFloor(), temp.getToFloor(),
                            temp.getFromBuilding(), temp.getToBuilding(),
                            temp.getPersonId());
                    Parse parse = new Parse(parRequest,transElevatorList);
                    ArrayList<MyPersonRequest> parsed = parse.doParse();
                    requestAll.add(parsed);
                }

RequestAll中,我使用HashMap结构来对若干个请求队列进行存储,其中key值是这个请求队列的第一个元素,value值就是这个请求队列。同时需要考虑到,在每个请求队列中的小请求需要满足前一个被完全执行后才可以继续执行第二个请求(一个人走了第一步才能继续走第二步,这很好理解)。因此我使用另一个HashMap结构来存储每个请求队列的标志位,即当该队列标志位为真时调度器才可以取出该队列队首的请求,该队列标志位为假时调度器不能取出该队列队首的请求。

	private HashMap<MyPersonRequest,ArrayList<MyPersonRequest>> requestAll;
    private HashMap<MyPersonRequest,Boolean> signal;
    private boolean isEnd;
    //当新的队列加入时,设置其标志位为true;
    public synchronized void add(ArrayList<MyPersonRequest> parsedRequest) {
        requestAll.put(parsedRequest.get(0), parsedRequest);
        signal.put(parsedRequest.get(0),true);
        notifyAll();
    }
	//当取出标志位为true的队首队首元素后将该队列的标志位设置为false;
    public synchronized MyPersonRequest getOneRequest() {
        if (this.needWait() && (!this.isEnd() || !this.isEmpty())) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        for (MyPersonRequest it : signal.keySet()) {
            if (signal.get(it) == true) {
                signal.remove(it);
                signal.put(it,false);
                notifyAll();
                return it;
            }
        }
        return null;
    }
	//当电梯执行完请求后,唤醒该队列的下一个请求
	//如果删去队首请求(已经被执行完的请求)后该请求的队列不为空,那么将该队列标志位设置为true
    public synchronized void notifyNext(MyPersonRequest request) {
        ArrayList<MyPersonRequest> newRequests = requestAll.remove(request);
        newRequests.remove(request);
        signal.remove(request);
        if (!newRequests.isEmpty()) {
            requestAll.put(newRequests.get(0), newRequests);
            signal.put(newRequests.get(0), true);
        }
        notifyAll();
    }
	//判断是否还有可以处理的请求
    public synchronized boolean needWait() {
        if (signal.containsValue(true)) {
            return false;
        }
        else {
            return true;
        }
    }

UML图

时序图

Part4 BUG分析

在第一次作业的互测时被Hack到一个输出线程不安全导致时间戳非单调递增的bug(没有认真看讨论区)。除此之外在强测和互测中没有被Hack到其他的bug。

但是在自测过程中出现的一些bug同样让人十分难忘。

输出线程不安全

在同步块与锁的部分已经说明,不再赘述。

滥用notifyAll

这一问题在第一次作业中还没有显现,在第二次作业中就会出现问题。由于同一楼座或者同一楼层多部电梯的存在,等待队列被这一楼座/楼层的所有电梯共享。而每个电梯线程在wait时都会在共享对象上进行等待。由于我沿用第一次作业中判断共享对象是否为空的语句:

public synchronized boolean isEmpty() {
        notifyAll();
        return requests.isEmpty();
    }

当一个电梯线程判断共享对象是否为空时,就会唤醒其他在共享对象上等待的电梯线程。而此时为进入等待的状态,则被唤醒的电梯又会调用isEmpty方法,而这又会唤醒其他电梯线程。如此反复,导致RTLE(我当时在公测第四、五个测试点报出这样的错误,也有同学认为这会导致CPU的轮询)。解决方法就是将这个方法中的notifyAll语句注释掉。

调度器等待时机的判断

解决了这个问题才最终保证了第三次作业的正确性。在前两次的作业中,我的大等待队列和每个楼座/楼层的小等待队列的实现方式是一样的都是实例化RequestTable类。但是在第三次作业中,RequestTable类已经无法满足大等待队列的结构需要,于是我重新实现了RequestAll类。在调度器等待时机的判断上,我写的是

public synchronized MyPersonRequest getOneRequest() {
        if (this.needWait()) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        for (MyPersonRequest it : signal.keySet()) {
            if (signal.get(it) == true) {
                signal.remove(it);
                signal.put(it,false);
                notifyAll();
                return it;
            }
        }
        return null;
    }
    
public synchronized boolean needWait() {
        if (signal.containsValue(true)) {
            return false;
        }
        else {
            return true;
        }
    }

当前大等待队列中没有调度器可以调度的请求时进行wait,当输入已经结束且请求队列中的元素已经全部处理完时,调度器线程依旧会进入等待导致死等的出现。

于是我把判断条件改为了this.needWait() && !this.isEnd(),于是新的问题出现了——CPU轮询。这是因为输出线程完全有可能在大队列中的元素完全处理完之前结束。设想这样一种情况:当前输入线程已经结束,但是大的等待队列中还有未被处理的请求,但是这些请求队列的标志位都为false,这些队列都在等待着电梯的唤醒。此时如果我这么写判断条件this.needWait() && !this.isEnd(),那么调度器将不会等待,而是不停反复询问,从而导致了轮询

正确的写法应该是

if (this.needWait() && (!this.isEnd() || !this.isEmpty())) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

Part5 心得体会

本单元的主要学习了多线程的知识,并且动手开发了一个可迭代的多线程电梯运行模拟程序(这是一件很有成就感的事)。纵观三次作业,整体的结构框架都以第一次作业的结构为基础,在增量开发的过程中只是经历了不大的改动,由此说明了好的结构框架有助于未来的扩展和迭代。在开始一个项目前,斟酌出一个较为完善的架构往往会达到事半功倍的效果。

同时通过课上的讲授和实验课训练了解了多种设计模式,如生产者-消费者模式流水线模式单例模式,其中的代码框架非常值得借鉴和学习。

在锁的使用方面,我现在还只仅仅局限于使用synchronized关键字来进行加锁。对于其他类型的锁了解还很少,这需要我在未来进一步的学习。

posted @ 2022-05-02 16:51  Selabarsayes  阅读(59)  评论(0编辑  收藏  举报