BUAA_OO_UNIT2

~~多线程是检验一个人血统是否为欧皇的极佳标准~~

1. 第一次作业

1.1 同步块设置与锁的选择

​ 由于Input与Elevator只会从Tray(货架)中put或get纸片人,故同步块只涉及Tray中方法。刚开始接触多线程学得比较菜,所以直接把所有可能产生data race的方法都加了synchronized关键字。由于第一次作业比较简单,只加了两个方法:

public synchronized void putPerson(Person person);
public synchronized void getPerson(...);

​ 这两个方法会操作共享对象personMap,一个扔纸片人一个拿,故需要加锁锁住Tray,保证串行访问。

1.2 调度器设计

​ 本次所有调度方法都包含在Strategy中。电梯有五种状态:UP、DOWN、IDLE、OPEN、CLOSE,是一个有限状态机,如下图所示。

​ 电梯在IDLE状态会query等待队列,若为空则wait();其余状态转移时,会调用Strategy.manage来获得下一次目标。本次优化存在bug,导致性能分只有8.4/15。

1.2.1 Random

​ 使用sstf算法,依次计算进电梯、出电梯与当前楼层最小值,设为下一个目标楼层。当电梯满时,仅考虑出电梯最小者。大概这样:

public int minIn();
public int minOut();
public int manage(Elevator e) {
    if (e.isFull()) return minOut();
    else return Min(minOut(), minIn());
}

​ 实质上,为了避免电梯在minOut == minIn时反复横跳,会在相等时让目标楼层为minOut。

1.2.2 Morning

​ Morning没什么好策略,只是让电梯等人满了或者接到输入结束信号时出发。略微优化在于,考虑对等待序列排序,先接低的再接高的,这样电梯结束时就会停在高层不需要回到底层。同时,为了判断下一步行动,需要传入电梯当前状态再判断。

now == UP -> minOut();
now == DOWN -> minIn();
now == IDLE -> wait until FULL or END

1.2.3 Night

​ Night我采用自顶层向下接,大概是LOOK算法。为了适应从顶层接,在Tray中新增方法maxIn,找到最远要进电梯的。因为Night一次性到达,故不可能出现IDLE状态。

now == UP -> maxIn();
now == DOWN -> minIn() or END;

1.3 架构分析

1.3.1 UML类图

​ UML类图如下。

1.3.2 UML时序图

​ 未标注为线程的类都位于主线程中。

电梯的时序图如下。

1.4 Bug分析

​ 本次作业在中测、强测、互测中均没有发现bug。

​ 由于第一次作业并发程度较低,同时我将共享对象Tray设计成线程安全类,故测试过程中没有线程安全问题。采用的策略包括随机数据以及手动构造极端数据,包括但不限于固定楼层反复走、Night同时从底层到某层、Morning同时到达等。

​ 互测时用自己的测评机、构造的自认为极端的数据跑屋里地其他电梯,都顺利地正确跑完。尝试未果后躺平了(屋子里也特别平静就是了)。究其原因,本次作业简单有之,数据强度不够也是一大问题。

​ 在写的过程中,bug的发现小部分依靠测评机,大部分依靠加System.out,手动看输出是否符合预期,比如Morning是否载满了人、Night是否从顶层向下等。本次出现并修复了如下bug。

  • Tray.manage中,elevator.isFull成立时,错误地返回了原有的dst。应该返回minout
  • Tray.getPerson()中忘记对left判断
  • CLOSE判断出错,在第一个if中判断电梯为空时立刻进入IDLE,导致变成傻瓜式电梯。导致我性能分严重丢失。修复后平均比原来快10s

​ 分析原因,主要是对电梯状态转移的理解不够清晰。

2. 第二次作业

2.1 同步块设置与锁的选择

