OO第二单元总结

OO第二单元总结博客

一、同步块与锁

多线程编程的一个难点就是同步块的设计和锁的选择。在哪儿加锁其实是一个技术活儿。如果锁得太少,可能导致线程安全问题,但是如果锁加得太多,又可能会降低多线程程序的运行效率(一种极端情况就是多线程退化为单线程),因此需要仔细考虑多线程锁的设置。

在第一次作业中,我对于多线程的概念还十分模糊,只记得老师理论课上讲的用synchronize锁上就完了,所以我把共享对象里面的每一个方法都加上了锁,也不知道究竟为什么要加,只知道都加上锁肯定不会错。在第二次作业前,由于自己查阅了一些与多线程编程相关的资料,也仔细研究了理论课的内容,我对于锁有了更多的认识。我意识到锁可以加在实例方法上,也可以加在静态方法上,前者需要获得当前对象的锁,而后者需要获得当前类的锁,二者的作用范围是不一样的。所以我在第二和第三次作业中尽可能地减少了锁的使用。一个很典型的例子就是,如果当前对象中的某一个参数在初始化后就不会被改变,那么对于它的“读操作”是否还需要加锁?答案显然是否定的,这是一个典型的“只读不写”问题,当前参数的值并不被其他地方修改,所以可以任意地对它进行读操作而不用担心线程安全问题。但是假如某个参数(比如候乘队列),它实时地会被增加或减少,由于它“既读又写”,所以必须要增加锁来保证某一时刻仅有一个线程可以访问到这个对象,从而保证线程的安全性。

二、调度器设计

在三次作业中,我并没有使用复杂的调度算法。对于单部电梯来说,我使用的都是look算法。而对于多部电梯之间的协调,我采取的就是最为朴素的“平均主义”思想,将请求平均分配给每一部电梯,避免某部电梯出现“饿死”的情况。

个人认为单部电梯的调度算法其实改进空间不大,所以便查阅了相关资料浅浅了解了一下多部电梯之间的调度算法。多部电梯之间的调度一般需要依靠电梯群控系统(Elevator Group Control System),电梯群的运行控制是一个多输入/ 多输出的多目标决策过程。在现实中,多电梯调度除了需要考虑运行时间外,还需考虑电梯能耗、电梯性能、建筑特点等多方面实际因素。电梯群控的调度一般有如下几种基本方式:

  • 分区调度

初始的电梯需要响应全楼层请求,之后的电梯按照1-5,5-10来进行响应)

该种方法在应对现实中电梯时效果较好,比如说午餐时间,大楼里的人都会向餐厅层流动,因此可以将电梯的运行区间控制在餐厅层附近,使餐厅层附近的请求能够得到更快的消耗。

优点:适用于大量请求出现在某一区域时的情况,同时调度相对简单,避免电梯长距离空行。

缺点:容易造成电梯群中忙闲不均匀,若长距离请求较多时可能会超时。

  • 搜索调度

搜索调度本质上是一种多目标规划问题。其一般常见解法有四种:约束法、分层序列法、功效系数法、评价函数法。评价函数法较为简单,因此我重点考虑评价函数。在分配电梯决策时贪心地选取评价函数最优的电梯

一种评价函数:

即当有新请求加入时,立刻模拟所有电梯接到该乘客所需要的时间,即评价函数为

$$
F(x) = T_{wait}
$$

选取时间最小的那个电梯进行分配。(无需开为线程,只需要定义一个“模拟”类,该类的方法与正常电梯类相同,只不过是将请求来到时的所有“状态”全部克隆并去运行)。这种方法的好处是可以最快地“消耗”请求,使乘客的等待时间最短。

另一种评价函数:

当新请求加入时,模拟该乘客的等待时间以及它坐上电梯后的电梯运行时间,选取加权平均最小的那个进行分配。即评价函数为

