OO第二单元总结

OO第二单元总结

摘要

本文主要分为三个部分,作业分析、Bug分析、心得体会;其中作业分析基本上复现了笔者进行开发时的过程,既有设计的描述,也有过程中遇到的一些问题;心得体会主要是笔者的一些感触,也希望能对大家有所启发。

第一次作业分析

架构设计及时序图

首先附上第一次作业的架构图和时序图:
image

image

第一次作业比较简单,这两张图看起来也比较简单,但其背后的故事却很长。

在第一次作业的时候,老师就说可能需要三个线程,一个生产者、一个消费者,还要有一个调度器。于是在最初一版的时候,设计了三个线程,其中输入线程和调度器线程用总候乘列表交互,调度器线程和电梯线程之间交互的既有调度器得到的请求,还有电梯的状态。这样设计的问题在于,我在第一次作业时以为调度器只是用来控制电梯运行的,但其实此时调度器还有一个默认的作用,就是请求的分发,而我却没有意识到,导致调度器负担过重,不符合单一功能原则;同时,由于第一次作业时刚刚接触多线程编程,就有三个托盘需要保证线程安全,其中后两个还是两个线程之间交互式的闭环,直接导致在控制线程安全的时候极为复杂,同步块写得到处都是,程序一直跑不出来;而且还有一个很恶心的地方在于,电梯的状态是通过托盘来传递的,因此调度器如果想知道电梯的状态,就需要电梯一直传各种参数到“电梯状态”(类)这个托盘当中,然后才能读取,而电梯和这个“电梯状态”类除了是否为线程之外几乎一模一样!(像是研讨课上讲的模拟电梯)

出于上面的这些原因,我进行了大刀阔斧的改动,首先是直接把调度器的线程给砍掉了,既然只有一个电梯,输入直接给这个电梯不就行了吗?如此一来,调度器和电梯之间交互的两个托盘也不再有存在的必要,也被直接删掉,因此就剩下了输入、候乘列表、电梯三个大类。那么电梯的运行由谁来控制呢?我把之前在调度器中实现的电梯的运行策略挪到了电梯的组成成员之中,相当于在电梯中内嵌一个控制单元,电梯每运行一步,控制单元便根据电梯的状态指定一个楼层,电梯根据指定的楼层运行即可,此时由于控制单元不是线程,运算的时候直接传电梯的参数即可,也不再需要维护交互的对象,整个架构就变得清晰起来,编写代码也变得很方便。

线程安全设计

说完了架构上的层次化设计,这一单元最重要的一点——线程安全也理应顺带而出。这三次作业都没有采用线程安全类的设计,因为中间有进行过线程安全类的尝试,但都以失败告终,很大一部分原因在于第一次作业和第二次作业刚完成时已把业务逻辑写到了线程里,而改造成线程安全类时又要对业务逻辑在此类中进行集中处理,改动较大,可能导致一些逻辑上的错误,中测无法通过。但在这个过程中,也的的确确地体会到了线程安全类的好处,同时也感到最开始的设计有多么重要,等到实现之后再改,就变得十分的困难了。

那还是紧承第一次作业的线程安全的设计来,由于只有一个生产者和一个消费者,同步块的逻辑就比较简单了,先来看看输入线程的同步块:

synchronized (waitTable) {
    if (request == null) {
        waitTable.close()    
    		waitTable.notifyAll();
        break;
    } else {
    		waitTable.addRequest(request.toString());
        waitTable.notifyAll();
    }
}

输入线程在产生请求时首先把候乘列表锁住,如果有请求就加入到候乘列表当中,并唤醒正在等待请求的电梯线程,如果没有则代表已经运行完毕,将候乘列表的完成信号置位,也唤醒可能正在等待的电梯。

然后再来看看电梯线程的同步块:

while(true)
    synchronized (waitTable) {
        if (waitTable.isEmpty() && waitTable.isEnd() &&
            passengers.isEmpty()) {
            close();
            return;
        }
        if (waitTable.isEmpty() && passengers.isEmpty()) {
            try {
                waitTable.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            toFloor = controller.shedFloor(currentFloor, passengers);
            moveOneStep(toFloor);
        }
    }
}

但电梯这块的同步块设计就有待商榷了,可以看到进程在运行时,所有的操作都需要拿到waitTable的控制权才能进行,因此其刚刚释放waitTable的锁,又会去重新竞争waitTable以获得其控制权,这时候就会出现一个很严重的问题,就是电梯在运行时如果电梯内部有人,就可能可以一直竞争到waitTable的锁,而如果电梯一直拿着waitTable的控制权,输入线程就没办法把得到的请求放入到waitTable当中,只能等到电梯把人送完,让出waitTable的控制权,输入线程才能把请求加到waitTable当中。或许善于思考的你已经发现了问题——这样的情况会导致大量的请求无法捎带

