(1) 线程安全策略分析
从多线程的协同和同步控制方面,分析和总结自己三次作业的设计策略。
第五次作业
第五次作业的内容是单部多线程可捎带电梯,支持人员上下、开关门。
最开始做第五次作业的时候,脑子里对于多线程程序设计的概念并不明显,只是从课堂上听到一些有关共享变量、synchronized方法的应用,没有理解其中的具体内涵。所以设计策略是一个脑洞:电梯一定能够扩展成多部,所以有一个主调度器;每一个电梯要维护自己的队列和状态,故分别用“灵魂”ElevatorSlave和“状态”ElevatorState两个类辅助电梯进行相应的操作。
使用一个主类MainClass进行输入请求和开始电梯、输出进程,当主类得到请求时,构造一个任务Task,当请求为结束请求时发送特殊任务END,否则发送普通Task给主调度器Manager的队列中。
Manager只负责两个事:分发任务和停止电梯。这里的请求队列需要保证线程安全,当时什么都不会的自己就搜到了个LinkedBlockingQueue,这是一个可以保证在对这个队列进行操作(如add,take)时不会发生错误的队列。从队首取得一个任务并分发给电梯,当得到特殊任务END时,发出结束标识,通知各个电梯(就一个)后续没有任务了(同样通过发送END任务)。
Elevator类的任务也很简单:从“灵魂”中getNextTask得到一个任务,当任务为END时停止电梯(如果没关门,那么关门),否则根据该任务更新电梯状态(开关门、目标楼层)并输出对应信息(上下楼、接送人)。这里预留了可到达楼层列表canReach,最大容量capacity、运行一层楼的时间moveTime、开门时间openTime、关门时间closeTime,为后续迭代提供空间。
对于ElevatorSlave类,要实现的功能就是输入Task存储,并在合适的时机返回一个合适的Task,这就是电梯自身的调度了。在这次作业中,我使用的算法是look算法,用两个堆分别表示当前电梯层数以上任务以及当前电梯层数以下的任务,堆的key值设为(如果没进电梯则是src,否则是des),并存一个当前堆(即当前是在往上走还是往下),并设定对应的更新函数,在此不多赘述。
最核心的多线程协同和同步控制就在这一个类中。
最开始一直不明白如何才能保证线程的安全性,因为synchronized关键字只能覆盖住一个方法、类,却难以对某个特定的对象的访问进行限制,也难以针对性的唤醒某一个方法。
当时,我在搜索到ReentrantLock并莫名其妙的解决了问题之后,在讨论区发表了下列看法:
正常情况下,我们使用wait、notifyAll、notify和synchronized关键字结合实现等待通知模型,但这样的模型对于唤醒是随机的/所有的,并不能唤醒指定的线程。比如电梯分配一个任务显然要在找到这个任务之前,这是一个有优先级的过程,否则处理不当就可能造成死锁。使用ReentrantLock,再借助于Condition就可以实现唤醒指定线程。
当晚,在仔细地参考了网上各种博客以及书籍之后,我逐渐明白,其实synchronized只是一个“进入拿锁,出门还锁”的简略形式,而真正便于使用和思考的其实是Lock、Condition以及对应的await和signal方法。
在此,我向课程组建议:先学习Lock和Condition,后学习synchronized,可能会大大提高我们理解的效率,而非反之。
在这里不复制讨论内容了,放一下讨论区截图,方便读者阅读:
至此,我已经将这一单元所需要用到的多线程的协同和同步控制理解透彻、并应用到一个简单的单部电梯上了。
第六次作业
第六次作业和第五次作业相差无几,由于我在第五次作业中预留了很多接口,第六次作业对于原先的架构几乎完全没有做改动,只是根据新电梯的特性(限制容量,多电梯)的要求修改了ElevatorSlave和Manager中的调度方法,原来的两个堆失效,转而使用waitingPassengers和loadedPassengers两个队列进行判断,具体算法如下:
当Manager发放任务的时候,分发到所有电梯waitingPassengers队列中,每一个电梯根据自己的需求“抢”任务,当一个电梯抢到任务后,标记该Task为taken态,且takenId为该电梯id,并在获取任务的时候调用updateLoaded方法除去被其他电梯抢占到的任务。
由于调度方法全新,相应线程安全方面也做了巨大的更改。首先是抢任务的过程需要对同一个所有电梯都共享的变量Task进行操作,所以在对应的方法中使用了类锁。其次是对于上锁时机的把握,以及确保共享变量Task在各种情况下都不会被误判。另外还进行了一些别的细节调整(比如锁进行上锁的时机,再比如Condition的名称,改为getNextTaskCondition,更加明确这个条件变量等待的原因)
还有一个小地方,就是输出的线程安全问题。由于这次可以多个电梯同时输出,可能会产生一些问题(比如先输出的时间大)难以处理,所以就加入使用了安全的输出类Output,通过锁进行控制。
另外,猜测下次作业可能会限制电梯可运行层数,并动态调整可运行层数,故新建了一个Info类,实现了基于floyd算法的task安排策略,暂且没有什么用。
第七次作业
猜对了一半,确实有限制层数,但没有动态调整。但依旧是可以用的。
所以架构就没有什么修改的地方,Manager中采取了一类电梯一个ArrayList的存储方式,对于这一类电梯使用第六次作业中的抢占式算法,分配给全部对应电梯并让电梯进行抢占。其中,通过上一次作业结束时floyd算法的的引入,Task在新建的时候就已经定好被哪些电梯承载了,即用一个列表表示这个任务的子任务分别需要的“电梯类型”、“起始地址”、“结束地址”。
这里涉及到严重的线程安全问题,因为一个Task会在电梯中被反复改变状态,对于有需要换乘的任务还要发送给主调度器重新发配任务。故对Task的锁进行了大量的修改,由于是多线程协作完成任务,对于这一个共享变量的上锁的位置和死锁的分析是重中之重。
对于主调度器Manager,由于共享变量elevatorsMap的加入,任务分配和加入电梯共享这一个变量,故加入锁进行控制。同时,由于任务是可以增加子任务下标并放回主调度器进行发配的,故putTask函数也进行了synchronized处理。
(2) 扩展性
从功能设计和性能设计的平衡方面,分析和总结自己第三次作业架构设计的可扩展性。
从设计原则检查角度,检查自己的设计,并按照SOLID列出所存在的问题。
设计模式的六大原则有:
Single Responsibility Principle:单一职责原则
Open Closed Principle:开闭原则
Liskov Substitution Principle:里氏替换原则
Law of Demeter:迪米特法则
Interface Segregation Principle:接口隔离原则
Dependence Inversion Principle:依赖倒置原则
我将依次分析第七次作业中这六大原则的实现与问题。
单一职责原则
一个类应该只有一个发生变化的原因。
每个类只需要负责自己的那部分,类的复杂度就会降低。如果职责划分得很清楚,那么代码维护起来也更加容易。相较于完成地复杂度极高的第一单元作业,第二单元的每次作业我都十分重视这一个原则:电梯类Elevator就只负责从本电梯调度器ElevatorSlave取出任务Task,并根据任务更新电梯状态ElevatorState;主调度器Manager只负责从输入MainClass接收输入,并将任务发派给各个电梯或让电梯停止;电梯调度器只负责调度算法,返回给电梯下一个任务。这样极小的降低了出错的概率,并极大地加快了debug定位错误的速度。我认为,在这个方面,第七次作业做的是比较不错的。
开闭原则
一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭
第七次作业,相较于前两次作业,最大的修改就是调度,除了调度器,每一个模块都是几乎对修改关闭的,而对于扩展是开放的。尽管如此,在严格意义上,我的“电梯”类仍然没有达到对修改关闭的要求,在修改调度方法的同时也修改了电梯更新状态的方式;而Task类没有预先设定好它的戏份,导致对其进行了大量的修改。在以后的面向对象程序设计中,在新建一个模块时,我应当更加清楚的定位这个模块的作用,尽量满足开闭原则。
里氏替换原则
所有引用基类的地方必须能透明地使用其子类的对象。
这次没有涉及到继承(线程用的Runnable),故暂且不提。
迪米特法则
只与你的直接朋友交谈,不跟“陌生人”说话。
如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。在本次作业中,我的主调度器Manager和电梯调度器ElevatorSlave充分的体现了这一法则,他们两个之间并不存在联系,在进行putTask操作时,通过与他们都相连的Elevator进行转发,降低了耦合度,提高了模块独立性,在确保高内聚和低耦合的同时,保证了系统的结构清晰。
接口隔离原则
客户端不应该依赖它不需要的接口。类间的依赖关系应该建立在最小的接口上。
本次各个线程都只实现了Runnable接口,没有依赖其他接口,故不谈。
依赖倒置原则
上层模块不应该依赖底层模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
这也是本次作业中做的比较优秀的一点。通过对于电梯、电梯调度器、电梯状态、主调度器功能的抽象,预先设计好一个大的布局,之后向其中添加点缀,实现细节。此次作业在这方面存在的不足是,过于强调大布局的情况下,对于细节的处理并不理想,在某些想要实现固定大布局中功能的方法中有大量的冗杂代码,可读性不是很强。
总结分析
本次作业中,使用了静态的floyd算法预处理每个人的行程,在考虑了一定性能的基础上放弃了更优的动态处理优先级的方法,保持了原有的框架不变,在保证功能正确性的基础上追求了一定的性能;同时,较好的实现了SOLID六大设计原则,但其中对于开闭原则的实现没有把握到位,对于依赖倒置原则的过度实现导致代码可读性下降。
(3) 分析程序结构
基于度量分析自己的程序结构。
度量类的属性个数、方法个数、每个方法规模、每个方法的控制分支数目、类总代码规模
计算经典的OO度量
画出自己作业的类图,并自我点评优缺点,结合类图分析
通过UML的协作图来展示线程之间的协作关系
复杂度分析
先列出三次作业MetricsReloaded计算的类复杂度分析:
第五次作业
第六次作业
第七次作业
可以看出,三次的复杂度几乎一致,在迭代开发的过程中几乎没有增加,最大的增加也只是在Info中使用了较为麻烦的方法处理floyd,可见架构的稳定性较高。
观察ElevatorSlave类的每个方法规模、每个方法的控制分支数目。peekTask方法使用的是分多钟情况寻找距离最近且合法的Task,if用的次数较多,故ev较高,LOC也较多。
观察制约关键Info。可以看出,Info类写的过于草率,非常面向过程,类的定义和使用、方法的声明也非常麻烦,没有很好的体现OO设计的基本原则。其中的圈复杂度较高是因为用了过多的判断在一个方法中。
观察使用DesigniteJava计算出的类的属性个数、方法个数、类总代码规模。同时通过LCOM的值可以看出,Info的内聚缺乏度高(太面向过程),ElevatorSlave稍缺乏内聚。
UML类图
第五次作业
第六次作业
第七次作业
可以看出,其中的Task类、Output类和ElevatorState类都“有进无出”,作为一个工具类进行处理;Manager不直接向ElevatorSlave发出任务,而是通过Elevator中介发送,说明符合了迪米特法则。也能很清晰的看出,除Manager因对于全局起到掌控作用而与其他类之间联系较多以外,类之间的耦合度是极低的,充分说明程序遵循了单一职责等原则。三次作业的迭代除了增加新类之外几乎无变化,也能说明程序很好的符合了开闭原则,并做到了高内聚低耦合。
协作图
(4) 分析自己程序的bug
分析未通过的公测用例和互测被发现的bug
前两次作业没有在中测、强测、互测中出现任何bug,唯一的bug是线程安全问题,第一条的时候已经分析的非常清楚了。
第七次作业很不幸地挂掉了一个强测点,且“暴动”后整整8h对着复现的log的debug,没有结果(完全找不出这个log某几句话产生的理由,甚至怀疑评测机有问题(小声)),一定不是评测机的问题,评测机肯定是毫无问题的(大声)。线程的所有锁有关的问题都检查过了,在我的理解范围内不存在问题。那大概这样。
自己发现问题的方法:一是肉眼调试,在初期bug比较显然、比较多的时候进行;二是通过向stderr中输出log之后继续对拍,使得很难复现的bug在复现的时候记录下当时的运行状态,并根据这个运行状态进行逐步分析,找到线程哪里出现了问题。
本地评测的时候第三次作业出现过很多bug,最重要的是一个复现率极低、但会时不时停不下来运行的bug。通过尝试对bug出现时的场景进行复现,在System.err中输出调试数据记录入log中,最终发现不是死锁,也不是什么多线程问题,而是一个关于Task类少考虑情况的单线程bug,但只有在极特殊的情况下才能够复现。具体内容为:我将一个Task分为两种形态:进入电梯,电梯之外;另外还记录了floyd预先计算出来的路径列表,故另还记录一个当前列表下标。当电梯抢到任务时,调用take()函数,判断能否抢占,如果可以则抢占,将任务的taken设置为true;上乘客时,Task改变为进入电梯形态;下乘客时,ElevatorSlave将Task改变为电梯之外状态,taken设置为false,并向主调度器中的putTask函数传入。由于是抢占式算法,每个电梯需要有一个方法判断某个任务是否被别人抢走,这里的判断写错了,写成了taken&&takenId!=id,没想到有的线程能一直到这个任务完成,出电梯了,才回来进行如此判断,此时taken已经变为false,但其实这一轮的抢占早已结束。改变记录id为记录id和type,双重判断即可避免此次bug。
这个bug让我明白,线程问题不一定是最主要的,不能忽视非线程安全的bug。
(5) 分析发现别人程序bug所采用的策略
列出自己所采取的测试策略及有效性。
分析自己采用了什么策略来发现线程安全相关的问题。
分析本单元的测试策略与第一单元测试策略的差异之处。
前两次互测都没hack成功别人的bug,有许多明显有错的交上去就是不给你评出问题,很无奈。
第三次因为强测爆炸可能分到B屋去了,hack了一批同学,希望同学能够和谐相处(((
测试策略:
- 手动构造数据
构造了一些边界数据,如同时同地多发、同时多地多发、同地多时多发等容易出现bug的数据,同时也构造了大部分调度完成后再加入新电梯等操作,在第三次互测中卡掉了某一位同学。
- 对拍测试
第二单元的测评和第一单元的差异之处在于,单线程的效率太低了,需要并发执行。不想学习python多线程的我使用了多进程测试,一下start 50个批处理文件对程序进行对拍,在互测的时候也是通过这样的方式,每个人分配10个批处理文件进行对拍处理。通过c语言编写的producer,方便快捷地修改程序中的bug,而不是逐个修改五十个对拍程序。
这样的问题在于,这样的土法多进程可能和多线程还是有一些区别,这也可能是我第三次作业bug没有被de出来的原因(?
但就第三次互测来看,效果应该颇佳,针对每一个可能是同质的bug只提交二到四次,尽可能在保证bug可复现的前提下给被hack同学尽少的压力。
对拍的效果图如下
(6) 心得体会
从线程安全和设计原则两个方面来梳理自己在本单元三次作业中的心得体会。
这一单元收获颇为丰富。
首先是第一部分讲到过的对于线程安全方面的理解,一个锁将非原子操作的访问共享变量操作锁住,保证了变量在调用时不会出现莫名其妙的问题;通过条件变量condition进行await和signal,对于指定条件进行等待和唤醒,极大地增强了我们处理线程安全的能力。在这里也再提出建议:先学习Lock和Condition,后学习synchronized,可能会大大提高我们理解的效率,而非反之。详情可以参考我在第五次作业中的相关描述。
其次是对于设计原则方面的理解。没有规矩,不成方圆,类似的,我们通过这些前人总结出来的经验,也就是所谓的面向对象的设计方法和原则,写出的代码能够具有更良好的扩展性,也更面向对象。尤其是单一职责原则,我认为这个原则就是面向对象本身所体现的巨大优势和特点,它在要求我们对于每一个对象的职责非常清晰的同时,赋予了我们写出一个完整、对扩展开放、对修改关闭、高内聚低耦合的软件实体的能力。
本单元三次作业对于这方面的训练较好,但第一次作业在毫无前置知识准备的情况下比较难以下手,而第二次作业仅仅增加了一个容量和多电梯,拔高又略显不够,总体难度相对第一单元还是低,但感觉第一次作业难度稍降,二三次作业难度稍增,对于我们的迭代开发可能会更有帮助。
总体来说,这一单元带来的知识丰富性以及体验都是相当好的,在整改了讨论区后讨论区的画风也变得好起来了,感谢老师、助教组和共同讨论的同学们,继续期待下一单元的精彩内容。