[BUAA OO Unit 2 HW8] 第二单元总结

前言

第二单元的主题是电梯调度问题,主要是初步学习多线程的编程思想,解决线程交互和线程安全的问题。早就听说第二单元多线程是OO的一座大山,荣文戈老师也说过以后上班遇到的程序基本上全都和多线程挂钩,所以多线程的学习是很关键的,给哪个对象加锁,如何正确且高效地给对象加锁,都是值得思考的问题。

这一单元的三次作业也是层层递进,逐步实现了电梯维修、加入电梯、限制电梯可达性和限制楼层服务电梯数量等功能。在本次作业中,我也学会了一些比较常用的设计模式如单例模式观察者模式等,以及经典的并发同步模式生产者-消费者模式,使得整个项目的耦合度更低,代码层次清晰。

第一次作业

第一次作业为模拟多线程实时电梯系统,实现6部电梯对实时加入的乘客请求做出反应,接到乘客并送到指定位置,需要模拟电梯的上下行、开关门以及乘客的进出。

架构

Producer-Consumer模型

首先分析需要将哪些类作为线程运行,首先就是输入InputHandler)需要不断读取请求,其次就是电梯(Elevator)需要不断反应请求并模拟运行。

而将这两个线程连接起来的就是请求(Request),所以生产者-消费者模型的结构也很清晰了,输入作为生产者,电梯作为消费者,在两个之间我们需要一个容器来盛放请求,于是设计请求队列(RequestTable)作为这个容器,容器有放入请求和取出请求的功能,而之所以容器不作为线程就在于放入和请求这两个动作的发出者不是容器本身(参考自助餐窗口,柜台是固定的)。

至此,我们可以得到以下的结构
image-20230412192000478

我采用的是所有电梯共用一个请求队列的做法,各个电梯自由竞争。

调度策略

电梯调度问题没有一个全局最优解,总会有一些情况使得一个算法劣于其他的算法。目前有的与电梯调度调度相关的算法有ALS,LOOK,SCAN等,课程组给出的算法是ALS,但往届学长大多选择的是LOOK算法,并且我也感觉后者实现的难度要相对低一点,所以选择了LOOK算法。

具体实现过程——

  • 首先电梯有一个初始的运动方向(建议使用 ± 1 \pm1 ±1,实现比booolean类型方便)
  • 判断是否需要开门
    1. 电梯内是否有人出电梯
    2. 该楼层是否有人的请求方向和电梯的运动方向一致
  • 判断电梯内是否有人
    • 如果有人,那么沿当前方向继续移动一层
    • 否则,判断请求队列是否为空
      • 如果不为空,接着判断当前方向是否有请求
        • 如果有请求,那么沿当前方向继续移动一层
        • 否则,变换方向
      • 否则,判断输入线程是否结束
        • 如果结束了,那么结束电梯线程
        • 否则,进入等待状态

如上所述,电梯做出的反应有OpenMoveReverseWaitOver5种,可以设计一个策略类(strategy)来封装LOOK算法,根据电梯目前的状态给出电梯需要做出的反应,电梯收到后进行相应的动作。这样可以使得电梯运行和判断相分离,电梯本身更加专注于动作,结构层次也更加清晰。

类图和时序图

image-20230412211753149
在这里插入图片描述

大致流程如上图所示,无论是放入请求还是输入结束,都会使得请求队列唤醒电梯,而电梯都会根据策略来进行下一步动作。

锁和同步

本次作业采用的是使用synchronized取得对象锁,避免线程安全问题,保证同一时间不会出现两个线程对同一对象写或者读写。

synchronized有三种加锁的方式——

  1. 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

    synchronized void method(){}
    
  2. 修饰静态方法:作用于当前类的所有实例,进入同步代码前要获得当前类的锁

    synchronized static void method(){}
    
  3. 修饰一个代码块:给指定的类或者对象加锁,在进入同步代码前需要获得指定类或者对象的锁

    synchronized(obj or example.class){
        //TODO
    }
    

修饰实例方法与在方法内修饰整个代码块取得当前实例的锁类似,而修饰静态方法与在方法内修饰整个代码块取得当前类的锁类似。

需要注意的是实例的锁与类的锁不同,实例的锁属于这个实例,类的锁属于类(废话)。讲这个的目的就是为了说明,当一个线程访问加锁的静态方法时,另一个进程依然可以访问加锁的实例方法,两者不冲突。当然加锁的方法和不加锁的方法也不会冲突。

