BUAA_OO第二单元总结

A u t h o r : g p f \mathcal{Author:gpf} Author:gpf
博客阅读体验更佳 https://solor-wind.github.io

关于线程

在哪里使用多线程

进入电梯这一单元,首先困扰我的是在哪里使用多线程。

这一单元的任务乍一看似乎并不需要使用多线程,只需要建类模拟即可。但是,从完成作业的角度出发,由于时间戳的检验,必须开启线程并sleep一定时间来通过测试。

那么,应该在哪里使用多线程呢?首先,官方投喂包给出了建议——输入要开一个线程,这就是第一个线程了。其次,6个电梯似乎也应该也应该对应6个线程,每个线程单独处理这一电梯的乘客。这大体就是要使用线程的地方。

线程和实例

知道了在哪里使用多线程,还要思考怎么使用。线程其实可以理解为任务,也就是宏观上同时有多个任务在执行。

首先是输入部分,可以把获取输入的部分写到run函数中,然后在主类里启动。

其次是电梯部分,电梯应该获取输入并处理乘客的需求,可以再输入类里启动电梯线程,把获取乘客、处理请求的部分写进run函数中。

输入类从标准输入获取输入,而电梯如何获取乘客呢?类比生产者-消费者模式,可以建一个请求列表,输入类将输入分类以后放入每个电梯对应的列表中,电梯再从列表里读取。具体的,可以再输入类中建立请求列表并存储,然后在新建电梯类时将已经建好的列表传递给电梯,这样输入类和电梯类就共享同一个请求列表。

在上面的分析中,可以将实例(比如某个类)理解为线程的载体,输入类的run让输入线程知道怎么执行任务,电梯类也是。而电梯线程、输入线程除了分别要执行电梯类、输入类中的一些方法,还要去访问一些实例,比如请求队列。当两个线程共同访问一个实例,或执行同一个方法时,可能会出现问题,这时就要上锁。

上锁

上锁的办法主要由三种:锁对象、锁方法、Lock类

锁对象
synchronized (a){
	/*To Do*/;
}

在这里,对象a成为了锁,执行这一段代码的线程持有锁a,任何其他线程没办法进入由a锁住的任何代码段(不限于这一个代码段)

锁方法
public synchronized void dosth(){
	/*To Do*/;
}

在这里,方法dosth本身成为了锁,任何时间内只有一个线程能进入并执行这一方法。

Lock类
private Lock lock = new ReentrantLock();
lock.lock();
/*To Do*/;
lock.unlock();

感觉既有锁方法的特性,又有锁对象的特性。在线程a进入 lock.lock() 之后,就获取了锁,其他线程无法再进入被 lock.lock() 限制住的代码段。

private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
readWriteLock.readLock().lock();
/*To Do*/;
readWriteLock.readLock().unlock();
/*..
*...
*/..
readWriteLock.writeLock().lock();
/*To Do*/;
readWriteLock.writeLock().unlock();

读写锁是一种特殊的锁,多个线程可以同时持有readLock,但当一个线程持有writeLock时,其他线程都被阻塞在外。

轮询与死锁

可以理解为一个线程的run代码如下:

void run(){
    while(true){
        synchronized (a){
			if(a>0)
                break;
        }
    }
}

其中a还由其他线程访问。可以看到如果a一直不大于0,那么线程就会反复执行这段代码,反复进行if判断,而且进入锁时还阻塞了其他线程,很浪费CPU时间。下面的代码就一定程度避免了轮询

void run(){
    while(true){
        synchronized (a){
			if(a>0)
                break;
            else
                a.wait();//简写了,实际还要用try-catch包围
        }
    }
}

这样,线程在a<0时会先等待,当a的值被其他线程修改时,由其他线程来唤醒这个线程。

具体到作业就是当请求队列为空且电梯里没人时,可以先让电梯线程wait,当输入线程加入新的请求后,由输入线程去唤醒电梯线程(因为共享同一个关于请求队列的锁)

一种死锁情况大致长这样:

public synchronized void dosth1(){
    dosth2();
}
public synchronized void dosth2(){
	dosth1();
}

如果线程A进入 dosth1() 的同时,线程B进入 dosth2() ,那么两个线程就会因为获取不到双方所需的锁而都处于阻塞状态,进入死锁。防止死锁的方法之一是尽量不使用嵌套锁。

代码架构

依托屎山

架构与时序图

在这里插入图片描述

首先是输入类,根据课程组的输入包创建线程,进而创建调度器类,调度器再创建每个电梯的请求队列和电梯本身,并将请求队列、全局队列放入电梯中。

当有输入时,输入类调用调度器的分配方法进行分配,调度器根据算法将输入放到某个电梯的请求队列中并唤醒这个电梯,而后电梯开始运行,直至所有请求执行完毕。

大致的时序图如下

在这里插入图片描述

作业变化

第一次作业

第一次作业只要求完成电梯运行即可,由输入为每个人指定电梯。

采用生产者-消费者模式。生产者即输入线程,不断提供新的输入;消费者是电梯,将请求处理完毕。

