面向对象程序设计-第二单元总结
同步块与锁
本单元是多线程编程练习单元,因此线程安全是重点,也是难点。而线程安全往往体现在锁的设置上,在此陈述一下锁的设计。
第一次作业
第一次作业较为简单,只有一部电梯,因而可以很方便的按照生产者-消费者模式进行构造,将输入和电梯作为两个线程进行处理,而只需在二者取放需求的“桌子”上进行加锁。为此我特地设计了一个WaitTable类(这个类将会贯穿整个第二单元),给WaitTable各个方法都加上了synchronized关键字,以WaitTable实例自己作为锁。这样既保证了访问WaitTable实例这个共享对象时的安全性,又保证锁的设计具有足够的逻辑。这个大概可以算是一个线程安全类?
public synchronized void addQueue(ArrayList<PersonRequest> waitQueue, int i);
public synchronized void addRequest(PersonRequest request);
public synchronized void removeRequest(PersonRequest request);
public synchronized boolean hasRequest(int floor);
public synchronized PersonRequest getRequest(int floor);
public synchronized ArrayList<PersonRequest> getRequests(int floor);
public synchronized void setEnd();
public synchronized boolean isEnd();
public synchronized boolean isEmpty();
public synchronized boolean isFloorEmpty(int floor);
public synchronized int getNum();
在加同步块的过程中,随着熟悉程度的增加,我大概理清了放在括号内的对象与相应的同步块之间的关系,当要操作某个共享对象时,一般就要将共享对象作为锁,做到随手利用既不浪费也不滥用。但是要注意的就是尽量不要在一个同步块中操作两个共享对象,无论引入嵌套还是不引入都有线程安全问题。以及使用实例锁时要保证使用的是正确的实例,而不要进行拷贝。
而在这次作业中,唯一看起来可能会线程不安全的就是WaitTable有一个发布内部容器的方法,发布后容器是否还处于同步块中就很难说了。因此这不算是一个线程安全的设计,只限本人还能在比较清醒地编程时使用(雾)。正确的做法是,如果要迭代,那么可以写一个迭代方法,专门用来迭代,并且包上synchronize。
第二次作业
第二次作业难度增大了,需要对三部电梯进行调度。因此,不可避免地,共享对象的数量会增多。再加上我的架构是分布式调度,输入线程与调度器之间具有一个共享对象,而调度器与每部电梯间都分别具有一个共享对象。因此,线程安全的问题就更为严峻。因此我将WaitTable更为完善化,以复用在各种情况下。依然是所有的方法都加锁,但是某些方法更倾向于被调度器使用,而另一些方法则是被电梯使用。当能够满足各方需求时,这个线程安全类便解决了这些棘手的线程安全问题。此外,在确实要用同步块时,避免使用嵌套同步块,防止发生死锁。
但是在此之外,还有另外一些比较麻烦的问题。一是分布式调度往往需要考虑电梯的状态,否则没办法给出具体的针对措施。而要获取电梯的状态是比较麻烦的事情,因为电梯也是线程,必然对于自己的信息有读有写,这样相当于又多了几个共享变量/对象。处于这种考虑,我最终放弃了利用电梯的信息,转而使用平均调度,从而避免了这个问题。(其实后来回顾时发现,调度器只需要读取电梯信息,而不需要写电梯信息,电梯信息的更改只有对应的电梯线程来进行。因此此时只需要加上volatile关键字,并保证电梯信息更改的合理性(不会说所在楼层中途变成一个离谱的数字等),就没有严峻的线程安全问题,只是调度器读取时得到的信息可能不是准确的,但参考现实电梯,这种对延迟的考虑也有一定的合理性)。二是电梯的添加与遍历可能是不同的线程在操作,这样电梯列也涉及到线程安全问题,因此需要将电梯列作锁以保证二者的互斥性。而如果还涉及到删除电梯操作,那么情况会更加复杂,电梯列的互斥与线程的关闭都十分麻烦,在此不表。
第三次作业
第三次作业相对第二次作业的变化,不在于电梯的参数变化,而在于电梯换乘操作。由于我本单元作业采用的是分布式调度,换乘操作需要交给电梯完成并返回至调度器的大需求池,因此出现了多个线程访问一个共享对象与两个对象间存在两个及以上的不一样的共享对象的情况。这使得死锁的出现成为可能。为了避免死锁的情况,我尽量避免同步块的嵌套,保证一个同步块内只涉及一个共享对象的操作(除非涉及到要求整个操作过程要保证数据的一致性),从而避免两个线程都出现等待锁的情况出现。此外,由于换乘的出现,输入线程的结束已经不能保证不会再有需求进入带调度的状态,此时便出现了调度器与电梯互相投喂的情况,一旦出现wait与notify(All)出现错位的情况,就会死等下去,直到超时。出于解决问题的考虑,我出了一个下策——调度器使用定时wait来保证不会都出现死等的情况,每隔一段时间查询一下电梯的运行状态与需求池的数量状态(这又是一个线程安全的注意点,特别是还可能有延迟的数据)。其实如果可以在调度器里事先知道会不会换乘,就可以通过记录是否还有需要换乘的人未到达换成楼层来避免这种情况。
调度器设计
第一次作业
因为只有一部电梯,所以第一次作业没有调度器。电梯的运行,进人出人,接送策略都由电梯完成,相对来说更接近电梯内置调度器的情况。因此在这里说一下电梯本身怎样“调度”自己。
本次单电梯采用的是类似于以当前等待电梯人群的最高楼层与最低楼层为扫描区间端点的区间反复扫描的算法(LOOK算法?),也略类似于ALS。当电梯静止时,若有需求需要满足,则到达那层并打开门接入。第一次作业时我选择的是最远的需求作为目标(当时也没有仔细分析)去接送。一旦电梯运行起来后,就会每到达一层检测一次,如果有同向的需求并且电梯未满便接上(同向指的是需求的目的相对起点与电梯的目的相对所在处相同)。电梯在这个方向上的目的会实时更改为同向最远目的的那个请求与最远起点中最远的那个,因而表现起来就是往一个方向上走到底才换向,这是Random模式。而Morning和Night由于起终点的特殊性,表现类似于上下反复扫描,简化了运行逻辑。只不过Morning设计为需要尽量等待人数凑满。值得一提的是,其实我的Morning设计的有点问题,既不应该先运楼层高的,也不应该先运先来的,更不能来一个进一个,这些都不方便运送最节省时间得需求——即最近的。正确的处理方式是先让人在外面等待,人齐再进,避免踢出操作。人齐再进也要优先拉入低楼层的人,以保证去往高楼层的人发生聚集效应,从而能够更加集中的运输减少时间。
电梯运行逻辑:(Random)
public void random() {
while (true) {
synchronized (waitTable) {
if (!waitTable.isEnd() && waitTable.isEmpty()) {
try { waitTable.wait(); }
catch (InterruptedException e) { e.printStackTrace(); }
}
}//等待控制
if ((hasPeopleIn() && !isFull()) || (hasPeopleOut())) {//开门条件
open();//开门
close();//关门
}
setGoalForRandom();//设定目标
while (!isArrive() || !isEmpty()) {//是否停下来
if ((hasPeopleIn() && !isFull()) || (hasPeopleOut())) {
open();//开门
close();//关门
}
setGoalForRandom();//设定目标
move();//移动
}
synchronized (waitTable) {//退出判断
if (waitTable.isEnd() && waitTable.isEmpty() && isEmpty()) {
return;
}
}
}
}
逻辑很简单,判断加动作,判断主要在于何时等待,何时开门(防止盲开浪费时间),何时结束,而动作可以单独封装起来,然后来进行调用,从而提高代码复用性。
第二次作业
第二次作业相对于第一次作业多的是一个中央调度器,而各电梯除了优化调度策略没有什么大的改变。我的中央调度器的设计十分拉垮,也分为三种模式,Random采用的是轮流分配与最少分配策略,在轮流分配时保证多部电梯的等待队列等待人数均衡。之所以这么设计,一方面是因为(懒),另一方面是没能设计出一个足够好地适应各种情况的算法,就算有些能够优化到极致,但负优化的那些往往可能会因为木桶效应大大拉低整体效率。Morning模式则采用优先分配低楼层目标需求,而Night模式则优先分配高楼层目标需求。
稍微再说一说我对我设计的调度器的理解(或者说分布式调度)。在整个电梯运行过程中,调度器负责的是将输入的需求分配给各个电梯完成,而各个电梯既无法看到已经分配给其他电梯的需求,也无法看到输入但未分配的需求,在各个电梯看来,好像所有楼层中就只有那些人(分配给它的人),而其他人被调度器的分配隐藏起来,这样电梯看不见,也才摸不着其他不应该由它处理的需求。这就很像地址映射,各个进程的进程空间是互相独立的,要访问资源需要MMU将地址转化才能取出。因而各个进程只能看到想让它看到的资源,而无法探索未分配或未交付给它处理与使用的数据。

