301
社区成员
发帖
与我相关
我的任务
分享在并发编程的情境下,有一些非常经典的模型/问题可供我们进行参考:
读者-写者问题:描述多个读者和写者对共享资源(如数据库、文件等)的访问。要求多个读者可以同时读取共享资源,但是写者在写入时需要独占资源,且写者和其他写者/读者之间互斥。
生产者-消费者问题:
这种模型的核心在于生产者和消费者之间的协调,以确保生产者不会试图在缓冲区已满时继续生产数据,而消费者也不会试图在缓冲区为空时继续获取数据。
哲学家就餐问题:经典的并发问题,描述了五位哲学家围坐在圆桌旁,每人面前放着一碗意大利面和一只叉子。他们只能通过拿起左右两边的叉子来吃饭,但每只叉子只能被一个哲学家拿起。问题在于如何避免死锁,以及如何有效地分享资源。
而我们在第二单元所解决的电梯调度问题,则完美符合了生产者/消费者模型。

首先应设立一个请求队列,用于保存哪些还没有得到处理的乘客的请求。然后使输入线程作为生产者,其负责产生新的乘客请求,并添加到等待队列中去;而电梯则作为消费者,其会在等待队列中获取到请求并且经由自己进行处理。
在传统的生产者/消费者模型中,需要保证消费者不会在缓冲区/队列为空的时候尝试获取去缓冲区/队列中的数据,也需要保证生产者不会在缓冲区/队列为空的时候向缓冲区/队列中投放数据。在电梯调度问题中,由于我们的候乘表/等待队列是无限大的,因此不需要考虑生产者不能生产的问题;但是消费者无法消费的情况是有可能出现的(长时间没有新的请求,电梯无法取得请求,从而原地等待),因此在设计程序时必须要考虑到这一点。
需要注意的是,由于今年的电梯调度不允许自由竞争(即多部电梯同时尝试从请求队列中获取请求,谁先争抢到就归谁),因此还需要加入一个调度器作为电梯以及乘客的中间层,结构如下图所示:

