BUAA OO第二单元总结

陈睿正-22371213 2024-04-20 19:49:58

hw5

架构设计

UML 类图(Main 类未画出)

img

UML 协作图

img

很好,多么简单粗暴。

虽然简单粗暴,但考虑到第五次作业的调度功能十分简单,可以合并在输入线程中完成,而 Main 函数在启动 Elevator 线程后可以继续担任输入线程,故本次作业除 Main 外只设计了 Elevator 一种线程。

调度策略

采用一种类 look 策略。(暂未听说过有和我采用了相同策略的同学,可以算是原创吧~~)

与 look 策略的相同之处在于,尽量不改变电梯运行的方向。

不同之处在于实现了 Ride 类,提前规划电梯的行程。一个 Ride 对象可以表示电梯的一次从底层/顶层至顶层/底层的单程运行过程(通过 upward 属性区分方向),其中记录了电梯在每层需要进入和离开的人的队列(被封装为 Record 类)和电梯在层与层之间的载客量(用于判断是否满员)。

Elevator 的运行过程可以看作是交替按方向不同的 Ride 运行,先从底层到顶层,再从顶层到底层,再从底层到顶层,循环往复。所以用一个 Ride 型的 List 即可表示电梯全部的运行过程(其中相邻的 Ride 是反向的),具体可见第六次作业的类图。(在第五次作业中,这个想法还不是很成熟,所以只用了 curide 和 nxtride 两个属性记录电梯在这次往返中的运行过程,不是特别优美,不再介绍。)

于是在 addrequest 中,依次询问列表里方向相同的 Ride,如果在请求区段里人数均未满,则将请求加入到 Ride 中(即在对应的楼层添加一个需要进入/离开的人,并更新层与层之间的载客量)。如果所有 Ride 均已满,则将一个新的 Ride 添加至列表末尾,并将请求加入其中。唯一需要注意的是,在将请求加入电梯当前所处的 Ride 中时,需要判断与电梯当前楼层的大小。比如电梯在 3 楼向上运行时,添加从 2 楼至 5 楼的请求。此时,单单对于当前的 Ride 对象来说,这个请求是可以添加进去的(假设没满员),但对于电梯的状态来说,显然不能将其添加至当前 Ride。

在电梯运行过程中,到达每层后向当前的 Ride 对象(队首的 Ride 对象)询问是否需要开门,如果需要,获取需要进入和离开的人的队列并输出,并进行以下判断:如果当前的 Ride 和下一个 Ride 在后续楼层均没有要离开或进入的人,则直接跳过当前 Ride 的剩余楼层,瞬移到下个 Ride 的本楼层,即 curfloor 不变,但方向改变,并从队首弹出当前的 Ride,使下个 Ride 成为当前的 Ride。

相比于 look 策略,此策略更能最小化“等待时间与期望时间之差的最大值”。试想如下情景:先发出的某个请求从 2 楼至 5 楼,但此后持续有从 1 楼至 5 楼的请求发出。对于 look 策略,虽然从 2 楼至 5 楼的请求最先发出,但电梯永远选择接 1 楼至 5 楼的乘客,不给 2 楼至 5 楼的乘客一点机会(是不是像极了 12306,只买中途票不买全程票就别想上车);而对于本策略,2 楼至 5 楼的请求发出后,就会被添加至最近可行的 Ride 中,而不会被后来的请求顶替,完美履行了先到先得的原则。

此外,这种策略设计在实现影子电梯时也有相应优势,见第六次作业(虽然最后我没有把它实现)。

然而,在强测中,这种策略似乎只带来了一般的性能(97.27分)。原因之一是有不少同学实现了量子电梯,而我并未实现(在这种策略上实现量子电梯并没有额外困难,只是我没想到);原因之二是考虑如下情况:在一个极短的时间片内来了从不同楼层出发的大量上行请求。对于 look 策略,它很可能第一趟首先把 1 层出发六个人的全部接完,第二趟再把 2 层出发的六个人全部接完;对于此策略,可能第一堂首先去 1 层接第一个到的人,再去 2 层接第二个到的人,……,第二趟首先去 1 层接第七个到的人,再去 2 层接第八个到的人,……,增加了许多额外的开关门次数。

同步块设计

为保险起见,Elevator 类的方法全部加了 synchronized 同步,暴力但安全。

hw6

架构设计

由于清明假期未能合理安排作业时间,导致 hw6 的诸多 bug 在作业结束提交之前未能修复,彻底寄掉了。假期结束后对此次作业进行了深入反思与重构,在此仅展示重构后的架构。┭┮﹏┭┮

UML 类图(Main 类未画出)

img

UML 协作图

img

在 hw6 中,Distributer 线程就非常有必要了,同时 Main 线程在启动所有线程后,继续担任输入线程的工作。

