一、前言
第二单元的三次作业是很有特点的三次作业。多线程电梯的设计思路和前两次电梯作业迥然不同,导致我花费了大量的时间去重构之前的代码,使其适应多线程电梯的作业要求;文件监视器是一个独立的作业,不像电梯和出租车那样是一个系列,因此写起来没什么包袱,感觉并不困难;出租车调度和多线程电梯写起来感觉比较相似,但出租车几乎没有算法上的难度,因此主要的工作都花费在了如何构建一个好的设计上面。这三次作业之间看起来没有什么关联,但却环环相扣,一步一步加深着我对多线程编程的理解。
我对这三次作业的总体难度评价为:多线程电梯 > 出租车调度 >= 文件监视器。(这个难度基本上是根据我的熬夜时间来判断的)
之所以排出这样的难度顺序,是因为多线程电梯和出租车调度有着一个共同的难点,而这个难点是文件监视器所不具备的——程序的运行时间需要与这个世界的真实时间保持同步。这是这两次作业的一个大坑,也是我在好几个深夜里不睡觉而被迫面对着电脑屏幕的罪恶源泉。虽然多线程极大地增强了用户与程序交互的即时性,但是为了同时保证交互的即时性和逻辑正确性,编程者需要付出许多额外的努力和工作。
二、多线程电梯
电梯系列作业是让我写得很不爽的三次作业。第一次的傻瓜调度,我设计了一套我自认为十分精妙的判断同质的算法,从而几乎没有阻力地无伤通过了公测和互测;但到了第二次ALS调度,噩梦就开始了:我发现自己的傻瓜调度算法完全无法移植到ALS上面,因而不得已更换了算法,并大面积重构了程序;到了多线程电梯,我又一次痛苦地发现,之前的ALS调度算法与多线程电梯的即时输入是不相容的,只好被迫又一次地重构。三次作业,三套算法,三种设计,如果有一个人连续三次分配到这样的代码,恐怕他根本不会认为这三次作业都出自同一人之手。早知如此,我在第一次电梯作业就应该使用模拟爬楼的算法,这样就不会有后面这么多糟心事儿了。
抛开这些悲伤的过往不谈,我的多线程电梯采用了与老师总结课上PPT相似的设计:当一条请求输入进来之后,会被发送到一个总请求队列中。主调度器根据当前三部电梯的状况,把这条请求派发到合适的电梯中去。每一个电梯保有一个自己的小请求队列和小调度器,主调度器派发的请求进入某一部电梯的小请求队列之后,会由这部电梯的小调度器来判断是否需要进行捎带。这样设计的好处是,把判断同质的过程和判断捎带的过程分离,将一个大的调度器类拆成两个调度器类,从而减少调度器类的代码量。
这次作业遇到的一个难题是:怎样让电梯精确地满足"运行一层楼花费3.0s,开关门一次花费6.0s"。因为是第一次接触多线程编程,对sleep和wait的用法还不太熟悉,为了保证公测能够通过,我采用了模拟时间的方法,即输出的是所谓的"电梯系统时间",是假的、事先计算出来的,而非直接取自系统时间。在电梯运行的过程中,让电梯线程sleep三秒钟或者六秒钟,以使模拟时间和真实时间同步。当然,既然使用了这种方式,就势必面临着时间差的问题。我解决这个问题的方式是:在电梯线程的无限循环里面,每一次循环体开始的时候先获取一下当前的系统时间,到循环体的最后判断一下已经过去了多少毫秒,并从睡眠的时间中把这个数字减掉。通过使用这种方式,我的程序运行得还算精准,整体误差不会大到一个不可接受的程度,互测中很难被发现与此相关的Bug。
本次作业的经典OO度量情况如下:
可见多线程电梯的实际代码量并不多,只有1000行左右(这其中还包含了实质上并没有被用到的ALS调度器和傻瓜调度器)。但是由于第一次使用多线程编程,对run方法和临界区域还不太熟悉,导致在电梯线程里的代码嵌套层数过多,如上图中红字所示。
本次作业的类图如下:
从雷图中可以看出来,本次作业在设计上存在着过度封装的问题。为了满足同步控制的要求,我在电梯类之外创建了一个Elevators类,其中用数组将三个Elevator类的实例包含在里面,调度器只能与Elevators类进行交互,而不能直接访问某一部电梯。这样做看似合理,但实际上是完全没有必要的。过度的封装使代码变得丑陋和臃肿不堪,需要无数个getter和setter才能完成全部所需的操作,这毫无疑问对代码质量是有害的。此外,由于害怕线程安全问题,我对Elevators类中的几乎所有方法都使用了synchronized标识,这样做虽然增强了程序的线程安全性,却极大地损害了并发性,同时相当程度上降低了性能。这些都是在之后的作业中需要改进的地方。
本次作业的时序图如下:
这次作业的线程协作设计较为合理,主调度器将请求派发至各个电梯保有的小请求队列,并在内部进行捎带判断,这极大地减轻了主调度器的工作量。
三、文件监视器
文件监视器作业是我认为自己写的比较顺利的一次,各种功能都很完备,也没有被别人挑出什么Bug。我想一方面原因是,这次作业的指导书规定不够明确,Readme的作用被无限放大,导致任何事情只要在Readme里提一句,就可以让对方无法扣自己分。例如,设计者甚至可以强制要求测试者在两次文件操作之间加入间隔,这使得程序的算法难度几乎降为0,甚至失去意义。再者,指导书明确规定,两次文件扫描操作间隔内不允许对同一个文件实施两次或以上的修改,这也很大程度上让这次作业变得很水。
文件监视器的主要训练目标是让同学们能够做出一个线程安全的设计,但并没有强调对于性能的要求,这是我认为这次作业一个很大的不足。如果没有性能要求,设计者完全可以把所有的方法都加上同一个锁,这样就可以保证不会出现资源争夺的现象。但是这样做对学习是没有帮助的,甚至是有害的,我觉得在下一届的课程中,应该对文件监视器的性能有着更高的要求。
导致这次作业难度不大的另一方面原因是,文件监视器并没有时间上的要求,即程序的时间不需要与外部真实时间保持同步。因此,设计者可以采用各种手段使自己的程序满足指导书中规定的要求,即使这些手段是以性能的损失为代价的。总体来讲,文件监视器是一个很独特的作业,既不承上也不启下,大概可以算作是两次系列作业(电梯和出租车)之间的一个小插曲。
本次作业的经典OO度量如下:
从经典OO度量中可以看出,本次作业的代码规模控制得很好,只有752行,且各种方法调用的嵌套深度都保持在一个合适的范围内。图中的红色警告是main方法,这是因为我将记录Detail和Summary的线程以匿名内部类的方式直接写在了main方法里,所以导致块调用深度大于均值。
本次作业的类图如下:
文件监视器的设计难度并不大,各个模块之间的层次也比较清晰。我设置了一个Snapshot类不断捕获文件结构快照,并在其内部对新旧两次快照进行对比,从而判断是否有文件发生了变动。在数据结构方面,我选择了HashMap而非树形结构,因为对于此次作业的要求(不需要比对文件夹,只需要比对监控区下的所有文件)来讲,树形结构的性能并不是很好,远远比不上HashMap的效率。
本次作业的时序图如下:
可见程序整体的逻辑并不复杂,无非就是在一个无限循环中不断捕获快照并进行对比。
四、出租车调度
相比于文件监视器,出租车调度要难写得多。这个难写不在于其算法,而在于出租车的要求多且杂。最令人痛苦的一个要求是一辆车移动一格的时间必须严格保证为200ms,这几乎就直接限制了程序的时间方式,即必须采用模拟时间,然后让程序的sleep时间向模拟时间靠拢。为了解决这个问题,我采用了sleepUntil方法,即先计算出租车应该在什么时候到达,然后再让程序睡到那个时间。这样做虽然有一点点耍赖,但确实很好地完成了指导书中的要求。
这次作业是系列作业,因此需要一开始就打好一个设计的基础。但很可惜的是,我并没有完成这个任务,因为在这次作业快要截止的时候,我发现自己的程序无法很好地处理同时有很多个请求一起输入的情况。这个问题也在互测中被测我的大佬一下就挑了出来。究其原因,是因为我为每一个请求都开启了一个线程,并让其运行三秒钟后自行终止,这虽然非常符合真实的逻辑,但却不适用于程序本身。因为每一个请求线程都可能会改变出租车的状态,因此需要为这个请求线程中涉及到变更出租车状态的地方加锁,一旦请求变多,达到百条的量级,就会使得线程之间互相阻塞,后面的请求得不到执行。此外,由于用户可以自由输入请求,所以实际上程序的线程数是由用户控制的,这显然是一种极不安全也极不合理的设计。在进行下一次出租车作业之前,我会想办法解决这个问题,把线程数控制在一个自己可控的范围内。
本次作业的经典OO度量如下:
这次作业的代码量并不大,1473行是包含了GUI的统计,将GUI排除在外后,实际只有900行不到。但我仍然觉得程序在许多地方显得过于臃肿,请求队列类几乎形同虚设,出租车线程设计得也不够优雅。这些需要在重构的时候加以解决。
本次作业的类图如下:
在类设计中,几乎所有的数据操作都是围绕TaxiSet类展开的。TaxiSet包含了所有出租车的信息,请求线程只能访问到TaxiSet类,而不能直接对Taxi进行操作。这使得多个请求线程可以使用synchronized以保证不会出现数据冲突的情况。
本次作业的时序图如下:
TaxiDispatcher出租车派遣类是整个程序执行流程的核心。TaxiDispatcher就是我所说的只会运行3秒钟的线程,它会从请求队列中提取请求,并通知乘客出发点周围的出租车抢单,并最终决定调度哪一辆出租车为乘客服务。
五、Bug分析
我的程序在多线程电梯和出租车调度中各被报告了一个Bug,其中多线程电梯是由于忘记对某一块输入部分进行处理而导致的公测格式错误,出租车调度则是上文中提到的无法同时处理大量请求的错误。前者是由于粗心马虎和测试不周全而导致的Bug,后者则纯粹是由设计导致。值得注意的是出租车调度的Bug,它使我对程序内线程数量和程序性能的关系有了更深的理解。
多线程电梯的互测中,我找到别人的Bug主要集中在捎带的判断上。可能是由于模拟时间和真实时间的同步没有做好,有些应该判断为捎带的地方对方并没有判断成功。我想这种问题很难从代码层面直接挑出来,只有通过大量样例的测试才能发现。文件监视器的互测中,我主要通过阅读别人的代码发现了Bug。对方没有做好重命名时的多映射检测,也没有完成指导书中要求的继续监控移动后文件的任务,这些Bug都可以在仔细阅读代码以后直接找到。更深层次的原因是我在写程序的时候也遇到了这些问题,因此在互测的时候就会对它们格外关注。出租车调度的互测中,由于代码量较大,且直接从代码中找逻辑Bug相对困难,我采用了集中压力测试的方法,即一开始就让所有的出租车集中在地图的左上角,然后集中输入请求进行压力测试。通过这样的方法,一些隐蔽的Bug才能被发现。
多线程程序的代码逻辑相比单线程程序复杂很多,有时候直接阅读代码也难以找到其中的漏洞。这个时候,测试样例的广度覆盖和压力测试的深度覆盖就显得很有必要了。此外,找到别人Bug的另一个好方法是回顾自己的设计过程,细数自己在写代码的时候踩过哪些坑,然后再去看别人是否犯了相同的错误。
六、心得与体会
很多同学都将多线程称之为"玄学",我想这是有一定道理的。不同于单线程程序的完全可控,多线程程序在运行的过程中可能会出现许多难以预料的行为,甚至有些行为不可复现,但对程序却有着致命的影响。编程者该做的,不应该是想着如何回避甚至掩盖这些问题,而是应该努力地去暴露问题,并争取对其加以修复。
提高多线程程序的性能并不困难,保证多线程程序的线程安全也不困难,但要想同时做好这两点,就变得非常困难。在这三次的作业中,我遇到的几乎所有多线程问题都可以归根结底为一句话:如何在性能和安全之间做出取舍。程序的时间需要和真实时间保持一致,这是对性能的要求,然而为了兼顾多线程的安全性,编程者可能需要采取一些同步控制的方法,这其中的时间差势必会导致程序时间和真实时间的不同步。这三次作业中,我尝试了一些解决这个问题的方法,最终发现,将模拟时间和真实时间结合起来,先计算出程序应该运行的时间,然后再让它睡到那个时间,这种方式既省脑子,也省资源,还能确保程序运行的正确性。
除此之外,程序的架构需要有一个足够优秀的设计才能经得起需求变更的考验。在写代码之前,先花一两天的时间在纸上写写画画,大致勾勒出程序的框图;然后先不写方法的主体定义,只写方法名和返回值,用这些尚待完善的半成品方法和类的属性搭出一个程序;最后,为每个方法填上具体的内容,完成整个程序。这一套流程可以有效地检验程序设计是否合理,也一定程度上减轻了工作量。我在出租车调度作业中使用了这个方法,并取得了令我满意的成果。
多线程编程还是很有意思的,当看到出租车在GUI上动起来的那一刻,我的心中真的有一种巨大的成就感。希望接下来的三次作业也能像前两个单元一样顺利。