OO_2020_Unit2总结

一、多线程电梯设计策略

首先我们可以看到,第二单元的作业设计与第一单元不同。在第二单元中,我们不仅需要设计多线程程序的结构框架,还需要考虑所设计的电梯采用的调度算法。程序框架和调度算法之间没有主次之分,我们需要协调程序结构框架和调度算法,使得我们的调度算法能够很简单的在所涉及的结构中实现。在这次作业中,我采用了生产者—消费者模式,输入模块  Input  将所得到的乘客数据输入调度器  scheduler  ,电梯 elevator 在根据调度需要从调度器 scheduler 获取行人。值得注意的是,多线程程序中极容易出现共享数据之间的冲突,比如多个线程同时对一个共享数据进行写入,或者在某个原子操作中发生线程切换,都会导致程序最终无法稳定地获得正确的结果。因此我们需要利用多线程同步或加锁的方式对共享数据的操作进行保护。但这又带来新的线程死锁的问题,这需要我们在设计线程同步的时候具有清晰的设计思路。因此在三次作业的设计中,遵守下面几条规则可以使自己的多线程程序更加安全且逻辑清晰:1、所有的共享数据只在共享数据类中进行处理(在本次作业中即在调度器 scheduler 类中处理),以保证所有的数据同步逻辑能在一个类中清晰的展现,方便编写和debug。2、采用观察者模式的思想,每个线程的状态由线程自身寻找合适的时机向监视器进行更新,而不提供监视器自由获取线程内部状态的方法,以避免原子操作被其他线程干扰。

此外,多线程的另一个需要关注的点在于:线程如何安全结束。由于一开始第一次作业中,我们只有一个生产者和一个消费者,而我又恰好找到了适合第一次作业的线程结束方法,因此没有对这类生产者—消费者模式的多线程程序如何安全结束进行仔细的思考。结果在课堂实验中面对多级生产者—消费者、每级多个生产者—消费者的多线程程序安全结束中栽了跟头。吸取了教训,我开始对生产者—消费者模式的线程安全结束条件进行了思考,最终总结出了一套结束流程:1、当每个线程处理完当前的所有请求后,就将线程的运行标记置0。2、一个线程可以结束运行,当且仅当该线程的运行标记为0,且该线程的所有上一级生产者(注意只有上一级)的运行标记为0。3、若当前线程的运行标记为0,但该线程的所有上一级生产者(注意只有上一级)的运行标记不全为0,则wait,直到新的请求进入线程之后,重新将运行标记置1。4、每个线程修改自己的运行状态标记后,需要提醒其他正在wait的线程(使用notifyAll)。

第一次作业

第一次作业的电梯较为简单,为单部多线程可捎带电梯,其多线程的思想主要体现在电梯和输入模块同时运行的方面。对于这种输入线程提供需要乘电梯的人,电梯线程获取乘电梯的人并完成乘客的需求,我们很容易就想到使用生产者—消费者模式。由于电梯无法立刻处理所有乘客的请求(因为电梯只有到了目标楼层才能处理乘客请求),我们需要在调度器中设立一个缓冲(等待)队列,用来存放已经发出请求,但仍未得到电梯响应的乘客。由于等待队列是电梯线程和输入线程共同访问的数据,因此我们需要使用 synchronized 关键字对向等待队列添加元素的方法 addPerson 、从等待队列获取元素的方法 getPerson 和获取等待队列人数信息的 checkPerson 方法进行同步保护。当 Input 线程结束输入并退出,且 Elevator 内和等待队列中都没有乘客请求时,两个线程都可以结束并退出运行。多线程的协同结构如下图所示。

在第一次作业中的调度算法,我采用了SSTF算法。该算法运用了贪心的思想,每次发生状态更新,如:楼层变化、电梯内人数变化、等待队列人数变化时,都会重新扫描电梯内人员需要到达的最近的楼层和电梯外等待队列可捎带的最近楼层,并将电梯的目的地设置为两个最近值中更近的那一个。由于每次运行的目的地都是各类请求中最近的楼层,因此电梯运行的状态只有四种:1、电梯内没有人且电梯的等待队列也没有人,此时需要将电梯的运行标记置0(在未来功能迭代的时候会有用)并wait等待直到新的请求输入。2、电梯内没有人但电梯的等待队列有人,此时将电梯的的目的地设置为离电梯最近的有人等待的楼层进行捎带。3、电梯有人且电梯未到目标楼层,此时电梯仅考虑捎带。4、电梯有人且电梯到达了目标楼层,此时电梯需先将到达楼层的乘客放下,然后再考虑捎带。

  

第二次作业

第二次作业相较于第一次作业,增加了多部电梯的需求,并且每部电梯开始有自己的人数上限,另外楼层数字不再连续,-1楼的上一层楼为1楼。本着迭代更新的思路,我们不妨从上一次的结构框架为起点,添加这次作业中新增的内容。首先是每部电梯拥有了自己的人数上限,这一点容易解决,在电梯中设置一个人数限制常量 limitNumberofPerson ,当电梯内的人数等于人数限制时,便不再考虑捎带;当人数少于人数限制时,单次捎带的人数最多为 nowNumberofPerson-limitNumberofPerson 。对于楼层不连续的需求,我们不能够再使用数组的下标隐含表示楼层数,但我们可以利用操作系统中学习到的虚拟地址与物理地址相互转换的思想,创建 storeyV2R 和 storeyR2V 方法,分别处理将数组下标转换为真实楼层和将真实楼层转换为对应的数组下标

public static int storeyr2v(int rstorey) {
        if (rstorey < 0) {
            return rstorey + 3;
        } else {
            return rstorey + 2;
        }
    }

public static int storeyv2r(int vstorey) { if (vstorey <= 2) { return vstorey - 3; } else { return vstorey - 2; } }

