2024 BUAA-OO Unit2多线程电梯调度总结

前言

第二单元我们学习了多线程程序的设计方法,并且以电梯调度这一生活场景为背景来应用多线程的设计理念,通过本单元的学习,我收获到了线程的创建、运行以及线程间交互的知识。

架构设计体验

第五次作业

第五次作业模拟北京航空航天大学新主楼的电梯系统,楼内有6部电梯,可在1到11层之间运动,从标准输入中输入乘客的请求,并且指定好了乘坐的电梯,然后输出每一时刻电梯的行为。

  • UML类图
    在这里插入图片描述
  • 线程协作图

请添加图片描述

本次作业我参考了实验的写法,采用了生产者-消费者模型,一共有InputThread, Elevator以及Schedule三种线程运行,其中InputThread充当生产者,将从标准输入中读入的数据存放进共享资源类RequestTable中,Schedule则是扮演消费者,每次从RequestTable中取出一个请求分配给对应的电梯。

在电梯运行策略上,我和绝大部分同学一样采用的是LOOK算法,参考了Hyggge学长的博客对于LOOK策略的阐释,将电梯的行为当作状态机的若干状态:

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。
  • 到达某楼层时,首先判断是否需要开门:
    • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
    • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。
  • 接下来,进一步判断电梯里是否有人。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯):
    • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
    • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。
    • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)。

但是我在实际写的时候发现上面的策略不太完善(可能是我的实现的问题叭),例如当电梯走到第8层时,电梯内所有的乘客的目的地都是在8楼以下,此时电梯理应转向,但是按照上面的策略来看,由于电梯内还有人,所以他还会沿着原来的方向继续前进。因此我在如果电梯内还有人的条件下,加上了是否需要转向的行为判断:如果电梯内的人的目的地都与电梯运行方向相反,那么就转向(REVERSE):

//Strategy.java
if (personCnt != 0) {
 	if (hasSameDirectionReq(curFloor,direction)) {
    	return Behavior.MOVE;
    }
    else if (needReverse(direction,destinationQueue)) {
    	return Behavior.REVERSE;
    }
    return Behavior.MOVE;
 }