​ 主要有三种类型加锁:

  • Tray:getPerson、putPerson、setTrue、setFalse,四者都锁住对应等候队列personMap.get(location)。前二者直接与等待队列进行交互,涉及共享对象人员的进出,故需要被加锁;后二者不直接与等待队列交互,而是对等待队列的isDelivered进行置位或复位,以标志队列的等候人员是否被完全接送,也属于共享对象,故需要被加锁

  • Strategy:manage方法,锁住Strategy对象与三个query方法,锁住与Strategy交互的Tray。前者原因与第一次作业相同,是为了每次通过Strategy间接访问Tray中等待队列,并对其状态进行修改;后三者加锁,一方面是因为Strategy是所有elevator的共享对象,所有elevator在IDLE状态都会进行query操作,为了保证一次只能有一部电梯通过Strategy对Tray进行状态访问与修改而必须加锁,同时所有的代码都有类似如下代码的结构

    synchronized (tray) {
        while(tray.isEmpty && !tray.isEnd) {
            tray.wait();
        }
    }
    

    在输入未结束但是当前没有等待的人时,进行query的电梯会在Tray上进行等待,故确实需要加锁保证电梯能正确地进行wait

  • 其他:锁住elevator对象,仅涉及在Morning模式。其中,电梯内部会在getPerson失败时执行this.wait(),而query方法会在某部电梯读取到输入结束时调用notifyElevator方法通知其他的电梯结束继承。此处加上synchronized并没有线程安全问题,仅仅是为了避免轮询而使用的wait、notify操作

2.2 调度器设计

​ 本次涉及多个电梯的调度。由于我采用集中式调度,只有唯一一个货架Tray,其中等待队列对所有电梯共享,故需要在等待队列中加入isDelivered的布尔值表示是否被完全接送,当minIn()等方法遍历等待队列时,遇到isDelivered == true会自动跳过。对此,需要配置方法setTrue与setFalse,在manage使用:

public synchronized int manage() {
    ...;
    setFalse(dst);
    setTrue(nextDst);
    return nextDst;
}

​ 每次更换目标时,都需要复位原有目标的布尔值,并对新目标置位。

​ 本次作业因为在强测被hack一次而暴毙,其余点都是99.9或100,说明性能优化还行。但实质上因为我写的sstf不是抢占式的,很可能会出现离楼层较近的电梯抢不到该楼层、远的电梯却能够因为提前调度而抢到的情况。

2.2.1 Random

​ 还是采用sstf算法,取minOut()与未被置位的minIn()中更小者作为下一个目标,对原有目标setFalse,对新目标setTrue。同时,为了防止反复横跳,在minIn()中读到原有目标置位时不能跳过

2.2.2 Morning

​ 集中式调度Morning有点难写,故采取了比较复杂的策略

if (stage == UP) return minOut();
if (stage == DOWN) return MINSTORE;
if (stage == IDLE) return waitPeople();

​ 而在waitPeople中,需要再次调用queryMorning,大致代码如下。synchronized代码块里为wait,略过不提。

private int queryMorning() {
    synchronized (tray) {...}
    if (!elevator.isEmpty || allElevatorEmpty) return MINSTORE;
    else if (isEnd) return null;
    else return MIN_VALUE;
} 

​ 大致思路为:

  • 若电梯非空,则具有接人的优先权,以尽量让所有纸片人进入单部电梯而快速出发;若目前在底层的所有电梯为空,则本电梯可以优先开门接人

  • 否则,如果此时输入结束,则此时能保证所有人都进入电梯,此时返回null,当读取到这个标志时,电梯中有人的会立刻出发,无人的会结束继承

  • 否则,此时电梯既不可以结束,又没有接人的任务,读取到MIN_VALUE时会继续this.wait(),等待有其他电梯从底层出发时把它叫醒,防止出现死等

    通过这样的方式,实现优先让某部电梯满载而快速出发。

2.2.3 Night

​ 本次的Night优化比较有意思。若采取上次作业的思路,每次只标志一个楼层的话,很可能会出现这样的情况:

楼层 等待人数
20 3
19 3
10 3
9 3
​ 不妨假设只有两部电梯。此时,很可能出现电梯1去20楼、电梯2去19楼,而非电梯1去20楼、电梯2去10楼这种能够让电梯更快往返的选择。在等待队列中增加liftID属性,setID与getID方法,用来对等待队进行标记;同时,增加方法getIdDelivDst,大致如下
for (i = MAXSTORE; i >= MINSTORE; i--) {
    if (getID == true) return i;
}
for (i = MAXSTORE; i >= MINSTORE; i--) {
    if (!list.isEmpty && !list.isDelivered) setTrue && setID;
}

