一、设计策略

  1. 单部先来先服务电梯

  第一次作业采用了最基本的生产者-消费者模型,电梯请求是模型中的商品,将控制器作为存储请求的仓库,主线程作为生产者向仓库存放请求,电梯作为消费者从仓库取出请求并处理。先来先服务的调度策略中,电梯一次只会处理一个请求,因此可将请求作为一个操作的原子。控制器储存请求使用队列结构,电梯每次取出队列首位的请求执行。

 

  2. 单部可捎带电梯

  第二次作业沿用第一次作业的结构,区别在于为了实现可捎带,电梯需要同时处理多条请求,因此不能再以一整个请求作为操作原子,而需要破拆成更小的任务。

  我设立的电梯的原子操作分为乘客进入、乘客退出、上移一层和下移一层,电梯在取出一条请求时将请求拆分成一个原子操作的序列,之后顺序执行队列中的操作,即可完成请求。捎带任务时,由于捎带的条件是请求与电梯运行方向相同且乘客上下的楼层都在电梯的运行方向上,被捎带的请求的原子操作序列可以和电梯当前的操作序列融合,实现多个任务共同执行。

  优化方面尝试实现look算法。当主进程试图将一个新请求放入控制器时,首先判断该请求能否立即被捎带,若可以,则直接加入电梯的操作队列中,否则放入控制器的存储链表中。控制器的存储链表分为两条,一条储存上行任务,一条储存下行链表,同时记录上行链表中起始楼层最低和下行链表中起始楼层最高的请求。当电梯执行完当前的操作序列,准备从控制器中取出一个新任务时,电梯将从这两个最远端的任务中选取起始楼层较近的一个执行。这个算法的目的是使电梯在一次上下行中能够尽量多的进行捎带,选取最远端的请求能够将同方向上所有的任务全部捎带。

 

  3. 三部停靠楼层不同的,有负载上限的电梯

  第三次作业与第二次作业的区别在于:1)多部电梯。需要考虑如何给三个电梯分配任务。2)电梯停靠楼层不同。一个请求可能不能由一部电梯单独完成,不能以请求为单位向电梯分配任务,需要进行拆分。3)有负载上限。电梯可能不能将所有可捎带的请求全部捎带,需要进行选择。

  为了沿用第二次作业中的结构,我在第三次作业中设计了一个主控制器和每个电梯各自的控制器。主控制器负责对请求进行预处理和将处理后的请求分配给每个电梯的控制器,请求的预处理即为此请求规划路线,包括是否换乘、在哪层换乘、乘坐哪部电梯。为简化问题,我通过静态方法规划路径,与电梯当前所在的楼层与状态无关,可直达的请求必选择直达的方式,不得不转乘的任务选择固定的楼层进行换乘。主线程经过主控制器的判断后,将处理的请求分配给对应的电梯的控制器,每个电梯处理各自的控制器中的任务的方式和第二次作业相近。区别在于电梯间对于换乘的通信和超过负载的处理方式。

  在电梯间的协调运作上,我让电梯在当前没有能够执行的请求时,向未来将出现请求,即来自其他电梯的换乘请求的楼层移动,但当移动过程中出现可立即执行的请求时,移动需要被打断,去响应需要立即执行的请求。为实现这一功能,需要将未来将换乘本电梯的任务存储在本电梯控制器的另一个队列中,当电梯完成一个请求(执行乘客退出的原子操作)时,检查该乘客是否需要换乘,若需要,则将下一步需要乘坐的电梯的控制器中该乘客对应的存储于未来任务序列中的任务移动到可立即执行的任务序列中。而当一个电梯没有可立即执行的任务时,向未来任务队列中首项的任务的换乘楼层移动。

  对于超过负载的情况,我使用拒载的方式。当电梯执行到某一条乘客进入的指令时,若电梯负载已满,则不让乘客进入,同时清除操作序列中该乘客退出的指令,然后将该乘客对应的请求重新加回到控制器的可立即执行队列中。

  优化方面,在任务分配上,由于使用了静态分配的方式,性能上已经受到限制,只能通过调整优先级调整各个电梯的工作量,从概率上能够提升一点效率。

 