Distributer 线程的必要之处在于,在 hw6 中,不仅输入线程需要 addrequest,Elevator 线程在 reset 放出还未到达目标楼层的乘客时同样需要 addrequest。在 hw5 中,addrequest 直接由输入线程代替调度线程完成;在 hw6 中,如果让 addrequest 由输入线程和 Elevator 线程代替调度线程完成,则后者会出现死锁问题。即当 Elevator 线程尝试调用其它 Elevator 对象的 addrequest 方法时,会在持有自身锁的基础上申请得到另一个 Elevator 对象的锁;如果此时另一个 Elevator 对象也在持有自身锁的基础上申请得到该 Elevator 对象的锁,则会触发死锁。虽然这个问题或许可以通过在调用其它 Elevator 对象的 addrequest 之前提前将自身锁释放来解决(没有尝试过,不知道这样做会不会引发其它问题),但新建一个 Distributer 线程,并采用生产者-消费者模型,让 Elevator 的 addrequest 方法仅由 Distributer 线程执行,无疑是最稳妥的做法。

对于 reset 操作,在重构之前,会直接改变 Elevator 对象内的参数。由于重构时已经得知 hw7 的内容,故将 reset 操作重构为创建一个新的 Elevator 对象。当 Distributer 向 Elevator(记为 oldElevator)发出重置请求后,oldElevator 会根据请求及当前楼层创建一个新的 Elevator 对象(记为 newElevator),此后,所有对 oldElevator 发出的乘客请求都会被 oldElevator 自动传递给 newElevator,直到 oldElevator 重置结束,通过 resetcomplete(public void resetcomplete(Elevator oldelevator, Elevator newelevator))方法通知 Distributer 后,Distributer 将列表内的 oldElevator 更新为 newElevator,并调用 start 启动 newElevator,oldElevator 正式退役。(注:newElevator 启动后,会先处理在启动之前分配给它的请求并打印 RECEIVE,很好地解决了电梯重置时不允许打印 RECEIVE 的问题。)

对于程序结束时的处理,不同于 hw5,Main 进程(输入进程)结束后不能立刻对所有线程 setend,因为此时可能还有电梯尚未完成重置,在完成重置的过程中还有可能产生新的请求。故 Main 进程输入结束后需要对所有电梯调用一次 waitforreset,等所有电梯不再处于重置过程时再发送 end 信号。

调度策略

电梯内的调度策略与 hw5 相同,并新增了量子电梯优化,即:在电梯移动,还没有输出 ARRIVE 之前,如果电梯离开的那个楼层产生了新的请求,则电梯立刻瞬移回刚才离开的楼层并开门。之所以能这样写,是因为课程组没有要求输出 MOVE-BEGIN。

电梯间的调度策略上,原本想实现影子电梯,无奈由于时间紧迫未能实现,在此进行简单说明。

hw5 中提到,本人采用的类 look 策略在实现影子电梯时具有一定优势,具体原因在于:电梯在接下来任何时刻的行为都已经由队列中的 Ride 确定,因此可以以 Ride 为单位计算用时和耗电量,并对 Ride 的边界(即电梯并不一定到达顶楼就会改变方向的情况)进行处理,相加即得电梯当前的用时和耗电量。若求加入某个请求后的用时和耗电量,只需要先遍历得出请求会被加在哪个 Ride 中,再对相应的 Ride 和两个边界计算增加的时间和耗电量即可。在整个过程中,不需要新建任何对象或线程,即可使用影子电梯实现对“系统运行时间”和“系统耗电量”的优化,而“等待时间与期望时间之差的最大值”则是电梯内的类 look 调度策略本身就具有的优势,怎么样,是不是非常完美。 不过遗憾的是,由于一直花时间在 debug 上,基于类 look 策略的影子电梯设想未能在作业中实现。

同步块设计

同 hw5,Elevator 类的方法均使用 synchronized 修饰,但 waitforreset 方法除外。前文已有提到,这一方法用于等待当前电梯完成重置。为了实现这一操作,在 Elevator 类中使用 Object 对象锁 lock,大体思路如下:

public class Elevator extends Thread {
    private final Object lock = new Object();
    private boolean willreset = false;

    public synchronized void addresetrequest(int capacity, long movetime) {
        //something
        synchronized (lock) {
            willreset = true;
        }
        //something
    }
    private synchronized void reset() {
        //something
        synchronized (lock) {
            willreset = false;
            lock.notify();
        }
        //something
    }

    public void waitforreset() {
        synchronized (lock) {
            if (willreset) {
                try {
                    lock.wait();
                } catch (Exception ignored) {
                    ;
                }
            }
        }
    }
}

这种设计使 waitforreset 方法在不获取当前对象锁的状态下也可以保证线程安全,因为方法内部仅涉及临界资源 willreset 和 lock,而两者只会在 lock 的 synchronized 块中被访问/修改,因此 waitforreset 方法仅需获取 lock 锁即可保证线程安全。(如果使 waitforreset 需要获取当前对象锁才能执行,则调用 lock.wait() 时并不会释放当前对象锁,导致当前对象锁被持续占用。)

而对于 Distributer 类,则仅在访问生产者-消费者模型的缓冲队列时使用 synchronized 进行同步控制。一开始,我确实仿照 Elevator 类的设计,将 Distributer 类的几乎所有方法添加了 synchronized 修饰,直到后来死锁频频出现,我才意识到对于 Distributer 这样线程之间频繁交互的类,应该尽可能缩减 synchronized 块的大小,使线程仅在访问临界资源时持有必要的锁,而在其它时刻,比如 Distributer 调用 Elevator 的 addrequest 前,及时释放不再需要的锁。

