BUAA-OO-2022Spring Unit2 总结

Unit 2 Summary

题目简介

  • 第一次作业需要设计一个基本电梯系统。一个楼共有 ABCDE 五个座,每座有 10 层,各有一部纵向电梯。

  • 第二次作业在第一次的基础上增加了一种横向电梯。电梯只能横向或者纵向运行。请求中除乘客外,还有增加横纵向电梯的请求

  • 第三次作业在上一次的基础上主要增加换乘机制。输入不保证乘客可以只乘坐一次电梯就到达目的地。

锁和同步块

在多线程编程中,最容易遇到的 bug 是多个线程同时访问、修改共享对象。比如某个线程在删除共享队列中的元素时,另一个线程正在遍历此队列。因此,有些线程会获取到错误的信息,导致程序出错。而解决方法就是加锁。正如其名,“锁”保证了一类线程使用(访问)共享对象的时候,其他线程不能对共享对象进行操作。锁的种类和实现方法有很多,比如对象锁,有对某个方法或者代码块上锁两种方法;再如类锁,可以对静态方法或者指定锁对象为Class对象来实现。

同步块设置

  • 创立了一个线程安全类,称为 RequestQueue,可以实现安全地向其中增加、访问、取出请求。

  • 创立了一个安全输出类,称为 OutputThread,“封装”官方输出包,可以实现安全地输出。

  • 其余地方均没有也没必要同步

锁机制的选择

Java 提供了两种锁机制,一种是使用关键字 synchronized 修饰,另一种是 ReentrantLock 。前者由 JVM 实现,后者由 JDK 实现。在本次作业中所有的同步块的锁我均使用关键字 synchronized 实现,主要出于以下考虑:

  • 根据前期调研,Java 中对于 sychronized 有很多优化,性能上 synchronizedReentrantLock 大致相似

  • ReentrantLock 可中断、支持公平锁、可以绑定多个 Condition 对象等多个高级功能。但是经过我分析,三次作业都用不上。根据准时制生产方式(Just In Time, JIT),完全没必要使用 ReentrantLock

  • 根据前期调研,synchronized 机制是 JVM 实现的一种锁机制,JVM 原生的支持它;而 ReentrantLock 不是所有 JDK 版本都支持。而且 synchronized 不用担心没有释放锁而导致死锁的问题,因为 JVM 会确保锁的释放。

综上,三次作业我都选择了 synchronized 来作为锁的机制。

锁的粒度选择

锁的粒度是指某个锁的范围的大小。锁的范围可以达到将整个类锁上,当一个线程访问这个类时,其他线程不能访问;也可以小到一两条语句。锁的范围过大可能将本能多线程操作的过程单线程化,安全但是性能差;锁的范围过小可能很难保证原子性,安全性差。本次作业中所有的锁粒度我都设置为整个对象(即同步一个方法),在保证线程安全访问的情况下尽可能减少性能的损失。

public synchronized void func() {
    ...
}

易错点

在 coding 中,我发现了一些关于上锁、wait、notify的易错点,现整理如下。

  1. 输出:本次作业需要用到官方输出包。当有多个电梯运行时,每个电梯都会产生输出。这相当于多个线程向同一块地址(共享对象)中写东西,因此需要加锁。否则会出现输出时间不递减的 BUG。

  2. wait()wait() 方法会释放占有的对象锁,线程进入等待池。所以当这个线程调用这个方法的时候必须持有这个对象的锁。否则会有 IllegalMonitorStateException 的异常

  3. notifyAll():这个方法的功能是唤醒所有等待的线程。如果在共享对象的每个含有 synchronized 的方法中都加入这个方法(不能说这是 bug),则很容易导致 CPU 时间超时。正如这个方法的功能,应该在被唤醒的线程有可能改变状态的时候使用这个方法(比如说一个新请求加入到请求队列或者请求队列设置结束),如果判断请求队列为空的时候(isEmpty())都要把所有 wait 的线程唤醒,就颇有今人对于“怀民亦未寝”的谐谑解释的含义了。

调度器