连接生产者和消费者的托盘是调度器和请求队列。输入线程调用调度器的分配方法,而调度器和每个电梯都共享一个请求队列,因此由调度器将请求放到对应电梯的请求队列中。

多线程下的共享对象只有请求队列,因此对请求队列加锁即可。

第二次作业

第二次作业不再指定电梯,而是由我们实现调度功能,同时还新增了电梯重置。

与第一次作业相比,新增了影子电梯这一类来实现相应的调度策略。具体来说,调度器调用电梯类获得当前电梯的状态,以影子电梯类型返回,而后再调用影子电梯中的方法模拟运行时间做出选择。对于电梯重置,新增了全局请求队列,调度器和所有电梯共享,重置时将电梯内的乘客和请求队列中的请求退回到全局请求队列,结束时调用分配方法进行再分配。

第二次作业中新增了全局请求队列这一共享对象,类比请求队列加锁即可。但是,影子电梯策略还使得每个电梯内的所有状态都称为共享对象,必须解决调度器线程和电梯线程的冲突。与第一次作业不同,我从锁对象改为了锁方法,又改为了使用Lock锁。但不停地改动、过多的共享对象终于使代码成为了屎山,臃肿不堪。

第三次作业

第三次作业新增了重置为双轿厢电梯。

对策是新增一个换乘楼层类作为AB两个电梯的共享对象,当一个要改变楼层时,先对换乘楼层中的数据进行修改,如果发现另一个电梯也在换乘楼层,则进入等待并将驱离请求设为true,另一电梯在会在离开楼层后唤醒这一电梯。

第二次作业已经是屎山,但第三次作业又让代码混乱程度更上一层楼。主要原因是使用了线程池,但由于电梯回调调度器这一策略使得结束条件难以设定,最终在不断尝试中勉强通过。当完成双轿厢电梯重置后,原电梯先要克隆出AB两个电梯放入调度器类,然后结束。当调度器将请求分配给已经结束的电梯时,还要通过线程池重启线程。这中间的线程调度过于复杂,导致了debug过程也极其困难。

复杂度分析

在这里插入图片描述

可以看到方法复杂度相比上一单元有了明显的提升。

allocate方法负责处理各种请求,包括重置请求,调用分配方法将其放入对应电梯的等待队列并唤醒电梯。除此之外,还承担了输出receive、判断结束线程池的任务,从而导致复杂度较高。

wtf方法负责影子电梯的调用,将请求放入影子电梯中并调用该电梯的模拟方法拿到运行时间,选择最小时间并返回电梯编号。其中由于需要判断电梯是否在重置、双轿厢电梯换乘楼层的选择等导致复杂度较高。

Shadow类中的in_out方法负责open状态下的人员进出。事实上在Elevator类中已经将in和out分离开来,但忘记修改影子电梯的方法。

Strategy类中的两个get_advice方法负责实现单个电梯的运行策略,接受当前电梯的状态并返回策略,涉及到大量的条件判断以及特殊判断,从而复杂度较高。

在这里插入图片描述

最终可以看到,Elevator、Allocater、Strategy由于包含了上述复杂度较高的方法,类复杂度也居高不下。

总共1118行(去除空行987行)

策略与性能

调度策略

采用影子电梯

什么是影子电梯?简而言之,就是把当前所有电梯的状态复制一份,称为“影子”。将需要处理的请求分别加入这些影子电梯的等待队列,模拟电梯完成运送过程,获得各个电梯运行所需时间,选择时间花费最小的电梯分配请求。

可以看出,影子电梯类似贪心,总是选择当前状态下的最优解,以此来逼近全局最优解。具体实现如下:

  1. 克隆电梯类。这一过程中要解决多线程共享对象下读写冲突,同时还要考虑克隆的“精度”问题。对于前者我选择将电梯类中需要克隆的对象在读写前后加上Lock类型的锁,对于后者我仅仅精确到电梯楼层与方向、开门状态和乘客,处于开门第几秒、移动第几秒等信息并未克隆(因为直接sleep了,想不到啥好方法,摆烂)。
  2. 模拟运行。将电梯克隆完毕后,需要将对应电梯类的代码进行修改,将 sleep(400) 换成 time+=400wait() 改为直接结束,运行结束返回时间。同时注意与原本电梯的策略应大致相同。
  3. 迭代修改。对电梯类进行修改时,也要注意对影子电梯类的对应部分进行同步修改,同时考虑策略类是否合理。
  4. 特殊判断。对于换乘电梯,其时间的计算改为运送至换乘楼层+开关门时间*2+运送到目的地楼剩余层数*移动时间。这只是一种简单近似,但完成了将双轿厢电梯归类到普通电梯的任务,使得两种类型的电梯都可以通过影子电梯来完成选择。除此之外,不对正在进行重置或者起始楼层时换成楼层顶点的电梯进行分配。

