目录
时间过得很快,转眼间第二单元也已经结束,又来到了总结的时间,相比于第一单元,如今的我更能以一个平和的心态认真反思自己的问题。也希望有机会的话,我的总结能为更多人提供一些经验。帮助他们少走弯路,顺利过关~话不多说,我们开始!
Part 1 第五次作业
1.1 线程安全
1.1.1 同步块设置
第五次作业即电梯单元的第一次作业,主要是实现同一栋楼中六部电梯的调度。
起初我采用了自由竞争的方式,但即使后来对其进行优化仍然也会有着小幅高于调度器调度的耗电量,所以在最后我设计了调度器来调度这六部电梯,形成了如下的结构:
在这种结构中,同步块就在红色虚线框所标注的区域内,因为这些都是某些线程之间的共享区域:
Global Request Pool: Input Thread 和 Scheduler Thread共享
Sub Request Pool: Scheduler Thread 和 Elevator Thread共享
1.1.2 锁的选择
和大多数人的选择不同,我选用了Java的Reentrantlock这样的显示锁来作为保证我代码线程安全的锁。
选择它的原因是这种显式锁添加起来在代码中比较明显,帮助梳理思路,一眼看出代码哪里加了锁,哪里没加。且显式的特性让它便于理解。
1.1.3 锁与同步块中处理语句之间的关系
加锁的时候,我常常会问自己一个问题:
在这样的一段代码中,是否会出现有两个线程对容器同时读写,全部同时写的情况发生?
如果有,那么这一段代码就是需要加锁的,保证这段代码只能同时被一个线程执行,这样就不会出现线程安全问题。
所以,锁与同步块中处理语句之间的关系一:是否涉及了两个线程对容器同时读写,全部同时写的情况,如果有,就需要加锁保证线程安全。
但同时也会有另一个问题:
在已经加锁的代码中,是否每条语句都需要在同步块中?
如果这条语句移出同步块不会影响程序结果,那么出于性能的考虑,它就需要被移出同步块。
所以,锁与同步块中处理语句之间的关系二:在同步块中的语句最好的情况应该是恰好都需要被保护,而不是有不需要被保护的语句混杂其中。
当然,对于Reentrantlock来说,我可以生成许多把锁,假设现在有两个方法,一个方法是对某一容器的添加元素,另一个方法是对同一容器的删除元素。
我是分别用两把锁锁住这两个方法,还是用同一把锁?
如果用两把锁,只能保证这两个方法分别不会被多个进程同时执行,但不能保证这两个方法被两个进程执行的情况,在这种情况下,产生了对同一容器的同时写,也会产生线程安全问题。
所以,锁与同步块中处理语句之间的关系三:考虑同步块中处理语句中的容器是否在别的同步块中也存在对该容器的读写,如果有,就要加同一把锁。
1.2 调度器
1.2.1 调度器设计与交互
1.总请求池与分请求池间的调度器
考虑到这样一个问题:
如果将总请求池与分请求池之间的调度器设计为静态方法,即进来一个请求,对其进行一次分配,就会产生一个问题,在分配的过程中,新请求是进不来的,因为代码正在执行分配的过程。
这显然不够效率,所以需要将该调度器设计为一个线程,让其和输入线程之间共享一个全局请求池,并使用await和signal的方法进行交互。
在这种设计下,调度器相对于全局请求池(总请求池)为消费者,输入线程为生产者,调度器采取await的方式而不使用轮询,只有全局请求池里面有了请求才会被输入线程唤醒进行调度。
调度完成后再次进入await状态,等待输入线程的唤醒。在这样的调度中,线程间的调度方式略微区别于生产者——消费者模型,因为只有输入线程唤醒调度器线程,并没有调度器线程唤醒输入线程,但思想是一样的。
2.分请求池到电梯间的调度器
考虑这样一个问题:
如果将分请求池到电梯间的调度器设计为线程,那么电梯的状态只要一改变,就要唤醒该调度器进行调度,而电梯状态的改变是频繁的,频繁唤醒会增加CPU压力,并且,电梯无需像总请求池与分请求池间的调度器那样考虑在调度中来请求的情况,即使是采用线程也不能加速调度这一过程,所以直接用静态方法是比较合适的。
在这种设计下,该调度器作为一种策略存在于电梯中,每次电梯在到达一层后两次调用该策略,第一次由该策略给出在该楼层需要上电梯的人员名单,需要下电梯的人员名单,之后由电梯继续执行根据名单更新电梯内的乘客。
然后第二次调用该策略,由该策略根据当前乘客的目的地和请求池中的请求决定出下一步前进的方向。
调度器与线程之间的交互是同步的,由电梯线程执行调度器代码。
1.2.2 调度器策略
结合我在1.1.1中贴出的架构图片,会发现我需要实现两个调度:
1. 如何将全局请求池的请求分配给不同的子请求池。(总请求池——分请求池)
2.电梯如何选择处理自己子请求池里的请求。(子请求池——电梯)
为了方便理解,我先介绍第二个调度:子请求池——电梯
我采用了标准的LOOK算法:
若当前电梯处于移动状态(即有方向),它会一直按同一方向运行,直到没有让它继续保持这个方向的条件(前面仍有乘客,乘客需要在前面下电梯),它就会立即转向或停层。
这种算法经过历代学长的考量,是解决电梯如何处理当前请求池中的请求的较好调度策略。
结合LOOK算法的这种特性,我写出了第一个调度:总请求池——分请求池
既然在LOOK算法下的电梯一般会一直保持一个方向行进,那么在分配请求时,我们就可以迎合这个特性,为其分配迎合其方向的请求,对于停层的电梯,就将它视为朝两个方向运行的电梯,与前一种电梯共同判断谁离这个请求出发楼层最近,然后把这个请求丢给它的 分请求池。这样就可以保证在这种情况下,这个请求是被最优处理的。
当然,也会出现六部电梯都朝同一个方向运行,而请求在反方向的情况,在这种情况下由于并无法计算让哪一步电梯去处理该请求最优,所以我采用了将其分配给当前电梯里请求最少的电梯。这样可以让它尽早被处理。
1.2.3 性能指标适应度
首先在电梯内部的调度中,采用LOOK算法,保证了单电梯时间与电量做到我所能做到的最优。
其次在电梯之间的调度中,采用自己创造的配合Look算法而产生的算法+最少请求分配,相比于自由竞争可以避免多部电梯同时为一个请求工作的耗电情况。做到极致节能,并尽可能的保证时间的最优。当然,在这种情况下,时间上肯定会稍微弱于自由竞争,这个是调度无法避免的事情。
但相比而言,在耗电与时间的取舍中,采用调度器会比自由竞争拿到大幅降低的耗电量,略微增加时间的结果,这显然是优于自由竞争的。
1.2.4 UML类图及分析
变化: 第五次作业作为电梯单元的第一次作业,还没有可以参照的对象,所以没有变化。
未来扩展能力:
结合UML类图和1.1.1的架构图可以看出,该架构还是具有比较大的可扩展性的,如果要新增新的指令,只需要修改MultiElevatorControl和RequestPool即可。
并且总请求池和分请求池复用同一请求池类,改起来工程量也比较少。
如果要换策略,只需要更换LookDecision和修改MultiElevatorControl即可。
1.2.5 UML协作图
1.3 Bug分析
1.3.1 出现过的Bug
本次中测,强测和互测均未产生Bug。
课下自己做测试的时候有过线程无法结束的情况,这个需要好好规划一下,让Input的关闭信号传给MultiElevatorControl并唤醒,关闭MultiElevatorControl线程(即总请求池——分请求池调度器),然后传递给六部电梯并同时唤醒它们。
这里着重要强调唤醒,因为如果不对进程进行唤醒,进程一直休眠是无法处理这个关闭信号的。
1.3.2 DeBug方法
在这一单元,如我在第一单元所承诺的那样,我继续构建了评测机进行测试,用来产生Bug。
找到Bug之后,采用了打印流的方法来定位问题函数进行DeBug。
Part 2 第六次作业
2.1 线程安全
2.1.1 同步块设置
与第五次作业相同,同步块依旧存在于总请求池和分请求池里面,只不过是增加了更多的方法,因此增加了更多的同步块。
2.1.2 锁的选择
与第五次作业相同,依旧使用Java的Reentrantlock。
2.1.3 锁与同步块中处理语句之间的关系
在第二次作业中,基于我的设计,我的代码产生了一个新的问题:
两条相邻语句之间需要保证一定意义上的原子关系,否则会出现维护请求从一个请求中被删除,但维护信号还未加入另一个容器。这时候在两条语句之间被打断了,有可能导致调度器认为当前没有维修电梯的错判。所以在两条语句上要加一个调度器进行上述判断时所用方法里面加的那把锁,这样保证了两条语句没有全部执行完之前,调度器无法进行判断,会被锁阻塞。
所以,锁与同步块中处理语句之间的关系四:考虑同步块中处理语句之间是否需要保持一定意义的原子操作,如果是,就需要加对应的锁。
2.2 调度器
2.2.1 调度器设计与交互
本次作业新增电梯的动态变化操作,分为维护和增加。我主要修改了总请求池——分请求池调度器(总调度器),在总请求池中新增两个容器分别存放这两种指令,并修改调度器对其进行处理。
具体的说:ADD和MAINTAIN指令均在总调度器处理并完成,对于ADD,总调度器帮忙生成电梯并加入电梯列表。对于MAINTAIN,总调度器向被维护电梯发出信号并将其移出电梯列表。
对于交互的迭代,其实整体上仍然与第五次作业交互相同,只不过增加了一些新的交互,例如调度器向电梯发送新的MAINTAIN信号。
其余设计与第一次作业相同。
2.2.2 调度策略
本次作业不涉及调度策略的改变,与第五次作业相同。
其实我是想优化我的调度策略的,但写了一个很复杂的调度后发现其表现极其不稳定,有时候远远优于旧的算法,有时候表现很差,所以最终还是没有改变调度策略。
2.2.3 性能指标适应度
与第五次作业分析相同。
2.2.4 UML类图及其分析
未来扩展能力:由于和第五次作业的整体架构相同,所以分析同第五次,但我在实现处理添加电梯的请求的时候,使用了构造函数来添加电梯,如果后面添加电梯有新的参数也十分方便扩展。(一语成谶)
变化:相较于第五次作业,其实架构变化比较大的地方在于如何结束程序。原先结束总调度器线程的方法是只要输入线程结束,便可以关闭线程,之后只要电梯没有工作需要做就可以关闭线程。
但在第六次作业中,这样做是行不通的,因为即使输入线程关闭了,也可能存在未完成的MAINTAIN指令,导致被维护的电梯向总请求池递交请求,如果按照原来的架构,此时总调度线程已经关闭,这些请求不会被分配,所以丢失了。
即使电梯没有工作,在MAINTAIN后也可能会有请求给已经关闭的电梯,这些请求不会被处理,也会丢失。
所以,关闭的架构需要做出比较大的修改,但经过我的思考,有一种巧妙的改法:
将维护的电梯也视为输入线程。
这样就不需要大幅改变关闭的架构,只需要在条件判断时多加几个判断就好。
2.2.5 UML协作图
2.3 Bug分析
2.3.1 出现过的Bug
本次中测,强测和互测均未出现错误。
课下进行测试时出现了一个BUG:对于同一个人,维修时人还没从电梯出来,即还未输出OUT,另一部电梯就已经输出IN了,这是因为代码语序设置错误,应该先输出OUT后将请求返回,而不是先返回请求再输出OUT,这样就容易被别的线程中断导致输出错误。
2.3.2 DeBug方法
同第五次作业。
Part 3 第七次作业
3.1 线程安全
3.1.1 同步块设置
与第五次作业相同,同步块依旧存在于总请求池和分请求池里面,只不过是增加了更多的方法,因此增加了更多的同步块。
3.1.2 锁的选择
与第五次作业相同,依旧使用Java的Reentrantlock。
3.1.3 锁与同步块中处理语句之间的关系
本次未产生新的关系。
3.2 调度器
3.2.1 调度器设计与交互
本次作业对电梯提出了新的要求——可达性,电梯仍然在1-11层之间运行,但只能到达某一些楼层,用11位二进制的01表示该层是否可达。(即掩码)
所以在总调度器的位置,调度器要做出比较大的变化,能够计算出一条可供乘客使用的路径。并记录这条路径且按时安排。
具体的设计:
考虑到电梯的基准策略为找最少换乘路线。而且我认为确实是寻找最少换乘能让电梯的效率在普遍情况下得到最高(不断换乘也是需要消耗时间的)
所以我采用了BFS(广度优先搜索),以电梯作为状态。找到第一个解就立刻返回,可以保证这是一条换乘最少的路线。
这样做在时间上会比BFS快很多,而且我也做了一定的优化,比如每一部电梯只被搜索一次。所以我最多搜索电梯数目个状态。
因为在当时那种情况下,电梯的状态是一定的,可达性也是一定的。第一次经由它搜索到不了,第二次肯定也到不了。
交互:
在本次作业中,电梯与总调度器的交互性大大加强,一个路径被规划好后,需要将每一个步骤所形成的——PersonRequest按顺序投入对应的电梯,且保证时序,即对于同一个人,要先完成前置运输请求,才能进行下一运输请求。(人是不能分身的,且不能Teleporting)
这就需要完成任务的电梯给总调度器返回完成信号,让调度器继续分配下一步骤PersonRequest给某部对应电梯。
3.2.2 调度策略
由于电梯可达性的加入,电梯策略需要做出一定的改变,所谓大道至简,所以我的策略就是:
如果只有一部电梯可以直接完成请求,就用一部电梯
如果有很多电梯都可以直接完成请求,此时问题就变为了前两次作业的调度问题,直接复用前两次作业的调度策略。
如果没有电梯可以直接完成请求,再进行BFS寻找最少换乘路线。
3.2.3 性能指标适应度
由上面的调度策略进行分析:
在电量上这种策略一般是比较优秀的,因为过多的换乘只会增加电量(开门,电梯的移动),如果不得不换乘也是采用了最少的换乘,使用了最少的电梯,开了最少的门。显然是很省电的。
在时间上这种策略只能保证在一般情况下比较优秀,因为有些时候,可能换乘会比直达都快,但在我算法中只会优先直达。
但换个角度,如果调度花费了太多时间计算,是不是也会导致时间的增加?这同样是需要思考的一个问题。
3.2.4 UML类图及分析
未来扩展能力:到这次作业,总调度器的扩展方案基本成型:
public void run() {
//int i = 0;
while (operateSign) {
//i++;
//System.out.println("第" + i + "次轮询");
HashSet<ElevatorRequest> nowElevatorRequest =
globalRequestPool.forEachElevatorRequest();
for (ElevatorRequest request: nowElevatorRequest) {
assignElevatorRequest(request);
}
HashSet<MaintainRequest> nowMaintainRequest =
globalRequestPool.forEachMaintainRequest();
for (MaintainRequest request: nowMaintainRequest) {
assignMaintainRequest(request);
}
HashSet<PersonRequest> nowPersonRequest =
globalRequestPool.forEachPersonRequest();
for (PersonRequest request: nowPersonRequest) {
assignPersonRequest(request);
}
HashSet<PersonRequest> nowRequestHadDone =
globalRequestPool.forEachRequestHadDone();
for (PersonRequest request: nowRequestHadDone) {
updatePerson(request);
}
HashSet<PersonRequest> personNeedFlush =
globalRequestPool.forEachPersonNeedFlush();
for (PersonRequest request: personNeedFlush) {
flushPerson(request);
}
checkCloseSign();
}
}
可以看出如果有新类型的请求,可以按照同样的模板进行添加,扩展性较好
电梯的变化方案基本成型:
public Elevator(int elevatorID,RequestPool requestPool,double arriveTime,int capacity,
int nowFloor,RequestPool globalRequestPool,int arriveAbleMask,
MultiElevatorControl control) {
this.buildingName = "NewMainBuilding";
this.elevatorID = elevatorID;
this.elevatorDecision = new LookDecision(requestPool,this);
this.doorState = false;
this.nowFloor = nowFloor;
this.direction = 0;
this.capacity = capacity;
this.personInElevator = new HashSet<>();
this.lock = new ReentrantLock();
this.condition = lock.newCondition();
this.workSign = true;
this.requestPool = requestPool;
this.readyToClose = false;
this.arriveTime = arriveTime;
this.readyToMaintain = false;
this.globalRequestPool = globalRequestPool;
this.arriveTime1000 = (long)(arriveTime * 1000);
this.arriveAbleMask = arriveAbleMask;
this.control = control;
this.thisFloorJustUpLoad = false;
this.thisFloorIsAdded = false;
}
构造函数简单直观,如果需要新的电梯参数直接加入即可,电梯扩展性较好。
甚至由图可知,代码有着不同楼的扩展性接口(可惜三次作业都没用上)
变化:
主要是新增了几个辅助类,同时将PersonRequest类转变为了Person类,将处理思路由处理请求变为了处理人。
在分请求池里面增加新容器,用来判断该请求是一个直达请求还是不直达请求中的一部分。
在总请求池里面添加新容器,用来接受电梯完成请求的回显,帮助总调度器非直达请求判断下一请求的分发。
在总请求池里面添加新容器,用来接受电梯维护时需要被刷新的人,如果对这个人的运输请求是属于不能一次完成的请求,此时由于路径已经变得不可达,所以就需要重新规划。
总调度器添加与这些容器对应的处理逻辑。
3.2.5 UML协作图
3.3 Bug分析
3.3.1 出现过的Bug
本次中测,强测和互测均未出现错误。
课下进行测试时由于加了一个新锁,且锁的同步块加的太多,出现了未知的死锁,大概跑评测机跑几百条会死一次,且无法复现,决定直接抛弃该锁,将这些同步块加入已有的锁中,用少许的性能换取线程安全。
3.3.2 DeBug方法
同第五次作业。
Part 4 三次作业稳定和易变分析
稳定性
结合三次类图可以看出,在1.1.1中展示出的这种总的架构在全程是不会改变的,维持了较高的稳定性。
架构中各个组成成分的职能在定位上也是不变的,调度器永远做调度的事情,请求池永远做存储请求的功能。功能不交叉,不重合。
电梯内部的调度策略比较稳定,一旦选定,就不会出现改动。这是因为给予电梯的请求一直没有发生改变。且给予电梯的请求本身就比较难以发生改变。
综上,我认为在第二单元,整体的架构是比较稳定的,且为固定功能提供策略的方法是比较稳定的。
易变性
结合三次作业,可以看出,代码主要变动一直在总调度器和总请求池这一部分,这是由于新请求种类的不断加入,使得这些全局的模块不得不对其改变做出响应,增加相应的处理模块。
也因此,我认为全局性的模块是最容易发生变化的,只要输入变了,它们一定受到影响。
其次的变动是电梯线程,它需要在后续支持维护,所以需要增加维护方法。
也因此,我也认为提供功能的模块是容易发生变化的,因为输入的改变决定着功能的调整。
Part 5 心得体会
线程安全
线程安全是第二单元并发条件下需要解决的问题,我想也是这一单元的核心所在。
对于线程安全的维护不应该是随心所欲,想到哪里写哪里,而是应该在写前心里要有一定的计划,所谓谋定而后动在这一单元我认为极其重要,在这一单元正式写代码前,我都会在心里规划很久,判断每一次加锁的合理性与必要性,然后才下手去写,如果只是盲目的直接去写代码就会很容易出现下面的问题:
1.忽视一些需要被保护的代码,出现线程安全问题,而出现线程安全在代码比较繁多的情况下是很难被de出来的。
2.在使用多把锁的代码段下不注意细节,导致了死锁的产生,而这些死锁可以100%复现还好,一旦出现偶尔死锁一次的这种死锁,再跑一遍又不死锁的这种问题,de起来更是如同噩梦一般。
3.保护了过多的代码,由于过多的同步导致了性能的大幅下降,体现不出多线程并发的优势。这样的后果虽然没有前面严重,但不是一个优秀程序员应有的操作。我想也不是OO这一单元的初心。
现如今CheatGPT的大火,背后有着的是高性能计算的支持。想必在未来一段时间,高性能计算领域必将迎来发展,而高性能计算中并发就是一个很重要的手段,所以,如何让线程变得安全不仅仅只是为了能够通过第二单元,也是在培养我们这种线程安全的思想,为未来可能在高性能计算领域立足打下坚实的基础。
层次设计
有很多同学很喜欢用自由竞争,因为简单好写,包括我在第五次起手写作业的时候也是先写出了一个自由竞争。
这种设计虽然简单,但层次确实是不够清晰,因为不存在总调度器,或者说总调度器是电梯本身或者JVM,这显然是不够合理的。
所以我在后来引入了总调度器,正式打造出了从一而终贯穿三次作业的层次结构,再次引用这张图:
在这种层次化的设计中,我将调度解耦为了两个部分,一部分是总请求池——分请求池的调度,另一部分是电梯内部的调度,调度的思路分开,不混杂。
我也将请求池分为了一总请求池和N分请求池的模式,让每一部电梯只能通过自己内部的调度策略看到自己子请求池的请求。这种设计思路可以无限重复构造复用,为第六次添加电梯打下了基础。
总体上说,各个模块的职能十分明确,不交叉,边界明确。这种层次化的架构为我的迭代带来了巨大的帮助,对于新的要求,能很快的根据各个部门的职能做出定位,并对其进行迭代,不会出现改一处而动全身的情况。(当然,迭代也不能破坏这种边界感。否则就失去了层次化设计的意义。)
有了这样的设计层次,让我感觉第二单元的作业反而是得心应手。几乎没有写出过十分恶性的Bug,导致整体架构的崩溃或者重构。
所以,经历过第二单元的设计,我进一步认识到了层次化设计对程序员设计复杂度较高程序时思路上的巨大帮助,且可以显著减少Bug的产生。并且可以很好的保证代码的扩展性,为代码留下更多的未来。