$$
F(x)=a_{1}T_{wait}+a_{2}T_{run},其中 a_{1}+a_{2}=1
$$

相对于上一种贪心算法,该种算法更贴近课程实际,即“总体运行时间”最短。加权平均的好处是可以根据不同的评价指标对系数进行调整。比如如果第三次电梯作业的评价指标新增了对于乘客等待时间的要求,那我们就可以适当提高a_{1}的大小,使候乘时间占更大的比重。值得注意的是当a_{2}=0时该种方法便退化为了第一种贪心算法。

事实上,同一时刻可能有多个请求同时到达,此时评价函数可以升级为

$$F(x)=a_{1}\sum_{i}^{n}T_{wait}+a_{2}\sum_{i}^{n}T_{run}, 其中n为该时刻的候乘人数$$

可以预料到,若要达到该时刻最优,则需要暴力遍历所有情况,数据量大时无法在较短时间内得到答案,不可取。因此可以退而求其次使用单人的贪心策略。

  • 基于专家规则的调度

多台电梯调度属于NP hard问题,可行时间内在空间中找到全局最优解的可能性很小。因此需要使用近似方法来寻找一个近似最优解。专家规则可以在传统解决问题的经验中寻求面向问题的一种策略,利用该策略在可行时间内寻找一个相对较好的解。它可以解决许多不能完全用数学作精确描述而主要靠经验解决的电梯。

从一定意义上来讲,所有的控制策略都是基于规则的。可以根据专家规则确定当前的交通模式,发布事先定义好的与这种模式相对应的调度决策。

优点:简单,快捷

缺点:专家规则具有局限性,且若人流出现某种特点(比如大量上行或大量下行),系统规则受专家知识的影响会很大。且专家系统主要适用于一些结构简单的建筑物,如果建筑物结构复杂或者电梯运行规则复杂,规则将大大增加,使电梯难以控制。

基本的电梯群控专家规则一般有如下几条:

1、顾客候梯时间短:
    if A梯离呼叫楼层近 and A梯中途停靠次数<=其他电梯
        then 分配A梯响应请求
    else if B梯离呼叫楼层比A梯稍远 and B梯中途停靠次数<<A梯
        then 计算A,B的外呼等待响应时间,分配时间短的电梯去响应
2、顾客乘梯时间短:
    if A,B梯离呼叫楼层距离相近 and B梯内呼次数>A梯
        then 分配A梯进行响应
3、避免电梯空走:
    if A,B梯离呼叫楼层距离相近 and A稍近于B但中途没有其他服务
        then 分配B梯进行响应
4、同向优先:
    if A梯运行方向于呼叫层同向但已经经过呼叫层或者与呼叫层异向,B梯运行方向与呼叫层同向且未经过该呼叫层
        then B梯优先于A梯考虑

 

然而,这仅仅只是本人的一些理论研究,由于时间原因并未付诸实践。同时由于作业的测试数据大多为随机生成,不像实际电梯一般具有特别的规律性,所以可能分区调度效果不显著。但是搜索调度是可行的,往届学长似乎也有通过类似于搜索调度的方式来寻求最短的调度时间。虽然这些理论没有被应用到自己的作业中,但是这其中所包含的思想可以被应用到自己未来的多线程程序的调度当中。

三、框架结构

本人在三次作业中主要使用的是“生产者-消费者”模型来作为线程间的交互架构。具体来说,代码中包括了三种线程和两个共享对象,构成了两级生产-消费关系。输入线程InputHandler与请求分配器Distributor之间通过一个请求池WaitingPool进行交互,请求分配器与各个电梯之间通过电梯的候乘队列VQueueHQueue(即纵向候乘队列和横向候乘队列)进行交互。输入线程会将请求放入请求池中,由分配器判断应该将请求放入哪一类型的候乘队列的哪一部电梯当中,电梯只需要根据自身的候乘队列通过Look算法对自己的运行进行调度即可。

  • UML图:

