面向对象设计与构造2022第二单元总结

第二单元博客作业

一、总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句之间的关系

​ 对多线程的学习理解中,我认为锁与线程之间的最为重要的关系就是:

线程因共享对象被其他线程掌控而被阻塞,线程主动放弃共享对象的掌控权而wait,线程因共享对象被其他线程释放而唤醒

​ 所以,共享对象在哪里,共享对象的访问在哪里,我的同步块就应该在哪里。在我三次作业的设计中,共享对象位于托盘类中,因此我的同步块全部都设在托盘类中一些方法的局部代码中。在我看来这样做的好处,就是从代码上将同步互斥设计集中起来,便于分析和调试。

​ 锁多了,程序的并发性会受到影响;锁少了,程序会面临线程安全问题。所以,不同线程通过同步块实现互斥访问,像极了“瓷器店里打老鼠”。

​ 具体来说,我用一个ArrayList<ArrayList>来维护横向/纵向电梯候乘表,那么横向电梯每层和纵向电梯每座的候乘表都是独立的ArrayList,所以,无论是输入线程分析完请求添加到指定层/座,还是不同的电梯线程来读取候乘表“抢人”或者判断,都只对涉及这一层/一座进行加锁,而其他的ArrayList不用加锁,这样的话,就使得不同的电梯线程可以同时访问候乘表进行请求的响应,很好提高了程序的并发性。

​ 以添加新请求方法中同步块为例来展示。

public void addPerson(Person newp) {
        int fromFloor = newp.getFromFloor();
        int toFloor = newp.getToFloor();
        char fromBuilding = newp.getFromBuilding();
        char toBuilding = newp.getToBuilding();
        if (fromFloor == toFloor && fromBuilding != toBuilding)  //horizontal
        {
            synchronized (floorReq.get(fromFloor)) {
                floorReq.get(fromFloor).add(newp);
                floorReq.get(fromFloor).notifyAll();
            }
        } else {
            synchronized (buildingReq.get(fromBuilding - 'A')) {
                buildingReq.get(fromBuilding - 'A').add(newp);
                buildingReq.get(fromBuilding - 'A').notifyAll();
            }
        }
    }

​ 同步块中的语句,最主要的就是对共享对象状态的访问和修改,因此,这些共享对象正是放在了同步块中,在并发场景下同一时刻最多被一个线程所读写,保证了其状态的正确性。

​ 作业过程中我遇到了另一种共享对象访问的情景,请看下面的部分代码

public boolean noMoreReqVertical(char building) {
        boolean ret = true;
        for (Person p : buildingReq.get(building - 'A')) {
            if (!p.getTaken()) {
                ret = false;
                break;
            }
        }
        return ret;
    }