以上两个新增功能需求仅对上次作业中的方法进行了修改,而多部电梯的需求则需要我们修改多线程协作的结构。但我们经过仔细思考就可以发现,多部电梯事实上可以转化为多个单部电梯构成的集合,因此我们可以为每个电梯设置一个调度器,再为所有的分调度器设置一个总调度器,使得每个分调度器由总调度器进行管理, Input 线程与总调度器相连。这样一来,分调度器和每个电梯之间的关系与上次作业完全相同, Input 线程和总调度器之间的关系也与上次作业相同,只需要构建总调度器和分调度器之间的关系,我们就能够最大程度的利用上一次作业的多线程协调结构了。

对于总调度器和分调度器之间的关系有两种选择:第一种为总调度器根据分调度器的状态为每个分调度器分配乘客请求;第二种为分调度器根据自己目前的需求从总调度器中主动获取乘客请求。从拥有的信息量上来讲,总调度器拥有所有分调度器的状态,但分调度器难以获取其他调度器的状态,所以从总调度器的角度进行分配更有利于得到更优的调度策略。因此在第二次作业中,我采用了第一种总调度器和分调度器之间的关系,通过总调度器对电梯全局的状态的分析向分调度器分配乘客请求。当 Input 线程结束并退出,且单个 Elevator 线程和它对应的分等待队列都没有乘客请求时,该 Elevator 线程可以结束并退出运行。多线程的协同结构如下图所示。

在调度策略方面,在每个分调度器和电梯之间,我们采取与上一次作业相同的SSTF算法。对于总调度器和分调度器之间的算法,我没有设计比较复杂的调度策略,而是采用了均摊的方式:总调度器按照乘客请求输入的顺序,依次为每一个分调度器分配一个乘客请求。这种均摊的方式的好处在于,分配代码十分易读且好写,并且在随机数据测试中平均时间表现较好,但由于没有根据具体的分调度器的状态进行分配,这样的分配方式仍有很大的优化空间。

第三次作业

第三次作业相对于第二次作业,增加了电梯种类的区分,共分为“A”、“B”、“C”三种电梯,每种电梯可停靠的楼层、上升下降的时间以及电梯最大的载客量都有所不同。并且输入线程可以动态地增加电梯线程。由于本次作业的整体思路仍旧是生产者—消费者模式,因此我们再次使用迭代更新的思路。对于增加电梯种类的区分,我们可以设计一个工厂类 Factor ,根据输入的电梯种类编号“A”、“B”、“C”来生产不同的电梯。对于每种电梯可停靠的楼层、上升下降的时间以及电梯最大的载客量都有所不同,我们也可以通过在工厂类 Factor 中给每一个新增的电梯设置这些属性,而不是使用电梯内固定的值。对于动态增加电梯种类,我们可以在 Input 线程中增加创建电梯线程的方法,当读取到增加电梯数量的指令时,我们调用创建电梯线程的方法,先利用工厂类 Factor 创建电梯,再将电梯的信息输入调度器 scheduler 中进行保存,最后调用 start() 方法运行线程。

private void elevatorStart(String sign, String type) {
        Elevator elevator = factory.makeElevator(sign, type, scheduler);
        scheduler.init(sign, elevator);
        elevator.start();
    }

需要较多处理的部分为每种电梯的可停靠楼层不同,因为某些楼层的请求可能导致一部电梯无法直接到达请求的目的地,需要两部电梯进行换乘。通过对可停靠楼层的观察可知,由于存在1楼、15楼这样“万能中转站”,因此所有的乘客请求均可以在1次换乘内实现。所以在第三次作业中,在所有程序开始运行之前,我先运用类似弗洛伊德算法的思想创建了一个静态的乘客请求换乘表,其中i和j分别表示起始楼层和目标楼层,最终 interchange[i][j] 中存储的是从i到j经过楼层数最小的换乘楼层k(显然,当从i到j能够直达时,k的值为j)。

        for (int k = 0; k < 23; k++) {
            for (int i = 0; i < 23; i++) {
                for (int j = 0; j < 23; j++) {
                    int gostraight = Math.abs(i - interchange.get(i).get(j)) +
                            Math.abs(j - interchange.get(i).get(j));
                    if (interchange.get(i).get(k) == k && interchange.get(k).get(j) == j) {
                        if (Math.abs(i - k) + Math.abs(k - j) < gostraight) {
                            interchange.get(i).set(j, k);
                        }
                    }
                }
            }
        }

未来乘客请求输入时,若读取的换乘楼层和目标楼层相同,则可以直接通过一部电梯到达目的地;若不相同,则需要先搭乘某部电梯到达换乘楼层,再以换乘楼层作为起始楼层重新输入总调度器进行分配,乘另一部电梯到达目标楼层。

            if (p.getTmpdest() == storey) {
                TimableOutput.println(
                        String.format("OUT-%d-%d-%s", p.getId(), Floor.storeyv2r(storey), sign));
                tmp.add(p);
                if (p.getFinaldest() != p.getTmpdest()) {
                    scheduler.add(p.getTmpdest(), p.getFinaldest(), p.getId());
                }
            }

在调度策略方面,对于分调度器和电梯之间的调度。我们依旧采取SSTF的调度策略。但对于总调度器和分调度器之间的分配,由于每部电梯增加了可停靠的楼层,因此无法向第二次作业那样直接均摊。

这样一来,似乎已经满足了所有的新增需求,但仅仅做到这一步会使程序中出现严重的线程安全问题,因为换乘的出现使得生产者—消费者的结构发生了改变。在前两次作业中,我们可以看到,生产者只有一个 Input 线程。但在这次作业中,由于换乘的出现,使得每个 Elevator 除了作为消费者之外,还是一个隐含的生产者,为其他所有电梯提供换乘的乘客请求。按照我们在开头所总结的线程安全结束流程来说,每个 Elevator 线程结束的条件变为 Input 线程结束输入并退出,且除自己外的其他电梯线程和它对应的分等待队列都没有乘客请求时,所有的 Elevator 线程才能够安全退出,否则需要等待。否则会出现换乘的乘客下了电梯之后,发现接下来需要乘坐的电梯早已停止运行,最终无法到达目的地。最终的多线程协作图如下图所示。

 

