BUAA_OO_2022第二单元总结

1 概述

1.1 基本思想

  • 让各个线程处于临界区的时间尽可能短,因此要让临界区操作尽可能简单,这样也有助于避免线程安全问题

  • 电梯只负责乘客上下电梯与电梯的上下移动,电梯的控制工作交给调度器完成

  • 在满足上述两个条件的前提下,代码实现要尽可能简单,即使有可能损失一些性能

注:该作业中没有使用UML类图与UML协作图来进行设计的说明,这是因为在我看来无论是UML类图还是UML协作图,它们的初衷都是为了辅助说明设计,让读者更容易理解设计。但是UML类图显然无法达到这个目的,它并不直观,而且大量的类与属性的罗列只会让读者望而却步(你真的会看吗?)。UML协作图也具有这个问题。个人认为UML图比较适合充当设计的详细说明,作为开发人员之间统一接口之用等目的。所以为了达到让读者更容易理解设计这一初衷,本作业中我用自己的方法画了一些架构图,希望老师与助教可以理解。

再注:图片在github上,请挂梯子阅读。

2 调度器设计

三次作业中采用的都是自由竞争模式,并没有一个全局的调度器。但是每一个电梯都有一个调度器,用来控制电梯的行为。

2.1 电梯运行逻辑

首先有必要说明一下电梯与调度器的交互模式。这一模式在三次作业中都是相同的。在我的设计中,每个电梯都有一个调度器,这个调度器是电梯的一个属性,控制该电梯的运行,不同电梯的调度器之间不可见。电梯使用Mealy型状态机设计,共有三个状态,分别为STOP,DOWN,UP

电梯的核心为一个while循环,循环流程如下图所示:

在一个循环周期中,电梯首先调用letPassengerOff函数,让电梯中已经到达目的地的乘客下电梯。然后调用调度器的check方法。调度器获取请求,并根据请求与电梯当前状态更改电梯的状态。电梯然后调用letPassengerOn方法,将等待乘客从某一容器中取出并放进自己的乘客容器中。然后关门。最后执行move操作,根据电梯的当前状态进行移动。

这一设计有一个主要的特点,就是电梯里并没有最小最大楼层或可达楼层这一属性,电梯完全按照自己的状态上下移动。也就是说从电梯看来,运行到负数楼层或者2147483647楼层也是合理的。而电梯的调度器中实际上也没有电梯可以运行的最大最小楼层这一属性,即从调度器看来,运行到负数楼层或者2147483647楼层也是合理的。调度器只根据电梯当前状态与当前请求更改电梯状态,而电梯只根据自己的状态运行,电梯运行的正确性依赖于请求的正确性。这一设计不但简化了控制器与电梯的逻辑,而且为之后的作业中扩展到横向电梯提供了方便。

2.2 电梯的状态机

2.3 文字描述的调度器策略

  • 若当前电梯处于STOP状态:出现请求后,如果是当前楼层的请求,则选择去往人数最多的方向,开始移动。否则电梯向存在请求的随意一个楼层运动。否则进入STOP状态。

  • 若电梯处于UP状态:每经过一层时,检查当前楼层是否有等电梯的人,如果有人并且人的运动方向是UP,并且电梯未满员,则开门,把人放上电梯。同时检查电梯上的人是否有到目的地的,如果是则把人送下电梯。如果前两步做完后电梯中无人,则查看别的楼层是否有请求,步骤与STOP相同。

  • 若电梯处于DOWN状态:每经过一层时,检查当前楼层是否有等电梯的人,如果有人并且人的运动方向是DOWN,并且电梯未满员,则开门,把人送上电梯。同时检查电梯上的人是否有到目的地的,如果是则把人送下电梯。如果前两步做完后电梯中无人,则查看别的楼层是否有请求,步骤与STOP相同。

3 架构迭代与可扩展性

3.1 第一次作业的实现

首先有一个InputHandler线程负责接收请求,然后根据请求的楼座将请求放置在5个buffer中对应楼座的buffer中,然后唤醒在buffer上等待的电梯。电梯则根据 2.2节描述的工作方式运行。

3.1.1 数据流

Inputhandler获取PersonRequest后将其转化为一个Person类型的对象,然后根据请求类型将其送到对应楼座的buffer中,并notifyall。电梯线程调用scheduler的check方法后,首先从该楼座的buffer中获取所有请求,并按楼层放进自己的hash表中。hash表的键是楼层,值则是一个waiter类型的对象。waiter类中有两个容器,分别为向上走的人与向下走的人。

随后scheduler根据电梯的当前楼层与电梯的当前运行方向以及hash表中的请求填充自己的waitQueue队列,即如果电梯当前楼层有与电梯运动方向一致的请求,就将该请求放进waitQueue中。电梯调用letPassengerOn方法后会将waitQueue队列中的请求放进自己的容器中,即完成乘客上电梯的动作。

