oo第二单元--电梯

吴佳峻-22373141 学生 2024-04-15 19:31:15

oo第二单元--电梯

第五周架构

总体架构

img

)

如上图,为了可拓展性开了很多类。

如以后可能出现其他请求,其他类型的电梯......

整个电梯调度系统的架构是围绕Controller中心展开的,它管理着电梯(Elevator实例)、请求池(RequestPool)和调度器(Scheduler)。系统启动时,InputThread开始监听输入,新的乘客请求被封装成MyRequest实例并加入到请求池中。调度器根据设定的策略定期从请求池中取出请求,并将它们分配给合适的电梯处理。

电梯作为独立的线程运行,根据调度器分配的请求执行相应的移动和乘客上下操作。电梯内部状态的更改(如当前楼层、门的开闭状态)通过锁来同步控制,以确保其准确性和一致性。当所有输入请求都被处理完毕,系统通过等待输入线程结束并执行任何必要的清理工作后关闭。

在实现多线程调度电梯的过程中,通过设计的同步机制和线程安全的数据操作,系统能够有效地处理并发请求,同时保持高效和稳定的运行。

线程有输入线程和电梯线程,分别为五个电梯开五个线程。而我设计的请求池(充当生产者,消费者模式的中的托盘)只有一个,因为后续可能有多栋楼,每栋楼的请求需要自由竞争而不是分配好了。这时候为每个电梯都单开一个请求池就很复杂了。电梯使用了接口实现,接口主要为了与策略类进行对接,获得自己下一步的行为。

img

可以看到复杂度也不高。

img

电梯策略

电梯分配策略

电梯运行策略

look:

1.若电梯中有乘客要到达的楼层在当前的方向上的前面,则保持当前方向不变

2.若1不成立,则若候乘表中有乘客出发的楼层在当前的方向上的前面,则保持当前方向不变

3.若1,2都不成立,则电梯原地等待

策略类只是给出电梯前进的方向,电梯允许乘客进来仅当电梯没满员且乘客的方向与电梯前进的方向相同。如果不考虑电梯与乘客方向的匹配,比如电梯在上行,有下行的乘客进来了,会导致整体用时增加。

电梯原地等待的时候需要使用wait,这个需要对requestPool上锁,使得电梯能在requestPoolwait,这样一来请求的话就可以通过requestPool中的notify通知电梯,让其继续工作或继续wait。

如下

