OO-2022-Unit2-BeihangCSE

OO第二单元


一、同步锁的设置和锁的选择

本单元中同步锁我主要选择了sychronized关键字 + wait() notifyAll的方式设置同步块

在程序中,RequestQueue是最主要的共享数据

public class RequestQueue {
    private ArrayList<PersonRequest> list;
    private boolean isEnd;

它负责存储每个电梯需要处理的电梯请求,输入线程需要向其中添加请求,电梯需要访问其中的请求并按照目前的状态取出需要处理的请求。而其中具体的PersonRequest只会被读取,而不会被写,因此无需设置同步锁。

因此电梯的所有方法都需要直接上锁,而PersonRequest可以直接访问,例如

public synchronized int mainStart() {
    return this.list.get(0).getFromFloor();
}

public synchronized int mainEnd() {
    return this.list.get(0).getToFloor();
}

public synchronized int mainLateralStart() {
    return (int) (this.list.get(0).getFromBuilding() - 'A');
}

public synchronized int mainLateralEnd() {
    return (int) (this.list.get(0).getToBuilding() - 'A');
}

同时,在电梯访问请求列表并且取用请求时,需要锁住对应的请求列表,以防止在多次访问之间输入线程添加了新的请求,导致多次访问之间访问的结果不同导致的操作不一致,例如

synchronized (upQueue) { //整个访问过程中uqQ被锁,不变
    if (upQueue.isEmpty()) { return; }
    upQueue.elevatorQueueSort(direction, floor);//选出主请求
    if (upQueue.mainStart() == floor &&
        upQueue.mainDirection() == direction && capacity < maxCapacity) {
        open();
        while (upQueue.mainStart() == floor &&
               upQueue.mainDirection() == direction && capacity < maxCapacity) {
            capacity++;
            inQueue.add(upQueue.topRequest());
            Printer.print(String.format("IN-%d-%c-%d-%d",
                                        upQueue.topRequest().getPersonId(), building, floor, id));
            upQueue.removeTop();
            if (upQueue.isEmpty()) { return; } //结束
        }
    }

同时,当请求列表为空时,电梯需要原地等待,这里就涉及到wait()nofitfyAll()的设计

电梯的设计如下

synchronized (upQueue) {
    while (upQueue.isEmpty()) {
        if (upQueue.isEnd()) {
            return; //线程结束
        }
        try {
            upQueue.wait(); //放出upQueue的锁
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

因此,需要一个upQueue.notifyAll()来将其唤醒。为了避免不必要的唤醒导致的轮询问题,沃恩著需要在必要的时候唤醒,而对电梯而言,必要的唤醒指的就是upQueue不为空或者结束。

因此只有两个地方需要使用notifyAll()

public synchronized void add(PersonRequest r) {
    list.add(r);
    this.notifyAll();
}

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

二、调度器的设置与变化

在hw5和hw6中调度器是一个单独运行的线程,整个架构是两级消费者-生产者机制,其中调度器就扮演了第一级消费者和第二级生产者,也就是从mainQueue中的请求取出并放入各个电梯的upQueue中。因此hw5和hw6中,调度器仅仅起到了简单地分配请求的作用。

在hw7中,因为有换乘的需求,因此调度器需要起到拆分请求的作用。

public class Scheduler {
    private static final Scheduler SCHEDULER = new Scheduler();
    private ArrayList<RequestQueue> requestQueues;
    private ArrayList<RequestQueue> lateralRequestQueues;
    private HashMap<BuildingPath, TransferFloors> access;
    private HashMap<Integer, PersonRequest> transferRequests;

其中存放了所有的电梯列表、一张需要换乘的请求表transferRequest和表明楼座间可达性的表access

access只由InputThread写入,Scheduler读取,因此无需任何同步设置。

transferRequestInputThread写入,当输入线程读到了需要换乘的请求,则将其放入这个换乘表里;由Elevator删除,当电梯处理完一个请求,则判断请求是否在换乘表里,如果不存在,则不做任何操作,如果存在并且乘客已经到达终点就将这个请求从表中删除,表明该请求处理结束,如果存在但乘客还没有到达终点就继续拆分这个请求。

为了简单化操作,将Scheduler设置为一个单例的数据结构(或者说一个处理器),当别的线程调用它的单例时,它执行一定的操作并且返回结果,例如Elevator处理完一个请求时

public void finishRequest(PersonRequest pr) {
    synchronized (transferRequests) {
        if (transferRequests.containsKey(pr.getPersonId())) {
            PersonRequest finalPr = transferRequests.get(pr.getPersonId());
            if (pr.getToBuilding() == finalPr.getToBuilding() &&
                pr.getToFloor() == finalPr.getToFloor()) {
                transferRequests.remove(pr.getPersonId());
                transferRequests.notifyAll();
            } else {
                PersonRequest nextPr = nextMove(finalPr, pr.getToBuilding(), pr.getToFloor());
                addRequest(nextPr);
            }
        }
    }
}

由于换乘表中也存放了一定的请求,因此要结束电梯线程时,也需要判断换乘表是否为空,为了少改动架构,我们对输入线程setEnd的部分进行了一定的调整

if (request == null) {
    synchronized (Scheduler.getInstance().getTransferRequests()) { //只有remove的时候需要唤醒
        while (!Scheduler.getInstance().isEmpty()) {
            try {
                Scheduler.getInstance().getTransferRequests().wait();
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    for (RequestQueue rq : requestQueues) { rq.setEnd(true); }
    for (RequestQueue rq : lateralRequestQueues) { rq.setEnd(true); }
    break;
}

三、线程协同的调度器模式

hw5

在hw5中,不涉及横向电梯的部分。调度器也没有如此复杂的功能,仅仅负责将mainQueue中的请求分发到各个楼座去。

此时,为了较简单地实现look算法,我为电梯设置了两个请求列表,分别表示上行、下行的请求,当电梯上行、下行时就只需要存取特定的列表即可。为了充分的look,事实上我已经为电梯规划好了它的行进路线,代码及其类图如下:

direction = -1; 
while (upQueue.inquiry(direction, floor)) { //查询下行区间内有无请求
    move(); //这时候只移动不接人进来,为了能接到最下面的上行请求
}
direction = 1; getUp(); close();
while (!inQueue.isEmpty() || upQueue.inquiry(direction, floor)) {
    move(); getUp(); close();
}//再考虑下行,同样先上行到尽可能高层
direction = 1;
while (downQueue.inquiry(direction, floor)) { //查询上行区间内有无请求
    move();
}
direction = -1; getDown(); close();
while (!inQueue.isEmpty() || downQueue.inquiry(direction, floor)) {
    move(); getDown(); close();
}

在后续的迭代中,我发现这里所谓的较简单地实现,只是减少了排序的工作,而且从某种程度上限制的电梯运行的自由度。但即使是这样,我也仍然在排序算法中出了锅,严重影响了性能。

hw6

在hw6中采取了自由竞争的策略,整体架构变化不大,只是增添了LateralElevator用于模拟横向电梯的移动,以及在RequestQueue中增加了适应横向请求访问、排序方法。

但在实现细节处,发生了巨大的变化。按照hw5一个电梯访问上行、下行两个列表的架构,hw6则需要多个电梯共享同一组上行、下行请求列表。问题在于要实现优雅的等待唤醒,电梯必须可用被上行或下行列表中的任意一个唤醒,换言之电梯必须持有两把锁再等待,这显然是不可能的。在询问了按楼层设置等待列表的同学(因为它们也需要一个电梯管理多个等待列表)后,他们给出的建议是将上下行请求包装成一个对象。考虑了修改的代码量,我决定放弃上下行列表的思路,在排序算法多下功夫,让电梯只需要管理一个等待列表即可。尽管目测复杂度很高,但在明确了排序的目标后,实现起来还是非常简单并且很难出错的。

 public synchronized void elevatorQueueSort(int direction, int floor) {
        Collections.sort(this.list, new Comparator<PersonRequest>() {
            @Override
            public int compare(PersonRequest r1, PersonRequest r2) {
                //不认为对EQ升序排列有任何作用,只需要把同楼层、同方向的置顶
                if (r1.getFromFloor() == floor) {
                    if (r2.getFromFloor() == floor) {
                        if (RequestQueue.direction(r1) == direction) {
                            if (RequestQueue.direction(r2) == direction) {
                                return 0;
                            }
                            return -1;//r1 is former
                        } else if (RequestQueue.direction(r2) == direction) { return 1; }
                        return 0;
                    }
                    return -1; //r2 is later
                } else if (r2.getFromFloor() == floor) { return 1; }
                return 0; //r2 is former
            }
        });
    }

这也提示我对横向电梯的管理,没有必要设置顺逆时针两个等待列表。

hw7

hw7中为了实现流水线的模式,单例模式的调度器需要实现请求的分发、请求处理结束时的响应以及指定换乘请求的换乘策略。

请求的分发和响应是流水线模式的具体实现。在InputThreadElevator分别负责向一个流水线中(也就是换乘请求表transferRequests)中放入任务、处理被分配的任务并且通知调度器进一步分配任务或者标志任务结束。

同时调度器还负责指定换乘请求的换乘策略,这主要依据两个新增的类BuildingPathTransferFloors来查找两楼座间可达的楼层,并依照少换乘、少绕路的策略指定换乘楼层。。

Scheduler的具体协作方式

四、bug分析

hw5

本次作业因为没有多电梯,只要使用了sychronized锁住共享对象就能很好地完成,不存在轮询等等问题。在完成中测的部分,主要的bug来自我的输出格式不规范。

强测阶段没有错点

互测阶段15刀全中。sad 😦 主要原因是没有设计线程安全的输出类,导致了Output not in Order的问题

值得注意的是,我的排序算法写错了。本来我的排序策略是1.在运动方向范围内2.上行按升序排列,下行按降序排列,以保证电梯只需要判断顶端的请求是否可搭乘就好。但把升降序的函数写错了,导致电梯访问顶端的请求总是不在同一层楼,于是性能分很低。

hw6

本次作业主要是加入了多电梯,以及横向电梯的环形路径。中测阶段的Bug主要是出现了频繁唤醒导致的轮询、和横向电梯的来回横跳。前者需要删除不必要的唤醒,后者则需要慢慢调试发现问题。最后bug定位到了电梯运行的策略中确定下一步运动方向的函数。

强测阶段没有错点

互测阶段被hack了一次。原因是判断电梯满员时仍接进了一名乘客。

hw7

本次作业添加了换乘要求和横向电梯在部分楼座不可开门的定制化电梯。中测阶段的bug首先仍然是排序函数写错了。后来重新明确了一下排序,或者说置顶的要求1.目标楼座可达2.同方向3.出发楼座是当前位置。其次是没有考虑同楼层的横向请求也可能需要换乘的问题,默认是可直达的就丢进请求队列,然后一直没电梯能接他。

强测阶段和互测阶段都没有错点

五、hack策略

因为知道自己hw5没包装线程不安全的输出类,于是也针对这一点hack了别的同学。

hw6和hw7都没怎么认真hack,主要就是依靠随机数据生成来找bug,但效果不佳。

六、心得体会

实话实说,这个电梯月确实像纪一鹏老师所说,没有前一个单元难度大,也没有出现当时hw2那样长时间找不到解决办法的焦虑阶段。但一定程度上也是因为我选择了自由竞争的策略。hw6时在脑海中简单构想了一下写一个根据预期时间分配请求的算法,发现需要考虑的变量太多,而且电梯是基于实时请求的,于是就放弃了,迅速转换策略完成了自由竞争。

在hw7中,我终于尝试了在上个单元没有使用的hashCode()equals(Object o)的方法来实现一个自定义的HashMap,效果很不错,也算是弥补了伤感单元在学习中留下的遗憾。

本单元的学习是我第一次了解“线程”这一概念,这不仅帮助了我在OS中的学习,也让我在多种编程中尝试使用多线程的思维并且考虑共享数据的安全问题。当然,我还有很多内容是没有掌握的,比如第二节课中教授的Lock的方法,比如信号量的方法,它们都是Java提供的、或者模拟系统底层逻辑的线程实现方式,在特定的需求下,往往能比sychronized的方法更容易理解、更高效。希望自己以后能继续学习。

posted @ 2022-05-01 14:52  Danny121008  阅读(37)  评论(1编辑  收藏  举报