需要加锁的位置就是有多个线程进行读写的共享数据,在本次作业种显然是请求队列,有输入的写,以及电梯的读写,为了保证每次操作的正确性,我们在进行读写之前都要对请求队列加锁,保证进行的是原子操作(执行过程不会被打断)。本次作业将请求队列内部涉及修改和读取修改变量的方法都加了锁,在外部对需要保证请求队列状态的代码块上了锁。

notifyAll的操作只在修改了共享变量之后存在。

复杂度分析

在这里插入图片描述

主要出在策略类中,因为涉及大量的if-else判断以及for循环遍历楼层的请求队列,所以这部分复杂度略高。

tips

二次询问

因为各个电梯共用一个请求队列,并且策略类将询问和执行分开了,所以可能会出现多个电梯在同一个楼层都给出开门的指示,但是显然只有一个电梯会接到人(接人操作上锁,所以不会出现接到同一个人),其他的电梯就只开门和关门,这样会白白增加耗电量(虽然自由竞争本身会出现多个电梯都向一个请求跑,耗电量巨大)。一种解决方法就是在开门的操作内部,先对请求队列上锁,然后再次询问策略类当前动作是否还应该是开门,如果是才将人取出,进行接下来的操作。

synchronized (requestTable) {
	Advice advice = scheduler.getAdvice(curFloor,curNum,direction,eleMap.get(curFloor));
	if (advice != Advice.OPEN) {
		return;
	}
	tmpQueue = requestTable.take(curFloor,direction,curNum - curQueue.size());
}
电梯反转

在电梯没人判断当前方向上是否有人的时候,不应该包含当前楼层,否则电梯直接沿着当前方向走了,会出现升天或者遁地的情况。

开门判断

为了简便,在策略内判断是否有人进入的时候,传入的当前楼层人数包括可能下电梯的人,所以可能因为超载判断为不需要开门,不过没关系,因为如果有人回下电梯的话就一定会开门,在取人的时候减去下去的人数即可。(要是没有二次询问这个操作的话,直接按顺序下人上人不用特意减,可惜删不得)

HashMap遍历删除

在从请求队列中取人的时候我使用了如下操作

for (PersonRequest person : curQueue) {
	if (curNum + tmpQueue.size() == Tool.capacity) {
		break;
	}
	if ((person.getToFloor() - curFloor) * direction >= 0) {
		tmpQueue.add(person);
		curQueue.remove(person);
	}
}

即一边遍历一边删除,但是运行的时候会出现java.util.ConcurrentModificationException的报错,于是去网上查找了相关资料。

简单来说就是HashMap内部维护了一个modCount变量,迭代器里维护了一个expectedModCount变量,初始两者相同,每次HashMap移除和新加元素的时候modCount会自增,此时迭代器里的expectedModCount不变,而迭代器遍历的时候会用到nextNode()方法,当两个值不等的时候就会抛出异常。

基本上JAVA集合类在遍历时不用迭代器进行删除都会报错,这样是为了防止高并发情况下,多个线程同时修改集合导致数据不一致。

解决方法就是使用迭代器进行遍历和删除,迭代器的删除也会先判断两者值是否相等,然后调用HashMap的removeNode()方法,最后会令expectedModCount=modCount,这样就不会出现错误了。

Iterator<PersonRequest> it = curQueue.iterator();
	while (it.hasNext() && curNum + tmpQueue.size() < Tool.capacity) {
		PersonRequest person = it.next();
		if ((person.getToFloor() - curFloor) * direction >= 0) {
			tmpQueue.add(person);
			it.remove();
		}
}

或者使用线程安全的currentHashMap替代HashMap,或者在遍历结束后再遍历取出来的列表对请求队列进行删除。

些许优化

在研讨课时,有同学提出既然自由竞争会出现1个请求唤醒6部电梯的情况,那么每来一个请求不使用notifyAll而是使用notify就可以减少耗电量,我觉得有道理,不过貌似这样就不像自由竞争了,虽然也和直接分配不同,总的来说还是一个不错的提议。

其次就是另一个同学提出在电梯取人的时候可以不直接锁整个队列,而是将对应楼层的队列锁住,这样其他楼层的电梯也可以在此时取人,虽然优化可能不是很明显,不过想法很好,显然要是为了正确性锁住整个队列是更好的,比较无脑,为了性能的话就要将需要锁的部分想清楚,不然正确性可能没法保证。

Bug分析

中测中出现上述有关HashMap删除的错误,强测和互测没有出现Bug,强测得分97.1553,还是比较出乎我的意料,毕竟自由竞争耗电量确实难蚌。

第二次作业

第二次作业在第一次作业的基础上需要模拟电梯系统扩建和日常维护时乘客的调度,同时电梯增加速度和容量参数。

架构

调度器