​ 通过isDelivered与电梯ID的共同标记,来实现一部电梯包揽尽可能多的高层,以实现快速往返。

2.3 架构分析

2.3.1 UML类图

​ 跟上一次相比,把糅合在Tray中的调度部分单独分开,放在Strategy中。

2.3.2 UML时序图

​ 同理,多了跟Strategy的交互。未标注为线程的类都位于主线程中。

2.4 自己程序bug分析

​ 本次在强测中被发现了bug,原因是Morning有概率出现电梯死等而没被notify,导致CTLE。最后在queryMorning最后返回null时将所有电梯notify,此时能保证不再有电梯死等。

​ 本次写程序时,大部分问题出现在Strategy类的query与manage方法中,主要原因都是对if判断条件的先后顺序理解不清,例如下面这种简单情况

method: {	
	while (tray.isEmpty && !tray.isEnd) wait;
	if (tray.isEnd) return null;
    else return minIn();
}

​ 此时,可能会出现等待队列不为空、但是输入已经结束的情况,但先判断结束会导致电梯提前终止。最后通过在各种地方加System.out再肉眼观察,找出了类似的bug。修复过程列出了所有情况的组合,再求出最简表达式,对所有可能跳出wait的情况一一作出判断,保证正常运行。

​ 其他问题为线程安全问题,有两点:Strategy遍历电梯序列时忘记加锁,导致Input执行ADD指令时会抛出异常;以及在强测中被测出的Morning死等。前者加锁即可,后者是因为当有电梯从底层出发、有电梯到达底层、输入结束这三者同时发生时,可能会因为调度原因而产生这样的情况:底层电梯读取到输入结束,从底层出发,尝试notify所有电梯,但此时另一部电梯还未到达底层进行wait,此时会导致死锁。解决方法是在电梯结束线程时尝试notify所有电梯,保证不出现死等现象。

​ 综合分析,本次作业测试方式绝大部分为测评机随机数据测试,未手动构造比较复杂、更容易出现并发错误的数据,导致被强测hack。

2.5 别人程序bug分析

​ 本次主要采取测评机盲狙与手动构造极端数据的方式(这时候倒是想起构造数据了),两种方式均发现了互测屋同学的bug。前者盲狙我采用完全随机与部分随机的方法,完全随机避免有同学在某些随机、非极端情况下出现bug,而部分随机会固定特定楼层,以提高出错的可能性;后者大概这么构造:

  • Night:一次性一群人在特定高楼层或低楼层到底层
  • Morning:一次性一群人在底层前往特定高层,或周期性地来一群人

​ 最后成功发现有一位同学在Morning大量人同时到来且有ADD指令时会出现电梯超载、Exception,另一位同学在某几个特定数据下会少载人而WA,但是交上去都没hack到……再次验证了多线程调度具有随缘性(俗称看脸)。

​ 本次互测让我感受到了本单元与第一单元测试相当大的不同。本次测试重点在于如何构造数据复杂度不高、但并发程度高的场景,即如何让更多电梯产生冲突,而非构造大量看起来复杂实际却并发程度低的naive样例,更加考究对多线程核心概念的理解与体会。同时,随机数据在限定特殊楼层比上单元能更有效地测出bug。

3. 第三次作业

3.1 同步块设置与锁的选择

​ 本次作业基本延续了第二次作业的同步块与锁,改动在于对Elevator遍历时改变了锁的对象,从原有的全电梯序列改成锁住特定类型的电梯序列,以保证Morning遍历时能够读取正确的电梯类型;新增判断是否所有电梯是否的方法allElevatorEmpty,以判断电梯状态,此处也需要锁住特定类型,防止出现ADD指令抛出异常。

3.2 调度器设计

​ 本次作业我采用固定路线的换乘,增加新线程Calculate,在其他进程开始前计算出所有固定路线,minIn()等方法每次读取的都是Person的换乘目标(最终目标也可以视为换乘目标)。

​ 首先,为三种类型电梯分别建立可以停靠的楼层表

    private final int[] stopA = {0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
    private final int[] stopB = {0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0};
    private final int[] stopC = {0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1};

​ 而三种类型电梯交集:A∩B = B = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19},B∩C = {1, 3, 19},A∩C = C = {1~3, 18~20}。为了避免换乘楼层过大而出现频繁开关门、上下楼,以及潜在的人员反复移动的情况,故考虑固定AB在3、11、19换乘,BC与AC在3、19换乘。大致计算流程为

