面向对象第二单元总结回顾

面向对象第二单元总结回顾

一、写在前面

本单元要求使用多线程实现电梯调度,响应乘客的移动请求。

基于五楼座-十楼层的场景,在三次迭代开发中,电梯系统逐渐能完成:接送上下移动的乘客、接送横向移动的乘客、接送任意移动的乘客、动态增加电梯、定制电梯等功能。

本单元的核心是多线程,输入输出、电梯调度和电梯运行由不同的线程执行。由于这是笔者首次编写多线程程序,线程安全问题成为了首要难题。此外,电梯调度策略也在实际写代码时造成了不少困扰。 正因这一单元如此具有挑战性,当真正写出自己满意的电梯时,成就感也是无与伦比的。

下文中,笔者将探讨本单元的一些核心问题。

二、线程安全问题的解决

本单元最最最重要的问题——线程安全

线程与线程之间往往需要共享数据,这些共享数据在读写时可能出现风险。两种导致安全问题的经典计算模式是Read-modify-writecheck-then-act。假如多个线程不加控制地使用共享数据,可能产生预期之外的行为。

为了解决线程安全问题,需要使用同步和锁。同步是通过synchronized标记,使程序运行时,同一时刻至多只有一个线程能拿到共享数据的“锁”。而只有这个拿到锁的线程能访问该共享数据,其他的线程都将被阻塞。除了synchronized,课程中还介绍了读写锁等更高效的方法。

笔者在完成本单元作业时,采用的正是synchronized解决线程安全问题。根据不同的需求,分别使用synchronized标记了代码块或类方法。使用synchronized解决线程安全问题简单有效。当某段程序涉及共享数据的访问,且可能存在其他程序同时操作该共享数据时,就使用synchronized标记。以下面一段代码举例:

本函数位于一个同步数据类中,该类具有类型为ArrayList成员in。本函数将修改in。为了避免其他线程同时读或写该成员,就需要本函数加上synchronize标记。

亲身体会表明,尽管synchronized在性能上不如读写锁等方法,但它简单、有效,对于刚编写多线程程序的人来说是非常友好的。

三、调度器的设计

本单元的第二个核心问题在于调度器的设计。一方面,调度器作为连接输入类和电梯类的中间环节,其是否作为线程,以及如何与其他线程交互,是有待商榷的。另一方面,调度器具体以什么策略将请求分配给电梯,很大程度上影响电梯的性能。

1、调度器与其他线程交互

笔者在设计中,将调度器作为单独的线程实现。这样设计可以使程序结构更加清晰:一方面,调度器线程与输入线程通过共享数据——输入请求队列WaitQueue交互;另一方面,调度器线程与每一个电梯线程通过共享数据——电梯待执行队列ProcessQueue交互。

2、调度器的调度策略

根据调度器与其他线程的交互方式,笔者的调度器需要负责将输入的各类请求分配给不同的电梯,更适合的称呼是“分配器”(Distributor)。同时,笔者在多电梯调度时采取了调度器直接分配的方式,而不是许多同学采用的“自由竞争”策略。在作业的迭代过程中,调度器的调度策略不断完善。

第一次作业中,由于每个楼座恒定只有一个电梯,因此调度器并不需要复杂的调度策略,直接根据请求的出发楼座和达到楼座,分配给相应电梯即可。

第二次作业中,出现了横向电梯,且每个楼座、每个楼层可以有多个电梯。首先,调度器需要根据请求信息,判断将请求分配给纵向电梯还是横向电梯。接着,调度器分析该楼座/楼层的每个电梯,判断该电梯的请求队列中,有多少请求与待分配请求同向且路线重合,由此判断电梯至少需要多少次往返才能携带该请求。最终,将请求分配给往返次数最少的电梯。

第三次作业中,出现了换乘请求,调度器根据各个楼座和楼层的电梯信息,分析乘客如何换乘。在实际实现中,采取了“换乘次数尽量少、换乘路线尽量短”的比较策略,实现换乘路线规划。