为了实现扩建和维护功能就需要一个统领所有电梯的容器,来记录目前还在运行的电梯,于是设计了调度器(Scheduler),同时由于第一次作业6部电梯一起抢一个请求实在让我挺难受的,所以我决定让调度器同时担任分发请求的任务,让每部电梯都有自己的乘客队列,于是产生了以下结构在这里插入图片描述

相比于第一次作业,输入得到请求有三种分别是PersonRequestElevatorRequestMaintainRequest,不同的请求处理方式肯定事不同的,于是在哪个部分对请求进行分类就是一个问题,我采用的方式是在调度器内进行分类处理,于是输入请求队列基本上就不需要改动,然后电梯依赖的请求队列变成了乘客队列也只是相当于换了个名字,总的来说原则就是让每个类的职责清晰明了。

换乘 + 线程结束

因为维修请求的存在,电梯可能放出没有到达目的地的乘客,所以会出现乘客需要换乘的情况,处理还是比较简单,直接将没有到达目的地的乘客更改出发楼层然后重新丢进请求队列即可,调度器会将其作为一个新的请求读取并分配。

但是随之而来就出现了另一个问题,电梯线程在何时结束,之前的结束判断是电梯内没人并且请求队列为空且输入结束,但是在本次作业请求来源不再只是输入,还可能是换乘的乘客,所以需要改变结束判断。

可以发现,虽然有换乘但是总的乘客数是不变的,于是可以设计请求计数(RequestCount)维护一个count,当输入请求队列放入乘客的时候令count+1,当电梯将乘客送到目的地之后令count-1,于是结束判断就变成了输入结束且count=0。

由于JAVA没有全局变量这个概念,所以可以使用 单例模式 实现全局变量,本次作业 请求队列请求计数都使用了单例模式(静态变量和方法实现在本次作业也可以,不过两者略有区别,在此不做讨论)。

有一点需要吐槽的是,课程组给出的PersonRequest不支持修改出发楼层,于是我自己写了一个Person,没什么不同只是为了修改,当然重新实例化一个PersonRequest也是可行的。(不过这个名字有点长,我不是很喜欢)

类图和时序图

在这里插入图片描述

在这里插入图片描述

复杂度分析

在这里插入图片描述

调度器的run内部根据请求队列的状态判断是结束线程还是等待还是处理请求,if-else语句导致复杂度较高。

tips

  • 本次作业中每个电梯都有自己的乘客队列,所以不再需要二次询问。
  • 理论课上讲解了读写锁的使用,能够允许多个线程同时进行读,不过现在jdk版本下synchronized效率也挺高的,也就没进行更改。
  • 这次作业要采用自由竞争也是可以的,由输入对请求分类然后进行操作即可,不过架构不是很好看就是了,提这个的目的只是觉得我的调度没有用到速度这一参数,自由竞争在这方面还是有一定优势。
  • 在接到维修指令之后需要立即将电梯从调度器中删除,防止有新的请求放入其中。

Bug分析

中测的时候出现了以下bug

  • 调度器请求队列中wait,请求计数不能直接唤醒,需要先获得请求队列的锁(我感觉这一部分我写的比较混乱)
  • 因为我自己写了Person,但是调度器在处理的时候忘记判断了,导致换乘的乘客没有处理,虽然我加了assert,但是运行的时候没加-ea参数,导致没及时发现错误。(值得一提的是课程组貌似也没有这个参数,所以找错还挺麻烦的,不过这是一个好习惯)

强测没有问题得分95.1183,互测出现了程序无法结束的问题,我找了很久也不知道问题出现在哪。

分配策略我采用的是纯随机(random),运气不好的话可能都分到一部电梯去了。

第三次作业

第三次作业在第二次作业的基础上,限制了电梯的可达性(只能在一些楼层开门服务)和楼层的最多服务电梯数。

架构

这次作业的架构没有什么调整,增加的功能都可以放在已有的模块中进行实现,故UML类图和时序图可以参照第二次作业。

功能实现

可达性

虽然规定了电梯只能在某些楼层开门服务,但在遇到维修的时候也是可以突破这个限制的,所以为了统一性我们不应该让可达性成为电梯的内置属性,而是通过调度器分配符合电梯可达性的乘客,让其看上去是满足要求的,实际上电梯都是功能完全的,能够在任意楼层开门。

那么问题就在调度器如何进行分配了,由于可达性的存在可能会出现乘客一趟不能到达目的地的情况,于是很自然的想到给乘客增加一个当前目的地的属性,对于这个属性的选取有很多种考量方法,我采用的和课程组类似——寻找最少换乘次数的策略,然后选择已有请求最少的电梯进行分配。

首先在调度器内维护一个二维数组map[i][j]表示有几部电梯能够从i楼到j楼,每次增加或者删除一个电梯就根据它的可达性对map进行更新