经过和同学交流以及揣测助教的意思,我发现大家对于调度器、自由竞争……这些东西的定义和理解都不同。我说说我的定义:

  • 调度器:在我的实现中,我是做了一个所谓“调度器”的东西的。它的功能很简单——根据请求信息,将请求分发到合适的队列中——实现也很简单,不需要考虑队列的大小、电梯的速度、电梯容量这些东西。站在调度器的视角,只有和它交接的队列,至于队列那一头有没有电梯、有几个电梯、电梯啥状态都和它无关。

  • 自由竞争:在我的实现中,自由竞争策略是电梯访问它的等待队列(本座或者本楼),根据自己的状态自由选择去哪层,接哪个人(可能同时有很多电梯目标相同,朝着同一层移动)。

有人说,自由竞争好像就是“市场经济”,由电梯来满足需求,由需求这个“看不见的手”决定电梯的走向和系统的运行。

另一种实现方式是将决策做到极致,调度器可以看到所有电梯的所有状态,包括电梯容量、速度、楼层等,并且根据这些信息加权决定下一个请求应该在何时搭乘哪个电梯,从而准确地将请求分给这个电梯单独的队列。

我很不喜欢这种设计方式。其一,这相当于人为构造了一个“上帝”类,这个类全知全能,掌握所有信息、把握所有动向。这样做必然会导致类之间耦合度高,信息传递频繁。可能在这几次作业中不能体现,但是一旦工程的复杂度提高,这样做一定会导致代码中含有大量的 bug。其二,单单从性能上考虑——其实很容易发现,局部最优解不一定是全局最优解。输入的信息不能确定,就像我们的人生也不能完全确定。很多决策都需要在不完美、不如意的情况下确定,如果总是钻牛角尖,反而会陷入自我怀疑和内耗的陷阱。

调度器的结构图片

电梯

电梯是整个单元的核心部件。单独将其拿出来的目的主要是综述电梯内部的运行策略和算法。三次作业中,我只有第三次作业才使用了最标准的 look 算法,性能分也最高。前两次都是类 look 算法,性能分低一些。

程序设计架构

第一次作业

结构分析

这是本单元的第一次作业,我认为是三次作业中最重要的一次作业,整体时间跨度最长。在这次作业中,我应用了设计模式中“生产者-消费者模式”。如下图所示,输入线程是生产者,将输入信息“生产”成为一个个请求(产品),然后将请求放到 RequestQueue (Table)中;调度器(dispatcher)是这个生产消费关系中的消费者,它从 RequestQueue(Table)中取出请求(产品),进行之后的处理。同时,调度器也作为第二个生产消费关系中的生产者,它将获得的请求经过判断,分类地交给各个独立的 RequestQueue,完成生产环节;这一对关系中的消费者是 Elevator,它从 RequestQueue 中取出请求、进行处理,完成消费环节。

生产者消费者模式图片

在这里面调度线程同时作为生产者和消费者不太好想。因为一方面标准的生产者-消费者模型只有一层结构,所以我们要考虑清楚我们为什么需要“从 1 到 2”;另一方面,也要考虑清楚为什么不能 or 没必要“从 2 到多”。

思考后可以发现,电梯线程完全可以不经过 dispatcher,直接从 mainQueue(RequestQueue)中获取请求;或者将 dispatcher 的功能整合到 InputThread 中,在输入线程里完成请求的分类。两种实现方式都可以避免生产者-消费者模式“从 1 到 2”的变化。但是,如果电梯直接从 mainQueue 中获取请求,那么遍历的代价太大,不满足性能要求;如果将 dispatcher 的功能融合到 InputThread 中,又不是很优雅。因为理想中的结构中,每个类应该只有一种功能,就像荣老师所说的:“铁路警察,各管一段”。我也很赞同这样的想法,并且将其作为我三次作业的设计理念。

而且,经过如上的流程分解,可以看到,请求经过一次请求分类完全可以满足性能和设计理念的需求。于是就没有必要再增加结构和流程了,避免了“从 2 到多”的变化。

UML图

 

时序图

 

功能分析

在整个架构中,我尽量保证每个类的功能单一,互不干扰。

首先,输入部分应该单独拿出来,作为一个线程。并且应该和 MainClass 主线程有区别。因为 MainClass 是整个系统的开始,负责将系统中各个线程创建好,可以理解为操作系统中的bootloader。整个系统启动之后,主线程就应该结束,任由创建出来的线程独立的接收和处理请求。虽然请求中有创建电梯的请求(类似于 fork()),但是这也不属于 MainClass 的职责了,而属于某个输入线程的职责。