二、 度量分析

  1. 单部先来先服务电梯

  电梯类的run函数里由于包含了处理一个请求的流程(移动、开关门、乘客上下),有面向过程的成分在,因此方法较长,复杂度较高。这个问题在三次作业中都有体现。

  时序图是一个基本的生产者消费者模型。

 

  2. 单部可捎带电梯

  复杂度较高的几个方法中,控制器的putRequest是主线程向控制器存放请求的函数,里面包含了维护look算法的上行下行请求链表的步骤。getRequest是电梯向控制器索取主任务的方法,同时包含了确定主任务后检索所有可捎带任务的步骤。电梯的pickQuest是将一个请求分解成原子操作序列的方法。Run是电梯每次从原子序列中取出一条指令并执行的方法。这些方法控制分枝较多,代码较长,同时覆盖了多个功能,不太符合面对对象的思想,我没有将这些函数进行拆分的主要原因是我不知道该给新的函数起什么名字。。。

  时序图比起上一次作业多出了一条由仓库主动(主线程运行)将任务塞给电梯的路线

 

  3. 三部停靠楼层不同的,有负载上限的电梯

  第三次作业的复杂度由于大部分沿用第二次作业的结构,没有太大的变化,多出一个复杂度较高函数是EditedRequest的构造函数,根据乘客的起止楼层规划线路,控制分枝很多。

  在第三次作业中存在主线程修改电梯对象、电梯线程修改自身、电梯线程修改另一个电梯对象的过程,线程之间的互斥关系比较复杂,最终交上的作业中也依然存在着一些时序上混乱的问题,没能取得理想的成绩。

  从soild原则上分析,由于这一次没有使用类的继承,主要从单一功能原则和开放封闭原则分析。在这一次的控制器设计上,我按照管理范围将控制器分为了三个层次:管理所有电梯,向各电梯分配任务的主控制器、存储主控制器分配的任务,决定执行策略的字控制器和将任务转换成原子操作,然后执行的电梯本身,从描述上就可以看出,每个层级依然承担着多项任务,这也导致我的程序中出现长度大,判断复杂的方法,在单一责任的角度看,程序的分层还不够抽象,导致部分功能混杂在一起。

  我第三次作业中的每一部电梯和它的子控制器之间的交互移植自第二次作业。但为了实现让电梯拒载,让子控制器存储将来换乘的任务,让电梯在完成一个任务时同时其他电梯它完成了该任务等功能,还需要对第二次作业进行一些修改,其中大部分的改动是通过新增成员变量和方法实现的,但一些方法中逻辑上的控制和判断依然需要修改,那些复杂度高的长函数是尤其的重灾区。这违反开放封闭原则,我也切身的体会到了修改完这些方法之后debug是多么痛苦,这也侧面的证明了单一责任原则有多么重要。

 

三、bug分析

  在这三次作业中我所遇到的bug主要是线程安全问题的的bug。由于滥用synchronized导致的死锁和由于synchronized了错误的对象导致的线程不安全。

  我发现java中一个线程可以通过synchronized嵌套来占据多个对象的锁,但wait只能释放其中一个锁,而不能将全部的锁释放。在我的电梯中,由于主线程(producer)可能调用控制器(tray)中的putRequest函数时可能直接将请求(product)交给电梯(customer),电梯线程在调用控制器的getRequest时需要先掌握自身的锁,getRequest函数需要得到控制器的锁,若控制器中没有指令,电梯线程就会放开控制器的锁进入阻塞,但电梯线程没有释放电梯自身的锁,因此当主线程向将一个请求直接交给电梯时,就会发生死锁。

  对于这种情况我没有好的解决办法,只能尽量调整同步块的范围,使其尽量不发生嵌套,但这又导致了另一种问题。我的控制器中有这样的一类表达:if (elev.canPickUp(request)) {elev.pickRequest();}。其中elev的两个函数是电梯类中的两个同步函数。可见两个函数逻辑上是应该紧接着连续执行的,但中间稍微将elev的锁放开了一下,就导致可能有其他线程抢到锁,插在中间执行,修改了elev的成员变量,导致电梯捎带任务时出现错误。

  我认为会发生这样的矛盾的根本原因是我没有处理好方法的调用关系。我刚开始设计程序结构时是因为担心cpu时间过高而放弃了让电梯轮询检查控制器中有没有新增的函数,但后续了解到,控制合理的轮询不会增加太多的cpu时间,若我将主线程严格限制在只能访问到控制器,由电梯轮询检查控制器是否改变,若改变则调整任务序列,也就不会出现上述的同步块嵌套等问题。

 

四、检测bug的策略

  第一、二次作业由于涉及的情况相对比较简单,可以人工构造不同情况的数据,如上下行的转向,等待后的再启动空转时的捎带等等在自己编程中总结出来的需要进行处理的情况。进一步的检测我通过自己写的一个随机测试数据生成器和定时输入器进行随机测试,将多个人的测试结果放在一起对比可以发现一些不正常的行为,猜测其原因并针对这一点构造数据,可以发现一些性能上的问题。

  第三次作业由于简单的输出难以进行压力测试,复杂的输出有难以判断正误,只使用随机数据进行检测,用一个模拟程序判断输出的结果正确或出现哪种错误。通过调整随机数据生成的策略可以适当转移测试的重点。

  没有想到能比较好的检查线程安全的方法。。。

 

五、心得体会

  多线程编程是在问题模型具有明显的并发性时选择的编程方法,这次实验的电梯就是一个典型的例子。进行多线程编程首先要线程的划分,确定模型的结构,这次实验中,我将每一部电梯作为一个进程,输入作为一个进程,我也见到有的同学为每一名乘客都建立了一条线程。确立进程后需要考虑的是进程间的隔离与通信,隔离是为了保证线程安全,互不干扰,通信则是线程间协作的需要。在线程安全上,我总结出的教训是,最好不要让一个线程能够直接修改另一个线程的对象,在生产者-消费者模型中,生产者线程和消费者线程是通过仓库这一缓冲区作为通信的中介,线程对象的每一个动作都应该是主动的索取,而不是被动的接收。这样的设计可以保证线程对象不会被其他线程修改,不用考虑自身对象的同步性。缓冲区的设计需要注意每一个同步函数应该只被一类线程访问,put函数只能被生产者线程访问,get函数只能被消费者线程访问,这可以简化设计线程安全时需要考虑的情况,防止复杂的情况发生。有时线程之间的通信可能经过多个缓冲区,可能发生死锁,这时可以使用动态加锁等方法,避免死锁发生。

posted on 2019-04-24 18:22  黄启元  阅读(234)  评论(0编辑  收藏  举报