二、架构设计的可扩展性

在第三次作业中,我采用了生产者—消费者模式。由于均采用了迭代开发的策略,因此在三次作业中,线程协作的结构保持的较好,但是过度依赖迭代开发,导致没有完成一些必要的重构和功能的分离,使得某些类的功能较为复杂和臃肿。下面从SOLID原则出发,分析第三次作业的架构设计的可拓展性和存在的问题。

  • SRP(单一责任原则):这个原则可能是这几次作业中做的最不好的一点。因为过于依赖迭代开发,一些必要的重构和功能的分离都没有做,特别是调度器 scheduler 类,因为在生产者—消费者模式中,调度器所执行的功能本身就较为复杂,再加上几次作业的功能添加和结构改变大都在调度器内,使得原本就不够美丽的代码变得更加雪上加霜。在第三次的代码设计中,调度器 scheduler 负责了从生产者获取乘客请求、利用总分配器向分等待队列分配请求、修改和检查线程运行状态、电梯线程获取等待队列状态、电梯线程从等待队列获取乘客请求。如此多的功能集中在 scheduler 一个类中,使得调度器类成为了一个“上帝类”,这不符合面向对象的设计原则。此外,过多的功能集中于一个共享数据类中处理,尽管所有的锁逻辑都在一个类中,但这会使得对同步锁的控制复杂化,从而增加出现bug的几率。事实上,解决也非常简单,将相似的功能分配到单个共享数据类中,并保持功能之间的树状结构关系。比如在这次作业中,我们就可以将从生产者获取乘客请求、利用总分配器向分等待队列分配请求、修改和检查线程运行状态这三个功能单独放置在一个总调度器中;将电梯线程获取等待队列状态、电梯线程从等待队列获取乘客请求放置在分调度器中。总调度器管理分调度器,从而保持功能之间的树状结构。当然必须要注意的是,将共享数据类分离之后,对同步锁的控制逻辑也将分离,因此需要仔细控制锁的逻辑,按照功能之间的树状结构关系向上调用同步方法,既符合信息的获取方向,又可以避免死锁的产生
  • OCP(开放封闭原则):这一点在这几次作业的迭代开发中做的也不是很好。这是由于每次作业新增功能的时候,对原有功能的需求便消失了。由于几次作业的功能之间又有很多相似之处,我选择了偷懒只在原本的功能中进行修改,以适应新的功能。但事实上,这样的做法风险很大。首先,我们这次作业中每个类的功能都称不上复杂,因此在原有的功能上修改以适应新的功能似乎并没有什么不便之处,甚至还很方便。但是功能组合复杂起来之后,如果还是按照在原来的功能基础上修改进行更新,可能会导致有些适应上次功能的参数忘记修改了,或者适应上次功能的方法忘记更新了等等,造成一些极难发现的bug。有时,处理和校对这些bug比重新写一遍更加费时间,因为我们需要理清可能适用于之前的功能、但不适合目前功能的代码逻辑。最合适的做法为,在最开始开发功能时,就设计一个功能接口,每次添加新的功能,不修改之前的类,而是利用接口创建一个新的类,从而获得新的功能。当然,这样的写法需要对接口设计有更高的要求,否则每次增添新功能都和重构一般复杂。
  • LSP(里氏替换原则):由于本次作业中没有使用接口或者继承,因此无法评价。但是根据OCP原则的要求,我们应当在添加新的功能的时候面向接口编程,而不是修改已有的功能类,这时就需要注意子类可以扩展父类的功能,但不能改变父类原有的功能,以保证接口或者继承类原本的逻辑不受拓展的改变。
  • ISP(接口隔离原则):由于本次作业中没有使用接口或者继承,因此无法评价。但同样的,如果使用了面向接口编程时,就特别需要注意这一点:实现功能时应当只调用需要的接口。否则无端地实现了无用的方法,不仅使整个类中的代码显得臃肿,更容易导致代码的逻辑混乱。
  • DIP(依赖倒置原则):这一原则比较难理解,大致意思为代码中的模块存在依赖关系时,高级模块不应该依赖低级模块,而是依赖抽象。总调度器和分调度器之间的依赖关系,我们就应当设置一个分调度器接口,总调度器利用分调度器接口与分调度器进行关联,而分调度器则实现分调度器接口来获取不同种类的分调度器。这样一来,就可以用单一的容器对多种不同的低级模块进行管理,有助于代码功能的拓展。在我的代码中,可以将各种电梯构建一个电梯接口,调度器根据抽象接口与电梯进行关联,而电梯则根据接口实现获取不同种类的电梯。当电梯的参数较多的时候,我们就可以用这样的方法,用多种电梯类来代替电梯中复杂的参数,有助于提升程序的逻辑清晰度。

三、基于度量的程序结构分析

第一次作业

1、UML类图:

2、度量分析:

 

3、 统计数据:

4、总结

在第一次作业中,由于电梯功能较为简单,因此调度器内需要实现的功能也较少。整个代码采用了采用了生产者—消费者结构。从度量分析中可以看出,本次作业中最复杂的部分在电梯线程的 run 方法内,但 run 本身复杂度尚可,因此这次作业的结构设计还是比较成功的。

第二次作业

1、UML类图:

 

2、度量分析:

 

3、 统计数据:

 

4、总结