中央调度器设计:(Random)

第三次作业
第三次作业调度器本来应该按照各电梯参数不同而给予不同的职责,但是由于样例的不可预测,职责过于特化反而不利于最广泛的适应能力。如果因为C电梯是快腿远程电梯就将所有的远程需求交给它,那么一旦需求量巨大,仅靠C电梯可怜的运力是完全不够的。因此我采用的还是佛系的分配,轮流合理(起点对该电梯要能到达)分配+运至最近策略,使得这三种电梯都能清空自身(不会把人关在里面)。换乘则是由电梯完成,由电梯确定是否能送到,如果不能,则主动生成新的需求并打回至调度器缓存,由调度器重新分配,解决了调度器拆分需求的同步问题。此外,限制了换乘次数,经过BC运送拆分过的需求做上标记,只允许分配给A电梯,从而减少错误分配次数,浪费开关门时间。
至于三种模式的编写,其实我发现已经没有多大区别了,一旦引入换乘,电梯就不能简单地地上下扫描。因此对于Night模式,我尽可能得使各电梯优先运能运的最远的需求,而避免换乘;对于Morning模式则采用与Random几乎相同的方式,只不过多了个等待人满的过程。因此在这里我觉得非常没有把握,但又无可奈何,这也算是一个调度器设计的难点。
可扩展性
第三单元UML图
UML顺序图
画完这两张图后,我对我自己所写的电梯程序也有了更深刻的理解。其有一个好处就是电梯有自己的调度器,完全可以脱离中央调度器而运行,中央调度器只是一个分配二传手。虽然我没有写出一个比较好的优化算法,但是这也增强了可扩展性,中央调度器可以使用静态优化算法和动态规划算法来增强分配的有效,而这只需要增加获取的信息与处理判断算法;也可以主动退出,让各个电梯自行在大需求池中去争夺需求。更重要的是,对于可能的增加需求,也有一定的适应能力。比如续乘电梯,不同的电梯可能有其独有的运行区间,从而必须满足换乘,而我的第三次作业实现了换乘,并且倾向于将人送到离目的最近之处,完全可以稍微更改调度策略和参数来适应这种情况。而对与有地下部分的电梯,只需更改电梯运行逻辑即可满足从-1到1的跳跃。
附:三次作业复杂度分析
第一次作业
第二次作业
第三次作业
bug分析
本次作业的强测与互测bug只有一个,就是第二次测试中的第九个测试点。
测试点如下:
[0.0]Random
[0.0]ADD-4
[0.0]ADD-5
[180.0]1-FROM-1-TO-20
[180.0]2-FROM-1-TO-20
[180.0]3-FROM-1-TO-20
[180.0]4-FROM-1-TO-20
[180.0]5-FROM-1-TO-20
[180.0]6-FROM-1-TO-20
[180.0]7-FROM-1-TO-20
[180.0]8-FROM-1-TO-20
[180.0]9-FROM-1-TO-20
[180.0]10-FROM-1-TO-20
[180.0]11-FROM-1-TO-20
[180.0]12-FROM-1-TO-20
[180.0]13-FROM-1-TO-20
[180.0]14-FROM-1-TO-20
[180.0]15-FROM-1-TO-20
[180.0]16-FROM-1-TO-20
[180.0]17-FROM-1-TO-20
[180.0]18-FROM-1-TO-20
[180.0]19-FROM-1-TO-20
[180.0]20-FROM-1-TO-20
[180.0]21-FROM-1-TO-20
[180.0]22-FROM-1-TO-20
[180.0]23-FROM-1-TO-20
[180.0]24-FROM-1-TO-20
[180.0]25-FROM-1-TO-20
[180.0]26-FROM-1-TO-20
[180.0]27-FROM-1-TO-20
[180.0]28-FROM-1-TO-20
[180.0]29-FROM-1-TO-20
[180.0]30-FROM-1-TO-20
[180.0]31-FROM-20-TO-1
[180.0]32-FROM-20-TO-1
[180.0]33-FROM-20-TO-1
[180.0]34-FROM-20-TO-1
[180.0]35-FROM-20-TO-1
[180.0]36-FROM-20-TO-1
[180.0]37-FROM-20-TO-1
[180.0]38-FROM-20-TO-1
[180.0]39-FROM-20-TO-1
[180.0]40-FROM-20-TO-1
[180.0]41-FROM-20-TO-1
[180.0]42-FROM-20-TO-1
[180.0]43-FROM-20-TO-1
[180.0]44-FROM-20-TO-1
[180.0]45-FROM-20-TO-1
[180.0]46-FROM-20-TO-1
[180.0]47-FROM-20-TO-1
这明显是一个针对性设计的样例,特征就是加入了新电梯,需求进入时间迟,紧逼时间,用来检验是否使用了加入的电梯以及是否同时起运与满运。而通过检查输出,我发现出现的问题是从一楼出发运行时电梯只进入了一人便关门走人,从而浪费了宝贵的一次往返时间,直接导致超时。
这真是个无厘头的bug!但是同时也很好定位。通过检查代码,我发现是电梯类Elevator中的enter方法操作有问题,当进来一人后,目标没有及时设定,从而导致进入条件中的“电梯空”未被满足,直接带人跑路。
private void enter() {
PersonRequest person;
synchronized (waitTable) {
while ((person = waitTable.getRequest(nowFloor)) != null
&& !isFull() && (personDirection(person) == getDirection()
|| (getDirection() == JUST && isEmpty()))) {//当电梯目标未设定时需要检测是否为空以保证不运送两个方向的人,反而导致人进不全,一旦有人进入,不再有任何一个条件满足,从而关门。
waitTable.removeRequest(person);
peopleInElevator.add(person);
TimableOutput.println(inString(person));
}
}
}
为了解决这个问题,就要在进来一人后及时设定目标,更换布尔表达式的触发机制,保证能够将人尽量装满。那么更改就是在random的运行机制里加上一个setGoalForRandom以及再加一个enter,在设定目标后在督促人进入即可解决问题。
open();
exit();
enter();
setGoalForRandom();//+
enter();//+
close();
但其实这里面还有一个问题,就是分配的时候可能会有一到两个的偏差,从而使得某个电梯需要再下来运送一次,拉长了运行时间。
发现bug
本单元由于多线程玄学的运行顺序,很多时候即使线程不安全,bug也不不一定能复现。此外,大家都十分默契地没有提交,因此本单元我都没有提交互测(懒)。因此在这里,我打算说一下de(hack)自己的bug的经历。
为了弥补第一单元未写测评机的遗憾以及保证自己电梯的正确性,我在做第一次作业时就写好了一个初步的测评机。测评机使用C语言写就,引用了讨论区的定时输入程序,补充了数据自动生成器(可调参)与输入输出分析比对器,使用bat脚本和循环调用脚本程序进行自动测试。效果嘛,其实还不错,在长时间的测试下不出现bug(指卡死和输出错误)的程序,基本上都能过强测,尽管数据是随机生成的。而后两次尽管不是我完成的测评机,但是算是以我第一次作业的测评机为基础完善的。也算是让我稍稍的锻炼了一下自动测试与测试设计的能力,以及编写代码的速度()。
在有了测评机的情况下,我发现了一些我的问题,包括调度器莫名其妙的卡死,电梯的某一个模式出现无响应,反复开关门输入输出乘客等,尽管说起来都是些笔误之类的,辅以对源代码的同步块的查找与判断嵌套,最终都解决了问题。而其中用的最多的是jvisualvm,倒不是为了紧逼CPU用时,而是用来定位运行时间异常的线程,从而为快速定位哪个线程类出现错误提供方便。
与第一单元相比,第二单元的bug除了WA,StackOverflow,OutofMemory外,还有可能出现多线程特有的死锁,死等,同时写等问题,而这些问题往往难以被发现,因为需要一定的机缘巧合错误才可能暴露出来,如高并发,人为sleep。因此,虽然错了说明一定错了,但是对了却未必一定对了。因此,足够多的随机数据尽管在一定程度上能够筛出一些错误,但更多的时候还是让人难免有些不安。
心得体会
尽管有点尴尬,但是有话直说,做这三次作业时,我对于怎样兼顾线程安全和调度优化十分头疼。调度器每需要多获取一些信息,就要考虑加相当多数量的锁,从而导致一些难以发现的细微错误出现。大概是我的进程类写的太混乱了,导致各类间复用度不够但又十分类似,为我写代码埋下了许多坑。而且这种输入的信息不确定,信息被捕获的时间也不确定的情况下的情况下优化调度算法,简直让人强迫症发作,就算自己想努力优化一把,但是一想到写出来的东西让人心烦,就往往放弃了(尽管有些时候调度不到位还不如不调度)。

浙公网安备 33010602011771号