个人认为架构值得改进的地方在于可以设立一个电梯工厂类和候乘队列工厂类,直接使用工厂类来生成相应的电梯和候乘队列,而不用单独拆分为两个类,以便在有更多种类电梯时能灵活增加策略。除去这一方面外,我认为自己还是保留了一定的可扩展性的,各个模块之间耦合程度较低,只需替换模块中的方法即可实现新的策略或运行模式。

 

由于横向电梯和纵向电梯的行为相同,因此在协作图中使用Elevator来抽象横纵两种电梯,使用Queue来抽象横纵两种候乘队列。协作图如下所示:

四、Bug分析:

1、自身的Bug:

在本单元三次作业中,本人一共出现了三个bug。这些bug都是在五行之内就可以修复的,但是却严重影响了程序的正确性,让自己强测扣了海量的分。问就是当事人追悔莫及。

  • 第一次作业:

首先,在电梯输出时,由于疏忽,我先进行了上电梯乘客的输出再进行下电梯乘客的输出。在我思路上,我把上电梯和下电梯放在同一个方法里面输出了,所以并未注意到其中的顺序问题。但是当乘客较多时,即同一层同时有多个上梯请求和下梯请求时,顺序错误就会导致我的电梯“在形式上”超载,被评测姬判为错误。这个bug很好解决,只需将顺序调换即可。

其次,我在第五次作业并没有考虑好线程安全的问题,使得在互测时输出时间顺序不单增,导致错误。请教了大佬后,我认识到输出包可能会被多个线程相互调用,因此可能出现输出错乱的问题。因此,我新建了一个输出类并把输出静态方法加上了synchronized关键字,由此保证输出线程的安全。

  • 第二次作业

第二次作业是一个血的教训,让我深刻理解了轮询所带来的坏处,以及多个线程之间协同的问题。

本次作业本人强测仅仅对了一个点,并未进入互测。强测结果解封后,发现错误的点全部都是CPU Time Limit EXceed,也就是可能轮询了。但是在我的理解里,我的设计并未轮询,且在课下测试时也未发现轮询的问题。经过仔细观察强测数据后,我意识到自己的问题出现在了notifyAll()上。在本人的实现里,同一栋楼的所有纵向电梯或同一层楼的所有横向电梯公用一个共享对象。我在isEmpty()isEnd()方法中写上了notifyAll()。问题在于,当一部电梯没请求在wait()状态时,如果另一部电梯调用了isEmpty()方法,就会将所有处于wait状态的电梯都唤醒,但是由于并没有请求分配给这些电梯,就会导致cpu空转,造成超时。修复bug的方法十分简单,只需减少不必要的notifyAll()即可。

  • 第三次作业

在本次作业中,由于测试较为充分,且利用了第二次作业bug修复的强测数据,本人的代码在强测中并未出现bug,也没有在互测中被人刀中。

2、他人的bug:

在第一次电梯作业中他人的bug主要都是超载。问题来源于对电梯状态判断的不准确,应该写小于号的地方写了小于等于号,使得电梯在满员的情况下仍可以加入一名乘客,导致超载。第三次电梯作业中的bug来源于横向电梯开门信息的判断。该横向电梯在该栋楼座并没有开门权限但是电梯却错误地开门,这导致了错误。

3、策略:

数据生成及测试策略:

本人生成的数据主要分为两种,一种是时间跨度长但是乘客较为稀疏的,另一种是时间跨度短但是乘客极为密集。个人写的数据生成代码大致有如下几个部分(以第三次作业为例):

import random
​
buildings = ["A", "B", "C", "D", "E"]   #楼座信息
WrongInfos = [16,8,4,2,1]               #错误的开关门掩码,因为必须起码能在两栋楼开门
​
def nofromrdbuilding(frombuilding):     #避免重复楼座
​
def rdSwitchInfo():                     #随机生成开门信息
    WrongInfos = [16,8,4,2,1]
    info = random.randint(1,31)
    while(info in WrongInfos == True):
        info = random.randint(1,31)
    return info   
