一、前言
本单元学习多线程,难点主要在于共享资源的互斥访问,同时要避免轮询和死锁。第一次作业指定了电梯,着重关注线程整体设计以及单部电梯的执行策略;第二次作业取消了指定电梯,由自己的调度算法来确定请求分配方式,同时加入reset
操作,加深对锁的认识;第三次作业,在此基础上增加了双轿厢电梯,我们需要保证两个轿厢不能同时位于换乘楼层,同时对于一些前两次作业使用的分配策略也产生了影响,需要重新设计、规划。
就完成情况而言,我第一次作业又双叒叕做得很痛苦~归结于多线程基础知识的预习不够到位,以及一些同步问题似懂非懂,直接看往年的博客又太繁琐了。不过,在实验课之后,拿到了非常漂亮简洁的实验代码,就直接把架构拿来用了。第二次作业,相对简单一些,主要问题就是分配策略和reset的处理方式,bug产生也是一些线程并发执行时,时间戳先后问题。第三次作业……寄!由于本人代码习惯不好,写着写着就一团糟了,于是被各种奇奇怪怪的不知道哪里冒出来的bug搞得焦头烂额,ddl前没能过中测,算是一个比较大的遗憾吧……
二、架构分析
1、 总体架构
线程设计
从标准输入中读取带时间戳的输入,将请求分为两部分:personRequest
和resetRequest
。其中,personRequest
放进线程安全的类waitQueue
中,resetRequest
直接经由handler
分配给各部电梯。这是因为reset
是收到后立即就可以执行的,而正常请求需要等待电梯空闲等条件才可以被执行。这里InputThread
和Schedule
构成一个生产者-消费者模型,生产者是InputThread
,消费者是Schedule
,而之间的流水线是waitQueue
,所以waitQueue
的类型必须是实现了互斥读写的MyRequestQueue
类,而不能用不安全的ArrayList
。
同样地,Schedule
和每个Elevator
之间也构成了生产者-消费者模型,传递的对象是elevatorQueue
。Schedule
类负责将普通请求分配给不同电梯,而ResetHandler
类负责将重置请求分配给请求指定的电梯。在第六次作业之后,加入了reset
,那么,从一个方面来看,已经被接收到elevatorQueue
中的request
,是有可能被撤回的,而撤回后又必须保证不能立刻被分配回来,这样就占用了大量cpu时间,产生无意义的循环;另一个方面,在elevator reset
时,不能执行请求,但如果把这个作为调度器的判断条件,就会产生往一部电梯狂塞请求这样一种bug。所以,在电梯reset
时,我同样会为它分配请求,但是此时的请求就会被加入requestToReceive
,等到电梯重置结束时,统一把这个容器类里面的请求copy到elevatorQueue
中,实现在reset
时不进行电梯运行操作。
类图
先说缺点吧,我这次作业设计的架构可以很明显地从图上看出来,把很多方法和属性都堆在了电梯类,同时,电梯类实现Runnable
接口,还需要一个run
方法,这整个类就显得繁杂冗长了 (差点就超行数了) 。同时,还有一个缺点就是Elevator
类和DecisionMaker
类分工不是很明确。一开始的想法是DecisionMaker
负责判断Elevator
执行哪个操作,然后由Elevator
调用特定方法,修改自身属性等。但是,在DecisionMaker
里需要判断的条件,比如ElevatorQueue
是否为空、是否结束这一类条件,在写运行方法时,还需要再次判断,以正常遍历容器。也就是说,同一个条件被两个类判断了两次,是不必要的重复,也导致了一些后面迭代时的逻辑混乱。
具体到最后一次迭代,也就是第三次作业。先从InputThread
中读取一条请求,如果是reset
请求,则直接分配给特定电梯执行;如果是普通请求,加入waitQueue
传递给Schedule
线程。Schedule
线程计算每个request
最佳去向,将waitQueue
中的请求remove出去,add进特定的elevatorQueue
中。然后在Elevator
线程中,采用Look算法,实现单部电梯的运行逻辑。当普通reset
到来时,开门放人,同时,没完成的request
都放回waitQueue
中重新分配。双轿厢reset
,创建一个“新的”电梯,设置始末楼层。同时为当前和新的电梯设置Occupied
对象用于对换乘楼层的互斥访问。特别地,当双轿厢的一方停止在换乘楼层时,立刻转向移动一层。
2、 同步块和锁
同步块
synchronized
关键字可以用于创建一个同步块,以确保在同一时间只有一个线程可以执行某个代码块。
其中,作用于不同作用域可以对不同对象上锁:
当synchronized
关键字修饰一个非静态方法时,它锁定的是调用该方法的对象实例。
当synchronized
关键字修饰一个静态方法时,它锁定的是该类对应的Class对象。
当synchronized
关键字修饰一个代码块时,此时需要指定一个对象作为锁。
public class Example {
private Object lock = new Object();
public void Do() {
synchronized (lock) { // 使用lock对象作为锁
do something;
}
}
}
锁
java.util.concurrent.locks.Lock
接口和它的实现类(如ReentrantLock
),可以实现更灵活的线程同步。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Lock {
private Lock lock = new ReentrantLock();
public void incrementCount() {
lock.lock(); // 获取锁
try {
do something;
} finally {
lock.unlock(); // 释放锁
}
}
}
还可以特定读写锁,缩小锁的范围。
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class MyReadWriteLockExample {
private int data = 0;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public int readData() {
lock.readLock().lock(); // 获取读锁
try {
return data; // 读取共享资源
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
public void writeData(int newValue) {
lock.writeLock().lock(); // 获取写锁
try {
data = newValue; // 写入共享资源
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
}
我在这次作业里用的基本都是synchronized
关键字,保证在每个用synchronized
修饰的方法最后都notifyAll
一下。
3、 调度器设计
调度器,这里是指我的Schedule
类,负责分配请求。看了前几年学长学姐们的博客,发现均分策略好像是可以通过的。于是我就在均分上稍微做出了一点点优化。以第二次作业没有双轿厢电梯为例,具体地:
每六条request
一组,第一个进来的request通过calculate
公式(是自己根据请求,电梯运行状态随便写的一个算式,用来评估分配给该电梯时性能高低),找出一个最合适的电梯,塞给它。然后,将这个电梯暂时从电梯队列“移除”。这样,在分配第二个request时,只剩下五部可供选择的电梯了。就这样一轮六个分配结束后,再将所有电梯“释放”出来,匹配下一组六条请求。这是一个均分和最近的折中算法,旨在……避免针对模6数据以及全塞一个电梯的极端情况。
4、 可扩展性
这里对于两种电梯都加上了起始楼层和终止楼层的属性,并配置了访存方法。如果未来的reset
还能够更改电梯执行楼层范围(比如一些酒店的电梯),或是只停靠奇数/偶数楼层(这个可能就需要增加数组了),一样可以通过set和修改if判断条件来实现。
三、bug分析
1、 三次作业的bug
第一次作业,由于对多线程还止步于一个了解状态,没有做好线程安全,导致了在使用迭代器遍历容器时,容器的内容同时发生了改变,导致re。修改方式就是让所有容器都实现线程安全的访存方法。 (当然,我最后偷懒用i去遍历了)
第二次作业,互测是非常经典的5个reset,然后塞一堆请求的数据点。产生错误的原因是在电梯调度时,无视了处于reset
中的电梯,而是去比较剩下电梯的性能,从而做出选择。(其实有想到全部reset
的情况,这时候wait
一下就可以了,但是完全没想过全给一部电梯的rtle问题)
第三次作业,逻辑混乱寄得惨烈,甚至出现了一些离谱bug,如下↓
后来debug时候发现还是同步控制没有做好,以及一些记录型变量未能正确记录当前线程运行状态。
2、 hack方式
因为本人比较菜,这次只进了第二次作业的互测。然后就随手捏一个把6个电梯全reset了然后紧接着输入一条request
的数据,刀了3个(思考ing)。这个是自己在写策略时发现的一个小问题,电梯都在reset
的话要wait
一下~~当然,加入之前提到的requestToReceive
就没事了。
3、 debug方法
最主要的还是print大法啦~~首先改写一下toString
方法,然后打印各个中间变量,或者监视电梯状态,看看究竟是在什么情况下产生了比较离谱的情况(比如电梯乱飞等)。
特别地:CTLE一般是出现轮询,这时候在每个run方法的循环中打印一下线程名字,看看哪里一直在不断循环占用cpu资源。比较好的解决方法是找到共享对象使用wait
,比较简单粗暴的解决方法是直接sleep
线程。
RTLE我主要遇到两种情况。第一种是调度策略不当,这个在上文解释过。还有一种就是kill
线程的条件设置有误,导致有线程无法结束,程序卡死无法退出。这种一般是打印出kill
掉的线程,看看是哪个(一般是电梯)没能成功退出。然后再进一步查询,是end
设置有误还是资源计数有误等等。
四、心得体会
多线程这一单元总体来说还是很难的!!比起第一单元,最大的难点是不清楚程序执行的过程,好多bug都无法在本地复现,也就不知道错误原因是什么了(只能瞪眼法)。不过,经过两次实验,也是对本来一头雾水的锁、同步控制等知识稍微有了点想法。实验代码真的是一个很伟大的东西!简洁易懂,同时对于作业还能够提供思路上和语法上的帮助。
挂了一次,以后要努力了……!