public void waitForReqVertical(char building) {
        synchronized (buildingReq.get(building - 'A')) {
            while (noMoreReqVertical(building)) {
                if (Tray.getInstance().checkTask()) {
                    break;
                }
                try {
                    buildingReq.get(building - 'A').wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            buildingReq.get(building - 'A').notifyAll();
        }
    }

​ 我没有对noMoreReqVertical方法的候乘表加锁,原因有两个:

  • 对于这个waitForReqVertical方法中可能要wait,因此在wait之前必须得对这个候乘表加锁,所以如果我在noMoreReqVertical再对同样的候乘表加锁,就会出问题。
  • 这个noMoreReqVertical方法也会被其他方法调用,而且我在编码上保证了调用它的时候一定是持有锁的。

​ 所以,在我看来,这个noMoreReqVertical看起来存在线程安全问题——如何保证调用该方法时不会有新的请求通过输入线程送过来?但实际上,每次调用它的地方是在同步块内,因此个人认为还是能够做到互斥访问的。

​ 在锁的选择上,由于时间原因,我只采取了synchronized同步块,并没有采用如读写锁、自旋锁等其他锁。

二、总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互

​ 在三次作业的调度器设计上,我主要还是聚焦于电梯本身完成各个请求的调度,没有做考虑所有电梯的中央调度器。换而言之,无论是横向电梯还是纵向电梯,面对同一层/座请求时采取自由竞争的方式。

​ 对于纵向电梯而言,其自身运行的策略是LOOK策略。电梯会往复上行和下行,捎带同向乘客。其转向的条件(以上行转下行为例)是

  • 已达到顶楼
  • 未达到顶楼,但是没有起始层在上方楼层的请求
  • 轿厢中的乘客没有要到达上方楼层的

​ 故当符合转向条件时,电梯便会转向。

​ 对于横向电梯而言,其运行的策略是指导书中所述的基准类ALS策略

​ 对于第七次作业的换乘问题,我的解决方法是在输入时候立刻分析,统一拆成"纵横纵"三个阶段,每个阶段对应一个同第六次作业一样的请求送候乘表,而每完成一个阶段,便进行标记以开启下一个阶段——此时再将下一个请求送候乘表。

​ 为什么进行三阶段拆分,主要是基于以下的考虑:

  • 由于电梯的载客量和运行速度均不同,因此对于“接当前人”这个任务而言,更需要突出自由竞争来实现合理的资源调配,先到者先得。
  • 由于会有适量的横向电梯,而且有一层的横向保底,因此仅换乘一次很大可能不会造成一层负载过大,而且可以有效减少过多等待环节和开关门时间。
  • 实现上迭代量小。

​ 这样的拆分使问题聚焦于选择哪一层使用横向电梯进行换乘。基准策略的选择是使\(|X-m|+|Y-m|\)最小的中转楼层\(m\)。因此受到启发,同时也与同学讨论,便yy了个“看似有点道理(实则没啥道理)”估价函数

\[\hat T = \hat T_{纵wait1}+\hat T_{纵trans1}+\hat T_{横wait}+\hat T_{横trans}+\hat T_{纵wait2}+\hat T_{纵trans2} \]

\[T_{纵wait1,2}=\begin{cases} |5-F_{from/mid}|\cdot \bar v_{now},\text{if from floor is not equal to to floor} \\ 0,\text{equal} \end{cases}\\ T_{纵trans1,2}|F_{from/to}-F_{mid}|\cdot \bar v\\ T_{横wait} = 2.5 \cdot \bar v\\ T_{横trans}=|B_{from}-B_{to}|\cdot \bar v \]

\(F=floor,B = building\)

其中待求变量就是\(F_{mid}\in[1,10]\)\(\bar{v}\)就是当前座各个纵向电梯运行速度的平均值,\(\bar{v}_{now}\)是当前座各个纵向电梯通过自由竞争经过一层楼所用的期望时间。其估计的一个考虑就是,有三部0.6秒的电梯和一部0.4秒的电梯时,不一定前者响应请求的时间就长,因此需要刻画这样的关系。

​ 假设该楼座速度为0.2s的纵向电梯有\(x\)个,为0.4s的纵向电梯有\(y\)个,为0.6s的纵向电梯有\(z\)个。那么估计

\[\bar{v}_{now} =\frac{1}{x+y+z}\cdot(0.2\cdot0.95^{x-1}x+0.4\cdot0.95^{y-1}y+0.6\cdot0.95^{z-1}z) \]

​ 接下来谈谈调度器与程序线程中的交互关系。

​ 首先,我的程序中只有三类线程——主线程、各种不同电梯的线程和一个输入线程。主线程不用多言,便是用于初始化和启动电梯线程、输入线程。电梯线程和输入线程并没有直接的交互,而主要通过托盘类进行间接交互——具体来说就是电梯线程会读写特定层座的候乘表,输入线程会写特定层座的候乘表。

​ 电梯线程在没有停机的情况下,会不断调用自身的调度器来获得一个行为指令。当然,电梯线程也会因为调度器在判断当前轿厢没人或同层/座无新请求时主动wait。相关代码片段如下

public void run() {
        while (!(terminal() && Tray.getInstance().juryVertical(getNowBuilding())
                && Tray.getInstance().checkTask()
                && (newPeople == null || newPeople.size() == 0))) {
            try {
                takeAction(getCommand());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    ...
}

​ 需要说明的是,在我的设计中,单部电梯的运行方式类似于一个有限状态机(在下一个部分详述)。那么,该电梯所属的调度器的职责,就是根据当前电梯的状态来读写特定楼座的候乘表,并生成指令返回给电梯。

三、结合线程协同的架构模式(如流水线架构),分析和总结

第五次作业基本确立了电梯的类有限状态机运行模式纵向LOOK策略的编写。其类图和协作图如下。

image-20220430160743130

image-20220430160807777

​ 在线程协作方面,输入线程和电梯线程之间基本适用于生产者消费者模型。电梯线程的主动等待,也正是轿厢无人且候乘表没有新请求的情形,对应于生产者消费者模型的“托盘为空”。

​ 单部电梯的运行类似于有限状态机。电梯内部的调度器会读取电梯当前的状态值和楼层、楼座、方向等信息,给出一个指令,其指令可能会导致电梯状态值的改变。

image-20220430162554532

第六次作业,由于增加了横向电梯,而且通过理论课的学习,我决定用单例模式改写托盘类,以减小耦合度,并将第五次作业中的(纵向)电梯类改写成电梯类,在此基础上新增纵向电梯类和横向电梯类,这两个类均继承自电梯类

​ 这次作业中我考虑自己的架构中调度器没有太大必要单独成为一个类,而且当他单独成为一个类的时候,势必会让我的电梯写出一堆访问私有属性的方法,反而破坏了封装性,于是将其代码直接放到电梯类之中。

image-20220430162834611

​ 时序图与第五次作业类似,只不过没有了显式的调度器。

image-20220430163045859

第七次作业,由于要考虑换乘情形,故新增task类来专门处理换乘中与托盘类、电梯类的交互工作。考虑到“三阶段式”换乘每一个阶段的请求和前两次无异,故我的task类中需要标注当前进行到了第几个阶段。一名乘客内部有一个他所在的task的引用。当这名乘客下电梯时,他就会与带的task进行交互,看要不要开启下一阶段,或者标记任务已经完成。而且这里很重要的一点,就是一个任务完成后,会唤醒所有的因候乘表中相应ArrayList等待的电梯线程,避免其一直不能检查停机条件而永远等待下去。

​ 在考虑电梯停机问题时,一个很重要的判据就是所有的换乘任务均已完成。首先,我们什么时候需要检查这个均已完成?一定是输入截止之后。接着,为了保证线程安全性,我借鉴了信号量的思路(当然实际没有使用Semaphore),在输入结束后立刻得到所有换乘任务数量,然后在检查完成时要判断完成数是否等于总数。

​ 第七次作业类图

image-20220430164352108

​ 第七次作业时序图

image-20220430164413979

四、分析自己程序的bug

​ 在这三次作业中,我在第五次作业的强测中遇到了一个RTLE。其发生的原因是,当时开门的判断逻辑只考虑了在那层时有没有人下电梯和有没有符合捎带策略的人上电梯,但没有判断目前轿厢是否已经满了,结果导致电梯在满载的情况下频繁开关门而不上下人,大大耽误了时间。

​ 因此,解决的策略就是加入对满载的判断,如果满载就不开门了。

​ 除此之外,在第七次作业最初提交中测时,遇到了大规模CPU轮询导致的CTLE,其原因是第七次作业中增加了换乘的请求,这样的请求有可能在全部输入截止后仍没有被电梯接收,而我之前电梯线程的wait所在的while循环条件是当发现同层/同座没有新请求且没有结束输入时等待,这就会导致结束了输入之后,电梯因不满足循环条件而反复轮询,而不是根据设计的等待到乘客换乘到它处理的同层/同座被唤醒工作,或者等待到所有请求全部执行完毕而彻底停机。 故修改的方式有两步,首先是重写while循环的逻辑,当该电梯线程发现轿厢无人且同层/同座没有新请求便可以wait。此外,在判断电梯是否该停机时,需要判断①输入是否结束②同座/同层是否有新请求③所有换乘任务是否全部完成④轿厢里是否还有人

五、分析自己发现别人程序bug所采取的策略

​ 在本单元的测试中,我主要采取的策略是数据生成器+行为评判器的方式。

​ 在第五次作业中,虽然有五个电梯,但这五个电梯彼此之间相对独立,因此测试的重点放在检查其中一部电梯面对大量请求时的行为正确性和线程安全性,所以生成数据的特征就是在同一时间点就一座楼送出大量请求。

​ 在第六次作业中,测试的重点有两个,一个是横向电梯的行为正确性,另一个是同层/同座多个电梯调度的行为正确性和线程安全性。所以一批数据仅增加一部横向电梯,且全部的乘客请求均是横向请求;另一批数据则纵向和横向电梯在允许范围内均有所增加,并且选定一个座和一个层做大量乘客请求。当然,两批数据在投放时间点上均采取同一时刻大量投放的方式。

​ 在第七次作业中,测试的重点是换乘行为的正确性,因此数据构造策略是先在允许范围内尽量多增加横向电梯,然后在同一时刻大量投放必定要换乘的请求。

​ 在互测中,第五、第七次作业并未找到他人的bug,在第六次作业中全横向请求的数据成功刀中一人,其程序可能横向策略有缺陷导致了RTLE。

​ 对于线程安全相关问题的测试,我最初设想有两种方式:一种方式是将一组数据对同房同学的程序进行多次运行;另一种方式是用大量数据进行广泛测试,用数据量来试图放大其出现线程安全问题的概率。在实际互测中,可能是由于数据本身的随机性,并没有卡出线程安全问题。

​ 本单元和第一单元测试的较大差异之处在于行为评判器不像第一单元调用调用sympy就能解决问题,我对本单元电梯的行为评判器进行了精心设计,也与其他同学讨论尽可能全面地覆盖各种错误的情形,包括以下内容:

  • 电梯在非当前位置开门
  • 电梯门已经打开但还要开门
  • 电梯在非当前位置关门
  • 电梯门已经关闭但还要关门
  • 电梯开关门时间间隔过快
  • 乘客上电梯的位置和该电梯当前位置不符
  • 欲上电梯的乘客已经在电梯里了
  • 乘客欲上电梯但门没开
  • 轿厢已满无法上人
  • 乘客下电梯的位置和该电梯当前位置不符
  • 欲下电梯的乘客压根不再该电梯上
  • 乘客欲下电梯但门没开
  • 纵向电梯一次移动超过一层
  • 电梯移动时没关门
  • 纵向电梯移动到不存在的楼层
  • 纵向电梯移动速度过快
  • 横向电梯在不可开关门的楼座开门
  • 横向电梯一次移动超过一个楼座
  • 横向电梯报告ARRIVE但实际没有移动
  • 横向电梯移动过快
  • 纵(横)向电梯横(纵)着走
  • 乘客试图搭乘尚未投入运营的电梯
  • 全部运行结束后发现还有人在电梯上
  • 全部运行结束后发现有人的请求没有被电梯接收
  • 全部运行结束后发现有人通过换乘最终到达目的地

​ 而行为评判器我采用Python面向对象来实现。评判器的评判逻辑就是通过读取输入和电梯程序的输出,通过正则表达式提取乘客和电梯行为信息,然后内部模拟各个电梯的运行过程。内部模拟中,横向电梯和纵向电梯同样也继承自一个电梯类,这的确减少了重复编码,也优化了程序的架构,提高了程序可读性和可扩展性。

​ 评判器本身在三次作业新增需求中良好迭代,在第一版完成后,仅需要做少量必要的补充即可满足下一次作业的评判需求。而且,为了更好地捕获错误行为和产生原因,评判器将评判过程输出到文件,如果最终没有发现错误再将中间文件删除。

​ 此外,行为评判器也能够很好地分析所有乘客的运输总时间等性能指标,有助于在批量测试后,分析和改进电梯调度策略。

心得体会

线程安全方面,在开启本单元前的一周,我仔细阅读了一本《Java并发编程深度解析与实战》,从中了解了同步块的一些基本用法和原理,也对信号量、阻塞队列的概念有了初步了解。这使得我在做第五次作业中虽然经常遇到CTLE、线程wait无法唤醒或是IllegalMonitorStateException异常,但总是有章可循,能够很快地定位和分析出错的原因并修改之。

​ 第五次作业完毕的那一周,我也报名了研讨课分享的任务来讲多线程与同步锁。在准备的过程中,我更深入地理解了阻塞和“主动等待”的差异,而且通过查阅资料从底层机制上了解了同步块锁机制(如下,图源于《Java并发编程深度解析与实战》)。这样的了解大大降低了后面作业中出现线程安全问题的几率。

image-20220430171940659

​ 当然,由于时间是有限的,而且在作业质量的高要求下,我还是在运用更多锁机制和保证程序正确性上做了tradeoff——也就是说我可能把更多的时间花在了验证程序正确性和利用自建评测机测试数据和分析上。所以,希望日后有时间再来对并发和相关锁机制有更多的了解和实践运用。

层次化设计方面,这一单元的作业相较于前一单元的作业,我感觉可迭代性可扩展性有了很大的提升。从实际来看,第六次作业相对于第五次作业,虽然重写了相关类以实现继承,但其实是在进行增量开发,没有发生大规模重构。从第六次到第七次更是如此,task类的引入我认为很好地体现了SOLID中O代表的开闭原则,它使得电梯运行的策略没有发生改变,仅仅是在外围进行一些控制,使得因为新增功能导致出现线程安全问题的概率下降。

​ 在这三次作业中我觉得另一个很好的地方就是类里面诸多方法的设计上体现了SOLID中S代表的单一职责原则。其实做第六次作业时我的横向电梯策略最初是有bug的,主要体现在转向上,而我的转向方法是相对独立的,因此我只需要对转向方法进行适当修改,就可以变换其策略,这样不仅编码简单,而且也能节省宝贵时间进行更多的尝试和测试。

​ 最后,我觉得电梯这么动态变化的事物如果只是转化为一条条输出不免显得有些干瘪,于是在写完行为分析器后很快就在此基础上通过灵巧的print输出实现了电梯运行的可视化。这在后两次作业的过程中也给予了我许多快乐和鼓励,当然也更直观地反应了整体的电梯运行情况,便于调试。这里也放张小小的GIF来展示其效果吧。

ua9co-9u5gz

posted @ 2022-04-30 17:53  LaiAng8086  阅读(80)  评论(0编辑  收藏  举报