private void updateMap(int access,int type) {
    for (int i = Tool.minFloor;i <= Tool.maxFloor;i++) {
        if (accessible(access,i)) {
            for (int j = Tool.minFloor; j <= Tool.maxFloor;j++) {
                if (accessible(access,j)) {
					map[i][j] += type;
				}
			}
		}
	}
}

对于最少换乘次数dis[i][j]的计算可以简单的使用floyd即可,赋初值部分

for (int i = Tool.minFloor;i <= Tool.maxFloor;i++) {
	for (int j = Tool.minFloor;j <= Tool.maxFloor;j++) {
		dis[i][j] = i == j ? 0 : map[i][j] != 0 ? 1 : 114514;
	}
}

算完最短路关于选择目的地采用map[from][i] != 0 && dis[from][i] + dis[i][to] == dis[from][to]判断即可,最后会返回一个可行的目的地,但是正规来讲的话应该返回一个集合,这样后续分配会更加均匀。

需要注意的是,设置当前目的地后电梯的策略类的判断同向也应该做出相应修改,其次每次请求被调度器取出时都会重新规划,而不是规划出总的路线,因为如果路线中的电梯被维修了就需要重新规划,为了简便就每次都算一次。

楼层限制

本次作业限制一层楼同时只有4个服务(开门)的电梯,2个只接人($set \sube new_set $)的电梯,后者属于前者,因为在我的实现中出去的乘客不会被马上接进来,所以只接人电梯相当于是没有出去的人。

Semaphore

实验课上介绍了Semaphore(信号量)的使用,锁的存在使得同一时间只有一个线程能够操作这个对象,而如果想要多个线程同时使用的话,就需要信号量了。信号量内部维护了一个计数器存着可以访问的共享资源的数量,线程要想访问共享资源就需要获得信号量,如果计数器大于0则允许访问并将计数器-1,如果计数器等于0则线程进入休眠,当某个线程释放信号量之后休眠的进程会被唤醒并尝试获取信号量。

Semaphore semaphore = new Semaphore(10,true); // 信号量总数,是否公平(先到的先获得)
semaphore.acquire(); // 获取信号量
semaphore.release(); // 释放信号量

获得信号量之后一定要释放。

实现

有两种限制,所以对每层楼都需要两个信号量记为s1(4,true),s2(2,true),当电梯需要开门的时候,如果是普通的电梯只需要获得s1即可,而只接人电梯则需要先获得s1再获得s2(顺序好像没关系),关门的时候再释放相应信号量即可,信号量都放在了请求计数中。

复杂度分析

在这里插入图片描述

getDispatchGoal是求下一个目的地的部分,内部有挺多的循环嵌套和if-else判断。

Bug分析

三次测试都没问题,强测得分95.2392,不过用hhl同学的评测机总会是不是出现线程无法正常结束的情况,通过输出信息发现是有乘客被吃掉了,放入了电梯但是并没有去接他,最后也没有发现是哪出问题了。

心得体会

  • 三次作业电梯运行、策略、输入以及请求队列部分原有的代码基本上都不会发生太大改变,主要的变化在于电梯增加维修的能力,调度器更换调度策略还有结束的判断等,不过只要大的框架定了后续的迭代也就比较简单了。可以发现的是后续迭代课程组对没有调度器的自由竞争并不是很友好,如果不在第二次作业进行重构的话,第三次作业需要改动的东西就更多了。不过架构的确定还是蛮看经验的,也不能说某一个架构就一定好,不过还是应该尽量满足solid原则。

  • 性能方面,我还是为了简单和易于实现起见,牺牲了部分性能,调度采用自由竞争、随机分配和均匀分配,有的同学采用影子电梯(复制所有电梯状态,模拟判断请求的最优分配方式),听说得分挺高的,不过本来电梯调度就没有全局最优,我认为首要的还是应该保证架构,有了比较好的架构,换一个调度器也不是很麻烦的事,就像荣文戈老师说的,分工好之后要是这个调度不行就换个程序员做调度器。

  • 总的来说,相比于第一单元,这一单元感觉架构更加清晰,各个模块耦合度不高,功能也相对清晰,更有面向对象的感觉。不过这次作业还是留下了一点遗憾,就是第二三次作业的bug还是没找出来,这也让我感到多线程的艰难。

参考

「BUAA-OO」第二单元:电梯调度 | Hyggge’s Blog

「BUAA OO Unit 2 HW8」第二单元总结 - 被水淹没的一条鱼

HashMap遍历的时候使用map.remove会报错

为什么 HashMap 不能一边遍历一边删除?

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值