if (C) return C;//C直达
if (B) return B;//B直达
calculate(AB);//计算出AB在3 11 19换乘中时间最小者
calculate(BC);//计算出BC在3 19换乘中时间最小者
calculate(AC);//计算出AC在3 19换乘中时间最小者
return MIN(AB, BC, AC, A);//返回最小者

​ 本次作业基本延续了上一次作业所有模式采取的策略,几乎没有更多优化。最后性能分18.8/20。(但是听说有没换乘19.9的,我只能%%%%%)

3.2.1 Random

​ 完全延续了上一次作业的思路。相比于优先让乘客到达目的地,我采取sstf优先接乘客。每次minIn()、minOut()都是遍历乘客的换乘重点,然后找到最小者作为下一次目标就行。

3.2.2 Morning

​ 基本延续了上一次作业的思路。Morning策略我最后采取的是非换乘直达,即C优先负责,其次B,最次A,优先让非空、更快的电梯满员接送。

3.2.3 Night

​ 基本延续了上一次作业的思路。Night策略我最后也采取非换乘直达,B、C自顶向下扫描,找到下一站的目标;A自底向上扫描,目的在于防止低速的A抢占了高速的BC,同时A在底层时高载客量优势比较明显。

3.3 架构设计的可扩展性

3.3.1 UML类图