四、线程协同的架构设计

三次作业后,程序的UML类图如下:

图中,第一次作业的类位于绿框中,第二、三次作业新增的类分别位于红框蓝框中。

各个类的功能如下表所示:

功能
MainClass 程序入口类,用于创建其他线程并启动。
InputThread 输入线程类,存储各类请求。
Distributor 调度线程类,处理电梯增加请求,并将乘客请求分配给电梯。
Elevator 电梯线程类,管理电梯移动,开关门状态等信息。
OutputThread 输出线程类,保证线程安全地输出电梯和乘客信息。
RequestQueue 请求队列父类。
WaitQueue 等待队列类,继承RequestQueue类,是InputThread类和Distributor类的共享数据。
ProcessQueue 待处理队列类,继承RequestQueue类,是Distributor类和Elevator类的共享数据。
Strategy 纵向电梯策略类,指导Elevator类移动和接送。
StrategyH 横向电梯策略类,指导Elevator类移动和接送。
ChangeRequest 换乘请求类,继承PersonRequest类,包含换乘请求的特殊类方法。

三次作业后,程序的UML协作图如下:

本程序基本上采取的是生产者-消费者模式,InputThread——Distributor形成一对生产者——消费者,Distributor——Elevator形成另一对生产者——消费者;生产者负责接受请求,并将请求放置到请求队列,消费者负责从请求队列中取出请求并处理。在第三次作业中,为了处理换乘请求,程序简单地借鉴了流水线模式,电梯根据自身接送能力,处理换乘请求的一部分,并在处理完后把剩余部分交还给Distributor类。

简要分析程序的拓展能力:

  1. 由于电梯策略单独封装成了类,而电梯仅根据策略判断如何移动和开关门。因此,假如之后有特殊的调度请求,需要更换策略,则仅需要修改策略类。
  2. 多电梯请求分配由Distributor类独立完成,同样,如果需要修改多电梯分配策略,只需要修改Distributor类。
  3. 以上两点共同反映了这样的设计思路:本程序的电梯是一种“傻瓜式电梯“,其既不用管自身应该去完成哪些请求(这一步Distributor类已经完成了,交给电梯的所有请求电梯都应该完成),也仅需根据Strategy类或StrategyH类提供的接送清单决定自身如何开关门和移动。

五、Bug分析和互测方式

笔者采用了评测机测试自己和其他同学的代码,测出了许多自己的Bug。

1、Bug分析

(1)第一次作业

第一次作业出现了轮询的问题。为了找到出现轮询的位置,采用了最简略的Debug方式,即在各个线程的run()函数中添加输出语句,检查哪个线程在持续输出信息。

通过输出信息的方法,很快发现电梯进程在持续输出信息。经过检查,发现电梯进程并没有设置wait()方法,由此导致其持续占用CPU资源。为了修复这一问题,在ProcessQueue类增加函数needSolve(),该函数由Elevator类调用,查询是否有需要相应的请求,若没有就wait()。该函数如下图所示:

(2)第二次作业

第二次作业出现了许多小细节导致的Bug。以其中一个影响较为严重的Bug为例(该Bug导致强测错了两个点):为了使电梯更高效地完成待执行队列的请求,每个电梯都带有一个策略类。该类会读取当前未进入电梯的乘客,选择其中的一些进入takeWho集合,表示这些乘客优先接送。遗憾的是,在实现过程中,由于笔者的粗心,部分已进入电梯的乘客仍可能被列入takeWho集合,导致程序出错。

(3)第三次作业

第三次作业出现了两个Bug。其中,比较值得探讨的一个Bug与横向电梯调度策略有关(该Bug导致强测错了一个点)。

