301
社区成员
发帖
与我相关
我的任务
分享本单元的内容是多线程的并发程序设计。从第一次作业的固定分配策略,到第二次作业的自由调度+电梯重置,再到第三次作业加入双轿厢电梯,每一周的作业都有新的线程协同需求要额外处理,最终形成了一个如下生产者-消费者模型架构的程序:

下面我将从需求-实现的角度来叙述我每一次作业的迭代思路和内容,最后做一个小总结。
需求:使用固定的电梯将乘客送至目标楼层,电梯应为可捎带电梯。课程组提供的捎带策略为ALS策略,但是既然作为“基准”,性能不会太好,于是我便选择了往年博客中提及最多的“LOOK”策略,简单描述如下:
实际上LOOK策略就是实现了一个从当前层的当前方向上的逐层扫描的算法,优化点在于不必每次都要上到最高楼层。
实现:架构上,与前言部分的结构没什么区别,只是由于第一次不需要实现调度器,所以把Scheduler省略了,只需要输入将对应的人送进对应电梯的请求队列即可。因此调度策略没什么可谈的,性能方面全权交给LOOK策略,但是第一次写的时候有一个小问题没有发现:在电梯转向时要下来的乘客方向与电梯的运动方向不同,导致需要开关门两次才能上人,这一点比较影响性能,后来加了一个转向时的特判改掉了。
但是本次作业对我而言真正的难点在于多线程的程序设计与共享数据的同步管理。刚开始把从队列取人那部分写成了轮询,后来才逐步懂得使用“Wait-Notify”模式进行设计。本次作业中的我还不了解ReentrantReadWriteLock等更灵活的锁机制,且考虑到共享的数据对象仅仅是WaitList中的请求,仅仅会在读取时和加入新请求时修改这个对象,因此我全部使用的是对方法加锁(使用synchronized关键字),好处在于简单、容易实现,但是坏处也有,这个一会再说。
public synchronized <Passenger> getRequestAt(param1, param2, ...) {
// do something with lock
}
public synchronized void addOneRequest(param) {
// do something with lock
}
类图如下:

架构使用7线程,Input Thread + 6 Elevator Thread。实现了策略与电梯运行的分离,根据特别值得一提的是,多线程程序使用断点调试的方式几乎是不可行的(某些相对独立的线程可能有用),特别是涉及到锁与共享对象这个问题时。这个问题比较困扰人,而肉眼盯真法又实在有些粗鄙,大量写System.out.println()一方面显得臃肿,另一方面不需要调试时一条一条删除太繁琐了。因此我新加入了一个类:DebuggingLog,用于批量管理打印的信息,设置一个全局的标志位来一键设置打印或者不打印。
public class DebugLogging {
private static boolean state = false;
private static HashMap<Integer, Boolean> selectElevator = new HashMap<Integer, Boolean>() {
{
put(1, true);
put(2, true);
put(3, true);
put(4, false);
put(5, false);
put(6, true);
}
};
public static void log(String s) {
if (!state) {
return;
}
System.out.println(s);
}
public static void log(String s, Integer id) {
if (!state || !selectElevator.get(id)) {
return;
}
System.out.println(s);
}
}
时序图如下:

代码规模如下:

