最近遇到了进程并发与互斥相关的问题。其中一个经典的问题就是读者-写者问题。本文对读者-写者问题的经典模式——读者优先,与两个拓展模式——写者优先、读写公平进行了描述和分析。笔者通过总结归纳,对互斥锁的理念(Idea)有了进一步的了解,并基于此提出了一些可能的方法。
(本文描述中,锁和信号量两个概念混杂使用,指代同一对象)
问题描述
读者-写者(Reader-Writer)问题
,描述了多个读者与写者对同一共享资源(如内存,文件等)并发访问的情境。其主要特点为:
- 读者间不互斥:读取操作不会改变资源内容,故其行为不具有互斥性。表现为:当有进程正在读取内容时,其他进程仍可以并发地执行读取操作。
- 读写/写写互斥:由于写操作会改变资源内容,写操作与其他任何操作都互斥。表现为:当一个进程正在写时,其他进程均阻塞等待。
- 多个读写者:本条件在读者优先、读写公平中容易被忽略(笔者一开始就忽略了这个条件),在写者优先中有体现,但其存在性在任何模式下都不可忽略。该特性将在下面的模式分析中提及。
以上是读者-写者问题的共同特点,下面对各个模式进行分析。
模式分析
读者优先
读者优先模式是出现于教科书中最多的模式。读者优先级高于写者。
特点
当有进程正在读取内容时,读者可以不受阻塞地直接读取内容。写者需要等待所有读取请求完成后才开始写入。
实现
设置信号量:
- 互斥信号量
wm
(write mutex)写写/读写互斥锁,rcm
(read count mutex)计数器锁,ws
(writer single)限制读者等待队列,见下; - 计数器
rc
(read count),表示当前在读取的读者数。
实现代码如下:(C表述,第一次实现包含了信号量结构体定义等内容,下同)
typedef struct {
int value;
void *list; // list of process struct, waiting for semaphore
}semphr;
# define mutex semphr
# define counter int
# define NULL 0
mutex rcm = {1, NULL}, wm = {1, NULL}, ws = {1, NULL};
counter rc = 0;
void P(semphr *sem);
void V(semphr *sem);
void reader()
{
P(&rcm); // acquire read counter lock
if (rc == 0) // the first reader?
P(&wm); // acquire writer mutex, wait for write progress done
rc++;
V(&rcm); // release counter lock
// Do-read;
P(&rcm); // acquire again
rc--;
if (rc == 0) // the last reader?
V(&wm); // release writer mutex, writer can acquire it
V(&rcm);
}
void writer()
{
P(&ws); // there must be SINGLE writer waiting/having wm lock
P(&wm); // acquire for writer mutex
// Do-Write;
V(&wm);
V(&ws);
}
- 具体说明都在注释中,简单实现不是本文的目的,重点请看下节“分析要点”
分析要点
-
此模式下,由于写操作的互斥性,写者都需要等待获取
wm
锁,而一旦中间由读者持有,其他读者都可以跳过等待的写者,直接开始读写。这个性质要求同时只能有一个写者请求或持有wm
锁
若不加ws
,则会引发顺序错误,图例:
- 为此,添加一个
writer single
互斥锁,仅允许一个writer等待/持有writer mutex,这样当一个writer持有wm
时(正在写入),Reader会优先于Writer排队等待wm
,达到读者优先目的。
- 为此,添加一个
-
由于写操作的互斥性,以及写者优先级位于最低(低于读者),写者不需要计数器记录等待数,只需加入
ws
信号量请求队列即可。
读写公平
读者优先模式下,写者很可能长时间得不到一个写入的机会。读写公平模式下,读者和写者公平地参与一个common
通用锁的竞争。
特点
当读者正在读时,其余的读者仍可以自然进入读取过程(仍然需要排队以获取对计数器的互斥访问权限)。但是当写者试图写入时,**写者与读者加入一个共同的等待队列中,**前面的读者运行完成后,写者就开始写入。排在写者后面的读者,需要等待写入完成后才能开始读取。
实现
设置信号量:
- 互斥信号量
wm
(write mutex)写写/读写互斥锁,rcm
(read count mutex)计数器锁,common
读写通用锁,用于公平等待。 - 计数器
rc
(read count),表示当前在读取的读者数。
实现代码如下:
mutex rcm = {1, NULL}, wm = {1, NULL}, common = {1, NULL};
counter rc = 0;
void reader()
{
P(&common); // add to common waiting list
P(&rcm); // acquire read counter lock
if (rc == 0) // the first reader?
P(&wm); // acquire writer mutex, wait for write progress done
rc++;
V(&rcm); // release counter lock
V(&common);
// Do-read;
P(&rcm); // acquire again
rc--;
if (rc == 0) // the last reader?
V(&wm); // release writer mutex, writer can acquire it
V(&rcm);
}
void writer()
{
P(&common); // add to common waiting list
P(&wm); // acquire for writer mutex
// Do-Write;
V(&wm);
V(&common);
}
分析要点
- 公平,本质上是让读者和写者**在一个等待队列上等待。而信号量本质上是对等待队列的一个封装。**我们只需要让读者和写者共同请求一个信号量,就能达到让它们在一个等待队列上等待的目的。
一种优化
开始,笔者想到的思路是,将信号量退化成一个单纯的等待队列。写者获取common
锁后,读者进入等待队列。而读者不会持有common
锁,它会在得到锁后立即释放,相当于仅做了一个对writer是否等待的测试。在这种思路下,将12行的V(&common)
移动到6行P(&common)
后,可以获得完全相同的运行结果。
这样的改动可能带来的优化是,缩小了Reader前期的Critical Section块大小,也就是缩短了互斥模式下代码的长度,在并发情况下一定程度上可以提高并发效率。(本项为笔者理论推算,没有经过实验,如果有误,烦请指正)
错误修正:本优化方法错误。在Reader释放common锁后,如果有writer在等待,将导致它们无序竞争。在此鸣谢读者buxiangxiezuoyea
的指正!
写者优先
特点
当有写者试图写入时,所有等待的读者(即等待获取reader counter锁的读者)让后,让写者抢先写入。当没有任何读者试图写入时,读者仍可以不互斥地读取。
实现
设置信号量:
- 互斥信号量
wm
(write mutex)写写/读写互斥锁,rcm
(read count mutex)计数器锁,rm
由写者持有的读者锁,wcm
(write count mutex)写者计数器锁,rs
(reader single)单写者锁 - 计数器
rc
(read count),表示当前在读取的读者数。
实现代码如下:
void reader()
{
P(&rs);
P(&rm);
P(&rcm); // acquire read counter lock
if (rc == 0) // the first reader?
P(&wm); // acquire writer mutex, wait for write progress done
rc++;
V(&rcm); // release counter lock
V(&rm);
V(&rs);
// Do-read;
P(&rcm); // acquire again
rc--;
if (rc == 0) // the last reader?
V(&wm); // release writer mutex, writer can acquire it
V(&rcm);
}
void writer()
{
P(&wcm); // acquire write counter lock
if (wc == 0) // the first writer?
P(&rm); // acquire reader mutex, block readers
wc++;
V(&wcm);
P(&wm); // wait for previous read / write
// Do-write();
V(&wm);
P(&wcm);
wc--;
if (wc == 0) // the last writer?
V(&rm); // release the reader mutex
V(&wcm);
}
分析要点
- 为什么写者优先情况下仍需要读者计数器?因为读取不具有并发性,可能有多个读者正在进行读取操作,写者需要等待所有正在读取的读者完成后才开始写入
- 写者计数器:由于写者优先,我们需要记录所有互斥等待的写者数目,只有当没有写者等待时,才允许读者读取。
- 读者锁
rs
:与读者优先类似,这个读者锁要求同时只能有一个读者参与请求rm
。这样,当写者需要写入时,可以获取rm
,以阻止后续排队的reader抢占rm
。否则,大量提前进入的读者排队等待rm
锁,写者必须排在后面,这与我们“写者优先”的预设不符。
总结归纳
等待队列
等待队列,本质上是对一个已经被占用的信号量(锁)请求时的等待过程。对应的等待事件为从该信号量被获取§到被释放(V)的过程。
综合上述三种模式的分析,我们对读者和写者等待队列的本质有了进一步的认识:
- 写者的等待队列非常容易理解,由于写操作的互斥性,必须等一个写操作(或是某些模式下的读操作)完成了才能写入,自然地产生
- 读者同样具有等待队列,读者的等待过程是等待
rcm
的过程,即,排队获取计数器的访问权限。当读者开始读操作后,由于没有互斥性, 读操作能够无互斥地并发完成。
实现优先
- 优先,特性表现在一类行为必须在另一类行为所有都结束后,才开始执行。如果这类行为在排队,会被后来的另一类行为抢先执行。
- 优先的实现主要由下列两个过程完成
- 增加高优先级方的计数器,在有高优先级行为时,获取一个对低优先级行为的锁,最后一个高优先级行为结束后,释放该锁。这个锁由高优先级行为控制,同时对低优先级行为互斥
限。当读者开始读操作后,由于没有互斥性, 读操作能够无互斥地并发完成。
- 增加高优先级方的计数器,在有高优先级行为时,获取一个对低优先级行为的锁,最后一个高优先级行为结束后,释放该锁。这个锁由高优先级行为控制,同时对低优先级行为互斥
实现优先
- 优先,特性表现在一类行为必须在另一类行为所有都结束后,才开始执行。如果这类行为在排队,会被后来的另一类行为抢先执行。
- 优先的实现主要由下列两个过程完成
- 增加高优先级方的计数器,在有高优先级行为时,获取一个对低优先级行为的锁,最后一个高优先级行为结束后,释放该锁。这个锁由高优先级行为控制,同时对低优先级行为互斥
- 低优先级行为增加单个限制(利用互斥锁),同一时刻只允许单个线程请求1中描述的锁。这样避免了在该锁(也即信号量,本文中两个概念互换使用)上排有低优先级的长队列,使得高优先级行为无法抢占执行。