实际上这样的写法是很有问题的,因为这个线程里的很多操作其实并不需要用到waitTable,也就没有直接关系,但却还拿着waitTable的锁,可见此时同步块的范围过大,导致出现了比较严重的问题;当时这样做的原因是对waitTable的判断都在if-else语句块内,没办法进行拆分,就只能全部锁住,而从这也能看出线程安全类的好处,就是在进行这样的判断时无需考虑语法上的拆分问题,只管调用线程安全类的方法即可,当执行到与要锁的东西无关的语句时,就已经出了同步块,让出了锁的控制权,实现起来也是很方便的。

至于调度器设计,我们通过以下两次作业细说。

第二次作业分析

架构分析

同样先展示类图以及时序图:(类图主要是设计的时候画的,可能不太完整,但能展示总体架构)
image

image

在读通了课上实验的代码之后,第二次作业作业的设计也就自然而然了,无论是从类图还是从时序图都能看出,所有的逻辑流动都是同向的,因而设计的思路比较清晰,即有两对生产者-消费者,第一对是输入线程和调度器线程,和第一次作业的生产者-消费者类似,其次是调度器线程和电梯线程,调度器只负责把请求进行分流,至于电梯如何运行一概不知,降低了两者之间的耦合性。

但即使只是这样的设计也是花了很长的时间进行思考的,其中碰到的一个问题是如何区分处理加电梯的请求和乘客的请求,刚开始想像官方包所给的例程一样进行处理,在输入线程中进行拆分,但其实是不够合理的,因为在调度器线程中,还要再对请求的类别进行判断,因此利用第一单元所掌握的知识,输入线程中无需判断当前请求是什么,通过父类继承的机制,只管加到候乘列表的队列当中,调度器去取的时候才进行判别,事实上也是降低了两者之间的耦合度,因为此时如果输入线程又多了别的种类的请求,都可以只在调度器内进行拓展,而无需修改输入线程的代码。

整体的架构成型之后,真正用来写代码的时间就比较少了,实质上就是把第一次作业代码的逻辑又复制了一份,因此开发上的难度还比较小,况且还有课上实验的代码做模板,心里也比较有底一些——但接着就碰到了线程安全的相关问题。

线程安全设计

之前也提到,因为没有改造好线程安全类,因此这里只展示线程运行时的同步块设计,而且由于输入线程的同步块与第一次设计一模一样,这里也不再展示,因此先来看看调度器线程的同步块——等等,这里先埋个伏笔,咱们先看看课上实验的调度器与处理器的同步块:

image

调度器

image

处理器

有没有发现一处很经典的加锁模式?调度器先拿起了waitQueue的锁,然后要每一个processinQueue的锁,而处理器先拿起了自己专属的那个processingQueue,然后要去拿waitQueue的锁——好一个死锁

这是我课上没有发现的。而由于课下仿照着这段代码去实现,测试时无论如何都跑不出来,最后对着加了同步块的地方细细琢磨,猛然才意识到了这个问题,于是对调度器里的同步块稍微设计了一下:

boolean end = false;
synchronized (waitTable) {
    if (waitTable.isEnd() && waitTable.isEmpty()) {
       end = true;
    }
}
if (end) {
   for (ProcessingQueue processingQueue : processingQueues) {
       synchronized (processingQueue) {
       		processingQueue.notifyAll();
       }
   }
   return;
}

为了把waitTable的锁和processingQueue的锁分开,通过一个boolean类型的变量end首先取得是否应该结束,再通过查看这个变量的值来决定是否结束。不过我觉得这个方法比较取巧,很不优雅,真正实现分离完全可以通过线程安全类,这样调用waitTable的方法时就锁住waitTable里的对象,调用processingQueue的方法时就锁住processingQueue里的对象,不会出现两者需要同时锁住的情况。

另一个线程安全的问题是逻辑上的问题,由于刚刚提到调度器需要对请求的种类进行判断,那么在从waitTable里取出请求后,还需要进行判断之后才能处理,就会出现这样的逻辑:

synchronized (waitTable) {
		if (waitTable.isEmpty()) {
  		 try {
   		 		waitTable.wait();
   		 } catch (InterruptedException e) {
      		e.printStackTrace();
   		 }
		}
		request = waitTable.getRequest();
}
if (request instanceof PersonRequest) {
   dispatch((PersonRequest) request);
} else if (request instanceof ElevatorRequest) {
		...... 
}

