第二单元总结:基于synchronize锁的简单多线程设计

单元统一的多线程设计策略

类的设计

电梯

  • 每部电梯为一个线程
  • 电梯从调度器接收原子指令,知晓自己的状态(内部的人/服务的人、运行方向、所在楼层)
  • 原子指令包括且仅包括
    • 向上走一层 / 向下走一层
    • 让哪些人进电梯
    • 让哪些人出电梯
  • 而电梯不可见其他电梯的状态、不可见调度队列的内容
  • 相当于电梯只是一个输出器和状态储存器,大大解耦

输入器

  • 输入器为一个线程
  • 输入器直接将输入的指令加入调度队列(在第三次作业时可能会将指令进行拆分后再一一加入)
  • 输入器与调度器、电梯均无关,不直接传送数据

调度队列

  • 调度队列使用单例模式
  • 支持push_back操作
  • 支持按某些策略取出元素
  • 保证线程安全

调度器

  • 调度器只是一个保存方法的类,直接由电梯调用申请一个原子指令返回,自己不开线程
  • 调度器使用单例模式
  • 调度器可知晓各个电梯的状态、可访问调度队列,向申请指令的电梯返回一个原子指令

线程间逻辑UML图

图中带[T]标记的都是线程,其他的都是被调用方法的普通类。

主线程(Main.main)只负责 构造并start一个ReQuestInput线程和若干个Elevator线程,本身无业务功能,在图中忽略。

注意到,是电梯主动请求Handler分配一个原子请求,执行完再次主动请求;而不是Handler主动向电梯发布任务!

因为电梯是线程,本身存在时序上的先后关系和竞争关系,相互独立;而调度器不是线程,也不知道电梯是否运行完毕。

(笔者认为在非synchronized / Lock的地方使用wait() / notify()是没有必要的且危险的,因为这一对操作的本质意义是对于条件变量工作的)

