Part 1 多线程间的协同和控制
限于OO博客格式,可以先去看看Part 3中的UML和Sequence Diagram再来看这一节
整体信号通讯
本单元作业,我统一采用了观察者模式(其实是套了观察者模式的皮的四不像,这点接下来会说到),借用了Java提供的Observable和Observer机制来实现。
Java的机制保证了对一个Observable对象,在我将一些Observer对象注册后,每次调用setChanged()
后再调用notifyObservers(Object o)
后,所有Observer(其实只有一个总的Scheduler)的update(Object o)
方法都会被调用。
在我的架构中,Observer只有一个,就是中心的总调度器,会接受:
- 输入线程的新请求
- 输入线程的终止信号
- 电梯线程的电梯空闲状态信号
- 电梯线程的换成乘客到中转站信号
为了区分不同Observable对象传来的不同信息,我在通知的时候加入了一个Notification类的对象来表明消息内容,该类内容大致如下:
public class Notification {
public enum NoteType {
INPUT, ELEV, TERMINATE, EXARRIVE, NEWELEV
}
private NoteType type;
private int id;
private Request req;
}
利用这样一个结构,可以装下所有的信息。特别是enum类型和使用,可以很好的提高代码可读性。
所有的信息都要被update方法分发并处理,这就造成update方法特别复杂,数据分析也证明了这一点。
具体分析各个线程,
- 输入线程,很简单,接受请求,在人员请求、电梯请求、输入结束时分别发送
INPUT, TERMINATE, NEWELEV
三种消息,每次收到新人员请求都会将其放在一个线程安全的容器内(这个容器其实我也选错了【捂脸】,我硬把一个队列当成数组用),而Notification中是不放东西的,而收到新电梯通知时,才会设置Notification中的Request引用。 - 电梯线程,电梯为空时发送
ELEV
类信息向Scheduler请求更多乘客;电梯将一位换乘者送到站后发送EXARRIVE
类信息告诉总调度器,让总调度器安排该乘客的下一步工作。
调度器判断输入结束且所有电梯任务结束,才会发送终止请求。而这个检测在每次电梯发出信号时和接收到输入终止时都会进行。而只要电梯结束运行,必然发送IDLE信号,所以理论上不存在无法结束的问题。
输入线程与调度器间的数据通信
绝大多数情况下,输入线程与调度器间的数据通信都通过ConcurrentLinkedDeque完成,在我的代码中,每次都是先把他转成List再操作,所以其实就是一个线程安全的容器(也许用Vector更好?)。
电梯线程与调度器间的数据通信
电梯的所有状态均存放在一个ElevInfo对象中,这个对象的引用同时被电梯进程和调度器持有,因此,任何对他的操作(包括电梯的运行和调度器的调度)都需要加锁。现在看来,我在这个结构里面存放的数据太多了,导致对他的锁定几乎会锁住一切活动。因此,这里可能是一个可分割的点。(补充:我又仔细想了一下,无论一个电梯的什么状态在改变,比方说上楼和总调度器塞了一个新信息进来同时发生,可能会是顺序执行利用新信息做出决策带来的收益大于快速开始上楼省下的一点点时间,这个怎么权衡)(P.P.S.如果放在一起收益是不确定的,但是数据之间关联度不大,放在一起不符合设计原则)
之所以设置ELEV信号,是为了负载均衡。无为而治(一个大共享队列)是一个很好的性能分提升办法,因为电梯之间可以选择最适合的乘客。而我的这种结构(集中式调度)想要做到这一点不仅颇为困难,而且还会严重加大耦合降低可维护性。我选择的折中办法是,让电梯在为空的时候才进行负载均衡。即使这样,代码的可维护性也降低了不少(怕不是“架构上大开倒车”哦)。
本人作业中真正的线程只有两类:输入线程和电梯线程。调度器去哪了呢?全局的调度器(负责在电梯间分配任务)只是一个对象,作为Observer监听八方来电(溜,而电梯内部控制电梯运行&接送任务等的调度器...由于我的错误设计(以及将错就错)和电梯混在了一起。
现在想来,我现在能想到的电梯、电梯内调度器和电梯信息的一个合适的架构应该是这样的:电梯负责楼层间的移动,由一个有限状态机实现;电梯内调度器是一个对象,负责开关门判断、上下人、方向的决定以及队列的管理,被电梯调用;电梯信息内仍然存放队列、楼层、等等几乎所有的信息,但是这些信息的锁要细粒度,调度器读取已经到站的换乘乘客时,电梯读取自己的运行时间、楼层等数据当然没必要阻塞。
Part 2 第三次作业 架构设计的可扩展性
可扩展性
本次作业可扩展性我做的一般。
电梯的各种参数都可以在创建时设定,我采用一个带属性的enum类型来表示电梯的类型,可以快速添加一种新的电梯(在当前框架内)。但是,不足之处也很明显。首先,我没有应用工厂模式,而是在构造时传入一个参数决定电梯的类型。此外,我并没有为电梯定义一个接口,这样在扩展时我坑会遇到麻烦。
调度器与算法的分离:不好,开始是将调度器算法电梯混为一谈,最后幡然悔悟(CheckStyle使人清醒.jpg,我居然奔着500行去了)把电梯决定方向的部分单独摘出来,但是仍然还是耦合较高。开关门判断、上下人、方向的决定,这些应该放在同一个部分内,且与电梯尽量分开。
SRP-Single Responsibility Principle
显然,这一点我做的不好,问题集中在电梯类上面,电梯这个类有着太多的职责,导致他的职责不太明确。就是说,模块化不够,应该进一步拆分对象。但是,方法的职责还算是满足了SRP。
此外,我过于追求:单电梯可以高效运行,让总调度器和电梯内调度器的职责划分不再明确。
OCP-Open Close Principle
这一点我做的还好,在课程范围内的修改不需要修改已有实现。然而,我的这种不需要修改更偏向于“过度设计”。也就是说,我不仅留出了扩展用的“口”,还对自己猜想到的扩展进行了一定的实现。这样其实是不好的。
结合这次作业和第一次作业,我对可拓展性有了新的认识。可拓展性不是说,你的电梯直接拿出来就可以在另一个不同需求下用,更不是说,如果我需要某某功能只需要传入一个参数;我的理解是,不论什么复杂情况,只要差异在一定范围(这个范围理应很大)内,电梯系统整体的架构不用改变,只用添加/更改新的电梯/调度算法。
LSP- Liskov Substitution Principle
怎么说呢,我就没有子类,LSP肯定满足啦(溜。
认真说,我的代码应该是可以实现这一点的,电梯对外暴露的其实也就是他的接口,因此用子类替换没有问题。
ISP-Interface Segregation Principle
没有接口(瘫软,不过如果有,我想应该可以做到(当然我第一次写的话那就不一定了)。
调度器可以分割成两个接口:1.选择方向操作和2.开关门、上下人相关操作
电梯提供一个接口:添加请求和结束运行
DIP-Dependency Inversion Principle
不好,我没有为电梯设置一个接口,也没有为调度器设置一个接口。不过对容器的读取都是通过List接口,姑且算是满足DIP。调度器和其他组分之间都是通过Observer/Observable传递信息,这样就可以在改动某些部分时不必考虑其他部分。
说到这,其实我的架构先天耦合很松,但硬是让我搞得高度耦合,原因就在于:总调度器可以直接读取分调度器/电梯内的一切数据。因为我认为总调度器只有知道所有电梯的信息才能更好的调度,这样就导致其实我电梯的ElevInfo是和所有元素共享的,这个结构一点都不能动。我知道这是个很糟糕的表现,但我目前没有好的办法。如果有同学有相关看法的话,希望可以在评论区,感激不尽!(跑题了,这应该是OCP和可拓展性的部分?)
P.S. 我觉得唯一能脱离这种困境的办法是无为而治,电梯自己从同一个队列抢人。但这样效率不一定有保证,只能算是一种绕开问题的方式,而且还会引入大量的同步问题。
Part 3 基于度量的程序结构分析
第一次作业
基本结构如图,MainClass启动调度器、电梯线程和输入线程,输入和电梯线程发生改变时,会发送消息给调度器,调度器做出相应反应。
时序图(不知道画的对不对)如图,控制器几乎所有对电梯的操作都是直接控制ElevInfo完成的。
依赖关系:总体来看比较好,没有循环依赖
复杂度:这次的复杂度集中在三个地方:电梯的arriveStat()
方法,电梯的run()
方法以及调度器的update()
方法。其中后两者存在相似的原因:run/update方法都是对任务进行一个分配。因为我的电梯采用有限状态机,所以run方法就是根据状态执行任务;而update方法同样有对不同类型的通知分派处理的代码,所以这两个方法复杂度高也算是有一定原因。
但是不可否认,我在编码时还产生了其他问题导致复杂度高。
- run方法中,我将睡眠时间作为返回值,在run方法中进行真实的睡眠操作。
应当将睡眠操作单独拆出一个函数改:应当直接在各个状态的处理方法内完成睡眠。 - run方法中,各个状态调用的函数是写死的,对可拓展性造成了不利影响。OO课程讨论区里菁神的帖子给了我一个很好的思路,采用接口的方式,每个状态都有一个handle函数统一调用,这样会更加优雅,可读性、可拓展性也会更好。
- update方法中,把分派和一些具体操作混为一谈,模块化不足。
arriveStat的问题就稍显复杂,因为他本身就是一个很复杂的状态。在状态机中,arriveStat是一个状态转换的交汇点。
- 应当把判断方向从arriveStat中分出去
- 应当把确定上下人从arriveStat中分出去
这样,arriveStat就只剩下确定下一个状态这一种职责,符合SRP原则了。
Method Name | Code Smell |
---|---|
update | Complex Method |
run | Complex Method |
run | Missing default |
arrivedStat | Complex Method |
Implementation smell也指出了这些问题
DesignCodeSmells中的问题是Unutilized Abstraction,可能是因为我在设计时的确留下了很大的优化空间(就是没有真的优化【悲】)。总调度器本来应该给一个电梯只分配最适合他的任务,电梯内调度器再根据这个准则完成任务。可惜写着写着就变成:先保证正确性,再加优化--->有效作业万岁!中测万岁! 了(溜
第二次作业
基本结构如图,Scheduler内维护一个电梯列表,在确定了向哪个电梯加入新请求后,操作就与HW5一致了。
新增加Person类是对PersonRequest的包装。HW5中直接使用的PersonRequest类,在处理电梯间问题时可能不太好用,因此在HW6我就对其进行了封装,以增加新的属性域。
时序图(不知道画的对不对)如图,控制器加入了新的功能:综合各电梯信息,选择最合适的电梯。同时,当一个电梯空了,控制器会从控制队列和其他电梯中拿出请求重新分配给这个电梯。
注意,这时电梯是在Main里面创建的,这样不好,应该交由Scheduler来规划。
依赖关系:仍然不错,没有红色
复杂度方面,HW5中的问题仍然存在,原因也与上次类似在此不再赘述,equals方法只能如此也不再赘述,主要看inoutStat()
和tryMove()
方法。
- inoutStat:管理进出人方法。我的设计是:开门-睡400ms-进出人-关门。进出人要和调度算法有关,这样才能获得更好的性能(sstf谁都接,look最好只接相同方向),就导致较为复杂。其实,进人和出人完全可以分开进行,把这个方法拆开,更符合SRP。
- tryMove:需要了解所有电梯的信息,然后把一个请求从一个电梯的等待接客队列取出并放入另一个。关于这个的修改我暂时没有想到好的解决方案,各位同学如果有什么想法可以在评论区指出,不胜感激!
这次ElevatorInfo给我报了Insufficient Modularization,的确,里面的内容是可以分开的,应该把电梯的状态信息和调度器状态分开。
第三次作业
基本结构如图。
本次新增了ElevType这一enum作为创建电梯的参数,规定了电梯的一些基本信息。PossibleFloor是个内部类,是为了能够初始化可到达楼层而使用的,如果各位有知道更好的方法希望可以在评论区说一声。我的部分代码如下:
public enum ElevType {
ATYPE("A", 400, 6), BTYPE("B", 500, 8),
CTYPE("C", 600, 7);
private String name;
private int [] availFloor;
private long moveSpeed;
private int maxPpl;
ElevType(String name, int moveSpeed, int maxPpl) {
this.name = name;
this.availFloor = PossibleFloor.getFloorList(name);
this.moveSpeed = moveSpeed;
this.maxPpl = maxPpl;
}
// Method: Getters
static class PossibleFloor {
private static final int [] AFloor = {-3, -2, -1, 1, 15, 16, 17, 18, 19, 20};
// B/C
public static int[] getFloorList(String s) {
if (s.equals("A")) {
return AFloor;
} /* B / C...*/
}
}
}
CcOutput是给TimableOutput加锁的Wrapper类,减少输出时序问题的发生。
时序图(不知道画的对不对)如图。乘客的请求进入控制器后,会检测能否直达。能直达就放入直达的电梯中最好的,否则就分割任务进行转乘(偷懒,静态转乘,只有1、5、15三层)。当一个转乘任务到达中转站时,电梯将其移入中转到站队列并向控制器发信号,控制器取出该请求并进一步分配。
依赖关系:还是比较好,没有循环依赖
部分存在问题的复杂度数据:
moveFromOtherElev就是HW6中的TryMove。这次把In、Out拆开处理,复杂度上好了一些。
Part 4 Bug分析---自身程序
三次作业中测、强测和互测并未发现Bug
在最终提交之前,我在程序中发现了一个很隐蔽的Bug,我甚至到现在都不确定是否的确存在此问题。我的调度器各个方法都加了synchronized关键字修饰和Lock锁定,然而,在对一个内部容器访问时出现了线程不安全导致的问题。经过大量排查,猜测可能是我Constructor未完成时就启动了电梯线程,而电梯线程启动后发现自身处于idle状态,向调度器发送信号请求乘客。此时Constructor正在修改容器,而收到信号导致update方法也被调用,触发bug。(注:这只是我的猜测,实践表明当我先处理完内部容器再启动线程时,Bug不再复现(P.P.S. Bug出现的检测应该足够精确,我采用300进程同时运行反复20次未重新出现,而修改前每三百次就会有约1-2次出现))
Part 5 Bug分析---他人程序
本单元测试,我大多采用自动评测技术,利用python的subprocess模块和多线程模块,构建多线程评测机以大量数据高负载评测,因此可以更好地检测问题。
互测时,我一般采用1000-3000条数据做初步检测,这时一些严重的Bug一般就会出现,同时我也可以得到大致的性能评分。这些Bug一般很容易就能在代码中发现问题。
然后,对于通过的代码采用300线程50次具有一定强度的测试,拉高系统负载的情况下,一些线程不安全的Bug就会出现(一般表现为RTLE)。
拿到数据后,用这一数据采用文件重定向的方式测试复现率及错误输出(同样是多线程)。如果是反复横跳,就去检测电梯的调度和方向选择;如果是无法结束,就去检查睡眠条件;如果是其他WA,对应检测乘客处理部分。
找到Bug后,相应的针对性构造数据以提高复现率(卡时间点等)。(其实效果并不是很好,本机上要卡到小数点后三位,但是评测机应该做了些机密的反向优化,让线程安全问题更容易发生,所以其实没卡的必要)
第五次作业:
- Archer:对电梯状态切换处理出现问题,导致出现
ARRIVE-0
的输出
第六次作业:
-
[UNHACKED Hash: b66e5835] Lancer:
在关门瞬间新增输入,会产生类似OPEN-CLOSE-IN的错误输出,原因在于出现了“Check and action”的竞争条件,未对门变量加锁。
[0.0]1 [1.2]1-FROM-1-TO-2 [2.412]2-FROM-2-TO-3
时间数据需要根据CPU速度等进行调整。
此Bug在互测中并未成功Hack。
-
Caster:进出人后到关门期间加入人,会被当成捎带请求加入队列。如果此时电梯为空,就会导致Bug。
-
Archer:线程安全问题,具体数据难以复现
-
[UNHACKED Hash: 9e815697] Berserker
Berserker的电梯有超时机制,5s未被分配就会强制分配。正常分配与强制分配在同一个for循环中,且不会跳出。设计数据,在5s超时时让该请求正好被正常分配,就会出现两次到站的情况。
[0.0]1 [1.1]21-FROM-1-TO-13 [1.7]14-FROM-1-TO--3
时间数据需要根据CPU速度等进行调整。
此Bug在互测中并未成功Hack。
第七次作业:
-
[UNHACKED Hash: afe48615] Caster:
Scheduler中isEmpty()方法前未加synchronized
-
Assassin:
RequestQueue findMinFrom算法有问题,导致可能会上下横跳卡死 -
Berserker:
线程不安全,导致主请求可能丢失。请求既可以由分调度器给,又可以由电梯去要,推测是其中出现了问题。
总的来说,这次的互测颇为玄学,一些Bug评测机上无法复现(这个倒正常,数据精度问题),然而一些我觉得没问题的人反而被Hack出了数千边测试也无法复现的Bug(甚至我交的某数据Hack没中想要的目标反而擦伤两个,绝了)。但是如果程序真的有Bug,中计的几率还是会很高的。
Part 6 心得体会
OO第二单元的代码和第一单元的感觉截然不同,并发程序设计里有数不清的大锅等着我们。但是如果把进程间的关系,同步搞清楚了,那么应该也就不会出太大的问题了。我是到处加锁的流派,但是我自认为这样是合适的,此处的并发目的并不是提高CPU利用率而是保证多程序同时运行,安全性的保障上更重要。同时,加锁范围内并没有大规模操作,CPU时间消耗也不是很大;此外,减少锁的方法可能需要牺牲部分性能。
我对锁的使用经历了:把synchronized当lock用---标准的lock,有点麻烦---该用synchronized结果还在用lock三个阶段。如果理解了原理,synchronized关键字也应该多加利用,合适的时候用可以大量减少代码量。
在此再次建议,先讲Lock,在讲synchronized关键字,这样才能更好理解,synchronized关键字只能算是一个语法糖性质的东西,要理解必须理解Lock。
我并没有设计好线程安全的类,而是逻辑上手动锁了相关代码块。这样更加灵活,但是牺牲了代码的可维护性,这不是一个好的做法。
关于性能分,我这三次作业的进步空间是一次比一次大(对,你没看错,是进步空间而不是进步,悲),我在架构上设计了大量机制可以允许请求在各个电梯间的重新分配,然而我最终并没有去实际利用这种机制。这可能就是过度设计的典型吧。怎么说呢,这个问题还是我对可拓展性的理解出现了偏差。真正的可拓展性一定不会是过度设计的,至少这一点可以肯定。
OO这个博客(鞭尸)单元当真设计的巧妙,现在看来自己的代码真是想把自己捶一顿(写的这是啥.jpb),但是或许这些思路也正是写完之后才能真正发现的呢?这也许就是瀑布型开发的理由吧。设计不可能一次性完美,在编写代码的过程中要及时重构(小范围、小幅度),这是我吸取到的教训之一。
代码质量分析要趁早,甚至可以说要在每一次Commit进行。为什么?如果我第五次作业看到我的度量数据,可能我当场就会去重新编写,修正一些问题。
OO课程的特点是,有效作业不难,想过强测,想拿更好的性能分,那就得数倍的努力了。很多同学都是在优化的过程中产生了问题。我还是觉得,正确性更加重要一些。
关于正确性,我一直在想一个问题,昂神曾经说过,对于一个程序,10000次正确也不一定能说明程序正确,而1次错误数据就能证明他出错。然而我一直在想,如果这个错误数据出现的概率真的是1/10000,甚至1/1e10,1/1e20这种数据呢?如果他的出现概率小到几乎不可能发生,远远小过了其他故障因素,我们是否可以认为他是没有问题的?这是不是一种鸵鸟算法呢?(P.S.其实也有可能是,一个错误数据代表了一类错误数据,这样发生的可能性就不如我们见到的那么小。然而同样的,如果这一类数据出现的概率也很小甚至不可能发现呢?能不能认为他是可以正常使用的?)
(最后小声嘟哝,我咋感觉UML图啥也没展示出来呢)
总之,OO是真的耗时间,但是收获也真的不小,希望接下来我能把收获的东西用出来,下次鞭尸博客时能少一点锤自己的冲动(。
限于水平,错漏之处在所难免,欢迎各位评论区指正。
(各位大佬各路神仙如果对我文中提到的疑惑有任何看法的话万望在评论区提点一下啊(卑