第二次作业相对于第一次作业来说,增添了一些细小功能,但主要的变化在于增加了多部电梯同时运行的需求。多部电梯的新增需求使得我们需要在调度器中增加分配器和为每个电梯分别设置分等待队列 ,并且要为分配器制定一个分配策略。但是在第二次作业中,我采用的分配策略是简单的均摊策略,因此调度器的复杂度虽有提高,但是幅度并不大。但是值得注意的是,电梯类中的 run 方法依然占据了很高的复杂度,这主要是由于我在 run 方法中实现了电梯运行的流程。虽然我没有将具体的操作放入 run 方法中,而是将每一个操作整合为方法进行调用。但由于电梯运行的流程较多,使得 run 方法虽然看起来不长,但是内部调用的方法极多,使得复杂度大大提升了。如果将 run 方法作为一个线程中的 main 方法去看待时,这样调用过多方法有些不合适,我们应当再将某些流程利用新的方法进行整合,尽量在 run 中只调用必要的顶层方法,降低它的复杂度。

第三次作业

1、UML类图:

 

2、度量分析:

 

3、 统计数据:

4、总结

第三次作业就明显能够看出,只无脑的进行迭代开发,既没有使用接口创建新增的功能,又没有将单个类中在几次几次作业里累计起来的过多的功能进行拆分 ,虽然每个新增的功能都拆分到了各自的方法中,让每个方法的复杂度尚可接受,但却使得几次作业迭代中那些关键的类的功能变得越来越复杂,越来越耦合。这不符合面向对象编程的编写思路。此外,这次作业为了解决不同种类的电梯的创建问题,新增了一个工厂类。因为每个电梯可停靠楼层不同,在工厂类需要存放每种电梯的可停靠楼层表。然后我为了贪图方便,趁着工厂类中存放了电梯可停靠楼层信息,就顺便将换乘表也在工厂类中实现了,然后将换乘表置入调度器 scheduler 类中。但很明显,换乘表的实现不应该是工厂类承担,而将换乘表置入调度器中也会使本就复杂的调度器要承担更多的任务,这既导致了类中方法的耦合,又导致了工厂类中的初始化方法变得极为复杂。事实上,新建一个类进行换乘创建,然后封装一个方法根据输入的起始楼层和目的地返回换乘楼层,这样才添加换乘这一功能的实现比较好的结构设计。

四、Bug分析

自己的Bug

这三次作业中,很幸运或者很不幸的是,我在所有中测、强测以及互测中都没有被找到Bug。但是由于多线程程序调度的不确定性,虽然每次测试都能平安通过,但并不保证我自己的程序内没有一些发生概率极低Bug。因此在这里我描述一些我在做作业的过程中发现和修正的一些Bug。

第一次作业:第一次作业的bug都不是线程安全的Bug:楼层容器初始化太小导致无法访问第16楼;SSTF的更新目标楼层的逻辑判断错误,导致有时电梯的目标楼层不是最近的楼层,使得一些乘客进入了电梯之后无法下电梯。

第二次作业:第二次作业主要是在设计的虚拟楼层和实际楼层的转换之间出现了Bug,并且优化了一下SSTF的调度策略,从原本的电梯制定目标楼层时只关心自己内部的成员到电梯既关心内部成员又关心等待队列的成员,性能得到了较大的提升。

第三次作业:第三次作业出现了一些线程安全问题。主要是由于电梯线程从纯粹的消费者变成了既是生产者又是消费者,因此线程安全退出的条件发生了变化,导致一开始电梯线程会提前结束或者一直结束不了,最终运用了开头总结的线程结束的流程解决了问题。

别人的Bug

这次我没有找到别人的bug,但是通过分析他人hack成功的数据,也能够得到一些多线程程序可能存在的一些问题的启发。首先最主要的问题在于线程的安全问题。不恰当的循环调用锁可能会导致死锁;不合适的线程结束判断逻辑可能导致线程未处理完所有数据就已经退出,或者线程处理完之后无法退出。还有一些CPU使用时间超时的问题,这主要是由于在线程不工作的时候,没有采用 wait 方法进行等待,而是采用轮询或者 sleep 一段时间再次询问。这样一来,一旦等待的时间较长,就有可能导致CPU被长时间的占用,且这样的占用毫无意义。

五、心得体会

在这次第二单元的作业中,令我很遗憾的是,我没能够在互测中找到别人的Bug。这主要是由于我之前debug的方法不合理导致的。我之前觉得debug没必要自己构建评测机,只需要打开别人的代码去理解并找到内在逻辑的问题,就能够找到bug。但是这次作业不一样了。首先是多线程程序的内在逻辑变得十分复杂,有可能一个代码单独存在的时候没有问题,但是与其他线程进行协作的时候就会出现线程安全问题。此外,多线程的程序对于数据的输入时间也有要求,而用手操作根本无法精确的控制数据输入时间。这也使得我既找不出bug,又难以构建有针对性的数据去尝试,尽管很希望去进行尝试,但又不知道如何下手,因此没有找到bug。这些问题都需要自己去反思。希望下次互测中,自己能做得更好。

 对于这段时间的多线程编程的体会,我觉得平时需要仔细去思考多线程的线程安全处理的一些规律。让我对这一点体会最深的就是在实验中,也是类似生产者—消费者模式的一道多线程程序题,但与我们电梯程序不同的是,实验中的生产者—消费者模式有多层,并且有多个生产者和消费者。结果没有仔细思考多线程程序到底如何安全结束的我在实验课上栽了跟头,最后写出的程序没法安全结束,往往在还没处理完数据就结束运行。

实验课过后,我便开始思考实验课上的程序到底是哪里出了问题导致线程无法安全结束。经过一段时间的寻找,我发现了,原来是判断生产者是否结束的部分,我检查的不是上一级的生产者,而是第一级的生产者,这就导致消费者在上一级生产者请求还未结束,但第一级生产者已经停止运行的时候,就会提早结束。那么到底应该如何设置生产者—消费者模式的线程安全结束?我最终总结出了写在文章开头的那套流程。利用这套流程,我成功地解决的在第三次作业中发现的线程安全结束请求变化的Bug,并取得了不错的成绩。可以说之前的思考最终带来了回报,还是很令人开心的。