事实上,影子电梯并不是性价比最高的选择,自由竞争(让电梯去抢人,谁先到谁先获得请求)实现更加简单,同时类似自然选择的策略让其效率并不低。但由于课程组通过receive输出禁止了电梯在未获得请求下的移动,自由竞争无法实现。

另一种仅次于以上两种方法的策略是随机数。看似无理,但效率也并没有低多少,而且实现更加简单一行解决。缺点是debug比较困难,很多bug没办法复现

最后就是课程组的标程——均匀分配。

运行策略

采用look算法

look算法是生活中大多数电梯的运行策略,平均情况下效率较好,而且符合人性,具体实现如下(参考Hyggge的博客):

  • 首先为电梯规定一个初始方向,然后电梯开始沿着该方向运动。

  • 到达某楼层时,

    首先判断是否需要开门

    • 如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
    • 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门让这个乘客进入。
  • 接下来,进一步判断电梯里是否有人

    。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯)——

    • 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
    • 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。
    • 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)。

当第一单元强测结束之后,我悲哀的发现look算法比另一种算法低了5分,这就是量子电梯

量子电梯的名称来源于其“瞬移”的特性,即如果按逻辑分析,会发现电梯的移动通过瞬移实现,本质是一种hack评测的方法,现实生活中不可能实现(所以是谁这么卷先想出来的

考虑如下输入,电梯开、关门时间均为0.2s,移动一层楼的时间是0.4s

[1.0]1-FROM-1-TO-6-BY-2
[1.3]2-FROM-1-TO-6-BY-2
[1.6]3-FROM-1-TO-6-BY-2

look算法会在1.0秒时开门,进入两个乘客,1.4秒时关门,开始移动,并在1.8秒时输出信息表明已经到到下一楼层了,忽略1.6秒时的乘客请求。

但量子电梯不是。前1.4s与look算法相同,关门后电梯并不移动,而是先等待0.4秒,如果有乘客到来则输出开关门信息让乘客进入,否则等待结束后后瞬移到下一楼层并输出信息。

这样一来,量子电梯就能比look算法捎带更多的人,效率更高。

评测机与bug

很幸运这一单元并没有在测试中出现bug,hack别人的数据大多由评测机跑出来

bug与解决方法

  1. 死锁。考察死锁的发生原因,可以发现锁的嵌套使用是必要条件,因此在代码中尽量避免嵌套使用锁大概就可以避免死锁。
  2. 线程陷入等待无法唤醒。一定注意有wait就要有notify,而且对应同一个锁,而且notify一定是比wait后调用。
  3. 轮询。按课程组实现即可,当有输入时,输入线程去唤醒电梯线程而非电梯线程一直去查看有无请求。
  4. 性能问题。请看如下数据点(真下头 ),如果实现不当会将所有乘客分配到同一电梯,导致超时。
[2.0]RESET-Elevator-1-3-0.6
[49.0]RESET-Elevator-2-3-0.6
[49.0]RESET-Elevator-3-3-0.6
[49.0]RESET-Elevator-4-3-0.6
[49.0]RESET-Elevator-5-3-0.6
[49.0]RESET-Elevator-6-3-0.6
[49.5]1-FROM-11-TO-1
......(上一行换个乘客id重复63)

评测机

和zx合作开发了评测机,我主要负责正确性检验,zx负责数据生成和多线程调用。

正确性检验

正确性检验采用模拟方式。首先读入输入,获得待处理的乘客信息。其次逐行分析输出,按输出顺序模拟6个电梯的状态,当出现不合法行为时返回报错。具体根据不同指令判断错误。架构如下:

def analyze_input(inputfile: str):
    #以乘客id为key,返回包含时间、出发到达楼层信息的字典,同时检查输入格式
    return waiters
def analyze_output(outputfile: str):
    #返回列表,提取输出的关键信息如时间戳、指令等,同时检查输出格式
    return actions
def check(waiters, actions):
    elevators={}	#加入电梯进行模拟
    for i in range(0, len(actions)):
        match action[1]:
            case 'RESET_ACCEPT':
                #检查,错误直接返回,正确则改变电梯对应状态
其他的心得

正确性检验的编写要求对指导书较为熟悉,并能考虑全面,顾及到尽可能多的情况。在编写的过程中,我对细节的考虑也不断完善,反过来修复了作业中的部分bug。

搭建评测机过程中有不少bug需要处理,因而版本迭代次数较多,又因为是2人合写,终于促使我使用git进行版本管理和合作开发。第一次主动使用git,不得不说确实舒服,也积累了相关经验。

具体代码已经开源https://github.com/solor-wind/BUAA_OO_TEST

心声

写过的最烂的代码,真的,没有之一。

烂的原因有两个:

  1. 各种函数回调、类与类之间的嵌套。为了解决无法结束、结束时没分配完乘客的问题,让电梯完成重置后再调用一遍调度器。这是最大的败笔
  2. 线程间的共享对象。要么锁不住,要么死锁。终究是临界区没有划分好,共享对象不够清晰明确。

收获是在经历了无比艰难的代码编写和debug之后,终于入门了多线程。

  • 29
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值