其次,调度部分应该单独拿出来,作为一个线程。这部分的功能如上,不再赘述。

最后,电梯运行部分应该单独拿出来,作为一个线程。请求的生命周期应该是“被接受(被分解)- 被分类 - 被处理”,电梯线程解决的是请求“被处理”的过程。本次作业中电梯使用 look 策略处理请求。具体内容如下:

  • 电梯的核心为一个 while 循环,内部功能如下

    • 电梯内部功能

    •  

    • 我在设计电梯功能的时候经历了一次重构。最初始的版本中,我还没能将电梯的功能抽象成这样独立的几步,而是堆在一起放在 run 中,这样导致代码很不美观。经过重构我的 run 方法只有 15 行。

  • 电梯状态转换(写给自己)

    电梯有两个主要状态:等待和运行。当电梯内部队列为空时,判断这座的共享队列是不是空的,如果是空的,则进入等待状态;当电梯处于等待状态时,如果共享队列请求到来或者共享队列被设定结束时,唤醒所有电梯,电梯进入运行状态。如果有请求,则去接请求,如果发现共享队列结束,则电梯结束。

  • 类 look 策略

    • 主要有两种情况。如果电梯内部队列不为空,则优先满足内部队列;如果电梯内部队列为空,则前往请求所在层数多的方向。

第二次作业

结构分析

这次作业增加了横向电梯,请求中新增了增加电梯的请求。设计上我仍然采用“生产者-消费者模式”。整体结构上相比第一次作业,只需要在第二级生产者-消费者关系中增加横向电梯的请求即可。

横线电梯的设计和纵向电梯类似,但是我没有将两者融合变成横纵均能通的“特种电梯”。这是助教不会在第三次作业中将电梯变成横纵均能实现,事实证明我运气还不错

输出部分设计成单例模式。这样设计避免了 OutputThread 作为参数传递到各个电梯中,减少了类之间的耦合。

遗憾:

  • 针对新增电梯的请求。我本想应用一下“工厂模式”,但是即便学习了“工厂模式”“抽象工厂模式”,也因为样例缺乏导致我没有完全理解这个模式,在尝试使用的过程中结构僵化,缺乏灵动性。没有发掘出工厂模式的优越之处,所以我最终也没有使用工厂模式。

  • 我没有设计策略类。在这三次作业中,我们完全可以不设计策略类,因为电梯只有一种模式。但是从长远来看,电梯完全有可能应用多个模式,使用不同的策略是完全有可能的,所以我的设计仍然缺乏可拓展性

UML图

相比于作业 5,仅新增了如下类

 

时序图

 

第三次作业

结构分析

这次作业中输入不保证请求可以通过只乘坐一次电梯到达目的地。因此需要对请求进行分段,请求的每一段都可以通过只乘坐一部电梯来完成,当一个请求的所有段都完成的时候,这个请求就完成了。于是,这部分涉及到如何给请求分段给请求分几段的问题

针对如何分段的问题,一个实现思路是请求内部维护一个队列,请求输入经过分段之后(确定横向换乘楼层就遍历最短路就行了),就变成了不同的请求保存在队列中。每次完成一个请求,就从队列中取出一个新的请求,再根据请求的状态进行分类即可。

针对分成几段的问题,其实最简单的思路是就分成三段“纵-横-纵”。核心思路是保证换乘次数越少越好,我们在出行的时候,除非换乘有明显优势或者不得不换乘,一般都不会换乘吧。一方面是换乘麻烦,不好实现;另一方面是换乘可能导致请求等待电梯的时间无法确定,确定换乘与否的权重标准高低不好确定当然如果使用数学建模的方法确定换乘权重,也行。这让我想起来操作系统王雷老师说的:有时候最经典的算法造成的性能损失并不是完全不能接受(有可能根本就没有损失,反而复杂的算法容易做出来“负优化”),而基础经典算法带来的架构稳定性是你无法想象的

遗憾:

  • 由于偷懒,没有将请求进行封装。在对请求分段的时候,不应该直接在请求类中维护分段队列,而应该封装一个新的类,新类内部维护请求队列,对外暴露队首请求的状态

