272
社区成员




本单元主要内容是电梯调度,多线程编程测试,锁的应用。本单元让我掌握了多线程的相关知识,对我非常有帮助!
本部分主要介绍我的架构中锁的选择和加锁位置。
在完成作业之前,我了解到了有很多种锁可供选择:synchronized锁,ReentrantReadWriteLock锁等。
其中,synchronized锁是绝对排他性的锁,无论线程是读或者写,只要抢到了锁,其余想要获取相同锁的线程就会被阻塞。这种锁的好处是简单明了,安全性高,只要有互斥需要就可以使用;但是坏处也比较明显,那就是区分细度不够高,如果有大量的并发读的操作,那么synchronized无法让众多线程一起读,而是一个一个互斥读。
另一个是读写锁ReentrantReadWriteLock,这种锁分为读锁ReadLock和写锁WriteLock,两种锁需要手动lock和unlock。当线程获取读锁后,其余线程仍然可以获取读锁,来实现并发读,同时阻塞写锁;当线程获取写锁后,阻塞读锁和写锁,只能互斥写。这种锁将读操作和写操作进行了精细划分,使得在大量并发读的场景中有更好的效率,同时默认是写锁优先,因此写操作的线程会拥有更大优先权,利于消息的更新。但是这种锁增加了编程的复杂性,更容易因为逻辑错误而产生死锁。
结合我自己的架构和思考,我最终选择了使用synchronized锁来作为唯一的锁,理由如下:
那么具体都在什么地方要上锁呢?
首先是RequestList需要上锁,该类采用单例模式,会且只会被实例化出一个对象,作为共享队列容器。对于该容器,需要设计add方法和get方法,并且均要上锁。同时还需要设计end方法,用来标识输入是否结束,以及告知ElevatorList是否结束。在add和end方法时,需要进行唤醒notifyAll操作,以叫醒阻塞的分派器线程。
其次是ElevatorList需要上锁,该类充当分派器线程和电梯线程的共享容器,用来保存电梯的信息和分配给电梯的乘客信息。对于ElevatorList对象,每个电梯都拥有一个实例化对象,用来保存该部电梯的独有信息。同样需要设计add方法,以及synchronized同步块。通过对这些线程共享对象加锁,可以保证线程安全。
在我的架构中,我专门为调度器设计了一个类,叫做Dispatcher。该类的作用就是负责从RequestList中get输入请求,然后经过调度策略的计算得到最佳分配电梯,最后将该请求add进最佳电梯的ElevatorList中。这样做的好处是符合高内聚-低耦合的设计原理,将整个流程拆分为请求分配-处理请求两个部分。这样分派器只需要设计如何计算和分配请求即可,电梯也只需要专注于处理被分配的请求,二者互不干涉,在以后迭代和更新时只需要修改其中一小部分即可,无需牵一发而动全身。
在Dispatcher中,最重要的一个步骤就是利用调度策略计算最佳电梯,为了实现这个步骤,我专门设计了一个DispatcherStrategy类,用来实现分配策略。在该类中,有一个递归方法dispatch(),用来尝试从RequestList中获取请求并分派给电梯,如果分派成果则返回true,如果没有成功则递归调用dispatch,并返回该函数的返回值,如果get到的请求是null,则返回false。该方法可以递归查找合适的请求并分配给电梯,从而避免重复查找同一个请求的情况。
在dispatch方法中,最关键的步骤是如何判断一个请求能不能找到最佳电梯,于是我单独设计了一个方法getBestEle,该方法传入一个请求作为参数,并根据该请求的具体信息和当前电梯的状态来决定最佳电梯,并返回该电梯的id。如果请求是SCHE或者UPDATE,则直接分配给对应的电*梯;如果请求时request,则利用调参算法来计算出最佳电梯。
第一次作业:由于第一次作业中指定了乘客请求需要搭载的电梯,因此在getBestEle方法中,只需要返回输入中指定的电梯id号即可。
第二次作业:在第二次作业中,可以自由发挥来决定乘客的电梯分配,因此可以自己设计分配方法来决定。我采用的是调参算法。所谓调参,就是参考电梯的各种信息和状态,用权重计算出一个综合得分,并选取得分最高的电梯作为bestElevator。但是仅仅这样还不够,因为有可能有一些电梯是根本不符合要求的,比如处在SCHE状态,或者人员超过了设定的人数上限等,这种电梯不能参与得分计算,因此需要提前筛选出合格的电梯,再计算综合得分并选取最高得分的电梯。当然,如果所有的电梯都不符合要求,那么这次分配就是失败,也就是没有最佳分配电梯,此时就需要递归查找下一个请求。如果所有请求都不满足分配要求,那么Dispatcher线程会判断是否结束了,如果没有结束则会sleep(50)来度过空白期。
第三次作业:在第三次作业中,我延续了第二次作业的调参算法,并在此基础上进行了一些调整。由于第三次作业新增了UPDATE指令,因此当电梯处于UPDATE状态时,电梯仍然无法参与调度,因此会被视作不合格,不会参与分数计算和选择。同时因为新增了双轿厢电梯,因此当请求的出发楼层不在电梯可运行范围内时,该电梯也被视为不合格电梯。当请求的出发楼层和到达楼层均超出电梯的运行范围时,该电梯也无法处理该请求,因此也会被视为不合格。此外,由于新增了双轿厢电梯,因此调参的信息维度也相应的增加了,并设计了新的权重。
我的调度策略是如何适配时间、电量、优先级等多个要求的呢?关键就在于我的调参维度中。在调参计算的时候,我选择了距离、当前电梯内人数、电梯总人数、电梯运行速度、电梯总优先级、乘客目标楼层是否在电梯可运行范围内这几个指标来进行计算,并配上了相应的权重。计算公式如下:
double result = 21 - 2.0 * distance - 2.5 * curPeoNum - 2.5 * totalPeoNum
- 10.0 * (double)speed / 100 - 3.5 * (double)allPriority / 100
- 30.0 * elevatorTable.isToFloorNotQualified(person);
通过该公式,我的电梯综合考虑了接到这位乘客要走的距离(楼层),电梯目前有多少乘客,电梯的运行速度等指标,并计算出综合得分,总而找到最适合该请求的电梯。那么这些指标是如何跟时间、电量、优先级相关联的呢?
首先,distance代表电梯接到该乘客要走的距离,这个指标就跟时间和电量相关。如果distance很大,说明电梯要走较远的距离才可以接到这位乘客,那么电梯就会让该乘客等待更长的时间,从而增加平均乘客等待时长;同时电梯也多移动了很多楼层,耗电量也增加。所以考虑distance可以一定程度平衡时间和耗电量。
其次,curPeoNum和totalPeoNum分别表示目前电梯中的乘客人数和电梯中的以及候乘表中等待的乘客人数。这两个指标也跟时间有关。如果一个电梯人数非常多,那么就算把该乘客分配到该电梯中,该电梯也应接不暇,无法及时接到该乘客,因此会造成该乘客等待时间过长的问题。所以如果curPeoNum和totalPeoNum过大,则表示该电梯不适合分配新的请求,于是会根据这两个指标动态调整。因此考虑curPeoNum和totalPeoNum也可以平衡时间这一因素。
同时,speed表示的是电梯运行速度,这个指标也跟时间相关。speed越大,说明电梯运行一楼层时间越久,那么乘客的等待时间也就越久,因此电梯评分也就越低,表明该电梯相对不适合接送。所以考虑speed因素也可以平衡时间。
最后,通过引入优先级allPriority来考虑优先级这一因素。allPriority是指电梯中所有乘客的优先级之和。allPriority越大,说明该电梯乘客总优先级越大,这样的话容易使得高优先级的乘客等待时间过久,导致平均加权时间过久。因此allPriority越大,电梯评分就越低。这样做可以平衡电梯的优先级之和,让每部电梯都能接到优先级相似的乘客,防止出现一部电梯聚集过多高优先级乘客的情况。通过这个指标,可以一定程度考虑优先级。
我的三次作业架构基本统一,在第一次作业的时候就确定了整体架构,并在后续作业中进行微调更改。其UML类图如下:
从该架构中可以看出,核心还是输入线程-分派器,以及分派器-电梯线程。只是在第二次作业中新增了共享容器-电梯线程的生产者-消费者关系,以及在第三次作业中新增了UpdateLock和TransferFloorLock两个锁类来辅助UPDATE操作和双轿厢电梯的实现。
在第一次作业中,我的核心架构是输入线程-分派器,以及分派器-电梯线程。两部分彼此独立,满足高内聚低耦合的特点。同时电梯的调度策略采用LOOK算法,并新建了一个ElevatorStrategy类用来给电梯提供策略。电梯线程内部通过策略类提供的策略指导来完成对应的操作。其代码如下:
@Override
public void run() {
boolean end = false;
while (!end) {
synchronized (elevatorTable) {
Advice advice = strategy.getAdvice();
switch (advice) {
case SCHE: {
elevatorTable.setScheduleFlag(true);
break;
}
case UPDATE: {
elevatorTable.setBeingUpdateFlag(true);
break;
}
case MOVE: {
if (elevatorTable.isUpdated()) {
isUpdateMove = true;
break;
} else {
normalMove(moveSpeed, false);
}
break;
}
case OPEN: {
openAndClose(); //电梯开门
break;
}
case REVERSE: {
elevatorTable.setDirection(!elevatorTable.getDirection());//电梯转向
break;
}
case WAIT: {
if (elevatorTable.isUpdated() &&
elevatorTable.getCurrentFloor() == elevatorTable.getTransferFloor()) {
moveAwayFromTransferFloor();
break;
}
elevatorTable.setWaitFlag(true);
try { elevatorTable.wait(); }
catch (InterruptedException e) { e.printStackTrace(); }
elevatorTable.setWaitFlag(false);
break;
}
case OVER: {
end = true;
break;
}
default: { }
}
}
examine();
}
}
通过这种方式可以让电梯自主运行,接送请求。
在第二次作业中,新增了自行调度功能以及SCHE指令。关于SCEH指令,我专门设计了一个scheRequestList容器来保存SCHE类型的指令。在分配器尝试进行get操作时,会优先判断有没有sche指令,之后再判断request指令。同时调度器会直接将sche指令分配给相应的电梯,无需计算评分。同时在电梯的策略类中,也是优先检测是否有sche指令,如果有的话就返回SCHE建议。这样做是因为SCHE指令在输入时会自动输出ACCEPT,之后在两个ARRIVE内必须输出BEGIN,否则视为错误。因此电梯必须尽快完成sche命令,否则将会超时。同时电梯进入sche状态后,分派器将不再给电梯分配请求。关于分派器的调参算法,前面已经叙述很多了,这里就是采用了前面的算法来计算电梯得分,并将请求分配给对应的得分最高的电梯。
在第三次作业中,新增了双轿厢电梯和UPDATE指令。关于UPDATE指令,我也专门设计了一个updateRequestList容器来保存UPDATE类型的指令。在分配器尝试进行get操作时,会优先判断有没有sche指令,之后再判断update指令,最后再判断request。同时调度器会直接将update指令分配给相应的电梯,无需计算评分。同时在电梯的策略类中,也是优先检测是否有sche指令,如果有的话就返回SCHE建议,之后再检测UPDATE,如果有就返回update建议。这样做是因为UPDATE指令在输入时会自动输出ACCEPT,之后在两个ARRIVE内必须输出BEGIN,否则视为错误。因此电梯必须尽快完成update命令,否则将会出现问题。同时电梯进入update状态后,分派器将不再给电梯分配请求。关于双轿厢电梯的实现,我是用一个状态来表示,同时标记电梯时A还是B。对于双轿厢电梯的运行move,会有单独的方法来进行特判。同时也需要对换成楼层上锁进行保护。
我的架构未来扩展能力也很不错,因为满足了高内聚-低耦合的原理,因此只需要根据相应的扩展部分特异性修改对应片段即可,无需调整架构。
我的架构的UML协作图如下所示:
在我的三次作业中,稳定的内容就是我的架构本身。两个生产者-消费者框架是最稳定的内容。无论需求如何变化,这个架构本身都是不会变的。同理,易变的内容就是调度算法和电梯的运行策略。这些内容会随要求的变化而变化,但同样因为满足高内聚-低耦合,因此也容易改变。例如调度算法,因为我把getBestEle封装成了一个函数,因此只需要修改这个函数即可完成调度算法的更改。我可以换成随机算法、均匀分配算法、调参算法等等。而电梯的运行策略被封装在了ElevatorStrategy这个类里面,因此也可以通过修改类内部代码来实现运行策略的变化。同理电梯如何响应策略,如何做出对应的行为也是易变的,也可以通过修改部分代码来实现。
关于update操作,前面的部分是新建容器,然后优先分配update操作。后面的部分就是update操作被分配给了电梯,然后电梯改如何实现update操作。我的做法是新建了两个类,一个是UpdateLock,一个是TransferFloorLock,通过这两个类来跟两部update电梯完成通信协作。具体做法为:当输入流在检测到输入了update指令后,会新实例化两个对象,也就是UpdateLock和TransferFloorLock。然后UpdateLock类继承了Thread类并重写了run方法,因此输入流会start这个对象,开启这个线程。然后把这两个lock和原来的update指令都封装在新的MyUpdateRequest对象中。代码如下:
if (request instanceof UpdateRequest) {
TransferFloorLock transferFloorLock = new TransferFloorLock();
UpdateLock updateLock = new UpdateLock((UpdateRequest) request,
transferFloorLock);
updateLock.start();
MyUpdateRequest myUpdateRequest = new MyUpdateRequest(
((UpdateRequest) request).getElevatorAId(),
((UpdateRequest) request).getElevatorBId(),
((UpdateRequest) request).getTransferFloor(),
updateLock, transferFloorLock);
requestTable.countPlus(2);
requestTable.addRequest(myUpdateRequest);
}
当电梯接收到update策略后,电梯会开始进行update状态,先将改状态置位,同时放锁,让dispatcher可以拿锁,然后自己进入update方法中进行后续操作。在后续操作中,我会允许电梯现在本层和上下一层从接送乘客,并最多只让电梯走一层。然后电梯会尝试跟updateLock进行通信,方式是让updateLock中的num+1,然后updateLock的run方法就是用while循环判断num是否是2,如果不是2则wait。电梯在跟updateLock通信后就抢TransFerFloor的锁并wait在上面,等待updateLock去叫醒。当updateLock的while循环结束后,说明此时两部update电梯都已经完成了前置操作,可以开始begin了,于是先sleep1s,然后再抢TransFerFloor的锁并notifyAll,叫醒两部电梯。之后电梯再完成清空候乘表的操作,然后设置参数并结束update状态。
首先,我用了两个标志位来判断是否是双轿厢电梯,一个是isUpdated,用来判断电梯是否经过了update成为了双轿厢电梯,另一个是isA,用来说明电梯是A电梯还是B电梯,isA当且仅当isUpdated为真时才有效。通过这两个标志位可以满足双轿厢电梯的各种判断。其次,对于双轿厢电梯,电梯响应建议的行为发生了一些变化。在open行为时,如果是双轿厢电梯则会采用其特异的move行为。该move行为会首先判断双轿厢电梯的目标楼层是否是transferFloor,如果是的话,需要抢夺换成楼层的控制权,也就是抢夺TransferFloorLock对象的锁。如果成功抢到了锁,说明电梯可以进入换成楼层,如果没有抢到,说明另一部双轿厢电梯正在占用目标楼层,那么需要等待其离开目标楼层,也就是释放锁,然后才可以抢锁并占据目标楼层。同时为了防止一个电梯长时间占用换乘楼层,我设计了一个电梯自动离开的操作。当电梯执行wait操作时,需要先判断是否是双轿厢电梯并且处在换乘楼层,如果是的话那么需要自主离开这个楼层并放开锁,让另一部电梯有机会进入,这样才可以保证双轿厢电梯轮流占用换乘楼层。
在多线程中,我出现的bug很少,只出现过一些比较经典的多线程bug。几个典型的例子是,如果要执行一个连续操作那么需要一直持有锁,否则可能在放锁的时候被其他线程抢到锁然后执行了一些操作,导致了后续线程再抢到锁的时候会出现错误。正确的做法应该是一直持有锁直到操作结束。代码示例如下:
public void tryOccupyTransferFloor() {
synchronized (this) {
while (isOccupied) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
occupyTransferFloor();
}
}
其余bug跟这个类似。总之就是要注意操作的连贯性、原子性,以及一个线程最好不要在持有锁的情况下阻塞在另外一个锁上,不然很容易死锁。
我新建了一个Debug类,内部有一个debug开关,并提供访问该开关的动态方法。如果有需要输出调试信息,那么就判断该开关是否是true,如果是true就输出调试信息。这样做的好处是提供一个全局开关,不用每次关闭调试的时候注意删除导致漏删。
线程安全:我统一选择了synchronized锁,并且在所有有可能并发读写的地方都上了锁(然后我的架构用读写锁和synchronized锁效果一样,因此我选择了synchronized锁,不然为了更好的效率我也会选择用读写锁),这样可以保证线程安全。我还注意了死锁,防止死锁发生。总体线程安全参考了四个原则:能获取锁的时候应该获取锁;有其他线程获取锁的时候其他线程不能获取锁;未获取锁的线程不能干扰其他线程获取锁;一个线程获取锁的需求应当在有限时间内得到满足。
我认为第二单元三次作业整体体验非常好!三次作业层层递进,由浅入深,让我深入理解了生产者-消费者模型,电梯调度,以及锁的使用;互测也很有特点,让我深刻体会了多线程的不可复现性;架构上锻炼了我的设计能力,让我深入理解高内聚低耦合;难度合理,能锻炼我们多线程的编码和debug能力,同时也不过分为难;创新点很有新意,双轿厢电梯和优先级让我们思考了更多,也真实模拟了现实生活中的场景,更贴合实际。最后感谢辛苦的助教们!!!