可以发现这是一个三级队列,第一级队列是buffer,第二级队列是scheduler中的hash表,第三级队列是电梯自己的passengers。这样实现的主要目的是使得线程处于临界区中的时间尽可能短,以减少线程阻塞的几率。事实证明这样做的损失明显大于收益。线程阻塞所消耗的时间相比sleep的时间而言完全可以忽略,而schedular一次就将buffer中的所有请求都放到自己的队列中,则会导致同一个楼座有多个电梯时,无法实现自由竞争。而这样一来性能就惨不忍睹了。

 

3.2 第二次作业的实现

得益于第一次作业的设计,第二次作业中并未修改电梯类与buffer类,以及电梯与buffer的交互。也没有修改scheduler类,因为无论横向电梯还是纵向电梯,电梯的位置就是一个数字,而一个人的请求可以归结为三个属性:

  • fromPos:这个人的起始位置

  • toPos:这个人的终止位置

  • fixPos:这个人的固定位置,如在楼层1横向移动的人的固定位置就是1.

第二次作业中有15个buffer,每个buffer对应一个固定位置,电梯线程则到对应的buffer中获取数据。将请求放进buffer后,实际上这个请求只有fromPos与toPos有用了。因此对请求的处理与第一次作业完全一致。

本次作业中修改了请求处理与分发环节,增加了一个PersonRequestQueue队列与Distributor线程。这主要是考虑到如果处理请求和分发请求都在Inputhandler中完成,则会拖慢Inputhandler的运行速度,影响数据向后的流动。修改之后,Inputhandler线程只负责接收请求,将请求放到PersonRequestQueue队列中。Distributor线程从PersonRequestQueue队列中拿出PersonRequest,转换为Person对象,并根据位置分发到15个buffer中的一个。

3.2.1 数据流

这样的实现依然具有第一次作业中所提到的问题,但由于时间有限,并没有做出改进。

 

3.3 第三次作业的实现

本着能不改就不改的思想,第三次作业基本沿用了第二次作业的设计,只不过增加了一个switchInfo类,修改了Person类的属性,Distributor的分发逻辑以及elevator判断一个人是否该下电梯的逻辑。第三次作业的基本思路是将一个人的路线划分为横向或纵向移动,每一次移动都当做一次请求完成,完成一次请求后,如果没有到达目的地,则从头再来。Person中有四个比较重要属性:

  • fromPos:这个人当前的起始位置

  • toPos:这个人本次行程的终止位置

  • toFloor:这个人的最终楼层

  • toBuilding:这个人的最终楼座

switchInfo中保存了所有横向电梯的可达性。Distributor拿到请求后首先查看switchInfo并规划这一次的移动路线,即设置fromPos与toPos并将Person放到某一个buffer中。elevator拿到请求后,每次循环查看这个人的toPos与电梯的nowPos是否相同。如果相同则让这位乘客下电梯。但是还要查看该乘客的最终目的地与电梯当前位置是否相同,如果不同则new一个PersonRequest对象并put到PersonRequestQueue中,相当于电梯又生成了一个新请求。由于不能再依靠Inputhandler发送Distributor线程的终止信息了,因此增加一个RequestCount静态类记录请求个数。

这样实现的最大好处是代码好写,要修改的地方很少。坏处是性能不怎么样,毕竟最坏情况下一个请求相当于三个请求。

4 临界区与互斥访问的设计

4.1 临界区设计

三次作业中的临界区有以下几个:

  • buffer

  • personRequestQueue

  • RequestCounter

其中电梯在对应的buffer上wait,由Distributor或Inputhandler负责唤醒。Distributor在personRequestQueue上wait,由Inputhandler负责唤醒。

4.2 互斥访问设计

buffer与personRequestQueue中都主要只有两个方法:get方法获取数据,put方法放置数据,并且get和put都会修改临界区中的数据。因此直接对方法使用synchronized加锁。

RequestCounter会有三个对象对其进行访问:Inputhandler调用其中的add方法,elevator调用其中的sub方法,以及Distributor调用其中的getCounterValue方法。由于Distributor每周期都要check RequestCounter并决定是否terminate,相比于add和sub方法的操作更为频繁,因此使用了读写锁实现。

 

5 bug总结

第一次作业

问题特征

出现了线程不安全问题,导致程序在没送完人之前就结束了。具体原因是当终止信号从Inputhandler开始向后传播时,没有考虑到电梯里依然有人的情形。

修复方法

判断电梯里没有人后再结束

第三次作业

问题特征