在线程安全方面,由于RequestTable类实例化的对象mainRequestTable是共享资源,所以我在RequestTable中的所有方法都加上了synchronized,为了避免ctle只在``addRequestsetEnd以及isEnd中加上了notifyAll()` 。

第六次作业

第六次作业加入了Receive约束,不再指定电梯,也即每一个请求到来时必须有且仅有一部电梯来接受这个请求,此外电梯也有可能接受到重置指令(reset),接收到重置指令的电梯需要尽快停靠后完成重置动作,再投入电梯系统运行。为安全起见,电梯重置时内部不可以有乘客,且重置动作需要时间1.2s

  • UML类图:

在这里插入图片描述

  • UML协作图:
    请添加图片描述

在处理reset时,我没有单独设置一个resetRequest的队列,因为考虑到如果同一时间发来大量reset请求处理起来可能不及时,所以我为每一个电梯都设置了一个boolean属性isReset,用来标识该电梯是否接受到了reset请求,每当inputThread读入一个reset请求,那么我就让schedule将对应的电梯的isReset设置为true,在电梯紧急停靠后,如果isReset == true,那么执行reset操作。同时我维护一个resetting属性,标志电梯是否在reset过程中。如果resetting的电梯被分配到了一个请求,照常把这个请求addRequest,但是暂时不输出RECEIVE信息,而是把这个信息加入到String的缓冲队列中,等resetting == false后再输出RECEIVE。

//Schedule.java
public void allocateElevator(Person){
  	//选择电梯id
		Elevator targetElevator = elevators.get(targetId - 1);
		targetElevator.addReceive("RECEIVE-" + person.getPersonId() + "-" + targetElevator.getElevatorId());
		targetElevator.addRequest(person);
		targetElevator.getRequestTable().notifyMap();
}
//Elevator.java
private ArrayList<String> receives;
public void addReceive(String receive) {
    if (resetting) {
    		receives.add(receive);
    } else {
        TimableOutput.println(receive);
    }
}

public void printReceive() {
    for (String receive : receives) {
        TimableOutput.println(receive);
    }
    receives.clear();
}

在分配策略上,我用的是影子电梯的方法,也就是模拟运行,首先深克隆电梯内的各种队列,然后将LOOK策略中每一个sleep(second)都替换成time += second,对于每一个新增的请求,每一个电梯都加入该请求模拟运行一下直到结束,选择耗时最短的那个。

本次作业有很多可以优化的地方,例如在影子电梯的判断依据上加上耗电量的评判依据;影子电梯的模拟运行单独建类,避免Elevator类过于臃肿。

第七次作业

第七次作业可以将电梯重置为双轿厢电梯,每个id有A,B两个轿厢,每个轿厢只能在[0, transferFloor]和[transferFloor, 11]之间运行,且两个轿厢不能同时位于换乘层。

  • UML类图
    请添加图片描述

  • UML协作图:
    请添加图片描述

本次作业中我的架构基本没变,因此UML协作图和上一次作业基本一致。由于有可能需要换乘,导致第二部电梯很难预测,所以我在分配策略上改动也很小,只计算能够接到该乘客的电梯中,哪一个跑到终点的运行时间最短,相当于求一下局部最优解,乘客从换乘层出来后被送回到mainRequestTable中,再进行下一次分配,换乘之后可以选择其它id的电梯乘坐。

对于双轿厢的处理,我采用了一种比较方便但是架构比较混乱的设计,就是在Elevator中包含一个属性private Elevator CarB,DoubleCarReset过程中可以直接修改CarB的参数,当电梯Reset完毕后,直接CarB.start()启动线程。

如果一部电梯在DoubleCarReset的过程中计划分配给轿厢B,但是这时候轿厢B还没有被创建出来,为了解决这个问题,我在Schedule里给六部电梯的轿厢B加了一个缓冲队列bufferOfCarB,如果分配给轿厢B并且轿厢B在重置过程中,那么就把请求加进缓冲队列里,否则直接加入请求:

//Scheduler.java
private ArrayList<ArrayList<Person>> bufferOfCarB;
public void allocateElevator(Person person){
    //TODO choose elevator
  	if (elevatorsB.get(index).isResetting()) {
    		synchronized (bufferOfCarB.get(index)) {
            bufferOfCarB.get(index).add(person);
        }
    } else {
        synchronized (elevatorsB.get(index)) {
            TimableOutput.println("RECEIVE-" + person.getPersonId() + "-"
                            + elevatorsB.get(index).getElevatorId() + "-B");
            elevatorsB.get(index).addRequest(person);
            elevatorsB.get(index).getRequestTable().notifyMap();
        }
    }
}

在防止轿厢碰撞的问题上,我借鉴了姜涵章同学的做法,新建了个OccupiedFlag类,由一部电梯的两个轿厢共同拥有一个该类的对象,如果有一个轿厢占据了换乘层,那么发出一个信号告知另一部轿厢,让另一部轿厢等待,直到这部轿厢离开换乘层,再通知另一部轿厢前往,这种方式可以使得一部轿厢前脚刚刚离开另一部电梯立刻顶上,实现无缝连接。

在研讨课上听到各位大佬的分享后。我觉得这次作业还可以优化一些:例如将电梯和电梯线程分离开来,电梯只用来存储电梯的相关参数,电梯线程只用来运行,这样更加贴合SOLID中的单一责任原则;将轿厢B从轿厢A中拆分出来,高内聚低耦合;Schedule可以采用单例模式,便于在各个类中使用。

三次作业总结

几次作业迭代下来,可以看出,其实线程之间的协作和整体上的架构并没有多少改动,宏观层面上的布局在第五次作业已经确定下来了,包括像RequestTable,Person,InputThread以及执行LOOK策略的Strategy这些功能比较简单单一的类修改都很少,后续两次作业的迭代基本都集中在对于Schedule以及Elevator运行逻辑上的更改,例如第六次作业对于Schedule的分配策略进行了改动,Elevator中加入了Reset的行为逻辑,第七次作业中Schedule中加入了B轿厢,同时判断Schedule线程终止的条件也发生了改变:从原来仅判断mainRequestTable isEmpty() && isEnd()到还需要加上判断所有电梯是否为空。

bug分析

本单元的bug主要分为以下几类:

  • CTLE:一般是由于轮询导致的CPU耗时过多,或者频繁地使用notifyAll()
  • RTLE:由于死锁或者线程不安全等原因导致的死循坏或者线程无限wait,尤其在影子电梯的部分经常出现
  • WA:WA的表现就五花八门了,归根结底主要还是线程不安全导致的,此外一些迭代过程中的细节没注意到也可能导致WA

debug方法:

  • 瞪眼法:针对出错的样例,自己就着代码的流程捋下去,这样的缺点就是对于专注力要求比较高,稍不留神就会定位出错,而且效率有点低。
  • print法:在一些分支内打印一些东西,用来判断程序是否运行到此处,这用来de轮询和RTLE的bug非常好用,但是缺点是由于多线程的玄学性,有的时候print之后可能运行就正确了。
  • jconsole法:用java自带的jconsole工具可以定位死锁出现的位置

心得体会

本单元的多线程学习是我进入大学以来压力最大的一次挑战,在第六次作业和第七次作业上我debug花费的时间几乎在20个小时左右。但是我也收获到了很多,比如锁的设置、线程交互、线程安全和原子语句等知识点,和OS课程也有交集。与此同时我也明白了不能盲目地追求性能分,例如影子电梯的设计中,即便各种情况都考虑到,但是最终性能分可能和随机分配差不了多少,反而增加了代码复杂度和debug难度。

  • 29
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值