为了简化横向调度,笔者设置了如下策略:当电梯里没有人的时候,电梯总是优先响应最近的请求。该策略会导致如下问题:假设初始情况下电梯在A座,每隔一段时间有少量从B座前往A座的请求,有大量从C座前往A座的请求。依照笔者策略,可能导致电梯始终只响应B座请求,吞吐量大大降低。

经过分析,笔者发现应该让电梯优先响应最远的请求。这种策略的缺点是电梯在当下需要移动更远,但优点在于电梯总是能最大载客(因为从最远的请求出发,可以捎带最多的乘客),其稳定性远高于原有的策略。

2、互测方式

三次作业主要采用评测机测试其他同学的代码。评测机主要分为生成测试数据,运行Jar包,验证结果三个部分。以下主要分析测试数据的生成策略。

评测机生成的数据较集中地针对少量楼座/楼层,以此扩大潜在的策略漏洞。针对性地测试少量楼座/楼层具有以下效果:

  1. 如果程序有低级漏洞,无论是集中测试还是广泛测试,都能测出程序漏洞;
  2. 集中测试能以更少的测试数据量,检测程序的性能是否满足要求;
  3. 线程安全问题方面,在笔者的架构下,电梯与电梯之间并不具有共享数据,因此无论十几种测试还是广泛测试,在线程安全问题上都有同样的测试效果。

此外,评测机生成的数据投送时间相对集中,这样更容易发现线程安全问题。集中投送使得大量线程同时开始运作,线程安全问题更容易暴露出来。

与第一单元的测试比较,个人认为本单元测试有以下区别:

  1. 数据生成模式的变化。第一单元的数据生成,主要参考形式化表述;而本单元的数据生成,主要参考空间(出发地点与到达地点)、时间(请求到达时间)、数据间关联(集中针对少量楼层/楼座)。
  2. 数据生成策略的变化。因为第一单元可以理解为只有一组数据,而第二单元涉及多组数据,存在数据间的关联。因此,第一单元的随机生成策略不再适用于第二单元。分析和实践表明,集中的、具有针对性的数据生成拥有更好的效果。

六、心得体会

本单元最大的收获是接触并使用了多线程。在这个过程中,”线程安全“无疑是最让人印象深刻的概念。现在仍能记得,将第一次作业写完的代码提交评测,收到10个CTLE时,整个人又震惊又好笑又无奈的心情。从最开始的茫然不知所措,到后来看到CTLE时沉着地Debug,笔者逐渐掌握了”线程安全“的处理方法。笔者有关线程安全问题的心得体会如下:

  1. 设计多线程程序时,最首要的是,确定哪些数据由哪些线程共享。
  2. 几乎每个线程都需要有wait()sleep()方法,在等待数据时释放CPU资源
  3. 程序设计之初,如果尚未厘清哪些程序段需要设置锁,用synchronize大量标记是一种简单而有效的方法。不过考虑到程序性能,后期仍需去除不必要的标记,或使用读写锁等更好的方法。

本单元同样涉及层次化设计,笔者在完成作业时也有一些“特殊的”体会。从第二次作业开始,由于笔者的时间安排原因,导致每次OO作业只有1天半时间完成。在时间的压迫下,笔者采用了最粗糙的程序完成方式,完全不再使用继承等方法设计代码。例如,在上方类图中可以看到,Strategy类和StrategyH类有极大的相似之处,设计一个父类可以使代码干净易懂,然而由于时间原因并没有这么做。

随后,笔者深深体会到了这种设计的代价。仍以StrategyStrategyH类为例:没有继承意味着不能使用多态,则Elevator类每次调用策略类的方法时,都需要用条件判断,写两遍程序,例如下图所示。由此,程序代码量大幅增加,观感极差。同时,这样的程序也是极难维护的。

总之,电梯作业非常具有挑战性,笔者从中收获了许多,也体会到了完成作业后的成就感。第三单元继续努力!

posted @ 2022-04-30 12:32  深夜竞走的KFC  阅读(46)  评论(1编辑  收藏  举报