OO随笔之纠结的第二单元——多线程电梯
阅读原文时间:2022年05月03日阅读:1

主要任务就是写一个电梯模拟器,读入每一个人的请求然后让电梯把他们送到想去的地方。

从第一次到第三次作业,三次的主要任务都是相同的,但是每次都增加了很多的细节,每次的难度都逐步增长,电梯复杂度和瞎跑度都大大提高;同时我们也对于多线程的设计、写法、调试也逐渐有了心得和经验。

以综合性最强的第三次作业为例。它要求的电梯和一般的普通的常见的电梯,又有很多不同:首先这玩意是目标选层电梯,在进电梯之前就先选好楼层,然后坐等电梯送你去;然后这玩意开关门十分鬼畜,开门一瞬间(即使只有一条缝),我们的乘客都可以从这一条缝挤进电梯;再然后这个电梯好像是支持全自动的:假如A电梯能从1楼到3楼(但不到5楼),B电梯能从3楼到5楼(但不到1楼),这时候一个想从1楼道5楼的乘客,他只需要在1楼设置好楼层,然后进A电梯,随后我们的电梯系统能够自动完成换乘的事情,而根本不用乘客去考虑:我需要先到3楼然后摆摆腿出来换乘。这就好比你从沙河上了昌平线然后地铁上睡了一觉,原地醒来就到了知春路。

我们呢先不管这些逻辑上的不熟悉,来考虑一下电梯的设计。

设计一个电梯系统,包含三个电梯,名为A、B、C,他们的可停靠楼层、最大载客量、上下层速度都不一样。可停靠楼层如下图所示:

要求对于所有不同时输入的请求,都有相应的调度,能够在最短时间内送人到达目的地。

输入处理:利用官方给的阻塞式端口输入,然后判断这个请求能不能由一台电梯独立解决;不行则拆解成两个请求。比如从-3到3的请求,我就拆解成-3到1,再从1到3。然后将请求加入一个队列。

电梯调度:调度器为每个空闲的电梯分配一个合适的任务,让他去跑。那么什么叫“合适的任务”呢?就比如本题的C电梯,它的合适的请求就是出发楼层和到达楼层都在C电梯的可停靠楼层范围内的那些请求,比如1-3、5-15,但像2-4、6-14就不是了。当然对于一组有先后顺序的请求,比如一个原本为从-3到3的请求,被拆解成-3到1,再从1到3——在-3到3完成之前,1到3也不算一个“合适的”请求。

捎带和被捎带问题:设计一个楼层类Floor,用来记录在着一层楼等待的乘客。电梯每到一个可停靠楼层,我们就先把想要这一层楼离开电梯的兄弟给踢走,然后在着一层楼中先找到那些能捎带上的兄弟,把他们带上。如果更详细地再分析一下,这其实还包含着主请求的变更,比如你在1楼接了一个FROM-1-TO-3,然后在2楼顺了一个FROM-2-TO-5的兄弟;那么电梯到达了3楼之后,把第一个兄弟踢走之后需要把主请求修改为FROM-2-TO-5,好让电梯接着走。

输出:电梯每执行完一步操作就对应输出“arrived”,“open”,“close”等。

多线程的设计

设计五个线程:主线程(兼输入)、调度器线程、三个电梯线程。调度器与主线程之间通过线程安全的“队列”进行交互,调度器与电梯之间直接通过调用异步的方法进行通信。

重构前:对于每个请求,我都随机派一个电梯去起始楼层 x 接人。而这分为两种情况:若该电梯能到达 x ,开门就完事了;要是到不了,再把这个“去楼层 x 接人”的请求放回队列。假如成功接到了人,电梯也还要动脑子:这个人想去3楼,我能不能到3楼呢?哎呀,好像不行。那,我又该在几楼把他放下来呢?我是不是该通知C电梯,让给他帮我一手,把这兄弟送到目的地。

重构后:拆解请求,让前文“想”的这个过程交给调度器,把任务分解、分配给傻瓜电梯,这样电梯就不用动脑子去想我该在哪里把这个人放下来,而是简单机械地执行调度器给的请求就好了(对电梯和程序员都友好)。

重构是件很纠结的事情:本来写了快一千行,但是好像又不满意,bug越写越多;重构吧,又怕遇到新的问题白白浪费时间。

前两次作业没有问题,就是老老实实按照指导书算法写,正确性没有什么问题。

第三次作业嘛。重构一下,就出事了:因为最大楼层不知道为什么写成了19(显然20才对啊,真是蠢),然后导致第20层楼捎带的时候会爆炸,表现在“这个人莫名其妙地在19楼进了电梯”,所以就爆炸了。出现这个问题,确实应该怪自己,怪自己畏惧近千行代码没有敢去一行行看,去找逻辑错误;怪自己没有想到用随机生成数据去做覆盖性测试,不然这样的问题肯定能发现。

类图:

方法复杂度:

(仅显示复杂度较高的方法)

明显可以看出disassemble方法(也就是那个拆解一个请求为两个所用的方法),由于包含了大量的if-else语句导致设计复杂度很高,但是本质复杂度很低。

另外两个复杂度大头相信大家也差不多:线程的run()方法,好像没什么方法来做有效的优化。

illegalFromFloor()方法中,采用了很多类似于

if(A == true) return true;

这样丑陋的代码,导致本质复杂度很高。

lift()方法由于包含了很多与捎带有关的和与电梯状态的修改,但是写法又是基于简单的for-if,所以本质复杂度不高但是设计复杂度比较高,所以这也是容易产生bug的地方,需要程序员仔细检查代码逻辑。

类复杂度:

  • 有些方法(参考性能分析-方法复杂度)的本质复杂度较高,体现的是程序员对语言的的不够熟练。
  • 有些特殊情况不能正常退出。
  • 电梯和调度器有些功能的分离还不是很彻底,因为有一些方法到底是属于电梯好还是属于调度器好,我还没有定论。有少许违背单一责任原则(SRP)原则。
  • 对于扩展不是很方便,虽然利用了工厂来建立新电梯,使得第四台电梯的加入十分方便,但是假如有其他“反人类”的需求比如乘客1不喜欢A电梯非要坐B电梯,那么对于既有的代码不能够保持原封不动。有违单一责任原则(OCP)原则。
  • 没有类的父子关系。不违背里氏替换原则(LSP)原则。
  • 高层模块“调度器”对于不同的底层模块“电梯”都能够同样地执行。符合依赖倒置原则(DIP)原则。
  • 仅使用了Runnable一个接口。不违背分离原则(ISP)原则。

通过这三次作业,逐渐地了解了多线程设计的思想。从第一次作业的毫无头绪、仿照课件,到查阅锁的使用,再到研究设计模式和SOLID原则,自己的代shi码shan也逐渐地OO了起来。对我来说,几大难题分别是:多线程的同步控制和电梯逻辑的控制。对于多线程的控制是通过了大量的本地测试和根据例子进行仿照,后来才开始系统的了解锁的使用,确立哪些东西需要加锁哪些不需要,哪些需要保护哪些不需要;线程之间可以有哪几种通讯的方式。另外一个难点是多线程环境下对于每个线程的逻辑debug。我写的电梯逻辑一开始是充满了许多bug的,后来是通过”走一步print一步“的思想来肉眼跟踪找到bug的。业界有人认为:多线程是程序员在工作中遇到的最大的魔鬼。在这三次作业中,我也感受到了,因为多线程的bug隐藏的一般很深,有些bug产生的条件也比较苛刻,甚于充满偶然性。