OO第二单元_多线程的磨练

电梯单元总结
--- ## 目录 * 从多线程出发审视设计策略 * 生产者和消费者 * Worker Thread * Summary * 多线程的协同 * 同步控制 * 从设计原则审视架构设计 * SOLID * 功能实现和性能优化 * 功能实现 * 性能优化 * 优化和设计的经验教训 * 度量分析 * UML类图 * 整体分析 * Bug分析 * 分析自己的Bug * 查找别人的Bug * 第二单元总结---多线程在我心

一、设计策略(电梯是消费者,所以吃人是理所应当的

总体架构如下:

生产者和消费者

在我的设计架构中,生产者和消费者这一设计模式是我设计的基石。而在三次作业中作业中,电梯都作为消费者出现,然而生产者却不尽相同。

在第一次作业中,输入线程直接作为生产者,而将输入存到一个以容器ArrayList(储存对象为Passage类,即处理好的“产品”)为主要存储单元的缓冲区内,其中含有getIn以及getOff两种方法充当消费者从缓冲区取数据和清理缓冲区的“get”方法,而其中addPassage则充当生产者将数据放入缓冲区的“put”方法。InputWaitingQueue以及Elevator三者构成了模型的生产者、缓冲区以及消费者。
而在第二、三次作业中,生产者变得模糊化了,并不直接由输入线程提供,而是由新加入的调度器线程充当生产者的模型,而除此之外,模型其他部分并没有什么改动,对电梯来说,它依然只是个面无表情的“吃人”机器,他不关心是谁提供了它的“食物”。

Worker Thread

使用这个模式其实是受了课上实验的启发,我在写的二次作业时完成了两版内容,第一版是完全由生产者消费者模型构成,写完后感到比较冗杂,也没有很好的满足低耦合的设计理念,主要是因为缓冲区(即调度线程和电梯队列)承担了过多的任务,让我感到它已经不是在单纯执行自己“份内”的工作了,这对于设计和迭代都是不利的。所以我在基层的电梯操作继续沿用生产者消费者架构的同时,引入了部分Worker Thread的模式。

我的设计是类似于实验课上的票务系统,输入端不停的投放生产物,存入第一个缓冲区总队列中,经由调度线程调度后,将总队列中人员分配给某一个电梯队列,之后每个电梯分别处理自己电梯队列中的任务即可。在相当于是来了一个Request,放入票池(也就是总队列),我的调度器充当了Channel的作用,然后我的worker(也就是电梯的私有队列)通过调度器获得Request,交给电梯进行处理。而在我代码的Worker Thread模式中,它不关心他的请求交出去后的处境。而且电梯不去要求任务而是等待分配。

Summary

单一来看其实两个模式都不是很适合于本次作业的完成,难以达到完全的高内聚和低耦合,也难以让实现线程之间协同和同步控制做的很好。而当我把两个模式和在一起时,他却体现出了较好的特性。下面从两个角度来看我选择设计策略的总体原因:

  • 多线程的协同
    我在第一次作业中只有两个线程,分别是电梯线程和输入线程,因为第一次作业其实就是在实现一个电梯内部的功能,故而我没有写调度器线程;而在二、三次作业中我加入了调度器线程,是为了保证电梯线程和输入线程的只实现自己的功能以及优化需求。

    我使用Worker Thread + 生产者消费者的设计模式,电梯线程和输入线程都只办自己的事,和其他两个线程并没有直接交互,他们分别通过电梯队列类总队列类和调度器进行交互。而调度器则根据从两个队列中获取的信息进行调配,将总队列中的“任务”根据我的加权算法分配给某一个电梯队列,在适当的时候根据两队列情况进行sleep和对电梯线程进行notify,没有直接的交互保证了多线程的安全性(获取的是期望的输出),而且通过这样的形式,各个线程之间的协同变的更加清晰。调度器分别作为电梯的供给者和输入的消费者,协调着多个线程,作用域也非常明显(所对应的队列)。

  • 同步控制
    在第一次作业中同步控制非常简单,因为只有两个线程的交互,所以上好锁就完事了。而第二、三次作业中,我将我的模式拆分成两部分,也就是上文提到的生产者消费者&Worker Thread模式,这样同步控制也变的简单了,只要锁好电梯和输入,根据两种模式的要求分别针对调度器进行上锁即可。

二、从设计原则审视架构设计(优化让我快乐

SOLID

实际上当我们要着手编写代码时,一定要优先考虑架构(在多项式作业中任务驱动的我差点咽气),一个好的架构可以支持你的优化以及后续的拓展。下面从6大设计原则来看看设计架构如何规划。

SOLID原则 翻译 真实的翻译
Single Responsibility Principle 单一职责原则 电梯就该老老实实当电梯原则
Open Closed Principle 开闭原则 加功能不要动我写好的电梯,更不要重构
Liskov Substitution Principle 里氏替换原则 你要知道你用的是哪一个线程
Law of Demeter 迪米特法则 电梯不要和其他线程直接沟通
Interface Segregation Principle 接口隔离原则 能少交互就少交互
Dependence Inversion Principle 依赖倒置原则 不存在的)好吧只是我没有涉及这个原则
这就是我开始写代码前的思考,依据这六大原则编写出的代码,会有较好的迭代和优化的空间

功能实现和性能优化

既然有了思路和约束,下一步就是功能实现和性能的优化啦。这很简单嘛这就需要我们考虑更细节的问题了。

  • 功能实现
    实际上,在观察过本次作业后发现,由于本次作业给出的时间范围较为宽泛,所以对于算法的要求确实不是很高,单纯的功能实现是比较容易的。我将功能实现分成两个部分:把人分配给每一个电梯;每一个电梯将自己队列中的人送到目的地。两部分具体的实现细节我都放在优化部分进行叙述。

第一次作业实际上就是整个专题中基本功能的实现---一个电梯如何完成自己的接人运送工作。我选择给每一个电梯配一个自己的队列,而队列里的人员就是就是等待这部电梯去接送的人员,为此我构造了一个Passage类,来存储请求的信息,以简化电梯队列的实现,并保证电梯只完成自己的工作不进行解码操作。而从迭代角度来看,我第二、三次作业基本没有更改我的第一次作业实现电梯功能的方法,只是在构造函数上和判断线程结束的地方略有修改。而二、三次作业改成多部电梯、楼层及人数限制,也并没有影响电梯的基础功能,只是引入了人员的分配问题,实际上就是调度器来协调电梯队列和总队列的事请,故而改动都集中在调度器类中。

  • 性能优化
    谈及优化的话,无非就是从两个方面来说:电梯运送自己队列中乘客的策略;总队列中人员如何进行分配。
    电梯运送自己队列中乘客的策略
    在这一方面实际上我考量了很多方面的因素,最终确定了两个思路:Dijkstra算法,或是局部贪心。而在我做了形式化验证和随机数据检测后,最终我得出一个结论,局部贪心就可以了,因为数据的不可预知性导致Dijkstra算法的实际意义不大。这里介绍一下我的贪心策略:电梯在到达一层后自动检索自己的队列,找到最近的目标(电梯内的人就是目的地,电梯外的人就是始发地),前往接人或送人,这样可以保证折返历程尽量小。下图是一个简单的例子:

主要代码段:

    for (Passage p : people) { 
            if (p.getInOrout()) {  // 人在电梯里
                disc = Math.abs(now - p.getGoal());
                if (min > disc) {
                    min = disc;
                    goal = p.getGoal();
                }
            } else if (!p.getInOrout()) {   //人还没上电梯
                disc = Math.abs(now - p.getLoc());
                if (min > disc) {
                    min = disc;
                    goal = p.getLoc();
                }
            }
      }

而电梯每一层都检索都要是否上下人,这样以应对不可预知的请求,完成捎带的策略。经过实际测试这样的方式对于单个电梯来说是比较优秀的方法,在第一次作业的强测里得到了99+的分数。
总队列中人员如何进行分配
这个地方的分配策略我思考了很久,因为涉及到两方面因素,每个电梯是否物尽其用以及人员是否适合某个电梯去处理。最终我构造了一个按权重分配人员的方法,由电梯内人数和电梯目前状态两方面决定。当然,还有一个初始化的过程,就是如果有空的电梯队列,优先放入空队列,保证电梯不空转。
主要代码段:

        int min = 20;
        int dis;
        int noResponse = 0;
        int temp = 0;
        for (int i = 0; i < elevators.size(); i++) {
            dis = Math.abs(elevators.get(i).getNowFloor() - p.getLoc());  \\ 计算里程
            synchronized (elevatorQueues.get(i)) {
                if (elevatorQueues.get(i).getPeople().size() > 4) {  \\人数过多则对应电梯不响应
                    noResponse++;
                    continue;
                }
            }
            if (dis < min) {
                min = dis;
                temp = i;
            }
        }
        if (noResponse < elevators.size()) {
            moveToEle(p, temp);
            return;
        }
        noResponse = 0;
        for (int i = 0; i < elevators.size(); i++) { \\如果所有电梯人数都超过5人则去除人数权重
            dis = Math.abs(elevators.get(i).getNowFloor() - p.getLoc());
            if (elevatorQueues.get(i).getPeople().size() == 7) {
                noResponse++;
                continue;
            }
            if (dis < min) {
                min = dis;
                temp = i;
            }
        }
        if (noResponse < elevators.size()) {
            moveToEle(p, temp);
        }

这是初版代码,后续实际上我增加了电梯的方向因素以及将和电梯的交互改成了和电梯队列的交互。但是总体差别不大,由于我个人的疏忽,导致在第二次作业中由于未适当休眠强测有两个CPUTLE,我简单计算了一下如果这两个点不错应该依然能够取得99+的分数。

优化和设计的经验教训

其实三次作业的优化我都做的还算成功,但是在第三次作业中我犯了一个致命的错误,多删除了一个判断,还没有加上我原本打算放入的新方法。大概是因为我周四写了一半临时外出了导致的吧。不过这个问题一下子把我的性能分杀了个一干二净,原本我沿用了上次作业的方法,可是我在写完后忘了将请求的时间因素加入电梯队列,还忘了加入新电梯后打乱电梯队列。导致性能分骤降。这个问题让我意识到,每次作业都应该有一个形式化验证和自动数据的统计,算是一个比较大的教训吧。


三、度量分析(如果只写一个类,那所有类都是平均水平了

UML类图

  • 第一次作业

  • 第二次作业

  • 第三次作业

整体分析

  • 第一次作业
    第一次作业是实现单一电梯功能,由于我个人认为单一的电梯调度写了也没什么必要,所以我将输入和电梯直接建立了联系,由此可见其最复杂的部分是电梯线程,也是由于其产生交互的原因。如下图:

  • 第二次作业
    第二次作业我加入了调度线程,因此复杂度主要集中在调度器上。是因为调度器起着勾连两者的作用。如下图:

  • 第三次作业我没有做很大的修改,只是改变了一些调度的方法,因此和第二次作业几乎没有区别,在此只展示修改较大部分。如下图:

  • 总结
    从总体来看,各个类基本满足了低耦合的要求,但三次作业都出现了在调度层面复杂度较高,不易维护的问题。优点和缺点都很明显,首先各个类能承担自己的任务不去干扰其他类的运作,而实际上呢,调度线程也确实不易维护,但是由于交互基本集中在调度线程,所以其他地方都易于维护不易出错。


四、Bug分析 (如果我不写,我就没有Bug

分析自己的Bug

我在三次作业中都使用的是形式化验证结合特殊样例进行测试,总体表现还算可以接受,但在第二次作业中强测出现了两个Bug,是在某种特殊情况下(全部电梯满载且又到来人员)有可能会导致CPU轮询导致CTLE。出现这个原因的主要问题还是由于个人构造的难以100%触发多线程的安全问题。所以在第三次作业我引入了自动测评的机制(不会有人以为自动测评就能找到全部错误吧不会吧不会吧不会吧),这次强测确实没有错误,但是被hack了一个难以复现的数据点,但实际上这个数据非常有趣,基本上不会随机出来,而我再次审视自己代码后发现之前的形式化验证有一些漏洞,会在这个特殊的数据点中出现波动。数据如下:

[1.1]1-FROM--3-TO-14
[1.1]2-FROM--3-TO-14
[1.1]3-FROM--3-TO-14
[1.1]4-FROM--3-TO-14
[1.1]5-FROM--3-TO-14
[1.1]6-FROM--3-TO-14
[1.1]7-FROM--3-TO-14
[1.1]8-FROM--3-TO-14
[1.1]9-FROM--3-TO-14
[1.1]10-FROM--3-TO-14
[1.1]11-FROM--3-TO-14
[1.1]12-FROM--3-TO-14
[1.1]13-FROM--3-TO-14
[1.1]14-FROM--3-TO-14
[1.1]15-FROM--3-TO-14
[1.1]16-FROM--3-TO-14
[1.1]17-FROM--3-TO-14
[1.1]18-FROM--3-TO-14
[1.1]19-FROM--3-TO-14
[1.1]20-FROM--3-TO-14
[1.1]21-FROM--3-TO-14
[1.1]221-FROM--3-TO-14
[1.1]231-FROM--3-TO-14
[1.1]241-FROM--3-TO-14
[1.1]251-FROM--3-TO-14
[1.1]261-FROM--3-TO-14
[1.1]271-FROM--3-TO-14
[1.1]281-FROM--3-TO-14
[1.1]291-FROM--3-TO-14

查找别人的Bug

这次说实话我犯懒了,没有去阅读他人的代码。。。只是做了一些自动化测试,而且似乎都在A屋,所以只发现了一个Bug。从策略来说,构造极端数据能容易hack到他人,随机数据反倒不够优秀。


五、第二单元总结---多线程在我心(你觉得你对你就对了吗?

多线程是真的非常有趣的东西,实际上嗷,我觉得还是难以达到熟练的完美的掌握锁和线程的并行。就我来看多线程实际上是在考验线程与线程之间的独立性,将需要交互的线程先隔离开来,再通过一个缓冲区去处理并联系这个关系。多线程是一个非常有趣课题,它一方面带来了效率和交互的方便的属性,另一方面又影响着程序的安全。但实际上,我个人认为锁的应用最重要的就是尽量在交互的时候满足一对一的对应,这样可以尽量避免死锁这样的问题。事实上,我还需要在接下来的作业和学习中更进一步去了解、认识和应用多线程,多线程永远在我心~


顺便:麻烦助教每次看我代码了,我也不知道为什么会疑似我有非法行为,我一直是一个守法好公民
posted @ 2020-04-17 11:59  Ganten  阅读(308)  评论(1编辑  收藏  举报