而电梯中结束的逻辑是这样的:

synchronized (waitTable) {
    if (processingQueue.isEmpty() && waitTable.isEnd() &&
            waitTable.isEmpty() && passengers.isEmpty()) {
        if (!state.equals("close")) {
            close();
        }
        return;
    }
}

可能不细想还察觉不到什么问题,但实际上会出一个大bug——当电梯的请求队列和乘客都为空时,只依据waitTable的情况判断是否结束,而若此时输入线程也已结束,那么waitTable就也结束了,但如果waitTable中还有请求的话,电梯是不会结束的。那么问题来了,当调度器取走waitTable里的最后一个请求,离开waitTable的控制块,而又没把得到请求加入到电梯的请求队列中的时候(因为还需要判断),会发生什么呢?——此时waitTable为空,所有结束条件全部满足,电梯线程结束!可此时调度器还没把请求分给电梯......

而实际上这个问题也是由于仿照课上实验的代码而来的,如果是逻辑的腾挪的话,为什么这个地方还需要看waitTable呢?为什么不看processingQueue是否结束呢?这也就是问题的症结所在,由于电梯线程本身和waitTable就不直接关联,对它的判断当然会脱离电梯本身逻辑控制,更不用说还要拿到waitTable的锁了。因此这个地方在processingQueue中实现对结束的判断即可,具体实现也不再多说。

调度器设计

这次作业中调度器的设计比较简单,每次只从候乘列表中取一个请求,如果是添加电梯的请求,则新增一台电梯,如果是乘客的请求,则进行平均分配,虽然听起来比较朴素,但实践起来性能还是不错的。

与其他线程的交互也比较简单,一方面是作为输入-调度器关系中的消费者,另一方面是作为调度器-电梯关系中的生产者,进行任务的分派,这里也无需展开了。

第三次作业分析

架构设计及时序图

类图整体上和第二次作业没有太大的区别,唯一有些出入的是在PersonRequest类下继承了一个子类:

image

时序图的话,由于添加了换乘的需求,也多了一些变化和功能。

image

下面仔细讲一下变化的地方。首先是架构图中的Passenger,为什么要多这么一个类呢?事实上,根据官方包所给出的PersonRequest类,很难承载换乘的信息,因为只有起始楼层和目标楼层,如果电梯只得到这些信息的话,每个电梯就需要根据自身的属性、可停靠楼层对每一个乘客进行判断,这样就要实现三种电梯,虽然同样可以通过继承和多态来实现,但总归是要在逻辑的地方反复斟酌,因此干脆从PersonRequest类进行拓展,增加中转楼层这一属性,使电梯将中转楼层视为目标楼层,而由乘客自己判断,如果目标楼层和中转楼层一一致,则已到达目的地,如果不一致,则再加入到候乘列表当中,等待调度器的再分配;同时,由于是从PersonRequest继承而来,架构中参数的传递也不需要进行修改,只需在特定的地方使用其特性即可,这又一次使我感受到了面向对象设计的好处与魅力。

在时序图中主要是两点,一点是从电梯到候乘列表加入请求的需求,这是实现换乘所必须的;另一点是结束条件的变化,由于此时电梯也变成了生产者,候乘列表的结束就不再只受输入线程控制,还受到电梯运行的影响,因此借鉴操作系统中的PV操作,每当输入线程增加一个PersonRequest时,总请求数+1,而当电梯将一位乘客真正运送到目的地时,总请求数-1,然后通过输入线程的结束和总请求数同时判断候乘列表是否结束。

由于线程安全相较于第二次作业没有任何实质性的改动,这里也不再多说。
而以上均是为换乘做一些基础性的工作,真正换乘的核心还在于调度器的设计。

调度器设计

为了保留电梯原有的设计,同时考虑到之前调度器的功能过于轻松与单一,这次给它派了个重一点的活——保证分配给电梯的请求,电梯都能在这个请求的中转楼层停靠。这样就需要一些算法的支持,以下是设计的部分算法:

image

这实际上是一种静态分配的策略,也会简单根据当前电梯请求队列里的人数做一些调整,但不会有太大的变化。而当增加电梯时,分给一类电梯的请求会在这类电梯中进行平均分配。这个思路比较简单,也比较好实现,具体就不赘述了。

可拓展性分析