故选择让电梯执行完一个原子操作后主动管调度器“要”请求(就是普通的函数调用,不涉及多线程通信和同步

或者说,没有必要使用多线程的时候,就不使用多线程!

多线程通信与控制策略

在尽量少用多线程的设计思想下(也直接导致了线程的共享数据大大减少),被多个线程共享的数据只有两个

  • RequestQueue:被输入线程和电梯线程共享(在三次作业中均存在)。其作用是保存全部的输入请求。
    • 由于调度器本身不是线程,所以只要在插入(输入器)和访问(电梯调用的调度器方法)中保证线程安全即可
    • 对于某些标记,访问和更改器方法标记为synchronized
    • 对于容器,使用Java自带的线程安全类。
      • 由需求决定该“请求队列”并不是一个FIFO的队列,而应该是一个支持遍历的ArrayList
      • 使用concurent.CopyOnWriteArrayList保证容器的线程安全(能用Java保证好的线程安全支持,就不要自己造轮子
  • ServingMap:被电梯间共享(在第三次作业——多电梯中存在)。
    • 其功能是保存各个请求已经被哪个电梯服务(或者没有电梯服务),防止电梯间重复承担请求。
    • 于是该对象被多部电梯之间共享,标记为static对象,要保证线程安全。
    • 在使用ServingMap时(全部操作,包括get()方法、遍历KeySet等)全部使用synchronized关键字,对方法或对象加锁。

在尽量少用多线程的设计思想下(也直接导致了线程间同步控制大大减少),不存在使用wait()和notify()的场景!

总结来说,笔者坚持少用多线程,从而导致使用多线程特性极少,安全性和鲁棒性显著提升,在测试的时候基本上没有不可复现的多线程bug。

第一次作业——傻瓜调度的单电梯

类图

 

可以看到,主线程Main只是构造和开启了电梯线程和输入线程而已,并没有其他的操作。在三次作业中均是这样。

输入线程将输入请求加入请求队列,调度器从请求队列中访问请求,构造原子操作发送给电梯。

代码度量分析

可以看到,除了构造和发送原子指令的方法,其他的方法复杂度都极低,而nextAtomicTask()方法也没有飙红字,说明本设计还是很简洁的!

从类复杂度也可以看出,整个设计复杂度低,十分合理。

bug分析与总结

在中测中,笔者曾经把评测机搞坏过一次,那可以说是“第零个版本”,出现了一些乱写的错误,

比如当时对线程的理解太过sb,没有选择让一个线程没事干的时候进行等待,而是当它完事之后自动死去,再开启新的线程重新run的设计。

众所周知线程的用法是让必须在多条线同时进行的工作并发,而不停的“断线”再“接线”显然不是线程的正确用法——还不如一直保证“线不断”,只不过在中间Thread.sleep()掉

创建和销毁线程都是危险的操作,必须做到程序员可控,不应该将其放在while等循环中,否则一旦程序有bug,将不断地创建新线程,吃掉大量资源的同时造成麻烦。

 

在使用了新的设计模式之后,笔者在公测和互测中均未被发现bug。

由于第一次作业太过简单,在互测中,未发现其他同学的bug。

第二次作业——优化调度方案的单电梯

算法

由于第一次作业的电梯(输出器和状态保存器)和调度器分离的良好设计,在第二次作业中只需改变调度器之中的nextAtomicTask()方法即可了

调度策略如下:

  • 如果能捎带(nowFloor == xRequest.fromFloor),则上电梯,无论是不是同方向
  • 针对某个请求,有电梯目标楼层定义:
    • 如果不在电梯内则去from层接他
    • 如果在电梯内则去to层送他
  • 电梯运行方向由整个队列的目标楼层决定,取max和min操作可以得到最高目标楼层maxTarget和最低目标楼层minTarget
  • 如果当前的运行方向向上,则定义电梯的本原子操作目标楼层Target为maxTarget
  • 如果当前的运行方向向下,则定义电梯的本原子操作目标楼层Target为minTarget
  • 由Target和当前楼层nowFloor的相对位置决定运行方向

可以看到,由于要知晓电梯内部的人(某人在不在、有哪些人在),还要知晓当前的全部请求情况,调度器的复杂度将会显著上升。

类图

可以看到,此次作业的架构和上一次完全一致,只在电梯内部增加了一个保存“哪些人在电梯内”的列表,

还增加了一个主请求对象(类似指导书中的主请求),其他的完全不变,也体现了本架构对新功能的支持性良好。

代码度量分析

(大部分复杂度均为1的方法被省去)

毫无疑问的,取原子操作的nextAtomicTask()方法复杂度最高。仔细研究代码后发现该方法的主要任务可以分成三块,而笔者为了写起来方便(懒),将它们写到了一个方法内。

其实将某些功能移出是更优的写法,因为显然“通过最高目标和最低目标确定目标楼层,从而确定运行上下方向”的任务应该是一个独立的逻辑模块。而事实上笔者在第三次作业中意识到了这个问题,将其移出了。

而电梯的doOneAtomic()方法中存在大量if语句,导致复杂度稍高。研究代码后发现部分if其实可以简化,而笔者为了保存代码的可读性没有进行简化,这里红字的判断有待商榷。

可以看出,调度器类由于要经常遍历电梯内列表、调度队列列表,存在大量的分支和循环操作,所以操作复杂度稍高。

bug分析与总结

由于第一次作业中不存在显然的bug,而本次作业又直接继承自第一次作业,那么定位和排查可能的bug的范围就很小了,便于测试和迭代,这也是保证可拓展架构的附加好处之一

也由此笔者在修改第一次作业到第二次时思路更加清晰:只需管新增和修改的函数即可,其他的全部当作一个黑箱,不修改也不关心。这进一步加强了其正确性,以至于笔者在写程序时没有碰到过完整的bug。

笔者在公测和互测中均未被发现bug。在互测中,也未发现其他同学的bug。

第三次作业——优化调度的多电梯

设计

总体来说进一步沿用第二次作业中的架构,对优化算法进行了一点小的调整。

电梯的构造函数变成传入参数(由于名称、可达列表、速度等属性并不相同)。

值得一提的是,为了保证电梯内的修改尽量少,笔者使用了离散化实际楼层的小技巧:

  • 设立实际楼层->虚拟楼层的映射,在给电梯发送信息时使用虚拟楼层号
  • 设立虚拟楼层->实际楼层的映射,在电梯输出、sleep()和调度器判断条件时使用实际楼层号

这样,电梯本身的代码几乎没有改变,只有在输出“ARRIVE-”和sleep()时需要得到真实的实际楼层号。这样在第二次作业中的“从1楼到3楼”可能在第三次作业中实际上是“从1楼到7楼”,

但是电梯内部的改动极少,而且以前代码的正确性会被继续保证(当作黑箱)

调度器方面,增加了一个HashMap<Request, ElevatorName>来维护哪些请求被哪个电梯服务,从而解决电梯的冲突问题,也巧妙地规避了多线程的其他问题,只需对HashMap对象加锁即可。

上述一切的保障都来自于调度队列的修改——本设计下,任何一条请求都应该是能被一部电梯独立完成的,所以我们需要将不能被一部电梯完成的请求合理拆分成两个请求

这个部分在Queue.pushBackRequest()中进行,下文将会进一步阐述。

类图

 

可以看到,架构设计基本不变,只是现在为了便于管理,电梯成为了RequestHandler的内部对象,有着聚合的关系。

代码度量分析

此次调度器逻辑变多,为了更好的优化从而导致了代码复杂度的增加,是可以预见的。

在电梯的doOneAtomic()方法中,部分输出模块可以单独变成一个方法,从而减少每个方法的循环和分支复杂度。

RequestQueue中的peekFront()方法同样因为更好的优化从而导致了代码复杂度的增加。

 

真正值得修改的是RequestQueue中的pushBackRequest(Request)方法。

本方法由于设计上需要判断请求的一部电梯可达性、拆分不一部可达的请求,故该方法的逻辑变得复杂了许多。
但是仍然写丑了,如果让我进行修改将是这样的:

  • 判断一部可达性,单独变成一个Boolean函数,从本方法中提取出去(判断任务如果没有附带着顺便更新值的功能,尽量单独摘出)
  • 求中间换乘楼层,单独变成一个int函数,从本方法中提取出去(求得一个值,而对当前环境不产生改变影响,尽量单独摘出)
  • 这样pushBackRequest方法就回归了其本质
    一部电梯可达吗?可达的话,插入请求!
    不可达的话,求得中间楼层,构造插入两个请求!

从类复杂度上看,由于电梯内增加了虚拟楼层<->实际楼层转换,而且输出时需要一层实际楼层、一层实际楼层地输出,增加了循环逻辑,导致其复杂度上升。

而奇怪的时RequestHandler的复杂度居然较第二次降低了,不是很理解。

bug分析与总结

本次对优化算法的改动较大,而且增加了离散化的虚拟楼层,故发生bug的几率大大增加。

我的测试策略如下:

  • 首先针对离散化后虚拟楼层和双向映射、以及换乘方案转换的代码进行测试:
    总共只有[-3,0) (0,20]共23层,于是我构建了共计23*22 = 506个测试用例,
    一开始在没有测评机时手动剔除相同类型的数据(如16、17、18、19、20层可以视作等价),再喂给程序进行逐一测试,保证楼层相关的正确性。
  • 其次是对多电梯协作和电梯容量等进行测试,这里主要是人工识别易错点,之后手动构建针对性数据进行测试,
    包括乘客是否可能分身、容量限制是否成功等。
  • 之后有了评测机后进行全面的自动测试。
  • 由于架构基本没有变化,只增加了一个由调度器维护的HashMap,故线程安全性非常好保障(归功于继承自安全的架构和新设计的简单性),不需进行测试。

笔者在公测和互测中均未被发现bug。在互测中,也未发现其他同学的bug。

总结

  • 尽量在一开始就选择相对好的设计架构,不但可以让拓展性得到保障,还可以在拓展时的思维复杂度大大降低,同时出bug的可能大大降低。
  • 尽量让方法回归其本质功能,不该放进去的不放进去。一些特定模式的功能也应该拎出来。
  • 尽量少用多线程,不得不用时才使用。慎重使用没有必要的sync / Lock搭配的wait() / notify(),它们是用来实现条件变量的相关操作的,不是让你真的去等待和唤醒的。
  • 不要经常重复创建新线程和让线程早死掉,即使使用join()方法也不好。线程的生命周期应该被程序员牢牢把握。
posted @ 2019-04-23 20:45  FuturexGO  阅读(287)  评论(0编辑  收藏  举报