文章目录
关于多线程
为什么要使用多线程
多线程贯穿本单元始终,但是,值得思考的是,这个单元的具体情景是电梯,也就是说实际任务是一个大模拟,那为什么需要多线程模拟呢?因为有多台电梯,我们必须把每个电梯视为一个独立的线程(不然时间戳检验过不了),也就是说,电梯之间并不知道彼此的存在,每个电梯只在乎自己根据自己当前的状态来完成自己的任务,然后通过sleep来完成时间的变化。这其实和多线程是不谋而合的,线程之间可以不知道彼此的状态,他们是并行的,都受一个调度线程来控制。
同步块的设置
同步块的设置有两种方法
synchronized (a){
/这是锁住了共享对象/;
}
//任何其他线程没办法进入由a锁住的任何代码段
public synchronized void aFunc(){
/这是锁住了方法/;
}
//任何时间内只有一个线程能进入并执行这一方法。
锁的选择
在Java中,有非常多用于线程同步的锁,我在这里介绍几种我相对了解的
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
readWriteLock.readLock().lock();
readWriteLock.readLock().unlock();
readWriteLock.writeLock().lock();
readWriteLock.writeLock().unlock();
读写锁是一种特殊的锁,多个线程可以同时持有readLock
,也就是说可以同时进行读取,但当一个线程持有writeLock
时,其他线程都被阻塞在外。对于数据库这种需要大量的读操作的,就可以显著提高效率。但是就我个人认为,在本单元作业中,读写锁除了比起synchronized稍微灵活一点,似乎并没有太大效率上的提升。
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
//使用AtomicInteger之后,不需要加锁,也可以实现线程安全。
public int getCount() {
return count.get();
这是原子类,用于实现原子操作,使用原子类,例如AtomicInteger
后,就可以不需要再加上同步块了,比起同步块,原子操作更加轻便,我曾经在电梯类中尝试使用原子类,但是最终因为不够熟练还是浅尝辄止了。
最终的三次作业中,我都使用的是synchronized
关键字来进行同步,主要需要同步的操作集中在对总请求表MainRequestTable
和每个电梯的等待列表RequestTable
中,凡是需要对这两个表进行读写的地方几乎都进行了同步操作。例如
public synchronized void addRequest(Person person) {
//把person请求加入容器requestMap
/*some codes*/
notifyAll();
}
public synchronized void delRequest(int mapKey, int arrayNum) {
//把“容器requestMap中的mapKey的value的第arrayNum个person”删掉
}
轮询
什么是轮询呢?我们来看一下第二单元第一次实验的代码
public void run() {
while (true) {
if (waitQueue.isEmpty() && waitQueue.isEnd()) {
//结束此进程
return;
}
Request request = waitQueue.getOneRequestAndRemove();//获取请求
if (request == null) {
continue;
}
//分发请求
}
}
我们可以看到,当request是空时,会执行continue语句,使得循环继续执行,那么这个循环就会一直执行到request不为空的时候(当然,实验代码在getOneRequestAndRemove()
中有wait()
,我这里假设没有),假如说下一个request数秒钟后才到来,那么以CPU的执行速度,这个循环可能已经执行了数万次了,如果我们在continue前面加上一个打印,就会输出几万次,这样很浪费CPU时间。
我在完成hw5
时候,在电梯类中就没有写wait(),第一次提交中测时候有好几个点产生了CTLE
,关于CTLE
位置的确定,我使用过的主要有两种方法,第一种是比较简单的直接利用Profiler
分析程序的运行,这样能够查看各个函数的运行时间,查出是哪个函数占用了非常多的运行时间,这样的缺点是难以区分RTLE
,第二种是在每个run()
方法后面加上输出,定位类之后再对每个语句也利用输出进行定位,这是助教提出的方法,非常实用。
代码架构
UML协作图
第五次作业
UML类图
基本思路
本次作业是多线程的第一次作业,此次作业不涉及到电梯分配问题。只需要设计一个相对简单的电梯系统就可以了。我也没有实现量子电梯这种没有什么实际意义的操作。
我的主要类有四个:Input,Schedule,Elevator, RequestTable。其中:
-
Input处理输入,把输入的乘客添加到主请求表。
-
Schedule进行分配,这次作业只需要根据给出的电梯编号进行分配就可以了。
-
Elevator实现电梯,与其所绑定的RequestTable进行交互,处理用户请求。
-
RequestTable类用于存储乘客信息,所有对这个类内容进行更改的操作都必须要上锁。
电梯运行策略使用LOOK算法,可以得到一个比较好的效果(也比较人性化,我是坚决不坐ALS策略的电梯的):
LOOK策略没有所谓的主请求,电梯只有运行方向这个状态,电梯根据内部乘客和外部请求决定如何移动。
- 电梯到达某楼层时,首先判断是否需要开门
- 如果发现电梯里有人可以出电梯(到达目的地),则开门;(能到则出)
- 如果发现该楼层中有人想上电梯,并且目的地方向和电梯方向相同,则开门。(能捎则捎)
- 接下来,进一步判断电梯里是否有人
- 如果电梯里还有人,则沿着当前方向移动到下一层
- 否则,检查请求队列中是否还有请求
- 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
- 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方,则电梯掉头。
- 如果请求队列为空,且输入没有结束,则电梯停在该楼层等待。
伪代码如下:
public Advice getAdvice(int nowFloor, int nowNum, int direction,
HashMap<Integer, ArrayList<Person>> destMap) {
if (/*有人要上或者有人要下*/) { //判断是否开电梯门
return Advice.OPEN;
}
if (nowNum != 0) { //若电梯里有人
return Advice.MOVE;
}
else { //若电梯里没有人
if (requestTable.isEmpty()) { //如果请求队列中没有人
if (requestTable.isEnd()) { //如果输入结束,电梯线程结束
return Advice.OVER;
}
else { //如果输入未结束,电梯线程等待
return Advice.WAIT;
}
}
if (hasReqInOriginDirection(nowFloor, direction)) { //若请求队列不为空且某请求的发出地是电梯"前方"某楼层
return Advice.MOVE;
} else { //否则,电梯转向(仅状态改变,电梯不移动)
return Advice.REVERSE;
}
}
}
数据结构
数据结构只有Person
和RequestTable
两种,前者用于存储每个乘客的信息,本次作业还有需要乘坐的电梯~~(要是每次都有就好了)~~,后者是十分关键的数据结构,能够对其进行修改的函数都加了锁。对其中判断是否结束的函数,还需要加上notifyAll
来唤醒正在等待的线程。在此后作业中,我的数据结构都没有发生太大变化,故不做赘述。
public synchronized boolean isEnd() {
notifyAll();
return endFlag;
}
程序如何结束
这次作业程序如何结束的条件比较简单,Input线程结束的条件是输入已经结束了,Schedule线程结束的条件和Input一样,因为在本次作业中分配进入某个电梯的乘客不会再返回到总调度队列了,乘客请求是本次作业唯一的请求,那么电梯线程在确定Schedule线程结束,也就是说电梯不会再接受到新的乘客了,那么在完成了电梯内部乘客和电梯等待乘客之后就可以结束了。
第六次作业
UML类图
基本思路
本次作业主要新增了两个需求,一个是要对电梯进行分配,另一个是要能够对电梯进行重置请求,接收到重置指令的电梯需要尽快停靠后完成重置动作,再投入电梯系统运行。为安全起见,电梯重置时内部不可以有乘客,性能参数(满载人数
、移动时间
)可能会发生改变。
我们先来看重置请求,根据题目描述,我们来逐个分析RESET请求的特点,首先是尽快停靠后完成重置动作,这个尽快是比较耐人寻味的,课程组规定要在移动两层楼的区间内,请求发出的5s内完成RESET。这迫使我们必须第一时间完成此操作。在研讨课上,有同学提出应该在原有的乘客请求列表之外单独加上一个重置请求列表,从中优先取出重置请求来完成。我认为这样有一定的道理,但是从原理上来说,重置请求和乘客请求是两种不同的请求,我们分配乘客到电梯和对某部电梯进行重置操作并不矛盾,那么它们理应可以同时执行,只要不把乘客分配到正在重置的电梯就行了。
//Input.java
if (request instanceof ResetRequest) { //要RESET
ResetRequest resetRequest = (ResetRequest) request;
schedule.resetElevator(id,capacity,speed); //直接调用方法,不经过线程的run方法
mainRequestTable.addReset();
}
//Schedule.java
public synchronized void resetElevator(int id,int capacity,double speed) { //重置电梯
//do something
elevatorMap.get(id).setResetFlag(true); //设置成需要马上RESET
}
之后,我们只需要在电梯获取策略的时候特判一下,如果Reset Flag是真的时候,返回Reset策略就可以了。这样在接受到一个RESET请求后,电梯最慢下次获取策略时候就会获得Reset策略,进而执行Reset操作。
再来看电梯内部不可以有乘客,这要求我们必须要能够把乘客送出电梯,并且把电梯的等待列表页清空,为了性能考虑,我把这些乘客送回主请求队列使用Schedule进行重新分配,这就涉及到了线程安全问题,但是由于所有能够对主请求队列进行写操作的函数都上了锁,所以我在这里保证了把乘客送回总请求表时候不会产生数据冲突。这里很多同学都提到要使用Buff区域来进行缓冲以避免一些可能出现的线程冲突,在这里我使用的Buff区域就是电梯的等待列表,先把电梯内部的人开门放出去,加入等待列表,之后再送入总请求列表。
if (advice == Advice.RESET) {
synchronized (requestTable) { //没人可以不开门
//开门并且把人送进等待列表
//开始重置,此时电梯不能接受任何请求
//这个时候才可以把人送回总等待表,否则有可能进行二次分配回来
//重置结束
}
}
最后我们来看电梯的参数可能发生变化这件事情,由于我的Reset请求由Schedule发出,电梯只能在该函数被调用时候才能接受到需要改变的参数,但是电梯此时尚未开始执行Reset操作,参数当然不能改变,所以我选用了状态机的方式,给电梯类增加了属性NextCapacity
、NextSpeed
,在开始执行Reset操作时候,用这两个值来替换电梯本来的Capacity
和Speed
属性。
//Schedule.java
public synchronized void resetElevator(int id,int capacity,double speed) { //重置电梯
elevatorMap.get(id).setNextCapacity(capacity);
elevatorMap.get(id).setNextSpeed(speed);
elevatorMap.get(id).setResetFlag(true);
}
//Elevator.java
public void reset() {
this.speed = nextSpeed;
this.capacity = nextCapacity;
//do something
}
对于电梯的分配策略,我个人并不喜欢影子电梯~~(其实也没有时间写)~~,我采取的是代价评估体系,通过此时所有电梯的状态来挑选出一个统计学意义上比较优质的电梯,但是在本次作业中,由于代价函数以及参数选取不够好,所以只能说险胜%6
方法。
if (能够捎带) {
//计算cost,主要是超载导致的cost增加
}
if (反向) {
//主要是距离导致的cost增加
}
if (请求表很多人) {
//额外增加cost
}
if (电梯内部人满) {
//额外增加cost
}
if (正在Reset) {
//最好不要分给它
}
程序如何结束
此次作业程序结束条件有所不同,不能简单地通过输入结束来判断调度器是否需要结束,因为如果输入最后是RESET请求时,电梯仍有可能把乘客送回总请求表,如果这时候调度器已经结束了,那么就不能进行分配了。所以我新增了一个条件,所有RESET是否都被完成。
//Schedule.java
if (mainRequestTable.isEmpty() && mainRequestTable.isEnd()) {
//标志所有电梯表的endFlag
if (mainRequestTable.getResetNum() == 0) {
for (int i = 0; i < requestTables.size(); i++) {
requestTables.get(i).setEndFlag(true);
}
return;
}
}
每次完成一次Reset,都修改MainRequestTable
的ResetNum
,并且notifyAll
来唤醒正在wait的Schedule,这样就能通过计数器的方式来使程序正常结束。
第七次作业
UML类图
基本思路
本次作业新增了双轿厢电梯需求,也即有一种新的RESET请求,可以使电梯变成双轿厢电梯,双轿厢电梯是指在同一电梯井道内同时拥有两个独立的电梯轿厢,而电梯系统默认的普通电梯是指在一个电梯井道内只有一个轿厢。为了保证两个轿厢不相互碰撞,将楼层分为上区、下区、换乘楼层,其中上区为换乘楼层以上的所有楼层,下区为换乘楼层以下的楼层,均不包含换乘楼层。在整个运行过程中,要求轿厢 A 只能在下区和换乘楼层运行,轿厢 B 只能在上区和换乘楼层运行,同一井道内的两轿厢不能同时位于换乘楼层。
我们还是逐步来看这个双轿厢电梯,电梯要先重置,这个部分和上次是一样的,按照正常情况进行RESET就可以了,对于如何产生新的电梯,我这里的处理是把原有电梯作为新的双轿厢A电梯,并且新建一个B电梯,但是值得注意的是,必须等到输出RESET结束之后才能把这两台电梯加入电梯表,否则就会违反RESET过程中电梯不能RECEIVE乘客的约束。需要给电梯新增运行楼层范围这个属性,电梯运行时必须在这个范围内,否则就会出现电梯飞天遁地的事情。进行双轿厢重置的电梯允许进行瞬移,A电梯的当前楼层为换乘楼层下一层,B电梯的当前楼层为换乘楼层上一层。
如何避免电梯相碰撞
这里我采取了和讨论区比较类似的方式,从原理来说,避免两个电梯相撞是一个简单的互斥问题,这个互斥问题的主要要求除了最基本的不能同时进入临界区之外,还应该避免忙等待,因为一个双轿厢一直等另一个双轿厢离开,会非常浪费效率,或者也可以说这是一种优先级反转的情况。那么第一步,我们要在双轿厢电梯中新增策略,主动退出换乘楼层。
if (advice == Advice.WAIT && type.equals("A") && nowFloor == transferFloor) {
direction = 向下;
move(); //若电梯满足:是双轿厢电梯,不需移动,位于换乘层三个条件,则需要向远离换乘层的方向移动一层
} else if (advice == Advice.WAIT && type.equals("B") && nowFloor == transferFloor) {
direction = 向上;
move();
}
接着,我们来考虑这把锁,这个比较简单,用二元信号量就可以了,但是鉴于我在整个代码中都使用的是synchronized关键字,所以我在这里还是使用这个关键字新建了一个类Permit
用于实现楼层的互斥。
//Permit.java
private synchronized void waitRelease() {
//唤醒线程(另一个轿厢)
//忙则等待
}
public synchronized void setOccupied() {
//等待锁释放
//占有锁
}
public synchronized void setRelease() {
//释放锁
//唤醒线程
}
双轿厢电梯共享这把锁,在电梯移动时候,我们特别判断一下电梯是否要处于换乘楼层,然后去尝试占有这把锁,离开时候释放这把锁就可以了。
程序如何结束
程序如何结束的条件又发生了变化,其实在上次作业的时候我就考虑过可以使用是否所有乘客都被送到目的地来判断线程是否需要结束,但是最终没有使用该条件。在本次作业中,即使输入结束、所有RESET请求都被完成,仍然有可能尚需分配,这是因为双轿厢电梯由于换乘,还会有乘客回到主请求队列。所以我们需要改变部分线程的结束条件。
//MainRequestTable里面
public synchronized void addPeopleUnsent() {
personUnsent++;
notifyAll();
}
public synchronized void subPeopleUnsent() {
personUnsent--;
notifyAll();
}
//Schedule.java
if (mainRequestTable.isEmpty() && mainRequestTable.isEnd()) { //标志所有电梯表的endFlag
if (mainRequestTable.getResetNum() == 0 && mainRequestTable.getPersonUnsent() == 0) {
//这下可以结束了
}
}
浅谈电梯调度
鉴于上次作业的调度函数表现不佳,我在这次调整了评估函数,取得了相对不错的分数,比大部分影子电梯都表现得更好。
//对每台电梯做计算,包括双轿厢的AB
if (电梯此时是空的,但是由于耗电量不应该优先调度不动的电梯) {
weight = moveTIme * Math.abs(elevator.getNowFloor() - person.getFromFloor()) + 1;
} else if (能够捎带) {
weight = moveTIme * Math.abs(elevator.getNowFloor() - person.getFromFloor());
//代价为每层运行时间 * 距离差
} else {
int goal = requestTables.get(i).get(j).getGoalFloor(elevator.getNowFloor(),elevatorDirect);
weight = moveTIme * (Math.abs(elevator.getNowFloor() - goal)
+ Math.abs(goal person.getFromFloor()));
//不能捎带,需要判断电梯目前最后到哪,然后代价为每层运行时间 * ((最后到哪 - 现在) + (最后到哪 - 人在哪))
}
//是否很多人等这台电梯,或者电梯是不是很满
weight += (requestTables.get(i).get(j).getRequestNum() > 10) ? 40000 : 0;
weight += elevator.isFull() ? elevator.getCapacity() * 100 : 0;
//还需要判断每台电梯的可达性
//如果是双轿厢,鉴于电梯的耗电量比较低,那么*0.75,使得能够比较优先调度双轿厢
稳定的内容和易变的内容
比较稳定的部分有输入线程,电梯的运行策略(Look),电梯线程的结束条件等。总的来说,我的三次作业中,大的逻辑一直没有变化,依旧是输入——分配——电梯运行。关于线程安全部分也没有太大的变化,都是synchronized关键字,都主要修饰请求列表和乘客列表。
易变的主要有新增的请求、新增的不同种电梯、乘客分配方法。这些对于大的框架来说更像是“增量”开发,对于原本的电梯运行,这些改动不会影响其正确性,我们可以不需要特别在意这些操作,把原来的操作封装好,然后把这些新的需求接进去就可以了。
BUG分析
很幸运本单元三次强测都没有出现Bug,但是三次互测无一善终。
第一次作业中我没有处理好线程安全问题,在大量请求挤入时候会出现在遍历电梯表的同时对电梯表进行修改,所以抛出了ConcurrentModificationException
错误,后续给写方法加锁解决此问题。
在第二次作业中,我在RESET过程中,错误地给一个函数加了锁,导致有可能出现死锁,概率非常低,但是有一位房友在短短两天时间内疯狂出刀40刀,最终命中了我,删去该方法的锁后解决了问题。
第三次作业中,由于改动了调度策略,我不能很好地处理“围师必阙”的数据点,有可能导致某些情况下一直分配给双轿厢电梯,在修改了分配函数后解决问题。
多线程情境下,debug比较困难。我采取的策略是输出法,输出电梯每次行动时的信息,通过分析问题发生前后的电梯的状态和调度器的状态来判断问题出在哪里。
心得体会
线程安全
想好哪个变量需要互斥访问,哪些线程直接需要等待和唤醒,然后按需选择同步方式(尽可能轻量,尽可能缩小范围)。锁并非越大越好,也不是越小越好,要根据需求合适地选择锁的大小和位置。
减少锁的套用,锁套着锁非常容易造成死锁。然而必要的地方,任何两个语句之间少了锁,就有可能在线程运行有变动,可能会触发各种各样的问题。
层次化设计
最终的架构被分为了三个部分,分配器分配利用电梯提供的状态进行分配,不关心电梯如何运送乘客;后端的电梯也不知道分配器的存在,认为只有自己在服务。这种解耦极大简化了整个程序的复杂度。
其他
OO第二单元终于结束了,其实我认为这一单元的设计挺好的,很好地锻炼了我们多线程的编程能力,比较遗憾的是在第二次作业里面程序的性能并不是很好,归根结底还是完成的比较匆忙,还是要仔细思考再开始写代码。