对于可拓展性,有几个方面我觉得可以总结:

  • 通过Passenger继承PersonRequest类实现了换乘的数据结构基础,但无需修改任何输入线程的代码,也无需修改调度器的核心分派逻辑,这可以视作开闭原则的一种实现。事实上,即使增加更多种类的请求,通过继承Request进行实现,可以达到相同的效果,这就能看出通过父类(即抽象)进行传递的好处,这也是符合依赖倒置原则的。
  • 由于中转楼层的设计,它可以实现不只一次换乘,因为每一次换乘时都可设置一次合适的中转楼层,直到最终达到目标楼层为止。所以核心还是在于调度器需要根据起始楼层与目标楼层的组合对中转楼层进行设定,但这个地方是比较面向过程的,如果楼层范围、电梯停靠楼层特性发生变化,这一部分是必须要改动的,因为这里没有一个抽象的算法过程,纯粹是通过手工设置而产生的,因而这里拓展性会差一些。
  • 不同的电梯和电梯的控制器都是通过工厂模式实现的,保证增加新电梯和新的调度模式时满足开闭原则,不会对原有的电梯和控制器进行修改,而是增加新的类实现
  • 由于控制器属于电梯的一个属性,因此电梯在运行时可以实现运行策略的转换,以适应在不同时间段条件的变化,达到切换模式的拓展需求。
  • 另外尽管分派的逻辑比较简单,电梯的运行也采取的是简单的look算法,但性能整体而言还是很不错的,我觉得这也是局部最优和整体最优一种奇妙的辩证关系吧。

程序bug分析

在讲三次作业设计的过程中,顺带提到了实现过程中所犯的一些错误,这里就主要说明一下在公测和互测中的一些问题。

第一次作业

Random模式的调度出现了问题,之前设置的是如果在同一层如果有人进或者有人出,电梯就在当前楼层停住,这样就导致如果有人进,但是电梯已经满了,电梯就在这一层完全停住,无法结束,而CPU又一直在运行,因而产生了CTLE的问题;所以改动也比较简单,改动逻辑之后便可通过。

第二次作业

是之前提到过的问题,由于电梯一直没有让出候乘列表的控制权,输入线程输入请求,因此电梯几乎一次只能运送一个人,导致超时。解决时可以缩小同步块的范围,不需要上锁的地方不加同步块即可。

第三次作业

这次就出了更令人心痛的问题——神仙数!在迭代的时候,由于电梯中关于载客量用的都是capacity,因此觉得拓展性很好,就没有在意,完全忘了在第一周写控制器的运行策略时,所有对电梯载客量的判断都用了6这个数字!!因为相隔时间太久,以致忘了这个问题,而中测又没有能够测出这一错误,最终在公测和互测中都被hack了好几个点,实在是可惜和遗憾。也是有了深刻的教训——不要再用神仙数了!

而关于找别人的bug,主要是构造一些极端一些的样例,因为也没有评测机,所以没有太多可以说的。

心得体会

由于是第一次接触多线程编程,从最开始对同步块、wait、notifyAll的懵懂,到现在基本能够掌握这些线程控制语句的基本逻辑和运行顺序,可以说多线程的能力还是得到了相当大的提高。同时也意识到,很多东西都需要自学、交流探讨得来,比如最开始用Runnable接口,但发现实现这个接口的类不能用start方法启动,查阅了很多资料后才发现需要把这个类通过Thread a = new Thread(实现Runnable的类)语句进行调用,然后通过a.start()来启动线程,还是需要不断探索的。

至于层次化设计,更是意识到了它的重要性。写第一次作业时,只是在心里想了一个大概的框架,然后就开始写,到最后发现实现不了,又要改,而且还是大量的改动,浪费了巨额的时间精力;而第二次作业首先花了两天时间把类图实实在在地画了出来,然后才开始动手写,不到半天就成功写完了,写的过程也比较流畅;到了第三次,架构上改动不大,但换乘的需求需要细细思考,因而就把换乘的策略、实现方式以及加电梯的方式都写了下来(第三次作业分析中的换乘策略就是在写代码之前写的),相当于一个设计文档,于是在实现的时候按步骤做即可,也完成得比较快,没花费太多时间。还是应了老师上课说的那句话——“设计上偷的懒,在实现过程中要花十倍的时间才能补回来”。

另一方面是测试,这是这一单元我做得很不够的地方,完全没有认识到测试的重要性,不仅不自己构造一些样例,甚至连前几次作业有的样例都没有进行测试,结果导致公测、互测中出了很多很多的bug,实在是令人警醒。

posted @ 2021-04-26 09:14  Aressfull  阅读(90)  评论(0)    收藏  举报