出现了RTLE与CTLE。CTLE是因为横向电梯wait的条件出错的,应该判断他能接的横向请求是不是空之后wait,而我直接判断的是该层的所有横向请求是不是空,导致wait不了,一直在轮询。RTLE是则单纯是因为程序运行的太慢了。

修复方法

判断buffer里如果有请求但不可达也要wait。

互测找到的别人的bug

  • 线程无法终止

  • 横向电梯没有判断可达性

  • 电梯线程轮询

  • 结果输出错误,可能是线程不安全,比如临界区没有加锁导致的吧

6 测试方法

使用数据生成器随机生成数据,并用评测机查看结果正确性。以下是第六次作业的数据生成。使用了WYJ同学与ZKG同学的评测机。

测试有效性

所有本地测试都没有发现问题。主要原因是测试的次数不够多。

发现线程安全问题

读代码。由于我的代码临界区都很简单,因此线程安全问题主要需要检查线程的终止逻辑。

测试策略与第一单元的差异

第二单元的数据生成比第一单元好写多了

"""
@Description :   python script 
@Author      :   Hongyu Guo 
@Time        :   2022/04/12 23:35:28
"""
# MAX_TIME:设置两条指令间隔的最大时间(s)
# MAX_LINE:生成指令条数

# 约束:
# 不能有ID号相同的person与elevator
# person的楼层与楼座有且只有1个不同,楼层从1-10,楼座从A至E
# 最初有5部纵向电梯,加入对应电梯前不能有相关请求
# 加入电梯后到相关请求前要至少间隔1s

import random

MAX_TIME = 70
MAX_LINE = 70
MAX_ELEV = 10
nowTime = 1.0
personId = 1
elevatorId = 6
building = ['A','B','C','D','E']
floor = []

def rd(a,b,c):
    r = random.randint(a,b)
    while(r == c):
        r = random.randint(a,b)
    return r

def genTime(control=False):
    global nowTime
    if(control == False):
        s = rd(0,MAX_TIME,10)
    else:
        s = 1
    ss = rd(0,9,10)
    time = s + 0.1*ss + nowTime
    time = round(time,1)
    nowTime = time
    return "[" + str(time) + "]"

def genPerson():
    global personId
    type = rd(0,1,2)
    id = personId
    personId = personId + 1
    frombuildingid = rd(0,4,5)
    frombuilding = building[frombuildingid]
    if (type == 0 or (type == 1 and len(floor) == 0)):
        fromfloor = rd(1,10,-1)
        return str(id) + "-FROM-" + frombuilding + "-" + str(fromfloor) + "-TO-" + frombuilding + "-" + str(rd(1,10,fromfloor))
    else:
        fromfloor = floor[rd(0,len(floor)-1,-1)]
        tobuilding = building[rd(0,4,frombuildingid)]
        return str(id) + "-FROM-" + frombuilding + "-" + str(fromfloor) + "-TO-" + tobuilding + "-" + str(fromfloor)


def genElevator():
    
    global elevatorId
    type = rd(0,1,2)
    # 0 为纵向,1为横向
    id = elevatorId
    elevatorId = elevatorId + 1
    if(type == 0):
        return "ADD-building-" + str(id) + "-" + building[rd(0,4,-1)]
    else:
        pos = rd(1,10,-1)
        floor.append(pos)
        return "ADD-floor-" + str(id) + "-" + str(pos)

def genData():
    file = open("input.txt","w")
    ret = 0
    flag = 0
    while(ret < MAX_LINE):
        if(flag == 1):
            time = genTime(True)
            flag = 0
        else:
            time = genTime(False)
        type = rd(0,5,-1)
        if(type < 2 and elevatorId < MAX_ELEV):
            req = genElevator()
            flag = 1
        else:
            req = genPerson()
        file.write(time + req + "\n")
        ret = ret + 1
    file.close()


if __name__ == '__main__':
    genData()

7 心得体会

7.1 多线程程序构造流程

1.确定线程与共享资源的个数与功能

2.不考虑轮询和线程结束的情况下完成各个类

3.找到线程wait的条件,避免轮询

4.加入线程终止逻辑

7.2 线程安全

尽量简化共享资源的设计,缩短线程处于临界区之中的时间与逻辑。挑选合适的互斥方法保证互斥访问,使得性能尽可能好。比如synchronized这种语言机制或者读写锁。

7.3 层次化设计

  • SRP: 类的职责应当单一。

    比如电梯只负责上下乘客以及运动,调度器只负责传递请求以及更改电梯状态。Distributor只负责分发请求。

  • OCP:无需修改已有实现(close),而是通过扩展来增加新功能(open):

    比如增加RequestCounter解决线程终止问题,增加Distributor解决请求分发问题。

posted on 2022-04-26 17:20  郭鸿宇  阅读(130)  评论(0编辑  收藏  举报