对架构进行分析,可以发现这种生产者-消费者的模式扩展性较高,一方面电梯策略是与电梯运行分离的,另一方面输入、分配与运行之间也是分离的。移动的只是Passenger这一种数据,使用两种不同的数据结构:WaitList与RequestPool(Public)来管理和承载,这样各模块之间就实现了分离,方便日后的拓展。
Bug分析:强测中没有出现问题,但是互测中出现一处ConcurrentModificationException,刚开始我百思不得其解,自认为共享数据的同步控制已经足够完善,但是后来仔细分析意识到(回应之前提到的方法加锁的坏处,当然也算不上坏处,只是我欠缺考虑>_<):方法返回后,锁就被自动释放。因此,务必在带锁的方法的返回值中包含尽量单一的数据源(保证后续操作是原子的)。而我的获得请求的方法是返回的一个容器:
public synchronized ArrayList<Passenger> getRequestAt(param, ...) {
return HashMap.get(param);
}
这就会导致方法的锁被释放后,返回的ArrayList是没有锁保护的。这时候对其的任何修改都是线程不安全的。这一点是作为多线程编程的菜鸟的我欠缺考虑的,后来我将数据结构由ArrayList改为CopyOnWriteArrayList,这种快照形式的数据结构保证了永远不会出现CocurrentModificationException,从而确保了线程安全性。
需求:实现自己的调度策略+电梯重置+Receive输出。Receive是为了避免自由竞争,电梯重置是为了线程通信。
实现:首先考虑电梯的运行。我们可以做这样一种状态转换:
Elevator.moving => Elevator.Stand_by => Elevator.Reset => Elevator.Stand_by
无论何时Reset,我们先将所有人(电梯内、外的等待队列)恢复至没有人,电梯就会进入Stand_by状态,此时Reset,Reset结束后回到Stand_by状态,这与电梯线程的初始状态是一致的,也就形成了状态的闭环。这个过程的实现只需要先将全部的乘客赶回等待队列重新分配,同时休眠一个固定的时延来模拟重置即可。
再谈锁的问题,考虑到本次加入了调度策略,因此读写共享对象的需求变得复杂,单纯使用synchronized不能控制多个条件下的await(),另一方面,考虑一个多读少写的情况下,使用单一的同步控制会使得并发性能较低。为此,我改为使用ReentrantReadWriteLock来控制同步,这个机制允许多个线程同时持有读锁,而有且仅有一个线程可以拥有写锁,这保证了写时安全与读时高效。本次的共享对象为公共队列(RequestPool)与私有队列(WaitList)中的Passenger对象,只需要将方法中的syn关键字去掉,改为对应的读锁与写锁即可。
public void setEnd() {
lock.writeLock().lock();
if (condition) {
try {
endCondition.await();
} catch()
}
// set something
lock.writeLock().unlock();
}
读写锁的使用大大简化了对于请求队列与重置队列的Wait-Nofity模型的控制,使得分别唤醒成为可能。
再谈架构与策略,由于自由竞争不被允许,那么一个既易于实现又能尽量维持高性能的算法就很必有必要。经舍友指点,我最终的实现是让人去选择一个合适的电梯,即选择一个能最快接到他的电梯,但是倘若没有接上(电梯已满),则甩回公共池中重新等待分配。这样可以一定程度上保证每个人的需求以最短的方式响应。
时序图如下:

类图如下:

Bug分析:在自行测试时,Debugginglog的使用非常方便,打印输出可以在关键的地方输出程序的状态,通过类中的电梯选择器还可以实现对于特定电梯的输出的过滤。在强测中的bug是:修改上次作业中叙述的那个开关两次门问题时,与或式的条件少写个括号导致有概率出bug,唐完了(
需求:双轿厢电梯+换乘+冲突处理
实现:电梯重置比上次多了些要求,因为换为双轿厢以后一个需要决策的问题是:_是与之前共享一个等待队列还是分别新建?_由于共享时换乘楼层的乘客对于之前LOOK策略的影响较大,最终我选择了使两个电梯分别独立的作为线程运行。现在想想,这是一种减少耦合的有效手段,使得不同的线程尽量不共享太多的内容。
另外一个需要解决的问题是怎么做才能实现双轿厢电梯的不碰撞?我的实现是:新建共享楼层ExchangeFloor类,由两个电梯共享,同时设计一个对两电梯可见的标志位来标记该楼层的占用情况,当电梯尝试进入共享楼层时,调用方法尝试获得锁(无论何种情况都只有一个电梯能抢到锁),另一个电梯尝试进入时会await()直到占用楼层的电梯离开(被signal())。而离开的策略可以使用如下的方法:
再讨论锁,本次作业中的锁与上次几乎没有变化,因为共享对象没有变化,共享模式没有变化,仍然是生产者-消费者模式,这是从第一次作业沿用至现在的模式,实践证明扩展性很好。因此还是采用对于共享对象的读写(公共请求池的添加请求/获取请求,私有队列的获取请求/添加请求)添加锁,仍然采用ReentrantReadWriteLock来管理同步,理由与上次相同。这次新增的共享楼层中的锁如下所示:
public void tryEnterExchangeFloor() {
lock.writeLock().lock();
if (isBlocked) {
try {
competitor.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
isBlocked = true;
lock.writeLock().unlock();
}
public void leaveExchangeFloor() {
lock.writeLock().lock();
isBlocked = false;
competitor.signalAll();
lock.writeLock().unlock();
}
代码的UML时序图:

代码的类图:

Bug分析:程序在最后一条是Reset时由于线程退出的判断条件有误导致停不下来,被hack好几次>...<
难,好难,真的难。但是我也真的从第一次作业那种面对多线程手无足措的状态走到了现在,虽然中途无数次想放弃,想着不会写也无所谓,但是还是一点一点的写到了最后,在舍友的帮助下强测中取得了还算不错的成绩,其实这个成绩的高低我倒是无所谓,但是真真正正学到东西的喜悦是炽热的,尽管过程是痛苦的。
现在想想,如此困难的OO课程如今也已经走过一半了。若问我累不累,我的回答肯定是“累死了”,但是还是要特别感谢一直帮助我的舍友们,还有一直支持我的朋友们,没有他们的帮助我肯定不会走这么远。
愿之后的三四单元能顺利。