首先我们可以看到,第二单元的作业设计与第一单元不同。在第二单元中,我们不仅需要设计多线程程序的结构框架,还需要考虑所设计的电梯采用的调度算法。程序框架和调度算法之间没有主次之分,我们需要协调程序结构框架和调度算法,使得我们的调度算法能够很简单的在所涉及的结构中实现。在这次作业中,我采用了生产者—消费者模式,输入模块  Input  将所得到的乘客数据输入调度器  scheduler  ,电梯 elevator 在根据调度需要从调度器 scheduler 获取行人。值得注意的是,多线程程序中极容易出现共享数据之间的冲突,比如多个线程同时对一个共享数据进行写入,或者在某个原子操作中发生线程切换,都会导致程序最终无法稳定地获得正确的结果。因此我们需要利用多线程同步或加锁的方式对共享数据的操作进行保护。但这又带来新的线程死锁的问题,这需要我们在设计线程同步的时候具有清晰的设计思路。因此在三次作业的设计中,遵守下面几条规则可以使自己的多线程程序更加安全且逻辑清晰:1、所有的共享数据只在共享数据类中进行处理(在本次作业中即在调度器 scheduler 类中处理),以保证所有的数据同步逻辑能在一个类中清晰的展现,方便编写和debug。2、采用观察者模式的思想,每个线程的状态由线程自身寻找合适的时机向监视器进行更新,而不提供监视器自由获取线程内部状态的方法,以避免原子操作被其他线程干扰。

此外,多线程的另一个需要关注的点在于:线程如何安全结束。由于一开始第一次作业中,我们只有一个生产者和一个消费者,而我又恰好找到了适合第一次作业的线程结束方法,因此没有对这类生产者—消费者模式的多线程程序如何安全结束进行仔细的思考。结果在课堂实验中面对多级生产者—消费者、每级多个生产者—消费者的多线程程序安全结束中栽了跟头。吸取了教训,我开始对生产者—消费者模式的线程安全结束条件进行了思考,最终总结出了一套结束流程:1、当每个线程处理完当前的所有请求后,就将线程的运行标记置0。2、一个线程可以结束运行,当且仅当该线程的运行标记为0,且该线程的所有上一级生产者(注意只有上一级)的运行标记为0。3、若当前线程的运行标记为0,但该线程的所有上一级生产者(注意只有上一级)的运行标记不全为0,则wait,直到新的请求进入线程之后,重新将运行标记置1。4、每个线程修改自己的运行状态标记后,需要提醒其他正在wait的线程(使用notifyAll)。

第一次作业

第一次作业的电梯较为简单,为单部多线程可捎带电梯,其多线程的思想主要体现在电梯和输入模块同时运行的方面。对于这种输入线程提供需要乘电梯的人,电梯线程获取乘电梯的人并完成乘客的需求,我们很容易就想到使用生产者—消费者模式。由于电梯无法立刻处理所有乘客的请求(因为电梯只有到了目标楼层才能处理乘客请求),我们需要在调度器中设立一个缓冲(等待)队列,用来存放已经发出请求,但仍未得到电梯响应的乘客。由于等待队列是电梯线程和输入线程共同访问的数据,因此我们需要使用 synchronized 关键字对向等待队列添加元素的方法 addPerson 、从等待队列获取元素的方法 getPerson 和获取等待队列人数信息的 checkPerson 方法进行同步保护。当 Input 线程结束输入并退出,且 Elevator 内和等待队列中都没有乘客请求时,两个线程都可以结束并退出运行。多线程的协同结构如下图所示。

在第一次作业中的调度算法,我采用了SSTF算法。该算法运用了贪心的思想,每次发生状态更新,如:楼层变化、电梯内人数变化、等待队列人数变化时,都会重新扫描电梯内人员需要到达的最近的楼层和电梯外等待队列可捎带的最近楼层,并将电梯的目的地设置为两个最近值中更近的那一个。由于每次运行的目的地都是各类请求中最近的楼层,因此电梯运行的状态只有四种:1、电梯内没有人且电梯的等待队列也没有人,此时需要将电梯的运行标记置0(在未来功能迭代的时候会有用)并wait等待直到新的请求输入。2、电梯内没有人但电梯的等待队列有人,此时将电梯的的目的地设置为离电梯最近的有人等待的楼层进行捎带。3、电梯有人且电梯未到目标楼层,此时电梯仅考虑捎带。4、电梯有人且电梯到达了目标楼层,此时电梯需先将到达楼层的乘客放下,然后再考虑捎带。

  

第二次作业

第二次作业相较于第一次作业,增加了多部电梯的需求,并且每部电梯开始有自己的人数上限,另外楼层数字不再连续,-1楼的上一层楼为1楼。本着迭代更新的思路,我们不妨从上一次的结构框架为起点,添加这次作业中新增的内容。首先是每部电梯拥有了自己的人数上限,这一点容易解决,在电梯中设置一个人数限制常量 limitNumberofPerson ,当电梯内的人数等于人数限制时,便不再考虑捎带;当人数少于人数限制时,单次捎带的人数最多为 nowNumberofPerson-limitNumberofPerson 。对于楼层不连续的需求,我们不能够再使用数组的下标隐含表示楼层数,但我们可以利用操作系统中学习到的虚拟地址与物理地址相互转换的思想,创建 storeyV2R 和 storeyR2V 方法,分别处理将数组下标转换为真实楼层和将真实楼层转换为对应的数组下标

public static int storeyr2v(int rstorey) {
        if (rstorey < 0) {
            return rstorey + 3;
        } else {
            return rstorey + 2;
        }
    }

public static int storeyv2r(int vstorey) { if (vstorey <= 2) { return vstorey - 3; } else { return vstorey - 2; } }