​ 下图是这次的架构图(IDEA自带的太大就不放了

​ 本单元设计的类比较少。模式采用生产者—消费者模型与事件驱动,让电梯变成状态机,在Stage规定的UP DOWN OPEN CLOSE IDLE这五个状态中运转,同时尽可能地少干事情,有任何状态变化都需要及时地问Strategy;Input就扔纸片人;货架Tray需要存储等待队列,对外暴露接口以反映当前状态;Strategy根据Tray暴露的接口与电梯传递的电梯,指导电梯下一步目标。各个类、线程分工比较明确。

3.3.2 度量分析

​ 下图是各个类的依赖矩阵,…处是116。

​ 可以明显看到,Strategy对Elevator、Tray与Stage依赖性非常高,这是符合预期的。Strategy需要根据电梯与货架状态,综合反馈给电梯下一站信息,故相当依赖于二者;而货架Tray对等待队列PeopleList依赖性很高,原因在于Tray本身就是PeopleList的一个封装、载体;对于Stage的依赖没有意义,因为Stage是枚举类。其余依赖性都在可接受范围内,说明很好地符合了低耦合原则。

3.3.3 UML时序图

​ 如下所示是手动画出的UML时序图,表示了各个线程之间的协作关系。未标注为线程的类都位于主线程中。

3.3.4 功能与性能的权衡

​ 本单元三次作业我都采用一个调度器与一个货架,多个电梯共享二者。

​ 好处也比较明显,容易结合抢人而让性能分起飞。仅有一个货架、一个调度器意味着没有信息共享问题,能够一次性获知当前货架状态,从而更好地抢人。同时,利用标记的方法,实现高效抢人,不会出现多部电梯同时抢的情况。这种架构设计我觉得相当自洽,能够与调度很好地结合。然而,坏处也比较明显,思路比较复杂难写,很容易出bug,需要提前好好地进行设计;同时,由于单个货架干的活太多,内部实质上比较混乱,可扩展性综合来说比较一般。

​ 性能方面,在Random采用sstf,在Morning采用等人,而在Night采用LOOK。

  • Random采用的sstf可扩展性比较好,与单个货架、单个调度器相当契合,通过标记、抢人策略,能够很好地保证正确性,同时性能也不错。

  • Morning问题比较大,可扩展性很差。由于电梯内部没有等待队列,想要让电梯等人、人优先进入非空电梯,需要很多冗杂的判断,很容易出bug。事实上,第二次和第三次作业很多时间都化在了修Morning的bug上。其内部冗杂的判断导致可扩展性相当差。若要优化,很可能需要改变原有功能与架构。

  • Night采用的LOOK算法可扩展性也比较好。通过标记的方式,自顶向下(BC)或自底向上标记(A),能够相当好地满足正确性,同时性能表现也相当优异。

​ 综上所述,第三次作业可扩展性实际相当一般。若要迭代开发,很可能需要小范围内重构。可见,真正靠谱的架构,一定是可以做到兼顾正确性和性能优化的,而不应该剑走偏锋。

3.4 自己程序bug分析

​ 本次作业未在中测、强测、互测中出现bug。主要通过部分随机测评、手动构造极端样例(例如针对换乘、并发等不同场景),效果还行。

​ 本次作业基本继承了上一次作业的架构,故bug很少。在写的时候出现电梯可能在错误的楼层开门的bug,在电梯中增加对开门的判断后即修复。另一个bug是在存在有电梯不为空、输入结束下,按照原有的输入结束时电梯即中止很可能造成有换乘需求却无法满足的情况,故最后对存在换乘的Random增加检测:

while (... && (!tray.isEnd || !allElevatorEmpty)) {
    tray.wait();
}

​ 通过检查其他电梯的状态,规避了提前结束。本次没有线程安全bug。

3.5 别人程序bug分析

​ 本次作业依旧采取部分随机数据进行盲狙与手动构造的方式。

​ 在此过程仅发现一位同学的bug。有同学出现在错误楼层停靠的bug,但是同一组数据在本地大量跑之后又没有复现成功orz。代码层面也没有发现异常,推测是由于多线程调度讲究缘分,缘分到了就出bug,最后也没有提交这个样例。

4. 心得体会

​ 总体上说,第二单元作业写起来感觉比第一单元顺手多了(指没有重构)。第一单元对JAVA的语法与面向对象思想还不够熟练,导致可扩展性相当差;第二单元提前看了往届的指导书,试着预判并提前预留了一些功能扩展空间。

4.1 线程安全

​ 本单元的线程安全问题浓缩起来大抵是血泪史orz。第二次作业多电梯对线程安全要求急剧提高,导致我在写的时候相当费力,需要考虑对特定对象加锁而非直接synchronized mthod,花费了不少时间思考死锁。

​ 采用生产者—消费者架构,货架Tray设置为线程安全类,将调度器Strategy方法设置为线程安全,所有电梯都无法直接与Tray进行交互,只能与Strategy简介操作Tray中的等待队列,在很大程度上规避了在奇怪的地方死锁与线程安全问题,及时出现了问题,只需要在Strategy中或肉眼debug或System.out进行debug,也很快锁定并解决了bug。可见线程安全的实现不仅需要思考各种并发场景与调教,也需要良好架构的支持。

4.2 层次化设计

​ 经历过上单元重构的惨痛教训后,我觉得层次化设计的能力得到了很大的提高。

​ 本次作业我采用生产者—消费者架构与事件驱动型,货架、生成者、消费者各司其职,消费者从货架中拿货物也需要通过Strategy的指导才能到某个特定等待队列中接人,而Strategy、Tray都是线程安全类,经过两个安全类与各种工作的合理分工,写起来思路比较清晰,也不容易出问题(大概);而事件驱动型针对电梯,电梯在且只在状态改变时才会尝试进行、改变行动,进而将电梯的行为划分为状态机,能够很好地分解各部分职能,减少了出错的概率。

4.3 抢人与分配

​ 关于这一点,研讨课上老师大概是这个意思:“抢人是偏向动态的规划,在运行时分配;而分配是静态的规划,在当前状态分配”。

​ 前者我比较赞同。我自己采用标记、抢人的方式,让多个电梯自由竞争,能够很好地让所有电梯都动起来,跑向离自己最近的楼层,有那么一丝动态规划的味道;后者我不太赞同。分配其实也可以动态地进行,比如增加缓存队列,每次分配不直接放在等待队列中而是放在缓存队列,在每次电梯状态改变重新分配,这样也行。但觉得可能信息共享有点问题,故最后我没有采取后者。

4.4 总结

​ 多线程确实很好玩,能让你时不时惊呼:“缘,妙不可言!”本单元在各种意义上都很好地锻炼了我对于并发、多线程的理解(以及锻炼了心态)。

posted @ 2021-04-23 20:53  _Winterfell  阅读(80)  评论(1编辑  收藏  举报