BUAA_OO Unit2

BUAA-OO Unit2单元总结

第一次作业

作业简介

模拟单部多线程电梯的运行。

实现思路

首先分析题目:要求实现单步可任意调度的多线程电梯,那么不妨提出几个问题:

  1. 什么是多线程?需要实现几个线程?
  2. 电梯的调度策略是什么?
  3. 整个系统的架构是什么?
  4. 用到什么设计模式?
  5. 可能用到的多线程相关方法

下面根据个人思路逐一回答这几个问题。

多线程

实现多线程是采用一种并发执行机制 。

并发执行机制原理:简单地说就是把一个处理器划分为若干个短的时间片,每个时间片依次轮流地执行处理各个应用程序,由于一个时间片很短,相对于一个应用程序来说,就好像是处理器在为自己单独服务一样,从而达到多个应用程序在同时进行的效果 。

多线程就是把操作系统中的这种并发执行机制原理运用在一个程序中,把一个程序划分为若干个子任务,多个子任务并发执行,每一个任务就是一个线程。这就是多线程程序 。(摘自百度百科)

简单来说,一个程序就是一个进程,而多线程则是将一个进程分为几个不同的、可以并发执行的线程。线程就是进程的子任务。

本次任务需要用到几个线程?

个人使用了输入线程和电梯线程双线程。

输入线程负责通过接口读入数据,将读到的数据存储在waitList中;而另一边电梯线程从waitList读出数据,通过电梯调度策略进行调度。当输入线程读到null(即输入结束),将标志位置true,输入线程结束并唤醒其他线程,电梯线程可检测到标志并在完成任务后结束线程。

电梯的调度策略是什么?

查找相关博客,发现电梯调度算法有:

  • FCFS算法:根据进程请求访问磁盘的先后次序来调度;
  • 最短寻道时间优先SSTF算法:访问的磁道与当前所在的磁道距离最短;
  • 扫描SCAN算法,即在一条路上来来回回的走,一个方向上走到头之后换反方向再走到头,如此往复;
  • 循环扫描CSCAN算法:规定磁头单向扫描,然后立即返回重新开始。

等。

指导书中介绍了ALS算法,此外查找往届博客发现常用的还有Look算法,最后(因为没有get到look算法)决定选择ALS(ALS介绍见指导书)。

PS.其实调度策略没有那么重要,只要电梯能正确地跑,强测就有85%的分了。此外,听学长们说,哪怕是傻瓜算法(即电梯再1-20层来回跑,有人需要上就进入,需要下就出去),都基本没有爆掉。。。

系统架构?