以上两个新增功能需求仅对上次作业中的方法进行了修改,而多部电梯的需求则需要我们修改多线程协作的结构。但我们经过仔细思考就可以发现,多部电梯事实上可以转化为多个单部电梯构成的集合,因此我们可以为每个电梯设置一个调度器,再为所有的分调度器设置一个总调度器,使得每个分调度器由总调度器进行管理, Input 线程与总调度器相连。这样一来,分调度器和每个电梯之间的关系与上次作业完全相同, Input 线程和总调度器之间的关系也与上次作业相同,只需要构建总调度器和分调度器之间的关系,我们就能够最大程度的利用上一次作业的多线程协调结构了。

对于总调度器和分调度器之间的关系有两种选择:第一种为总调度器根据分调度器的状态为每个分调度器分配乘客请求;第二种为分调度器根据自己目前的需求从总调度器中主动获取乘客请求。从拥有的信息量上来讲,总调度器拥有所有分调度器的状态,但分调度器难以获取其他调度器的状态,所以从总调度器的角度进行分配更有利于得到更优的调度策略。因此在第二次作业中,我采用了第一种总调度器和分调度器之间的关系,通过总调度器对电梯全局的状态的分析向分调度器分配乘客请求。当 Input 线程结束并退出,且单个 Elevator 线程和它对应的分等待队列都没有乘客请求时,该 Elevator 线程可以结束并退出运行。多线程的协同结构如下图所示。

在调度策略方面,在每个分调度器和电梯之间,我们采取与上一次作业相同的SSTF算法。对于总调度器和分调度器之间的算法,我没有设计比较复杂的调度策略,而是采用了均摊的方式:总调度器按照乘客请求输入的顺序,依次为每一个分调度器分配一个乘客请求。这种均摊的方式的好处在于,分配代码十分易读且好写,并且在随机数据测试中平均时间表现较好,但由于没有根据具体的分调度器的状态进行分配,这样的分配方式仍有很大的优化空间。

第三次作业

第三次作业相对于第二次作业,增加了电梯种类的区分,共分为“A”、“B”、“C”三种电梯,每种电梯可停靠的楼层、上升下降的时间以及电梯最大的载客量都有所不同。并且输入线程可以动态地增加电梯线程。由于本次作业的整体思路仍旧是生产者—消费者模式,因此我们再次使用迭代更新的思路。对于增加电梯种类的区分,我们可以设计一个工厂类 Factor ,根据输入的电梯种类编号“A”、“B”、“C”来生产不同的电梯。对于每种电梯可停靠的楼层、上升下降的时间以及电梯最大的载客量都有所不同,我们也可以通过在工厂类 Factor 中给每一个新增的电梯设置这些属性,而不是使用电梯内固定的值。对于动态增加电梯种类,我们可以在 Input 线程中增加创建电梯线程的方法,当读取到增加电梯数量的指令时,我们调用创建电梯线程的方法,先利用工厂类 Factor 创建电梯,再将电梯的信息输入调度器 scheduler 中进行保存,最后调用 start() 方法运行线程。

private void elevatorStart(String sign, String type) {
        Elevator elevator = factory.makeElevator(sign, type, scheduler);
        scheduler.init(sign, elevator);
        elevator.start();
    }

需要较多处理的部分为每种电梯的可停靠楼层不同,因为某些楼层的请求可能导致一部电梯无法直接到达请求的目的地,需要两部电梯进行换乘。通过对可停靠楼层的观察可知,由于存在1楼、15楼这样“万能中转站”,因此所有的乘客请求均可以在1次换乘内实现。所以在第三次作业中,在所有程序开始运行之前,我先运用类似弗洛伊德算法的思想创建了一个静态的乘客请求换乘表,其中i和j分别表示起始楼层和目标楼层,最终 interchange[i][j] 中存储的是从i到j经过楼层数最小的换乘楼层k(显然,当从i到j能够直达时,k的值为j)。

        for (int k = 0; k < 23; k++) {
            for (int i = 0; i < 23; i++) {
                for (int j = 0; j < 23; j++) {
                    int gostraight = Math.abs(i - interchange.get(i).get(j)) +
                            Math.abs(j - interchange.get(i).get(j));
                    if (interchange.get(i).get(k) == k && interchange.get(k).get(j) == j) {
                        if (Math.abs(i - k) + Math.abs(k - j) < gostraight) {
                            interchange.get(i).set(j, k);
                        }
                    }
                }
            }
        }

未来乘客请求输入时,若读取的换乘楼层和目标楼层相同,则可以直接通过一部电梯到达目的地;若不相同,则需要先搭乘某部电梯到达换乘楼层,再以换乘楼层作为起始楼层重新输入总调度器进行分配,乘另一部电梯到达目标楼层。

            if (p.getTmpdest() == storey) {
                TimableOutput.println(
                        String.format("OUT-%d-%d-%s", p.getId(), Floor.storeyv2r(storey), sign));
                tmp.add(p);
                if (p.getFinaldest() != p.getTmpdest()) {
                    scheduler.add(p.getTmpdest(), p.getFinaldest(), p.getId());
                }
            }

在调度策略方面,对于分调度器和电梯之间的调度。我们依旧采取SSTF的调度策略。但对于总调度器和分调度器之间的分配,由于每部电梯增加了可停靠的楼层,因此无法向第二次作业那样直接均摊。

这样一来,似乎已经满足了所有的新增需求,但仅仅做到这一步会使程序中出现严重的线程安全问题,因为换乘的出现使得生产者—消费者的结构发生了改变。在前两次作业中,我们可以看到,生产者只有一个 Input 线程。但在这次作业中,由于换乘的出现,使得每个 Elevator 除了作为消费者之外,还是一个隐含的生产者,为其他所有电梯提供换乘的乘客请求。按照我们在开头所总结的线程安全结束流程来说,每个 Elevator 线程结束的条件变为 Input 线程结束输入并退出,且除自己外的其他电梯线程和它对应的分等待队列都没有乘客请求时,所有的 Elevator 线程才能够安全退出,否则需要等待。否则会出现换乘的乘客下了电梯之后,发现接下来需要乘坐的电梯早已停止运行,最终无法到达目的地。最终的多线程协作图如下图所示。

 