private void waitForRequests() {
        synchronized (requestPool) {
            while (静止) {
                try {
                    requestPool.wait(); // 在请求池对象上等待
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                updateScheduler();  //这里更新状态
            }
        }
    }

有关上锁

在整个系统中,锁被用来控制对共享资源的访问,确保在多线程环境下的数据一致性和线程安全。

使用锁的目的和场景

  1. 确保数据一致性:在多线程程序中,共享资源(目前只有请求池中的请求队列)可能会被多个线程同时访问和修改,使用锁可以保证这些操作的原子性和一致性。

  2. 线程间的同步:特定的操作可能需要等待某个条件满足后才能继续执行,例如,一个线程可能需要等待直到电梯到达某个楼层。通过Condition等同步工具,线程可以在条件不满足时挂起,直到条件被另一个线程改变并通知等待的线程继续执行。

  3. 避免死锁:在设计使用锁的系统时,需要特别注意避免死锁情况,即两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行。通过合理设计锁的获取和释放逻辑。

synchronized关键字和Runnable接口

使用synchronized关键字和实现Runnable接口在Java多线程编程中分别解决了不同的问题,它们的用途和目标有着本质的区别:

使用synchronized关键字
  1. 目的synchronized关键字的主要目的是保证多线程环境下的线程安全,通过对方法或代码块进行同步操作,确保任一时刻只有一个线程可以访问被同步的资源或执行被同步的代码。
  2. 用途:它通常用于控制对共享资源的访问,如共享变量或对象,避免出现数据不一致或竞态条件等问题。
  3. 工作原理:当线程进入synchronized标记的方法或代码块时,它会自动获取关联对象的监视器(monitor)锁;当线程离开时,锁会被释放。如果其他线程尝试获取已被锁定的监视器锁,则该线程会被阻塞直到锁被释放。
实现Runnable接口
  1. 目的:实现Runnable接口是为了定义一个可以在单独线程中执行的任务。Runnable本身不创建新线程,它需要与Thread类配合使用来启动新线程。
  2. 用途Runnable接口用于将“任务”与“执行任务的机制”解耦。一个Runnable对象封装了一个任务,而Thread对象则是执行该任务的机制。
  3. 工作原理:通过实现Runnable接口的run方法来定义线程的任务。创建Thread实例时,将Runnable实例作为参数传递给Thread的构造函数。调用Thread实例的start方法会导致在新线程中执行Runnablerun方法。
区别总结
  • 目标不同synchronized用于同步访问,保证线程安全;而实现Runnable接口用于定义可以并发执行的任务。
  • 机制不同synchronized通过锁机制控制对共享资源的同步访问;Runnable通过定义run方法,实现在新线程中执行的代码逻辑。
  • 使用场景不同:当需要保护代码区域不被多个线程同时执行以避免竞态条件时,使用synchronized;当需要定义并执行一个线程任务时,使用Runnable接口。

简而言之,synchronized是解决多线程之间访问资源冲突的同步机制,而Runnable是定义线程执行任务的一种方式。在多线程编程中,两者经常一起使用,但它们解决的问题和应用的上下文完全不同。

在这段代码中,ReentrantLockCondition 被用来控制对共享资源的访问,并且允许线程之间进行精细的同步控制。让我们分别来看这两个部分的作用和如何一起工作。

synchronizedReentrantLock

ReentrantLock

ReentrantLockjava.util.concurrent.locks 包中的一个类,提供了比 synchronized 方法或语句更广泛的锁定操作。它具有完全的互斥锁的行为,并且拥有与使用 synchronized 方法和语句相同的基本行为和语义,但是它更加灵活。

Condition

Condition 实例通常与一个锁关联,并且提供类似 Object.waitnotifynotifyAll 方法的功能,但是它提供了更强大和更灵活的线程间同步方法。通过在特定 Lock 对象上创建一个或多个 Condition 对象,它允许线程获取一个锁并且在某个条件上等待,直到另一个线程唤醒它们。

结合使用 ReentrantLock 和 Condition 的优点
  • 更灵活的线程同步:相比于 synchronized 方法,ReentrantLockCondition 提供了更细粒度的线程同步控制。比如,它们允许在多个等待集(Condition对象)中分别等待,这在使用内置的监视器锁时是不可能的。
  • 可中断的等待和超时等待Condition 等待可以被中断,并且可以设置超时时间,这为线程提供了更多控制。
  • 公平锁ReentrantLock 提供了创建公平锁的选项,这意味着锁会按照线程等待的顺序来分配,防止饥饿。

ReentrantLockCondition 在这个电梯模拟程序中被用来确保对电梯状态的修改是线程安全的,并且允许更复杂的线程间协调,比如在电梯需要暂停或者在满足特定条件前不进行操作时进行等待。

在决定是否使用 synchronized 关键字还是 ReentrantLockCondition 对于同步控制,主要取决于你需要的同步和线程通信的精细度以及灵活性。

使用 synchronized

RequestPool 类中,使用了 synchronized 关键字来同步方法,这确保了在任何时刻只有一个线程能执行这些方法。synchronized 方法简单直接,对于许多基本的同步需求来说已经足够。它也自动处理了锁的获取和释放,减少了编码的复杂性。此外,synchronized 方法使用了内置的条件队列:每个对象都有一个内置的等待集,可以调用 wait() 方法来挂起当前线程,以及 notify()notifyAll() 方法来唤醒等待中的线程。这些特性对于你的 RequestPool 类来说可能已经足够。

使用 ReentrantLockCondition

ReentrantLock 提供了比 synchronized 更精细的锁控制。与 synchronized 相比,ReentrantLock 允许尝试非阻塞地获取锁、尝试获取锁并可中断、公平锁等高级功能。而 Condition 实例提供了类似于 Object 监视器方法的能力,但是每个 ReentrantLock 可以有多个 Condition 实例,这提供了分别管理线程等待集的能力,可以让线程有选择地等待特定条件的满足,而不是调用 Object.wait() 那样在同一个内置条件上等待。

选择依据
  • 简单性 vs. 灵活性:如果你的同步需求相对简单,不需要高级的锁管理特性(如可中断的锁获取、公平锁、或者精细的条件等待控制),那么使用 synchronized 可能更为简单直接。考虑到 RequestPool 类的现有用法,synchronized 可能已经满足需求。
  • 性能考虑:对于大多数情况,synchronizedReentrantLock 在性能上差异不大。不过,在高度竞争的情况下,ReentrantLock 可能会提供稍微更好的性能和更高的吞吐量,因为它支持更细粒度的锁管理和条件变量控制。
  • 特定特性需求:如果你需要利用 ReentrantLock 提供的高级功能,如尝试锁、可中断的锁获取、公平性选择或者有多个等待条件集,那么 ReentrantLock 将是更好的选择。

总的来说,考虑到 RequestPool 类的现有设计和需求,没有特别复杂的同步或等待/通知需求,使用 synchronized 关键字已经足够。

上锁内容

首先

  1. BasicElevator 实例的等待 (wait()) 必须是针对 RequestPool 实例对象的。即,BasicElevator 中的 wait() 调用应该是在 requestPool 对象上进行的,类似于 requestPool.wait(),而不是在电梯对象(this)上。

  2. RequestPool 中的 notifyAll() 会唤醒所有在该 RequestPool 实例上等待的线程,包括那些因调用 requestPool.wait() 进入等待状态的 BasicElevator 线程。

在我的架构中,本次作业中需要synchronized的只有requestPool,因为其中的请求队列可能被多个电梯线程访问。而某个电梯线程本身是不用的,因为没有多个线程去访问它。

其中requestPool中要上锁的方法有

public synchronized void addRequest

public synchronized boolean removeRequest

public synchronized PriorityQueue<MyRequest> getAllRequest()

public synchronized void setEnd()

第一个方法是输入线程调用,对requestPool的请求队列改变,第二,三个在某个电梯线程中调用,第四个方法是结束是需要设置状态requestPool的结束状态。

同样,当一个电梯wait在requestPool上时,什么时候需要唤醒他呢,也只有以下几个方法:

public synchronized void addRequest(MyRequest request)

public synchronized boolean removeRequest(MyRequest request)

public synchronized void setEnd()

第三个是因为电梯要根据这个去退出run的循环。

当一个线程调用某个对象的 wait() 方法时,它会做两件事:

  1. 释放当前对象上的锁:这使得其他线程能够进入这个对象的同步方法或同步代码块,执行操作并可能调用 notify()notifyAll() 方法。
  2. 进入等待状态:线程会保持等待状态,直到其他线程在相同的对象上调用 notify()notifyAll(),或者(如果提供了超时时间)直到超时。

下面说的量子电梯的实现也需要将电梯去放在requestPool上wait,直到时间到了或者来新的请求。

优化内容

不得不说,量子电梯几乎是完全的正优化,他从性能评判的各个方面都保证了其不弱于正常实现的电梯。

正常情况是电梯先发出日志信息,再执行sleep,比如已知到达某一层楼,只有再经过一定时间才能到达下一层,而量子电梯就是将这个过程反过来,先等待一定时间,再根据等待后的情况决定电梯下一步的日志信息,例如在开门之后,先等待电梯运行一层的时间,如果没有请求在这个时间内到达,那么电梯瞬移到下一楼层,如果有请求到达,那么可以瞬间开门。

当电梯需要在关闭门前等待一定时间时,它通过调用 wait(waitTime) 进入等待状态。如果在等待期间其他线程在电梯对象上调用了 notify()notifyAll(),等待的电梯线程(this)将被唤醒,继续执行等待之后的代码。

因此,是电梯本身(更确切地说,是代表电梯逻辑的线程)在 wait,等待要么是由超时时间结束,要么是由其他线程调用了相同电梯对象上的 notify()notifyAll() 方法而提前唤醒。

如下

private void move() {
        if (运动状态) {
            synchronized (requestPool) {
                while (System.currentTimeMillis() < waitEndTime) {
                  
                    if (waitTime > 0) {
                        // 被其他线程唤醒
                        // 上楼状态回退
                        try {
                            requestPool.wait(waitTime);
                            updateScheduler();
                            ArrayList<MyRequest> pickUpList = scheduler.whoToPickUp();
                            if (!canMove(pickUpList)) {
                                return; //这里直接退回到run里
                            }
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                }
            }
            // 指定时间内没有被其他线程唤醒
            // 成功运行
            ......
        }
    }

实现后

就是这样的效果

[1.0]1-FROM-1-TO-6-BY-2
[1.3]2-FROM-1-TO-6-BY-2
[1.6]3-FROM-1-TO-6-BY-2
[1.9]4-FROM-1-TO-6-BY-2
[2.2]5-FROM-1-TO-6-BY-2
[2.6]6-FROM-1-TO-6-BY-2
[   1.0050]OPEN-1-2
[   1.0060]IN-1-1-2
[   1.2960]IN-2-1-2
[   1.4160]CLOSE-1-2
[   1.6050]OPEN-1-2
[   1.6050]IN-3-1-2
[   1.8970]IN-4-1-2
[   2.0050]CLOSE-1-2
[   2.1960]OPEN-1-2
[   2.1960]IN-5-1-2
[   2.6080]CLOSE-1-2
[   3.0150]OPEN-1-2
[   3.0150]IN-6-1-2
[   3.4230]CLOSE-1-2
[   3.8300]ARRIVE-2-2
[   4.2400]ARRIVE-3-2
[   4.6480]ARRIVE-4-2
[   5.0580]ARRIVE-5-2
[   5.4660]ARRIVE-6-2
[   5.4660]OPEN-6-2
[   5.4670]OUT-1-6-2
[   5.4670]OUT-2-6-2
[   5.4680]OUT-3-6-2
[   5.4680]OUT-4-6-2
[   5.4680]OUT-6-6-2
[   5.4680]OUT-5-6-2
[   5.8760]CLOSE-6-2

可以一次运上去,至于为什么会多次开关门,只能说权衡性能后只能这样了。

第六周架构

总体架构

针对这次作业,我对架构进行了两个调整:

1.将电梯内部的运行调整为状态机的模式,因为随着电梯功能的增多,if-else是在过多,不好看

2.将原本只有一个的公共请求池分成了六个小的,因为在实践过程中,这样更直观,后续进行电梯的模拟也会更加方便

其余架构同上周

一些感想

课程组的一些调整直接将性价比最高的自由竞争给ban了,要求我们必须先给出乘客的分配,这样的话貌似选择不是很多了,只有影子电梯(深克隆找局部最优),为电梯设置优先级这俩种比较好的选择,我选择了第一种,并且实现了对正在reset的电梯的模拟,yysy真的难写,当模拟reset后代码的复杂度暴增,我的电梯类也超出了500行。当然,一个折中的思路是对不在reset的电梯进行模拟,对reset的电梯算出优先级?however尽兴就好,下面是我的一些实现(不保证正确)

reset实现

先谈谈reset的操作,因为量子电梯的存在,使得电梯上下两层楼的时间将可能>3.8s,因此的话我也放弃了让他多运行两层楼的想法,当检测到reset的时候直接return回去。
reset开始前要将电梯乘客表中的乘客重新调度,这里会调用调度器,因此调度器也成为了输入线程和电梯的共享资源,需要对其方法进行上锁。此外,还要将电梯的请求池中的乘客也重新调度。

电梯模拟

深克隆

第一步要做的就是将我们与电梯运行有关的类都写个deepclone方法

向下面这样

public synchronized RequestPool deepClone() {
        RequestPool clonedPool = new RequestPool();
        clonedPool.isEnd = this.isEnd; // 基本类型,直接赋值
        clonedPool.waitQueue = new PriorityQueue<>();

        ......
        clonedPool.requestQueues.put(key, clonedQueue);
        }
        return clonedPool;
    }

之后我在调度器中的模拟时间方法中进行克隆并且传递给影子电梯

requestPools.get(elevator.getEId()).deepClone();

像这样进行,这里一定要复制好每一个需要的属性,以后修改或增加了属性也要考虑到。。。

不带reset的电梯模拟

先实现影子电梯类ShadowElevator,这一步也很好实现,将其作为基础电梯的子类即可,注意调用属性时候只能使用settergetter

哦对这里还要注意父类的方法为protected或public,子类才可以重写或调用(de了半天虚空bug)

对于电梯状态的获取,我采用了读写锁的设置来保证其正确性。但是我测试发现貌似不加读写锁也是可以的,其中一个原因可能是当我们同时获得电梯所有属性继承到影子电梯后,影子电梯会进行相同的运行;当然更大的原因应该是克隆所需要的时间相较于电梯一个动作的最小时间间隔(0.1s)显得微不足道,能够获取到电梯进行完一个动作的完整状态而不用担心其改变。当然最好还是加上锁。

思考一下,我们的影子电梯时时刻刻都在运动,即没有静止状态,或者一次性的上下人,当请求池和内部乘客表都为空时候结束。因此其状态过程就像这样:

ShadowElevator

switch (currentState) {
                case MOVING_UP:
                case MOVING_DOWN:
                    /*
                    for (MyRequest request : requestPool.getAllRequest()) {
                        OutputThread.println(getEId() + "requestPool:" + request.getPersonId());
                    }
                     */
                    performPassengerExchange();
                    updateScheduler();
                    move();
                    break;
                default:
                    throw new IllegalStateException("Unexpected Elevator State: " + currentState);
            }

此时reset的处理很简单,当我们检查到电梯的状态为reset时,跳过这部电梯,当我们发现六部电梯都在reset时,让调度器循环sleep0.01s去等待电梯reset完。

Dispatcher

 while (true) {
            MyRequest requestCLone = request.deepClone();
            IElevator bestElevator = findBestElevator(requestCLone);
            //IElevator bestElevator = elevators.get(2);
            if (bestElevator != null) {
                // 如果找到最佳电梯,则设置请求的电梯ID并添加到请求池
               .......
                    OutputThread.println(String.format("%s-%d-%d", "RECEIVE",request.getPersonId(), bestElevator.getEId()));
                    requestPools.get(bestElevator.getEId()).addPersonRequest(request);
                }
                break;
            } else {
                // 如果没有找到合适的电梯(例如,所有电梯都在维护),处理请求失败或进入等待队列
                Thread.sleep(100);
                // 这里可以选择将请求加入到某个等待队列
            }
        }

进行reset的模拟

先看大体框架:(省略了深克隆)

Dispatcher

private IElevator findBestElevator(MyRequest request) {
        IElevator bestElevator = null;
        long shortestWaitTime = Long.MAX_VALUE;
        ......
        for (IElevator elevator : elevators.values()) {
            long waitTime = 0;
            ......
            if (elevator.getEstate() == EState.RESET) {
                waitTime += elevator.getResetRemainingTime();
                //OutputThread.println(String.valueOf(waitTime));
                //continue;
            }
            requestPoolClone.addPersonRequest(request);
            waitTime += getWaitTime(elevator, requestPoolClone);
            //OutputThread.println(+ elevator.getEId() + ":" + String.valueOf(waitTime));
            if (waitTime < shortestWaitTime) {
                //OutputThread.println(String.valueOf(waitTime) + elevator.getEId());
                shortestWaitTime = waitTime;
                bestElevator = elevator; // 注意这里返回的是原始电梯,而不是shadowElevator
            }
            //OutputThread.println(elevator.getEId() + ":" + waitTime);
        }
        ......
        return bestElevator;
    }

对于reset模拟的实现,我分成了三步(还有无数的细节):
1.电梯中返回剩余时间
这个很简单,当电梯开始reset的时候存一下开始时间,用当前时间减去这个时间就能得到。

BasicElevator

public long getResetRemainingTime() {
        readLock.lock();
        try {
            long elapsedTime = System.currentTimeMillis() - this.resetStartTime;
            //OutputThread.println(String.valueOf(elapsedTime));
            long remainingTime = RESET_DURATION - elapsedTime;
            //OutputThread.println(String.valueOf(remainingTime));
            return Math.max(remainingTime, 0);  // 确保返回值不为负数
        } finally {
            readLock.unlock();
        }
    }

注意读锁的使用

2.用reset后的电梯进行模拟
首先要重写构造函数,将reset后的新的电梯容量和速度传进去作为影子电梯的属性。
这里有个坑,对于我的实现来说,想要让影子电梯正常运行,我必须获取电梯之前的状态,注意这里不能将其置为静止,因为之前说过,影子电梯是一直在运动的。这里的话我的实现是在策略类中,当想将状态置为reset的时候,就要将其之前的状态存一下,用作初始化影子电梯。之后影子电梯的运行是一样的。

public ShadowElevator(IElevator original, RequestPool requestPool, Scheduler scheduler,
                          Dispatcher dispatcher, int newCapacity, int newSpeed) {
    this.state = original.getLastState();

3.怎样在reset后输出receive
如果选用的是reset的电梯,我们并不能立刻输出receive,这时候就必须将这些乘客存到一个等待表里,当电梯reset结束后,要检查自己的等待表中是否有乘客,如果有的话再将其加入到自己的请求池中,同时输出receive。

dispatcher

requestPools.get(bestElevator.getEId()).addWaitRequest(request);

这里的话又会引申出一个坑,对于一部在reset的电梯,等待表中的乘客也要进行模拟,因此在构造reset的影子电梯时候,还要将其等待表中的请求克隆到他的克隆请求池中,或者克隆到他的里面的乘客表。

贴几个简单样例

[1.6]RESET-Elevator-1-8-0.3
[2.6]2-FROM-5-TO-1
[2.6]3-FROM-5-TO-1
[2.6]4-FROM-5-TO-1
[2.6]5-FROM-5-TO-1
[2.6]6-FROM-5-TO-1
[2.6]7-FROM-5-TO-1
[2.6]21-FROM-5-TO-1
[2.6]22-FROM-5-TO-1
[2.6]23-FROM-5-TO-1
[3.0]RESET-Elevator-1-5-0.4
[   1.6060]RESET_ACCEPT-1-8-0.3
[   1.6070]RESET_BEGIN-1
[   2.6240]RECEIVE-23-2
[   2.6240]ARRIVE-2-2
[   2.8190]RESET_END-1
[   2.8190]RECEIVE-2-1
[   2.8200]RECEIVE-3-1
[   2.8200]RECEIVE-4-1
[   2.8200]RECEIVE-5-1
[   2.8200]RECEIVE-6-1
[   2.8200]RECEIVE-7-1
[   2.8200]RECEIVE-21-1
[   2.8200]RECEIVE-22-1
[   2.9980]RESET_ACCEPT-1-5-0.4
[   3.0010]RESET_BEGIN-1
[   3.0040]RECEIVE-2-2
[   3.0050]RECEIVE-3-2
[   3.0070]RECEIVE-4-2
[   3.0090]RECEIVE-5-2
[   3.0100]RECEIVE-6-2
[   3.0110]RECEIVE-7-3
[   3.0110]ARRIVE-2-3
[   3.0120]RECEIVE-21-3
[   3.0130]RECEIVE-22-3
[   3.0390]ARRIVE-3-2
[   3.4140]ARRIVE-3-3
[   3.4450]ARRIVE-4-2
[   3.8230]ARRIVE-4-3
[   3.8550]ARRIVE-5-2
[   3.8550]OPEN-5-2
[   3.8550]IN-23-5-2
[   3.8550]IN-2-5-2
[   3.8560]IN-3-5-2
[   3.8560]IN-4-5-2
[   3.8560]IN-5-5-2
[   3.8560]IN-6-5-2
[   4.2170]RESET_END-1
[   4.2330]ARRIVE-5-3
[   4.2330]OPEN-5-3
[   4.2330]IN-7-5-3
[   4.2330]IN-21-5-3
[   4.2340]IN-22-5-3
[   4.2640]CLOSE-5-2
[   4.6410]CLOSE-5-3
[   4.6730]ARRIVE-4-2
[   5.0510]ARRIVE-4-3
[   5.0820]ARRIVE-3-2
[   5.4590]ARRIVE-3-3
[   5.4910]ARRIVE-2-2
[   5.8700]ARRIVE-2-3
[   5.9000]ARRIVE-1-2
[   5.9000]OPEN-1-2
[   5.9000]OUT-23-1-2
[   5.9010]OUT-2-1-2
[   5.9010]OUT-3-1-2
[   5.9010]OUT-4-1-2
[   5.9010]OUT-5-1-2
[   5.9010]OUT-6-1-2
[   6.2770]ARRIVE-1-3
[   6.2770]OPEN-1-3
[   6.2780]OUT-7-1-3
[   6.2780]OUT-21-1-3
[   6.2780]OUT-22-1-3
[   6.3090]CLOSE-1-2
[   6.6870]CLOSE-1-3

由上,我们仅仅能达到局部最优

[1.6]RESET-Elevator-1-8-0.5
[1.7]RESET-Elevator-2-8-0.6
[1.8]RESET-Elevator-3-8-0.7
[1.9]RESET-Elevator-4-8-0.3
[2.0]RESET-Elevator-5-8-0.4
[2.2]RESET-Elevator-6-8-0.2
[2.4]1-FROM-1-TO-6
[2.8]2-FROM-1-TO-6
[   1.6050]RESET_ACCEPT-1-8-0.5
[   1.6050]RESET_BEGIN-1
[   1.6980]RESET_ACCEPT-2-8-0.6
[   1.6980]RESET_BEGIN-2
[   1.7980]RESET_ACCEPT-3-8-0.7
[   1.7980]RESET_BEGIN-3
[   1.8980]RESET_ACCEPT-4-8-0.3
[   1.8980]RESET_BEGIN-4
[   1.9980]RESET_ACCEPT-5-8-0.4
[   1.9990]RESET_BEGIN-5
[   2.1980]RESET_ACCEPT-6-8-0.2
[   2.1990]RESET_BEGIN-6
[   2.8060]RESET_END-1
[   2.8990]RESET_END-2
[   2.9990]RESET_END-3
[   3.0980]RESET_END-4
[   3.2000]RESET_END-5
[   3.3990]RESET_END-6
[   3.3990]RECEIVE-1-6
[   3.3990]RECEIVE-2-6
[   3.4000]OPEN-1-6
[   3.4000]IN-1-1-6
[   3.4000]IN-2-1-6
[   3.8060]CLOSE-1-6
[   4.0090]ARRIVE-2-6
[   4.2130]ARRIVE-3-6
[   4.4180]ARRIVE-4-6
[   4.6210]ARRIVE-5-6
[   4.8240]ARRIVE-6-6
[   4.8250]OPEN-6-6
[   4.8250]OUT-1-6-6
[   4.8250]OUT-2-6-6
[   5.2280]CLOSE-6-6

综上所述,整个结构变得很复杂了,也多了很多耦合关系,几乎每一个类都成了共享对象,比如调度器由电梯和输入线程共享,请求池由调度器和电梯共享,电梯又由调度器,策略类共享,必须要理清出谁在调用谁,才能准确的上锁。

当然我并不能保证我的实现是完全正确的,甚至可能因为哪里的疏忽导致错点,性能不如算优先级的电梯......还是那句话,尽兴就好。

第七周架构分享

怎样实现双轿厢电梯

首先,怎样能凭空增加六个电梯线程呢?为了避免新开,关线程的繁琐操作,在刚开始就开12个电梯线程,只不过让他在没有收到dcreset请求时候处于停止状态即可。
如何具体实现双轿厢电梯呢,为了少写一点),没有选择使用子类来实现,而是直接在原来的电梯上加上几个flag,如下,同时增加max和minfloor用于修改look策略

elevator.java

     private boolean isDC = false;
    private int maxFloor;
    private int minFloor;
    private int transFloor;

如何实现换乘

然后就是如何实现换乘了。两种策略,在乘客来的时候就规定好其路线,或者让他先走一段,出来再重新规划。由于之后电梯行为的不确定性,选择第二种。那么如何判断乘客是否到站,以及怎样添加换乘楼层呢,我是在myrequest中添加了一个新的属性stack changefloors,同时修改一些方法,这样look策略类几乎就不需要改变了。

MyRequest.java

private Stack<Integer> changeFloors = new Stack<>();
public void popFloor() {
        this.fromFloor = changeFloors.pop();
    }
 public void pushFloor(int toFloor) {
        changeFloors.push(toFloor);
    }

这样只需要在调度的时候选出第一个局部最优的电梯时候,如果需要换乘就把那个楼层push进去就好

 public int getToFloor() {
        return changeFloors.peek();
    }

以上这这个操作就可以使得look策略几乎不需要修改了。

如何避免双轿厢电梯相撞呢

如何避免双轿厢电梯相撞呢?就是移动时当检测到另一部电梯在换成楼层时候就wait,之后另一部离开时去通知他。这里也可以用一个信号量来实现。move(指楼层改变的一瞬间)前看看是不是需要更新

if (scheduler.getOther().inTransFloor()) {
            synchronized (this) {
                try {
                    wait(); 
            }
        }
        move
       "ARRIVE"
        if (atFloor + 1 == transFloor || atFloor - 1 == transFloor) {
            synchronized (scheduler.getOther()) {
                scheduler.getOther().notify();
            }
        }

这个操作直接去唤醒了另一个电梯,没有经过什么中转(不直到有没有什么问题),但是实现很简单。

如何关闭

为了影子电梯能正确的克隆电梯状态,所有电梯都要在最后在能关闭,因此在调度器中增加一个信号量,

private int peopleNum = 0;
private int resetNum = 0;  // 上次增加

之后对其进行更新

如resetNum新增更新

public synchronized void addDoubleCarReset(DoubleCarResetRequest request) {
        Scheduler scheduler = schedulers.get(id);
        // 调用重置a
        scheduler = schedulers.get((id + 5) % 12 + 1);
        // 调用重置b
        resetNum += 2;
        之后要唤醒指定电梯
        synchronized (requestPools.get(id)) {
            requestPools.get(id).notify();
         }
        synchronized (requestPools.get((id + 5) % 12 + 1)) {
            requestPools.get((id + 5) % 12 + 1).notify();
         }
        
    }

personNum同理,最后要检测一下

public synchronized void close() throws InterruptedException {
        while (resetNum > 0 || peopleNum > 0) {
            wait();
        }
        for (int i = 1; i <= 12; i++) {
            requestPools.get(i).setEnd();
        }
    }

影子电梯模拟

首先和上次一样,要遍历所有电梯,只不过这次对于双轿厢电梯要多检测一下

if (elevator.getIsDC()) {
            if (needsTransfer) {
                requestClone.pushFloor(transFloor);
                requestPoolClone.addPersonRequest(requestClone);
                waitTime += (long) (Parameter * getWaitTime(elevator, requestPoolClone));
                // 处理换乘逻辑
                IElevator other = elevators.get((elevator.getEId() + 5) % 12 + 1);
                RequestPool requestPoolClone2 = new RequestPool();
                requestPoolClone2.addPersonRequest(requestClone);
                waitTime += (long) (Parameter * getWaitTime(other, requestPoolClone2));

这里如果使用a是指定了使用了对应的b的,因为到达换乘楼层后还要重新调度一遍,因此这里其实仅仅简单做一个判断就好,同时,为了使请求较优先使用双轿厢电梯,设置了一个Parameter的参数,这个参数我设置为0.8,使得双轿厢电梯更容易被选用.

总结

架构总结

img

img

由上图,架构如下

1. 初始化

在系统的入口点(Main.java),进行以下几步初始化操作:

  • 输入线程的启动:启动InputThread线程,该线程负责实时监听并处理外部输入(如用户通过界面输入或从文件中读取请求),并将生成的请求对象放入调度器中。
  • 启动调度器:实例化Dispatcher,并在调度器中创建电梯线程,以及对应的12个请求池,12个scheduler,策略类。

2. 输入处理(InputThread.java)

InputThread的主要职责是持续接收外部输入,调用调度器Dispatcher的不同方法,并将其转换为便于处理的请求格式MyRequest

3. 调度(Dispatcher.java)

调度组件是系统的核心,负责决定如何响应请求:

  • 调度决策Dispatcher获取待处理的请求后,利用影子电梯模拟来确定最佳的电梯来响应这些请求,即把请求加入到电梯对应的requestPool中。

4. 电梯运行(BasicElevator.java)

  • 每个scheduler负责具体的LOOK算法,给出电梯的运行方向,以及需要运送的乘客
  • 状态更新和移动BasicElevator根据接收到的调度指令,更新其状态并执行实际的上下移动。
public void run() {
        while (true) {
            updateScheduler();
            EState currentState = scheduler.nextState();
            switch (currentState) {
                case STILL_UP:
                case STILL_DOWN:
                    waitForRequests();
                    break;
                case MOVING_UP:
                case MOVING_DOWN:
                    move();
                    break;
                case RESET:
                    try {
                        resetElevator(scheduler.getNewCapacity(), scheduler.getNewSpeed());
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    break;
                case END:
                    return;
                default:
                    throw new IllegalStateException("Unexpected Elevator State: " + currentState);
            }
        }
    }

如何保持高内聚低耦合

在迭代开发过程中,保持高内聚与低耦合是软件架构设计的重要原则,它有助于提高代码的可维护性、可扩展性和复用性。下面我将详细解释如何在迭代开发中实现这两个目标:

高内聚 (High Cohesion)

内聚性描述了模块(类、函数等)内部元素之间的关联强度。高内聚意味着模块内部的功能紧密相关,这使得模块更易于理解和维护。

功能单一原则:确保每个模块或组件只负责一个功能领域。

BasicElevator只负责电梯内部的运行逻辑,专注于处理与单个电梯相关的所有操作和状态管理。

LookScheduler负责给出运行的路线只负责调度算法的实现和决策的生成。

InputThread的主要职责是监听系统输入(可能是用户输入或自动化脚本输入),并将其解析为具体的请求。

精简接口:模块的接口应只暴露必要的操作,隐藏内部实现细节,这有助于将功能相关的操作组织在一起。

利用封装:通过将数据和操作数据的方法封装在同一个类中,增强相关性,减少外部干扰。

实现了IElevator 接口

状态查询:该接口仅仅定义了电梯有关状态的查询,包括查询电梯当前楼层、运行状态(提供给策略类和影子电梯)等。

这个接口很好的封装了电梯内部的运行逻辑。

重构:在迭代中定期检查并重构代码,以确保模块聚焦于单一职责,去除不必要的依赖。

第一次作业为一个大的请求池,之后改为了六个,因为调度本身就是调度器的工作,请求池应该仅仅承担一个托盘的作用,让生产者和消费者进行合作。

低耦合 (Low Coupling)

耦合度量的是模块间的相互依赖程度。低耦合表示各模块之间的依赖关系较弱,修改一个模块不会显著影响其他模块。

接口与实现分离:通过接口或抽象类来定义组件间的交互,而非直接依赖具体实现,从而减少组件间的直接依赖。

Scheduler 接口

调度决策定义:该接口定义必要的方法来支持调度决策,如基于当前电梯状态和等待处理的请求计算最优路径和调度策略。

虽然依赖于请求和电梯状态数据,但通过定义清晰的接口接收这些数据,避免与具体的电梯控制和调度器直接耦合。

使用依赖注入:依赖注入(DI)是一种减少代码耦合的技术,通过外部传入依赖对象,而非在模块内部创建。

如上面,对于reset怎么样传入电梯,是先将该请求由调度器给予策略类,由策略类再去传给相应的电梯,这样避免了调度器和电梯的进一步耦合,实现了电梯只与调度器交互的逻辑,实现了其内部功能的高度内聚。

模块化和包管理:将系统分解为逻辑上独立的模块,每个模块负责一组特定的功能,并通过包管理工具来管理依赖。

如下

img

如何实施

持续评估和规划:在每次迭代开始时,评估现有的设计和实现,画一些交互逻辑的图,来规划如何改进以增强内聚和减少耦合。

使用代码分析工具

如何实现reset

img

因为量子电梯的存在,使得电梯上下两层楼的时间将可能>3.8s,因此的话我也放弃了让他多运行两层楼的想法,当检测到reset的时候直接return回去。

reset开始前要将电梯乘客表中的乘客重新调度,这里电梯会直接调用调度器,因此调度器也成为了输入线程和电梯的共享资源,需要对其方法进行上锁。此外,还要将电梯的请求池中的乘客也重新调度。如果选用的是reset的电梯,我们并不能立刻输出receive,这时候就必须将这些乘客存到一个等待表里,当电梯reset结束后,要检查自己的等待表中是否有乘客,如果有的话再将其加入到自己的请求池中,同时输出receive。

对于双轿厢reset,无非是加一个判断,成立的时候修改电梯内部的属性即可。

public void resetElevator(int newCapacity, int newSpeed)
            throws InterruptedException {
        this.transFloor = scheduler.getTransFloor();
        if (transFloor > 0) {
            isDC = true;
        }  //双轿厢判断
        disembarkPassengers();  // 放下内部乘客
        "RESET_BEGIN"
         请求池乘客重新调度
        重置1.2s
        修改属性
        "RESET_END"
        将调度器分到等待表中的乘客再放到请求池中
    }

如何实现双轿厢

img

首先,怎样能凭空增加六个电梯线程呢?为了避免新开,关线程的繁琐操作,在刚开始就开12个电梯线程,只不过让他在没有收到dcreset请求时候处于停止状态即可。
如何具体实现双轿厢电梯呢,为了少写一点),没有选择使用子类来实现,而是直接在原来的电梯上加上几个flag,如下,同时增加max和minfloor用于修改look策略

elevator.java

     private boolean isDC = false;
    private int maxFloor;
    private int minFloor;
    private int transFloor;

接下来如何实现换乘呢

两种策略,在乘客来的时候就规定好其路线,或者让他先走一段,出来再重新规划。由于之后电梯行为的不确定性,选择第二种。那么如何判断乘客是否到站,以及怎样添加换乘楼层呢,我是在myrequest中添加了一个新的属性stack changefloors,同时修改一些方法,这样look策略类几乎就不需要改变了。

MyRequest.java

private Stack<Integer> changeFloors = new Stack<>();
public void popFloor() {
        this.fromFloor = changeFloors.pop();
    }
 public void pushFloor(int toFloor) {
        changeFloors.push(toFloor);
    }

这样只需要在调度的时候选出第一个局部最优的电梯时候,如果需要换乘就把那个楼层push进去就好

 public int getToFloor() {
        return changeFloors.peek();
    }

以上这这个操作就可以使得look策略几乎不需要修改了。

如何避免双轿厢电梯相撞呢

如何避免双轿厢电梯相撞呢?就是移动时当检测到另一部电梯在换成楼层时候就wait,之后另一部离开时去通知他。这里也可以用一个信号量(可以实现为一个类)来实现。move(指楼层改变的一瞬间)前看看是不是需要更新

if (scheduler.getOther().inTransFloor()) {
            synchronized (this) {
                try {
                    wait(); 
            }
        }
        move
       "ARRIVE"
        if (atFloor + 1 == transFloor || atFloor - 1 == transFloor) {
            synchronized (scheduler.getOther()) {
                scheduler.getOther().notify();
            }
        }

这个操作直接去唤醒了另一个电梯,没有经过什么中转,实现很简单。

这里要注意我的atfloor使用了volatile关键字修饰来避免对整个方法使用synchronized.

volatile关键字可以确保变量的可见性。使用 volatile 修饰变量可以确保每次写操作都立即被刷新到主存中,并且每次读操作都直接从主存中读取,从而保证了该变量在所有线程中的可见性。这个在我的理解中完美适配了双轿厢电梯对另外一部电梯的数据获得,避免出现了一个电梯因为未及时更新另一部电梯的状态而导致无法被唤醒。

  1. 性能开销较低
    • volatile 的操作成本低于 synchronized,因为 volatile 不涉及锁的获取和释放。synchronized 需要在进入和退出同步代码块时处理锁的获取和释放,这是一个相对重的操作,尤其在高竞争环境下。
  2. 避免死锁
    • 使用 volatile 不会导致线程阻塞,因此不会引起死锁问题。而 synchronized 可能由于多个线程间复杂的锁交互而导致死锁。
  3. 代码的简洁性
    • 使用 volatile 可以实现更简洁的代码,因为它只需要标记变量而无需管理同步代码块。相对地,使用 synchronized 需要明确指定需要同步的代码区域。
  4. 适用场景
    • volatile 适合用于状态标记或者作为触发器的情形,尤其是在变量的读写操作不依赖于当前值或者只有单一线程修改变量值的场景中。例如,用 volatile 来检查一个布尔标志以停止线程。
    • synchronized 更适用于涉及多个变量的复杂交互或有实际的临界区需求的情况。

bug分析

主要还是多线程中可能导致的互锁,轮询等问题,绝大部分问题都可以通过print来解决,简单样例可以设置断点来解决,对于互锁问题,idea的调试自带了获取线程转储这一功能,导出来问下gpt就能知道是哪个线程的问题了。其他情况上文均已提到。

心得体会

线程安全

实现细节和挑战

在电梯调度系统中,线程安全是一个核心的考虑因素,因为系统需要处理多个线程同时操作共享数据资源的问题。在我的架构中,调度器并没有单开一个线程(个人觉得可能没什么必要),而只是由输入线程和电梯线程作为生产者和消费者从托盘上放置,获取请求。电梯内部状态(如当前楼层、门的状态)和请求池是两个主要的共享资源。

  1. 电梯内部状态的同步

    • 每个电梯作为一个独立的线程运行,必须在执行移动或停止时确保其状态的准确性。使用synchronized或lock来保护状态改变是一种常用的方法,它可以避免比如“两个电梯同时响应同一层楼请求”的逻辑错误。
    • 使用锁时,还需要注意避免死锁和降低锁的粒度,以优化性能和响应时间。
  2. 请求池的线程安全操作

    • 请求池是一个动态的数据结构,用于存储和管理来自乘客的电梯调用请求。因为这是由多个线程(输入线程和各个电梯线程)访问和修改的共享资源,因此必须确保在添加和取出请求时的线程安全。
    • 可以利用锁或者并发队列来管理对请求池的访问,确保在任何时候只有一个线程能够修改请求池。

通过实际操作这一系统,体会到了线程安全设计的复杂性以及它对系统稳定性和效率的重要性。同时,也学习到了如何合理使用锁,以及如何通过设计来减少需要锁保护的代码区域,从而优化系统性能。

层次化设计

结构和实现

  1. 模块划分

    • 输入处理模块:负责监听和解析用户输入,生成请求对象并将它们加入到请求池中。
    • 调度模块:定期从请求池中提取请求,并根据某种策略将请求分配给合适的电梯。
    • 电梯控制模块:各电梯独立运行,响应分配给它们的请求,并更新自身状态。
  2. 接口定义

    • 系统各层之间通过明确定义的接口进行通信,这不仅提高了模块间的独立性,还使得各部分更容易被独立测试和替换。

通过这种层次化的设计,学习到了如何有效地组织代码,将复杂系统分解为更小、更易管理的部分。此外,明确的模块职责和接口定义有助于减少代码间的耦合,使得未来的修改和扩展更为简便。

...全文
264 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

301

社区成员

发帖
与我相关
我的任务
社区描述
2023年北航面向对象设计与构造
学习 高校
社区管理员
  • YannaZhang
  • CajZella
  • C_ecelia
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