引言
承接上文我们介绍了信号量机制和应用信号量机制实现的进程同步和互斥,这一节我们将围绕一些经典问题对信号量机制展开更深入地探讨。
读者/写者问题
读者/写者问题与我们之前遇到的问题类型不同,它描述的是:
有读者和写者两组进程,它们共同访问同一个文件。
对于读者,它可以与多个读者共同读取文件(因为不会修改到文件);
对于写者,它不能与其他任何进程共同访问文件(如果另一进程是写,则可能覆盖同一内容;如果是读,则可能修改正在读的内容)。
这里的互斥问题是读写和写写互斥的问题,但与之前不同的是,除了实现读写和写写的互斥,我们还要实现读读的“不互斥”,即满足”读写互斥,写写互斥,读读允许“
Version1
首先准备一个信号量 rw = 1
表示当前是否有进程在访问文件(注意一开始是没有这样的进程的,1 表示的不是进程数目,是使用互斥信号量给定的初始值,代表任何时刻只有一个进程可以访问)。
在不考虑“读读不互斥”的情况下,我们的伪代码是这样的:
Writer(){ Reader(){
while(1){ while(1){
P(rw) P(rw)
写文件 读文件
V(rw) V(rw)
} }
} }
这个代码可以实现读写互斥和写写互斥,但显然无法实现“读读不互斥”,因为每个读进程之间也会受到 rw
的影响,使得某一时刻只能有一个读进程访问文件。
Version2
我们考虑从读进程入手,做一些改进。这里读和读不能同时进行的本质原因在于,所有的读进程都会经历“检查并上锁”这个步骤,而一个读进程进入后就会马上检查并上锁,导致另一个也想要进入的读进程被阻塞,所以我们考虑:能不能不要让所有的读进程都经历“检查并上锁”这一步骤?也就是说,某些进程可以跳过 P 操作,直接进入临界区,这样一来,这些进程就不存在在 P 操作这里被阻塞的可能性。
什么样的进程可以跳过 P 操作呢?
就是中间的那些读进程。因为一开始肯定要有读进程上锁、最后肯定要有读进程解锁,所以上锁和解锁的任务交付给第一个和最后一个进程,而中间的那些进程来去自如,只需要负责读文件,不需要参与上锁和解锁。
为了区分读进程的次序,我们准备一个 count = 0
的变量,它表示的是当前有多少个读进程正在访问文件。然后在读文件的前后,我们分别对 count
进行加一和减一的操作,每次读文件开始之前 count
会加一,所以在此之前如果变量为 0 ,说明当前读进程是第一个读进程;同理,每次读文件之后 count
会减一,所以在此之后如果变量为 0 ,说明当前读进程是最后一个读进程;
此时伪代码如下:
Reader(){
while(1){
if(count==0)
P(rw)
count++
读文件
count--
if(count==0)
V(rw)
}
}
但是这样会产生一些问题。比方 1 号读进程首先进入并上锁,然后在 P 操作之后、count
加一变成 1 之前,进程切换到 2 号读进程,那么 2 号读进程就会卡在 P 操作这个地方,陷入阻塞,显然这时候无法实现我们想要的“读读不互斥”;又比方说,1 号读进程在 count
减一变成 0 之后、释放 rw 之前,进程切换到了 2 号读进程,那么 2 号同样又会被卡在 P 操作这里。所以我们还要进行改进。
问题其实就出在,对 count
的检查和赋值不是一个原子操作,这导致的结果是,如果在检查和赋值之间的空隙,进程发生切换,则必然会使得另一进程陷入阻塞。那么能不能让这两个操作一气呵成呢?事实上,可以把 count
当作是一个互斥访问的资源,对 count
的访问是互斥的,也就说明一个时间段内只能有一个读进程去访问它,即使这个过程中切换到了其它进程,那个进程也会被阻塞,从而保证只有一个进程可以访问 count
,而这个访问就是检查和赋值,这种情况下,检查和赋值一定是不会被中断的。
准备一个互斥信号量 mutex = 1
表示对 count
的互斥访问,将检查和赋值封装在一个 PV 操作里。伪代码如下:
Reader(){
while(1){
P(mutex)
if(count==0)
P(rw)
count++
V(mutex)
读文件
P(mutex)
count--
if(count==0)
V(rw)
V(mutex)
}
}
现在我们再来跑一下过程。
假设还是 1 号读进程运行到 P 操作的时候,进程切换到了 2 号读进程,那么由于互斥信号量 mutex
的存在,导致 2 号进程进入了 mutex
对应的阻塞队列 —— 是的,这时候看起来 2 号进程还是被阻塞了,不过我们要关注到的是,阻塞它的信号量是 mutex
,不是 rw
。这意味着,在进程重新切换回 1 号进程的时候,1 号进程一旦执行了 V(mutex)
,就可以将 2 号进程唤醒并送到就绪队列了。也就是说,尽管 2 号进程还是经历了“阻塞”这个过程,但是这个过程只是为了确保 1 号进程检查和上锁两个操作的原子性,一旦操作完成,2 号进程马上就被唤醒了。而之前那种情况不同,之前的情况是,导致 2 号进程被阻塞的是信号量 rw
,除非 1 号进程读完后释放,否则 2 号进程会一直处于阻塞状态。这就是说,2 号进程永远不可能与 1 号进程同时读文件,但是改进后是可以的。
但事实上还有另一个问题更加严重——“读写不公平”。也就是说,这样的代码本质上是对读进程更有利的。
因为对读进程来说,一旦第一个读进程进来了,中间即使穿插再多的读进程,也都是允许的,他们根本不受到 rw
这个“锁”的限制;而对于写进程,它的运气就没这么好了,写进程只要想进来,就必须通过 rw
这个“锁”,而这个“锁” 实际上又掌握在最后一个读进程手里 —— 这就是说,万一读进程源源不断进来,始终轮不到最后一个读进程解锁,那么写进程就只能陷入无尽的等待了。
Version3
既然 rw
这把锁无法做到公正对待每一个进程,那我们就考虑在外层加一把“更大、更公正的锁”——所有的进程,无论读还是写,无一例外必须先通过这把“锁”的检查。为此,我们准备一个新的互斥信号量 w = 1
,并将 Writer 和 Reader 的一些关键操作封装在 w
的一对 PV 操作里。此时,伪代码如下:
//用于实现对共享文件的互斥访问
semaphore rw = 1; //记录当前有几个读进程在访问文件
semaphore mutex = 1;//用于保证对count变量的互斥访问
semaphore w = 1//用于实现写优先
int count = 0; //用于记录读进程的次序
writer() {
while(1){
P(w);
P(rw)
写文件
V(rw);
V(w);
}
}
reader() {
while(1)
P(w);
P(mutex);
if(count==0) P(rw);
Count++;
V(mutex);
V(w);
读文件
P(mutex);
count--;
if(count==0) V(rw);
V(mutex);
}
}
我们来跑一下流程。
假设首先来到 1 号读进程,那么它就会执行 P 操作上锁,这个过程中即使有写进程想进来,也会被送到 w
对应的阻塞队列。在 1 号读进程执行到 V 操作之后,写进程才会被唤醒并送到就绪队列,之后就轮到写进程执行了,而写进程虽然通过第一个 P 操作,但是被卡在了第二个 P 操作(读进程尚未释放 rw
),所以他来到了 rw
对应的阻塞队列。
注意!重点来了,如果这时候 2 号读进程也想要访问文件,那么在以前,它是不需要通过任何检查就可以直接来读文件的,并且直到 2 号读进程释放 rw
之后,写进程才能真正来执行写文件的操作。但是现在由于我们加了一把“更大的锁w
,导致 2 号进程必须先通过 w
的检查,而由于写进程抢先在他之前上了锁,所以 2 号读进程被送到了 w
对应的阻塞队列。也就是说,现在的情况是:写进程等着 1 号读进程释放 rw
,而 2 号读进程等着写进程释放 w
,1 号读进程是让一切正常进行下去的关键。在处理机又来到 1 号读进程并执行 V(rw)
之后,写进程从 rw
的阻塞队列被唤醒,继续往下执行写文件的操作。而在写进程真正执行完之后,w
才能得到释放,由此又唤醒了 w
阻塞队列中的 2 号读进程,2 号读进程来到处理机运行。
如果换一种情况,是按照 写者 — 读者 — 写者的顺序,那么由于读者在第二个写者之前,所以是读者作为阻塞队列队头,第二个写者则次之,在后续执行过程中,根据队列“先进先出”的原则,也会是读者先于第二个写者访问文件。
综上,这种情况下,实际上谁先到、谁就在后续过程中先被执行(而不是像之前,无论写进程先还是后,读进程都可以“无视规则”抢先一步执行)。由此,我们就实现了“读写公平”。
总结
上文根据王道课上给的案例进行的分析,博主自身在理解这里的时候感觉不是很好记忆,自己结合老师上课的讨论摸索了一套方法解决这个问题。(信号量命名和王道不一致)
首先是解决读者优先问题,设置一个balance信号量(也有写写、写读互斥的作用),解决读写互斥放一个mutex信号量(读的时候不能写),读读允许同样设置一个count计数,配套设置cmutex解决count计数的原子操作。
具体实现思路如下
- 先设置好信号量初值
semaphore mutex = 1;
semaphore cmutex = 1;
semaphore balance = 1;
int count = 0;
- 然后对写者进程进行分析
writer() {
while(1) {
P(balance); // 读写平衡
P(mutex); // 读写互斥
写文件...
P(mutex);
V(balance);
}
}
- 对于读者进程进行分析
reader() {
while(1) {
P(balance); // 读写平衡
P(cmutex); // count操作互斥
if(count == 0) P(mutex); //读写互斥
count--;
V(cmutex);
V(balance);
读文件...
P(cmutex); // count操作互斥
count--;
if(count == 0) V(mutex);
V(cmutex);
}
}
- 读写互斥→mutex(斥)
- 读读允许→cmutex、count(允)
- 解决读者优先→balance(平衡)
由于读者写者问题确实有他自身的复杂性,所以一定要有自己的理解,不然很容易就不记得该如何解决,按照演绎的顺序就是三个信号量(一斥二允三平衡)+一个计数器count。