二、架构设计的可扩展性

在第三次作业中,我采用了生产者—消费者模式。由于均采用了迭代开发的策略,因此在三次作业中,线程协作的结构保持的较好,但是过度依赖迭代开发,导致没有完成一些必要的重构和功能的分离,使得某些类的功能较为复杂和臃肿。下面从SOLID原则出发,分析第三次作业的架构设计的可拓展性和存在的问题。

  • SRP(单一责任原则):这个原则可能是这几次作业中做的最不好的一点。因为过于依赖迭代开发,一些必要的重构和功能的分离都没有做,特别是调度器 scheduler 类,因为在生产者—消费者模式中,调度器所执行的功能本身就较为复杂,再加上几次作业的功能添加和结构改变大都在调度器内,使得原本就不够美丽的代码变得更加雪上加霜。在第三次的代码设计中,调度器 scheduler 负责了从生产者获取乘客请求、利用总分配器向分等待队列分配请求、修改和检查线程运行状态、电梯线程获取等待队列状态、电梯线程从等待队列获取乘客请求。如此多的功能集中在 scheduler 一个类中,使得调度器类成为了一个“上帝类”,这不符合面向对象的设计原则。此外,过多的功能集中于一个共享数据类中处理,尽管所有的锁逻辑都在一个类中,但这会使得对同步锁的控制复杂化,从而增加出现bug的几率。事实上,解决也非常简单,将相似的功能分配到单个共享数据类中,并保持功能之间的树状结构关系。比如在这次作业中,我们就可以将从生产者获取乘客请求、利用总分配器向分等待队列分配请求、修改和检查线程运行状态这三个功能单独放置在一个总调度器中;将电梯线程获取等待队列状态、电梯线程从等待队列获取乘客请求放置在分调度器中。总调度器管理分调度器,从而保持功能之间的树状结构。当然必须要注意的是,将共享数据类分离之后,对同步锁的控制逻辑也将分离,因此需要仔细控制锁的逻辑,按照功能之间的树状结构关系向上调用同步方法,既符合信息的获取方向,又可以避免死锁的产生
  • OCP(开放封闭原则):这一点在这几次作业的迭代开发中做的也不是很好。这是由于每次作业新增功能的时候,对原有功能的需求便消失了。由于几次作业的功能之间又有很多相似之处,我选择了偷懒只在原本的功能中进行修改,以适应新的功能。但事实上,这样的做法风险很大。首先,我们这次作业中每个类的功能都称不上复杂,因此在原有的功能上修改以适应新的功能似乎并没有什么不便之处,甚至还很方便。但是功能组合复杂起来之后,如果还是按照在原来的功能基础上修改进行更新,可能会导致有些适应上次功能的参数忘记修改了,或者适应上次功能的方法忘记更新了等等,造成一些极难发现的bug。有时,处理和校对这些bug比重新写一遍更加费时间,因为我们需要理清可能适用于之前的功能、但不适合目前功能的代码逻辑。最合适的做法为,在最开始开发功能时,就设计一个功能接口,每次添加新的功能,不修改之前的类,而是利用接口创建一个新的类,从而获得新的功能。当然,这样的写法需要对接口设计有更高的要求,否则每次增添新功能都和重构一般复杂。
  • LSP(里氏替换原则):由于本次作业中没有使用接口或者继承,因此无法评价。但是根据OCP原则的要求,我们应当在添加新的功能的时候面向接口编程,而不是修改已有的功能类,这时就需要注意子类可以扩展父类的功能,但不能改变父类原有的功能,以保证接口或者继承类原本的逻辑不受拓展的改变。
  • ISP(接口隔离原则):由于本次作业中没有使用接口或者继承,因此无法评价。但同样的,如果使用了面向接口编程时,就特别需要注意这一点:实现功能时应当只调用需要的接口。否则无端地实现了无用的方法,不仅使整个类中的代码显得臃肿,更容易导致代码的逻辑混乱。
  • DIP(依赖倒置原则):这一原则比较难理解,大致意思为代码中的模块存在依赖关系时,高级模块不应该依赖低级模块,而是依赖抽象。总调度器和分调度器之间的依赖关系,我们就应当设置一个分调度器接口,总调度器利用分调度器接口与分调度器进行关联,而分调度器则实现分调度器接口来获取不同种类的分调度器。这样一来,就可以用单一的容器对多种不同的低级模块进行管理,有助于代码功能的拓展。在我的代码中,可以将各种电梯构建一个电梯接口,调度器根据抽象接口与电梯进行关联,而电梯则根据接口实现获取不同种类的电梯。当电梯的参数较多的时候,我们就可以用这样的方法,用多种电梯类来代替电梯中复杂的参数,有助于提升程序的逻辑清晰度。

三、基于度量的程序结构分析

第一次作业

1、UML类图:

2、度量分析:

 

3、 统计数据:

4、总结

在第一次作业中,由于电梯功能较为简单,因此调度器内需要实现的功能也较少。整个代码采用了采用了生产者—消费者结构。从度量分析中可以看出,本次作业中最复杂的部分在电梯线程的 run 方法内,但 run 本身复杂度尚可,因此这次作业的结构设计还是比较成功的。

第二次作业

1、UML类图:

 

2、度量分析:

 

3、 统计数据:

 

4、总结