因此,第二单元最终的架构其实就是双层的生产者/消费者模型。针对于主请求队列而言,输入线程是生产者,调度器是消费者,负责把某一个请求甩给某一个电梯进行处理;针对于某一个电梯的请求等待队列而言,调度器作为生产者,电梯作为消费者。至于最终作业中的各种限制以及要求(双轿厢电梯、重置请求等)都只需要在上述模型的基础上去进行具体的实现即可。
在多线程/并发编程的情境中,数据竞争是我们非常不希望看到的一个局面。当出现数据竞争时,我们的程序变得不再确定,其运行的结果可能每一次都不一样,甚至有可能会导致程序崩溃等严重的问题。因此给临界资源加锁是非常重要的。
但是简单的上锁并不代表就已经很好地解决了问题,这是因为锁的获取和释放都是比较占用资源的操作,频繁的上锁/解锁不仅会拖慢程序运行的速度,而且会让程序的并行度下降。考虑两个极端的情况:
Object lock = new Object();
int x = 0;
synchronized (lock) {
while (x < 100) {
x++;
}
}
x = 0;
while (x < 100) {
synchronized (lock) {
x++;
}
}
在上述两个循环中,其中一个循环只在循环开始处进行了一次加锁操作,而另一个循环则每一次进入循环体都会进行加锁操作,很明显第一个循环会快的多
//Thread 1
new Thread(new Runnable() {
public void run() {
synchronized (lock) {
while (conditionA) {
resourceA.do_some_thing();
}
}
};
}).start();
//Thread 2
new Thread(new Runnable() {
public void run() {
synchronized (lock) {
while (conditionB) {
resourceB.do_some_thing();
}
}
};
}).start();
在上述两个线程中,明明访问的是不同的资源,却用了同一把锁进行上锁,导致本来可以并行运行的多线程程序退化到了单线程运行,程序效率降低了一倍。
这两个例子都说明虽然给共享资源上锁可以保证对这些资源的互斥访问,但是锁的数量、类别、上锁方式也都是我们在编写程序时需要考虑的方面。因此,为了保证程序的正确性同时不牺牲太多性能,我在编写本次作业的程序时,上锁遵循了以下几个规则
考虑任何资源发生数据竞争的可能性,即是否会有两个及以上的线程同时对该资源进行读写/写写操作。如果有,就对该资源上锁;否则不要上锁。
比如说,在电梯类中维护的电梯当前所在楼层、开关门状态等等这些变量,只会在电梯线程这单个线程中被用到,因此完全没有必要进行加锁操作;而同时在电梯类中也有一个用于标志电梯是否在进行重置的变量,由于该变量会被调度器线程以及电梯线程进行同时的读/写操作,产生了data race,因此需要用同步代码块进行包裹,保证对于该临界资源的互斥访问。
在保证正确性的前提下,尽可能缩短临界区的大小:在上述的第二个例子中提到,如果被锁保护的临界区过大,会显著降低程序的并行性,导致程序性能差。因此,在保证程序的行为正确的前提下,应该把所有临界区的大小都降至最低。因此,在我的程序中,我没有用 synchronize 关键字去修饰任何方法,因为这样做会把整个对象都锁柱,是完全没有必要的。同时,也没有必要一进入某个方法就直接上锁,这样也会增加临界区的大小,只需要在访问某个资源前后进行加锁/解锁操作即可。还是以电梯类作为例子:对于电梯类而言,其大部分资源都只会被电梯线程这一个线程所用到,根本没有必要进行加锁的操作;只有和重置相关的几个方法、变量有可能会被同时读/写。只需要保证在这些方法内部对于这些资源进行操作时,被同步代码块进行包裹即可。
给需要保证原子性的操作序列上锁:上面两个规则都是针对于某一个资源的访问而言的,但是程序想要正确运行需要多个资源的配合。有时候我们希望对于一些资源的操作序列是紧挨着的,或者说,原子的。我们不希望在这些操作进行完毕之前,被其他线程插入了其他的操作。
由于没有自由竞争,因此电梯就只需要考虑自己的请求队列中含有的请求。电梯的运行策略有非常多种,例如
我在设计中采用了LOOK算法来指导电梯的运行。需要注意的是,电梯的调度算法并没有一个最优的解,我采取LOOK算法主要是因为其比较贴近于现实生活中的电梯采用的运行方式。
如果先不考虑各种操作所花的时间,其实电梯可以被看成是一个状态机,其停靠的楼层、开关门状态这些属性的集合就是状态空间中的一个点,而最终电梯的运行可以被看作是状态机在状态空间上的一个迁移。基于这样的思想,我在三次作业中实现了一个 Strategy 策略类,其实质就是电梯这个状态机的状态迁移函数。该类接受一个电梯类作为状态,然后提供 nextStep 函数来给出下一步的状态。由于电梯本身的运行策略在三次作业中其实没有发生什么变化(即便是双轿厢电梯、如果只考虑其中一个轿厢的话,会发现其实和普通的单轿厢电梯差别不大,大部分代码完全可以复用),该类在这三次作业中迭代的部分很少。
public class Strategy {
private final Elevator elevator;
...
@Override
public StratContent nextStep() {
...
}
}
第一次作业中,每一个请求都指定了要分配给哪个电梯,所以调度器使用的调度算法非常简单,其核心逻辑如下
int elevID = nextRequest.getElevatorId(); //获取请求的电梯ID
this.elevatorQueues.get(elevID).addRequest(nextRequest); //分配给对应ID的电梯的请求队列
同时,由于电梯是状态机、策略类又只给出策略,需要一个电梯的控制类来完成状态的迁移操作:
public class ElevatorRunner extends Thread {
private final Elevator elevator; //作为状态机的电梯
private final Strategy strategy; //作为迁移函数的策略
private final RequestQueue elevQueue; //电梯的请求队列,相当于输入
@Override
public void run() {
//在这里接受策略类给出的策略,并根据策略更新电梯的状态、维护请求队列
...
}
}
在第二次作业中,主要的迭代有以下两点:
首先是调度器的重写:本次作业中请求可以分为两种,乘客的请求还有重置请求,对于重置请求而言,由于其自身指明了电梯id,因此调度器可以直接将其调度到对应的电梯的请求序列中去,让电梯自己处理;对于乘客的请求调度,我采用了一个极为简单但又一定程度上非常有效的做法:随机法。我采用该方法主要有以下两点考虑:
代码量以及迭代难度:可以说,随机法在本次作业所有的调度方法中,代码量应该是最少的,其核心逻辑其实就只有一行代码:
int scheElevId = new Random().nextInt(6) + 1;
由于这个方法不依赖于具体的电梯种类、属性等等,其迭代开发难度也是0,不论是怎样的电梯系统,一行代码直接解决问题(
不易针对:确定的调度算法会陷入一个问题:通过构造特殊的数据,可以一定程度上控制调度器生成的调度序列,而如果该调度器没有将可能出现的情况考虑全面的话,就有可能会出现把所有的请求全都压到一个电梯上,导致超时等问题。
但是不得不承认,随机算法在稳定性和表现上不尽如人意。在强测时,有很高分数的点也有很低分数的点。对于同样的输入,某一个运行的结果完全没有办法复现,因为本次随机的序列和下一次生成的序列可能完全不相同,这给程序的debug造成了较大的困难。
然后是重置请求部分:如果把电梯的运行时间、最大承载量这些属性也考虑为电梯的状态而不是不变的性质的话,那其实重置请求也无非就是状态空间的一次迁移,可以被合并到策略函数中进行处理:
ResetRequest resetRequest = this.elevQueue.getResetRequest();
if (resetRequest != null) {
//pending reset request
if (this.elevator.checkWhoWantToGetOut() != -1) { //如果当前楼层有人要出电梯,立即开始重置
return new StratContent(-1, StratType.RESET); //这里给出的重置策略其实还包含了先让人下电梯,不仅仅只是重置
}
else if (
this.elevator.getCrtFloor() == this.elevator.getProperty().getMaxFloor()
||
this.elevator.getCrtFloor() == this.elevator.getProperty().getMinFloor()
) { //如果当前处在最高层或者最底层,立即开始重置
return new StratContent(-1, StratType.RESET);
}
else if ( //如果这一层的上一层或者下一层没有人要出电梯,那么也开始重置
this.elevator.checkWhoWantToGetOutAt(
this.elevator.getCrtFloor() + (upFlag ? 1 : -1)
) == -1
) {
return new StratContent(-1, StratType.RESET);
}
}
由于作业要求从收到重置请求到开始重置电梯不能运行超过2层并且受到重置请求开始到重置结束时间不能超过5s,在状态迁移时,重置就必须得到优先的处理。
同时,由于重置的时候不能产生请求的分配,因此电梯还需要维护一个是否在重置的标志,以便其他线程进行访问来获知当前电梯的重置状态。
第三次作业在第二次作业的基础上新增了一种电梯:双轿厢电梯。即两个电梯公用一个井道来完成乘客的运输。
首先对于调度器而言,其实它并不是很关系双轿厢电梯的实现细节,不管是怎样的双轿厢电梯,总能够通过合作来达到运送乘客的目的(两个轿厢的运送范围是覆盖了整个楼的)。唯一要注意的是由于双轿厢电梯的耗电量更小、承载力更强(毕竟有两个轿厢),所以随机时可以一定程度上更偏向于选择双轿厢电梯。可以用加权随机来实现。
对于双轿厢电梯自己而言,其实可以将两个轿厢拆开来看,互不干扰。而且临界区的存在其实只影响双轿厢电梯的运行而并不影响状态的迁移决策:比方说有某一个双轿厢电梯的换乘楼层是5层,一个乘客想从1层上到5层,那么轿厢A会先把乘客送到4层,然后尝试去5层,这个时候有可能轿厢B在5层,因此轿厢A无法上到5层,但是这只是影响了轿厢A的运行,并不影响到轿厢A的决策(或者说状态迁移),哪怕轿厢B在5层待很久很久,轿厢A也还是需要上到5层去,这个决策并不会因为共享层被占用而改变。因此决策类基本不用重写,但是在电梯状态迁移时、需要做到互斥访问:
public void intoSwap() {
synchronized (swapOccupied) {
while (swapOccupied.getBoolVar()) {
try {
swapOccupied.wait();
} catch (Exception e) {
e.printStackTrace();
}
}
swapOccupied.setBoolVar(true);
}
}
public void leaveSwap() {
synchronized (swapOccupied) {
swapOccupied.setBoolVar(false);
swapOccupied.notify();
}
}
每当某个轿厢的下一个状态迁移操作是移动至换乘楼层的时候,其调用 intoSwap(),如果换乘楼层是空的,便可以直接进入,如果换乘楼层非空,则需要等待另一个轿厢离开换乘楼层。在该轿厢离开换成楼层的时候,调用 leaveSwap() 解除对共享资源的占用同时通知另一个正在等待的轿厢(如果有的话)


在这三次作业中,发生的 bug 主要可以被分为以下两类:
对于边缘情况考虑欠佳,导致出现的处理逻辑上的问题:
在第二次作业中,引入了重置请求。由于电梯在重置的时候不能输出 receive 请求,因此我当时就简单地写了一个判定:如果 scheduler 随机到的电梯仍在在重置期间,那么就重新生成随机数,直到 scheduler 随机到一个没有在重置的电梯。这种策略的问题在于,如果某一时刻有较多的电梯在重置,同时有大量的乘客请求涌入,那么 scheduler 就会把所有这些乘客的请求全部分配给少数几个还在运行的电梯。其他的大多数重置的电梯在重置结束后便进入闲置状态。这种负载的不均衡在极端情况下会导致超时。解决方法是给每个电梯的等待序列引入数量上限,还原传统的生产者/消费者模型中有限制的缓冲区/队列。这样的话即使只有一个电梯能够运行,由于请求上限的引入,其负载也能维持在一个可以接受的水平,而其他的请求则等待其他电梯重置结束之后再进行分配。
由于评测机是根据输出的信息而非程序本身的状态来做评测的,因此如果程序的状态迁移和输出这两个步骤不是原子的话,就很容易出现程序实际的运行行为和程序的输出脱节的情况,从而导致测评错误
在第7次作业中,引入了双轿厢电梯。为了实现普通电梯向双轿厢电梯的转化,我的设计中在电梯重置结束以后,双轿厢电梯会新开两个轿厢的线程,而原来普通电梯的线程则会结束。问题出在输出 reset end 到双轿厢电梯真正开始运行的原子性没有得到保证:普通电梯重置以后双轿厢电梯立即开始运行,此时本来应该会跟随输出 reset end,但是这里忘记了加锁,于是便出现了还没有结束重置但电梯已经开始 receive 和运行的情况。
在以前的编程经验中,编写的都是单线程的顺序程序,从来没有遇到也不需要考虑死锁、数据竞争这类在并发编程的语境下所特有的程序 bug。这次 OO 第二单元的学习正好和操作系统进程相关的内容重合在一起,让我在并发编程的路上迈出了第一步。