UML图

时序图

 

This color for HW5 && This color for HW6 && This color for HW7

针对三次作业,我使用不同颜色进行区分。可以看到 HW5 的内容对于三次作业有决定性的建设作用,HW6 和 HW7 在此基础上做迭代和增量开发。

自测互测 bug 分析

强测 && 互测:三次强测互测中没有被测出任何 bug。

本地测试情况:本单元我使用的是其他同学搭建的数据生成器和结果检查机,我只做了搭建测评机,进行高强度的黑盒测试。本地测试的bug主要有

  • 电梯在两层楼间横跳(hw5):主要原因是电梯设定下一个运动方向时选择人数多的那一层。

我实现的电梯,没有出现过多线程相关的bug(轮询,不安全的访问等)。本单元中出现的bug只有电梯策略导致的bug。事实上,在本地强测时也是这种情况。我认为这和我在开启电梯月前学习多线程内容和积极实践(包括积极重构)有很大的联系。

他人 bug 分析

第一次作业我发现了同学两个bug,主要是因为访问了线程不安全类;还有没有使用线程安全输出

第二次作业发现了同学的bug,但是无法进行测评。bug 原因是多个线程(多于 3 个)访问同一个共享对象是没有做到线程安全。但是由于数据限制,无法提交测评

第三次作业我发现同学的一个bug,原因是本次横向电梯判断 wait 的条件需要进行更改。我的特殊数据使得一位同学 ctle。

心得体会

在本单元我完整的体会到了多线程开发,学习了如何维护线程安全。

  1. 在设计模式上。对于“生产者-消费者模式”,我发现了它的灵活性。可以由一个生产者对应一个消费者(输入线程和调度器之间的关系);可以有一个生产者对应多个消费者(调度器和电梯之间的关系);甚至一个线程可以同时作为生产者和消费者(调度器)。对于单例模式,我将其应用到 OutputThreadRequestCounter 中,减少了实例的传递,有效减少复杂度。

  1. 线程安全问题很重要,需要注意但是没有必要特别注意它。设计的原则是只在共享对象内部设置同步块、方法按需设置同步块。在遇到多线程工程时,我们首先需要在层次化设计和结构上保证访问共享对象的线程是可控的,然后再进行对于线程安全类的集中管理。遇到线程安全问题,不能奉行“修修补补,亡羊补牢”的策略,不能哪里有问题就在哪里加锁。如果出现在设计上无法解释的线程安全问题,就要及时重构

  1. 关于重构,在本单元的作业中,我不像 Unit 1 一样抵触重构了。相反,我认为重构是一个让我们充分理解我们代码和整体结构的有效手段。hw5中我写完第一版后积极进行重构,有效缩减 Elevator 类 run 方法的行数,将电梯运行过程合理抽象整合。这个过程保证了我这三次作业电梯策略没有出现任何bug。另一个重构成果是将策略从共享对象(RequestQueue)中抽离,放到电梯类中何时的位置上,这个过程保证了我三次作业中访问共享对象没有出现任何问题。

  2. 关于性能,在本单元的作业中,强测分数分别是 95、95、99。第三次性能分显著提高的原因是我使用了标准 look 算法。首先需要说明的一点:不存在完美的算法或者策略。针对现有写出来的策略,只要我想,我就能构造出针对性的数据。这意味着两种不同的算法必然有的点我快,有的点你快。但是性能分的计算是偏向大多数人的,这意味着,如果某个点我快,但是大多数人都慢,虽然我能得到100,但是大多数人不会太低;反之如果某个点,我稍慢于大多数人,则我的性能分显著低于平均分,导致性能显著低。(这是我自我安慰的一种借口)但是性能的好坏本来就是随着数据点的不同而不同的。希望课程组可以考虑一下这种因素。

  3. 关于互测。我认为本单元的互测强度不够,我所理解的本单元的训练目标是以写出线程安全的结构优先的。但是互测的时候由于线程少,线程切换导致的bug难以触发,课下能测出的问题,不能交到测评机(导致不能督促同学debug不能挣分,有些郁闷)。

  4. 最后恬不知耻地纪念一下这个电梯月

posted on 2022-04-29 21:31  Zhang_kg  阅读(175)  评论(1编辑  收藏  举报