hw7

架构设计

UML 类图(Main 类未画出)

img

UML 协作图

img

在 hw7 中,考虑到代码的简洁性以及单轿厢电梯与双轿厢电梯在外部封装上的一致性,故设计 Elevator 父类(相当于接口),其下设置单轿厢电梯 SingleElevator 和双轿厢电梯 DoubleElevator 两个子类,即可在 Distributer 中无需考虑电梯的具体类别,简化代码逻辑。

对于双轿厢电梯,显然只有一个 DoubleElevator 线程是不够的。为此,设置 HalfElevator 线程,执行双轿厢电梯中的单个轿厢需要的操作,而 DoubleElevator 线程担任两个 HalfElevator 的 Distributer,即 DoubleElevator 类的设计思路与 Distributer 类在很大程度上相同,只是执行的操作不同。在 Distributer 中,需要执行的操作为添加请求,而在 DoubleElevator 中,需要执行的操作为“通知另一个电梯尽快离开换乘层”和“通知另一个电梯可以进入换乘层”两者之一。当某个 HalfElevator 开始向换乘层移动时,作为生产者向 DoubleElevator 的消息队列添加“通知另一个电梯尽快离开换乘层”的操作,消费者 DoubleElevator 线程读取消息后调用另一个 HalfElevator 的 moveout 方法,通知其尽快离开换乘层;等待 400 毫秒后,在 HalfElevator 输出 ARRIVE 消息前,首先调用 DoubleElevator 的 tryentertransfer 方法,DoubleElevator 根据记录的换乘层状态返回是否成功,若成功,则更新换乘层状态,否则 HalfElevator 调用 wait 进入等待状态;直到另一个 HalfElevator 离开换乘层,向 DoubleElevator 的消息队列添加“通知另一个电梯可以进入换乘层”的操作,DoubleElevator 线程读取消息后 notify 先前的 HalfElevator ,使其恢复运行并重新调用 tryentertransfer。由此,我们比较完美地实现了双轿厢电梯相互不碰撞的设计。值得一提的是,“通知另一个电梯尽快离开换乘层”这一操作的设计使电梯不会盲目离开换乘层,即倘若电梯在换乘层送完某个乘客后,请求队列为空,则和往常一样进入等待状态,直到接收到“尽快离开换乘层”的通知后才会出发向其它层移动。(在很多其它设计中,电梯只要到达换乘层,就会选择不等待而直接离开。)至于为什么要将“通知另一个电梯尽快离开换乘层”和“通知另一个电梯可以进入换乘层”这两个操作改为生产者-消费者的形式,而不是由 HalfElevator 线程直接执行,原因与 hw6 中 Distributer 线程的设计相同,即这样会使 HalfElevator 在持有自身对象锁的情况下尝试获取另一个 HalfElevator 的对象锁,有可能造成死锁。而尝试进入换乘层这一操作并不会引发此类问题(因为 DoubleElevator 中已经保存换乘层的状态,无需再访问另一个 HalfElevator 对象),所以没有对此操作采用生产者-消费者模式。

调度策略

在 hw6 的基础上增加对双轿厢电梯用时和耗电量的计算即可,只是当请求不能一次性完成时,可能需要估算。(没实现咱也不瞎说了)

同步块设计

大致与 hw6 相同,即 SingleElevator 类和 HalfElevator 类的几乎所有方法使用 synchronized 修饰,而 Distributer 类和 DoubleElevator 类仅在访问生产者-消费者模型的缓冲队列时使用 synchronized 进行同步控制。

DEBUG 方法

前文已经提及设计中曾存在的死锁等 bug,对于此类 bug,使用 java 自带的 jconsole 检测死锁即可。

对于其它类型的 bug,往往只能在线程按同样的顺序执行时才能复现,我基本采用了 System.out.println() 的传统方法。有时打印造成的开销甚至可能造成线程执行顺序的改变(也可能只是我的担心),对此采用如下方式,将需要打印的变量先记录,程序结束后再打印。

// Main.java
public class Main {
    public static int debug;

    public static void main(String[] args) {
        // Mainbody
        System.out.println(debug);
    }
}

由此,在需要打印 variable 的地方加入 Main.debug = varible,即可防止打印造成执行顺序改变。

心得体会

第二单元的作业相比第一单元难度存在较大提升,对于之前从未接触过多线程编程的我来说是一项较大的挑战。在线程安全方面,避免死锁的方法即是尽量保证线程在尝试获取其它锁前释放不必要的锁;在层次化设计方面,相比第一单元,增加了对生产者-消费者的设计认识,在 Distributer 等采用此设计的类中,生产者由调用本类方法的其它线程担任,而消费者由本类自身的线程担任。未来希望掌握更多多线程编程的常用模式,以及 ReentrantLock 等更高级锁的用法。

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

301

社区成员

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

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