四个类:

  • Main

    主类,为两个线程提供入口。(记得初始化时间戳

  • InputThread

    输入线程,仿照demo输入数据,同时设置一个输入结束判断符号isEnd。读入数据时需要把waitList锁住,添加进新元素后再notifulAll。输入结束后也需要再锁住waitList,通知其他线程isEnd已经变成true。

  • WaitList

    仓库类,负责存储所有PersonRequest,也是两个线程间的共享对象。

    这个线程负责两件事:从Input中读取数据和存储数据。注意,Elevator向仓库读数据如果读到为空,先wait,把CPU交给输入线程去读,下一次如果读到了就继续调度,反之则是输入结束。

    此外这里推荐将waitList设置为static类型的变量,这样在其他类时可以直接用WaitList.getWaitList调用,很方便。

  • Elevator

    调度线程,从waitList中读到数据后进入调度算法调度。

    我设置的判断是否继续调度的标志是输入结束 && 等待队列为空 && 电梯队列为空(不应该只看isEnd是否为true,如果等待队列和电梯队列里还有人应该把人送完再继续跑。)

    在run方法中,最开始先判断if (输入结束 && 等待队列为空 && 电梯队列为空),若不成立再进行调度。如果direction=0,即此时电梯队列为空,则将第一个元素(此处可优化,在后面优化思路里细讲)的目的地(request.getToFloor())设为电梯的目的地(targetFloor)后再对这一层的请求进行分析;反之,电梯队列不为空,则继续运行,direction>0就向上,反之向下。

    对每一层的请求分析过程是:先检查电梯队列中是否有人要下车(有就送走,记得开门),如果没有了就把direction设置为0,再看现在是否满足电梯队列为空 && 等待队列不为空,若满足,把第一个人的出发地(request.getFromFloor)设为电梯的目的地,去接人;然后检查这一层外是否有人在等待,如果这人的运行方向和电梯此时想要前进的方向相同并且电梯没满,就捎带上,一起跑;最后检查有没有关门。

    电梯的运行过程是:每到一层,Thread.sleep(400)后改变电梯当前的楼层数,再按照上述方法对这一层进行检查,如此循环,跑到direction为0为止。

设计模式?

再来想想电梯的运行过程,发现其本质就是输入线程生产数据,电梯线程消费数据,中间用一个“仓库”waitList来存储数据。因此决定采用生产者消费者模式

在实际的软件开发过程中,经常会碰到如下场景:某个模块负责产生数据,这些数据由另一个模块来负责处理(此处的模块是广义的,可以是类、函数、线程、进程等)。产生数据的模块,就形象地称为生产者;而处理数据的模块,就称为消费者。

为了不至于太抽象,我们举一个寄信的例子(虽说这年头寄信已经不时兴,但这个例子还是比较贴切的)。假设你要寄一封平信,大致过程如下:

1、你把信写好——相当于生产者制造数据

2、你把信放入邮筒——相当于生产者把数据放入缓冲区

3、邮递员把信从邮筒取出——相当于消费者把数据取出缓冲区

4、邮递员把信拿去邮局做相应的处理——相当于消费者处理数据

(摘自https://blog.csdn.net/u011109589/article/details/80519863)

另外提供几个带源码的博客,可以加深理解:

  1. https://blog.csdn.net/u010983881/article/details/78554671
  2. https://blog.csdn.net/ldx19980108/article/details/81707751
  3. https://blog.csdn.net/u010800201/article/details/78522758
可能用到的多线程方法?
  1. synchronized
  2. wait
  3. notifynotifyall

度量分析

代码架构

时序图

代码复杂度分析

可以看到代码复杂度在一个很客观的情况。

优化思路

首先对自己的代码进行优化分析:

  • 首先是最致命的(因为写完就快没时间了所以没有对三种模式进行分析特判
  • 其次对于每次选取的主请求,我采取的是直接取请求队列中的第一个的方法。但其实在这里可以做一点文章:添加分析方法,对队列中的请求进行分析,取可以最先到达的请求。

其次加上研讨课dl们的分享:

  • 印象最深的是一个(丧心病狂的)优化:把openDoor的时间设置为0.4s,closeDoor的时间设置为0s,在某些情况可以多捎带一些人。。。
  • 对于Morning模式,又三种方法:来一个就走,等人齐再走,装满6个就走。可以思考一下哪种收益最高。
  • 对于Night模式,每次选取楼层最高的请求而非单独的第一个在很多时候能得到更高的效益。

bug分析

自己的代码在强测和互测中都没有被hack(不过性能分也不高就是了)。本地测试曾经发现不能正常结束电梯运行的问题(就是电梯会一直跑),后来加上当电梯队列和等待队列为空时就停就可以了。

看os去了没时间看互测啊呜呜呜),不过最后看了眼同组被hack的同学,有结束过早导致没有把人送完的问题、有RTLE,也有CTLE。RTLE一般情况就是程序不能正常结束,多数情况是输入出问题或者调度策略停下来的那一块出问题;CTLE则是轮询,即电梯进程中不断调用Input里面的方法,解决方法是在电梯里添加wait。

第二次作业

作业简介

模拟多部多线程电梯的运行。

实现思路

本次作业有两个大方向:

  1. 集中调度

    所有电梯线程共用一个waitList。

  2. 分治

    先由总调度器向各个电梯分派请求,再由每个电梯自行调度。

相较而言前者简单,我采取的也是前者的方式,总体而言就是当waitList不为空时,所有电梯都去接这个人,谁先抢到谁nb。这样必然会导致所有电梯向一个地方冲,所有电梯都堆在一起了,所以如果出现类似[1.0]1-FROM-20-TO-3 [1.2]2-FROM-1-TO-3这样的数据会死的很惨,但它真的很简单很好理解()具体思路如下。

相较于第一次作业,增加了两个类:

  • Controller
    • 含有一个ArrayList<Elevator>成员eleList,初始化三个电梯线程;同时有一个addEleRequest方法,用于向ArrayList中添加成员。
  • Person
    • 在原有PersonRequest的基础上添加了taken标志,标志这个请求是否已经进入电梯。

一些类的修改:

  • InputThread
    • 如果是人请求,则加入waitList;如果是电梯请求,则加入eleList。
  • Elevator
    • 原有架构会导致轮询问题,需要在run方法里面添加条件:如果等待队列为空则wait
    • 每接到一个人将其的taken变量设置为true
    • 所有方法都加锁,这样才能确保是“抢人”而不是很多个电梯重复接同一个人。

好像修改真的挺少的orz。。。如果不是被轮询的问题折磨估计2小时就能改完。。。当然,性能和简单不可兼得,我似乎能预感到这次的性能分也不会太高。。。

度量分析

UML类图

时序图

代码复杂度

红色的Elevator类中具体超时的为run方法,多个电梯都去竞争同一个请求时都在调用这个方法,因此复杂度较高。

优化思路

刚刚看到性能分有亿点惊讶,“无为而治”yyds!在本次作业中没有将调度器设置为一个线程对人请求进行分配,直接将请求加到共享队列中由电梯自行抢人后性能分时98+(这还是在我没有特判三种模式的情况下)。

看了一下性能分得分最低的一个点是Night模式较多请求一起到达时所有电梯一起奔向某一层,但这也是抢人模式必然导致的问题,就不管它辣!

bug分析

在强测和互测中都没有被发现bug,也没有发现别人的bug。。。

第三次作业

作业简介

模拟多部不同型号电梯的运行。(型号不同,指的是开关门速度,移动速度,限载人数,以及最重要的——可停靠楼层的不同。)

注,本次作业参数如下:

实现思路

首先将请求分为两类:

  • 一是可以直接到达的请求;
  • 二是需要换乘的请求。

上面的两种分类比较抽象,在不同的电梯中类型可能是不确定的。比如在A类电梯中所有请求都是可以直接到达的请求;而B、C中就说不好。

由于第二次作业采用的是集中式调度并且不想重构,所以第三次作业延续了第二次作业的思路:采用一个共享队列给所有的电梯,电梯自行负责抢人+调度。

具体说来,为每种电梯设置了一个boolean类型的path[]成员,存储是否能够到达对应楼层。以B类电梯为例,path为:

path = new boolean[]{false, true, false, true, false, true, false,
                    true, false, true, false, true, false, true,
                    false, true, false, true, false, true, false};
//电梯是1-20层,所以path[0]设置为了false

在最开始时(此时direction=0),先取出等待队列中的第一个请求p(1);

  • 如果currentFloor == p.getFromFloor(),那么设置电梯的目标楼层为p.getToFloor()
  • 反之,设置电梯的目标楼层为p.getFromFloor()

每到新的一层,同第二次作业,如果电梯在这层可以停靠,那么电梯先检查自己队列中是否有人要出电梯(注:判断是否要出电梯的方法与第二次作业不同,描述如下):

  • 如果在当前楼层与该请求的目标楼层之间,此部电梯仍然可以到达,则不下;
  • 反之,则下;
  • 一句话总结就是,一步一步把乘客运到目标位置。
  • 举个例子,1号乘客需要从1楼到10楼。先被C电梯接到,则C电梯送到3楼;不巧ta又在3楼被B电梯接到,那么只好在9楼下,等待1号电梯把ta接到10楼。

实现函数如下:

private synchronized boolean isOut(Person r) {
        if (currentFloor == r.getToFloor()) {
            return true;
        }
        int i;
        boolean keepGoing = false;
    	//direction == 0 即电梯当前停止了
        if (direction == 0) {
            if (r.getToFloor() > currentFloor) {
                for (i = currentFloor + 1; i <= r.getToFloor(); i++) {
                    if (path[i]) {
                        keepGoing = true;
                        break;
                    }
                }
            } else {
                for (i = currentFloor - 1; i >= r.getToFloor(); i--) {
                    if (path[i]) {
                        keepGoing = true;
                        break;
                    }
                }
            }
        //direction > 0 是电梯正在上行
        } else if (direction > 0) {
            for (i = currentFloor + 1; i <= r.getToFloor(); i++) {
                if (path[i]) {
                    keepGoing = true;
                    break;
                }
            }
        //direction < 0是电梯正在下行
        } else {
            for (i = currentFloor - 1; i >= r.getToFloor(); i--) {
                if (path[i]) {
                    keepGoing = true;
                    break;
                }
            }
        }
        return !keepGoing;
    }

出了电梯之后,记得判断是否到达最终的目的地,如果不是,则需要进行换乘:

if (r.getToFloor() != currentFloor) {
    synchronized (WaitList.getWaitList()) {
        WaitList.addRequest(getTransfer(r));
    }
}
private synchronized Person getTransfer(Person r) {
        r.setTaken(false);
        r.setFromFloor(currentFloor);
        return r;
}

再检查这会儿是否满足电梯队列为空且等待队列不为空,如满足,重复(1).

接着,如果电梯在这层可以停靠,检查门外的乘客p是否要上来。进入电梯的条件比较复杂:p.getFromFloor == currentFloor && !p.getTaken() && !isOut(p) && p.getDirection() * direction > 0 && eleList.size() < this.capacity。都满足了就可以进入电梯进行下一步操作。

如果开了门记得关了。

最后,如果eleList.isEmpty() && WaitList.isEmpty(),也就是对这个电梯而言没有更多请求了页不会再跑了,要让WaitList唤醒等待池中的所有线程。(如果其他电梯还在跑就不管它,反之如果在等待的就可以结束了,大家都结束了程序自然就结束了)

度量分析

UML类图

EleList类和第二次作业的Controller类一样的,只是换了个名字(因为它真的没有起到Controller的作用orz)

时序图

代码复杂度

Elevator干的活实在是太多了。。。

优化思路

啊,其实从理论分析如果有一个比较优秀的调度方法的话,这次分布式调度可能性能分会好很多,但是不想重构就不优化了。

此外,由于抢人具有较大的偶然性且每次决定是否要进出电梯都是在当下的状态中进行的最优判断,所以其实抢人的性能分也不会太差(吧)(希望强测结果出来了不要打脸)

bug分析

课下bug:CPU_TIME_LIMITED,可能不是轮询,也可能是因为判断条件出错而导致的死循环。

多线程的调试比较玄学,所以print大法yyds!建议在调试过程中多输出每个节点的状态,这样可以比较清楚地知道哪个地方不太对。

此外在多线程的问题中,JProfiler真的是一个很好的工具,在其中可以看出每个线程的运行情况(它命名是Thread-1/2/3/4之类的格式化命名,对应的是各个线程的启动顺序)。如果发现自己的线程中绿色过多,那,检查检查轮询吧()

Unit2原则分析

SOLID原则

  • 单一责任原则
    输入类仅负责读取输入,同时最终向电梯发送结束信号;
    电梯负责将指令装入自身的队列,同时负责自身的整个调度,责任略多。
    • 开放封闭原则
      使用的电梯类延续自第一次作业,在后续作业中增加了一些方法和属性,改动较小。
      输入线程延续自第一次作业,几乎无改动。
      增加了EleList类,用于完成对电梯类的实例化和增加新的电梯。
    • 里氏替换原则:未涉及继承。
    • 接口分离原则:线程实现了Runnable接口。
    • 依赖倒置原则:WaitList是共享队列,输入线程和不同电梯对WaitList的控制存在依赖关系。

心得体会

OO的Unit2主要考察了多线程问题,难度相较于Unit1(个人认为)是有所下降,且不同于老师的观点,我认为hw5是三次作业里最难的一次。原因在于,第一次接触到多线程,无论是对多线程这个概念的理解,还是一些常用方法的掌握,都是几乎为零。
因此,在这样的情况下,我选择先看一些网上“生产者消费者模式”的代码模版、阅读学长学姐的博客,在有了一定了解的基础上再动笔。而在这样的情况下,我在第一次作业选择只用两个线程,即输入线程和电梯线程来进行调度而没有增加调度器线程。而到了hw6时,(虽然老师再三强调没有调度器会很难写),但我在Unit1时已经重构吐了所以坚持不想重构,加之阅读了学长学姐们的博客后发现不用调度器,而让电梯自由竞争也可以得到较好的成绩,所以就头铁的坚持不加调度器。所以从老师的角度看,我前两次的电梯的拓展性应该是很不好的,但从我自己的角度看,后面两次作业相较于第一次作业的改动是真的很小,每次都不超过40行,因此拓展性其实还算不错。
但是后面两次作业也都花费了自己6h+的时间,因为线程安全出了问题。本单元的作业主要有两种线程安全问题:CPU_TIME_LIMITED和REAL_TIME_LIMITED,很不幸,我都遇到了,还不止一次。两次的6h+也都花在解决这两类问题上。
简单说来,CTLE是程序轮询或者死循环,即在一个线程中不断调用另一个线程的方法来进行判断,或者是电梯调度的某个判断条件出错导致电梯在某一楼层不断循环判断自身却不动。而RTLE则是死锁或者程序结束条件不对。在课堂上老师也专门强调过,线程中的wait和notify方法一定是对应的,一个wait后面接一个notify,这样能大概率保证程序不会死锁。
最后谈一点自己的心得感想吧。三次作业都采用的无调度器的自由抢人模式,第一次的成绩92+,第二次98+,第三次因为线程安全T了一个点92,正常情况也应该是96+,成绩也还算看的过去。但是在OO的学习中,有一点安于现状之嫌,只要过了中测就心安理得的不管了,不去思考更多优化可能,不去尝试新方法(例如老师课上提到的读写锁等方法),其实是非常不好的。诚然,考虑到这个月自己的压力还是比较大,这样的选择无可厚非,但确实——少了一份尝试,也就少了一点收获。希望能自我反思,在Unit3的学习中,尝试更多可能,也立下一个小小的flag吧:在Unit3的两次研讨课中上去分享一次。
下一单元的总结来看我flag倒没倒哈哈哈。
以上。

posted @ 2021-04-22 12:59  blurrrr  阅读(235)  评论(0)    收藏  举报