​
def gen_Elevator_Horizontal(elevator_id):   #生成横向电梯
    speed = [0.2, 0.4, 0.6]
    capacity = [4, 6, 8]
    floor = rdfloor()
    switchInfo = rdSwitchInfo()
    request = "ADD-floor-"+str(elevator_id)+"-"+str(floor)+"-"+str(random.choice(capacity))+"-"+str(random.choice(speed))+"-"+str(switchInfo)
    return request
​
def gen_Elevator_Vertical(elevator_id):     #生成纵向电梯
​
def rdRequest():                            #确定随机生成请求的类型是新增电梯类还是新增乘客
​
def gen_Person(person_id):              #生成乘客请求
​
def generateData(maxElevator, num_people, time_interval,time_start,time_end):   #生成测试数据

在生成数据时会通过随机数种子来判断是加入乘客请求还是加入电梯请求。为了便于互测,代码还设置了最早开始时间time_start和最晚结束时间time_end

个人认为多线程一个主要需要避免的问题就是轮询和程序无法终止。为了有针对性地检查这两方面,在生成数据时可以将num_people设为一个小值(比如说1),再将maxElevator设为一个较大的值(比如说10),再通过讨论区大佬的方法来观察CPU的运行时间。

另一个就是线程安全问题,主要是担心多线程对临界区的读写问题。解决线程安全的第一步肯定是阅读代码,仔细地检查共享对象各个方法和类在该加锁的地方是否加锁。第二步是进行压力测试,本人采用的方法就是在小时间间隔内投放大量数据,使共享对象被频繁地读写,增大共享对象的读写压力,以此来测试线程安全问题。事实证明第一步阅读代码检查是一种更加快捷且有效的方式。

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

第一单元只需要在意输入和输出而无需考虑中间步骤,因此在测试时是一种相对静态的测试,主要针对的是代码中的逻辑漏洞,比如说化简为零时直接输出空串或正则表达式有误。但是第二单元的测试更像是一种动态的测试,需要考虑数据输入和输出的中间过程,静态地观察代码一般不容易发现问题,只有在运行时由于多线程之间的交互问题才会导致bug出现。因此第一单元的测试较为简单,需要覆盖的情况并不算多,甚至可以手动构造数据来测试,而第二单元必须得使用自动数据生成,而且得从请求输入时间和请求内容两个维度上去变化,尽可能多地去做测试才能提高正确性。

五、心得体会:

  • 线程安全方面

多线程最重要的一点就是要保护线程安全。在本单元第一次作业时我对线程安全还似懂非懂。但随着理论课的深入,我逐渐领悟到了线程安全的重要性。在保护线程安全的过程中一个最重要的东西就是“锁”,而这个“锁”主要就是要解决读写冲突的问题。同时锁的粒度还不能太大,否则会使多线程程序的性能下降。随着线程的增多,线程安全问题愈发地需要被重视。不过我个人认为解决线程安全问题的一个最好的办法还是从设计入手,不要一上来先写代码,而是先理清楚有哪些线程和哪些共享对象,哪些方法是要被读的,哪些方法是要被写的。捋清楚它们的关系之后再判断有哪些方法或对象需要被加锁,哪些需要notifyAll(),从设计的层面就先考虑好线程安全方面的问题可以有效降低后续出错的概率。

  • 层次化设计方面

主要理解了“生产者-消费者”模型以及“分级调度”的作用。“生产-消费”模式可以使代码整体框架更加清晰,而“分级调度”可以降低耦合性,使每一级调度的功能都相对简单,每一层只需要管好自己的事情即可,可以有效降低后期扩展的难度。

posted @ 2022-05-01 20:57  乔治爱OO  阅读(64)  评论(1编辑  收藏  举报