第二次作业相对于第一次作业来说,增添了一些细小功能,但主要的变化在于增加了多部电梯同时运行的需求。多部电梯的新增需求使得我们需要在调度器中增加分配器和为每个电梯分别设置分等待队列 ,并且要为分配器制定一个分配策略。但是在第二次作业中,我采用的分配策略是简单的均摊策略,因此调度器的复杂度虽有提高,但是幅度并不大。但是值得注意的是,电梯类中的 run 方法依然占据了很高的复杂度,这主要是由于我在 run 方法中实现了电梯运行的流程。虽然我没有将具体的操作放入 run 方法中,而是将每一个操作整合为方法进行调用。但由于电梯运行的流程较多,使得 run 方法虽然看起来不长,但是内部调用的方法极多,使得复杂度大大提升了。如果将 run 方法作为一个线程中的 main 方法去看待时,这样调用过多方法有些不合适,我们应当再将某些流程利用新的方法进行整合,尽量在 run 中只调用必要的顶层方法,降低它的复杂度。

第三次作业

1、UML类图:

 

2、度量分析:

 

3、 统计数据:

4、总结

第三次作业就明显能够看出,只无脑的进行迭代开发,既没有使用接口创建新增的功能,又没有将单个类中在几次几次作业里累计起来的过多的功能进行拆分 ,虽然每个新增的功能都拆分到了各自的方法中,让每个方法的复杂度尚可接受,但却使得几次作业迭代中那些关键的类的功能变得越来越复杂,越来越耦合。这不符合面向对象编程的编写思路。此外,这次作业为了解决不同种类的电梯的创建问题,新增了一个工厂类。因为每个电梯可停靠楼层不同,在工厂类需要存放每种电梯的可停靠楼层表。然后我为了贪图方便,趁着工厂类中存放了电梯可停靠楼层信息,就顺便将换乘表也在工厂类中实现了,然后将换乘表置入调度器 scheduler 类中。但很明显,换乘表的实现不应该是工厂类承担,而将换乘表置入调度器中也会使本就复杂的调度器要承担更多的任务,这既导致了类中方法的耦合,又导致了工厂类中的初始化方法变得极为复杂。事实上,新建一个类进行换乘创建,然后封装一个方法根据输入的起始楼层和目的地返回换乘楼层,这样才添加换乘这一功能的实现比较好的结构设计。

四、Bug分析

自己的Bug

这三次作业中,很幸运或者很不幸的是,我在所有中测、强测以及互测中都没有被找到Bug。但是由于多线程程序调度的不确定性,虽然每次测试都能平安通过,但并不保证我自己的程序内没有一些发生概率极低Bug。因此在这里我描述一些我在做作业的过程中发现和修正的一些Bug。

第一次作业:第一次作业的bug都不是线程安全的Bug:楼层容器初始化太小导致无法访问第16楼;SSTF的更新目标楼层的逻辑判断错误,导致有时电梯的目标楼层不是最近的楼层,使得一些乘客进入了电梯之后无法下电梯。

第二次作业:第二次作业主要是在设计的虚拟楼层和实际楼层的转换之间出现了Bug,并且优化了一下SSTF的调度策略,从原本的电梯制定目标楼层时只关心自己内部的成员到电梯既关心内部成员又关心等待队列的成员,性能得到了较大的提升。

第三次作业:第三次作业出现了一些线程安全问题。主要是由于电梯线程从纯粹的消费者变成了既是生产者又是消费者,因此线程安全退出的条件发生了变化,导致一开始电梯线程会提前结束或者一直结束不了,最终运用了开头总结的线程结束的流程解决了问题。

别人的Bug

这次我没有找到别人的bug,但是通过分析他人hack成功的数据,也能够得到一些多线程程序可能存在的一些问题的启发。首先最主要的问题在于线程的安全问题。不恰当的循环调用锁可能会导致死锁;不合适的线程结束判断逻辑可能导致线程未处理完所有数据就已经退出,或者线程处理完之后无法退出。还有一些CPU使用时间超时的问题,这主要是由于在线程不工作的时候,没有采用 wait 方法进行等待,而是采用轮询或者 sleep 一段时间再次询问。这样一来,一旦等待的时间较长,就有可能导致CPU被长时间的占用,且这样的占用毫无意义。

五、心得体会

在这次第二单元的作业中,令我很遗憾的是,我没能够在互测中找到别人的Bug。这主要是由于我之前debug的方法不合理导致的。我之前觉得debug没必要自己构建评测机,只需要打开别人的代码去理解并找到内在逻辑的问题,就能够找到bug。但是这次作业不一样了。首先是多线程程序的内在逻辑变得十分复杂,有可能一个代码单独存在的时候没有问题,但是与其他线程进行协作的时候就会出现线程安全问题。此外,多线程的程序对于数据的输入时间也有要求,而用手操作根本无法精确的控制数据输入时间。这也使得我既找不出bug,又难以构建有针对性的数据去尝试,尽管很希望去进行尝试,但又不知道如何下手,因此没有找到bug。这些问题都需要自己去反思。希望下次互测中,自己能做得更好。

 对于这段时间的多线程编程的体会,我觉得平时需要仔细去思考多线程的线程安全处理的一些规律。让我对这一点体会最深的就是在实验中,也是类似生产者—消费者模式的一道多线程程序题,但与我们电梯程序不同的是,实验中的生产者—消费者模式有多层,并且有多个生产者和消费者。结果没有仔细思考多线程程序到底如何安全结束的我在实验课上栽了跟头,最后写出的程序没法安全结束,往往在还没处理完数据就结束运行。

实验课过后,我便开始思考实验课上的程序到底是哪里出了问题导致线程无法安全结束。经过一段时间的寻找,我发现了,原来是判断生产者是否结束的部分,我检查的不是上一级的生产者,而是第一级的生产者,这就导致消费者在上一级生产者请求还未结束,但第一级生产者已经停止运行的时候,就会提早结束。那么到底应该如何设置生产者—消费者模式的线程安全结束?我最终总结出了写在文章开头的那套流程。利用这套流程,我成功地解决的在第三次作业中发现的线程安全结束请求变化的Bug,并取得了不错的成绩。可以说之前的思考最终带来了回报,还是很令人开心的。

猜你喜欢

转载自www.cnblogs.com/marvinhehehe/p/12710052.html
今日推荐