从本章开始关于同步的讨论:怎样同步多个线程或多个进程的活动。为允许在线程或进程间共享数据,同步通常是必需的。互斥锁和条件变量是同步的基本组成部分。
互斥锁和条件变量出自Posix.1线程标准,它们总是可用来同步一个进程内的各个线程的。如果一个互斥锁或条件变量存放在多个进程间共享的某个内存区中,那么Posix还允许它用于这些进程间的同步。
这在Posix是个选项,在Unix 98却是必需的(参见图1-5中IPC类型为“进程间共享的互斥锁/条件变量”的那一行)。
本章中我们将介绍经典的生产者-消费者问题,并在解决该问题的方案中使用互斥锁和条件变量。对于本例子,我们使用多个线程而不是多个进程,因为让多个线程共享本问题中采用的公共数据缓冲区非常简单,而在多个进程间共享一个公共数据缓冲区却需要某种形式的共享内存区(将在第4部分中讲述)。我们将在第10章中提供使用信号量解决该问题的其他方案。
互斥锁指代相互排斥(mutual exclusion),它是最基本的同步形式。互斥锁用于保护临界区(critical region),以保证任何时刻只有一个线程在执行其中的代码(假设互斥锁由多个线程共享),或者任何时刻只有一个进程在执行其中的代码(假设互斥锁由多个进程共享)。保护一个临界区的代码的通常轮廓大体如下:
lock_the_mutex(...);
临界区
unlock_the_mutex(...);
既然任何时刻只有一个线程能够锁住一个给定的互斥锁,于是这样的代码保证任何时刻只有一个线程在执行其临界区中的指令。
Posix互斥锁被声明为具有pthread_mutex_t数据类型的变量。如果互斥锁变量是静态分配的,那么我们可以把它初始化成常值PTHREAD_MUTEX_INITIALIZER,例如:
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
如果互斥锁是动态分配的(例如通过调用malloc),或者分配在共享内存区中,那么我们必须在运行之时通过调用pthread_mutex_init函数来初始化它,如7.7节中所示。
你可能会碰到省略了初始化操作的代码,因为它所在的实现把初始化常值定义为0(而且静态分配的变量被自动地初始化为0)。不过这是不正确的代码。
下列三个函数给一个互斥锁上锁和解锁:
#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mptr);
int pthread_mutex_trylock(pthread_mutex_t *mptr);
int pthread_mutex_unlock(pthread_mutex_t *mptr);
均返回:若成功则为0,若出错则为正的Exxx值
如果尝试给一个已由另外某个线程锁住的互斥锁上锁,那么pthread_mutex_lock将阻塞到该互斥锁解锁为止。pthread_mutex_trylock是对应的非阻塞函数,如果该互斥锁已锁住,它就返回一个EBUSY错误。
如果有多个线程阻塞在等待同一个互斥锁上,那么当该互斥锁解锁时,哪一个线程会开始运行呢?1003.1b-1993标准增加的特性之一是提供一个优先级调度选项。我们不讨论该领域,不过下述概括足以说明其内容:不同线程可被赋予不同的优先级,同步函数(互斥锁、读写锁、信号量)将唤醒优先级最高的被阻塞线程。[Butenhof 1997]的5.5节提供有关Posix.1实时调度特性的具体细节。
尽管我们说互斥锁保护的是临界区,实际上保护的是在临界区中被操纵的数据(data)。也就是说,互斥锁通常用于保护由多个线程或多个进程分享的共享数据(shared data)。
互斥锁是协作性(cooperative)锁。这就是说,如果共享数据是一个链表(举个例子),那么操纵该链表的所有线程都必须在实际操纵前获取该互斥锁。不过也没有办法防止某个线程不首先获取该互斥锁就操纵该链表。
同步中有一个称为生产者—消费者(producer-consumer)问题的经典问题,也称为有界缓冲区(bounded buffer)问题。一个或多个生产者(线程或进程)创建着一个个的数据条目,然后这些条目由一个或多个消费者(线程或进程)处理。数据条目在生产者和消费者之间是使用某种类型的IPC传递的。
我们一直使用Unix管道处理这个问题。这就是说,如下的shell管道就是这样的问题:
grep pattern chapters.* | wc -l
grep是单个生产者,wc是单个消费者。Unix管道用作两者间的IPC形式。生产者和消费者间所需的同步是由内核以一定方式处理的,内核以这种方式处理生产者的write和消费者的read。如果生产者超前消费者(也就是管道被填满),内核就在生产者调用write时把它投入睡眠,直到管道中有空余空间。如果消费者超前生产者(也就是管道为空),内核就在消费者调用read时把它投入睡眠,直到管道中有一些数据为止。
这些类型的同步是隐式的(implicit);也就是说生产者和消费者甚至不知道内核在执行同步。如果我们改用Posix消息队列或System V消息队列作为生产者和消费者间的IPC形式,那么内核仍然会处理同步。
然而当共享内存区用作生产者和消费者之间的IPC形式时,生产者和消费者必须执行某种类型的显式(explicit)同步。我们将使用互斥锁展示显式同步。图7-1展示了我们使用的例子。
图7-1 生产者-消费者例子:多个生产者线程、单个消费者线程
在单个进程中有多个生产者线程和单个消费者线程。整数数组buff含有被生产和消费的条目(也就是共享数据)。为简单起见,生产者只是把buff[0]设置为0,把buff[1]设置为1,如此等等。消费者只是沿着该数组行进,并验证每个数组元素都是正确的。
在第一个例子中,我们只关心多个生产者线程之间的同步。直到所有生产者线程都完成工作后,我们才启动消费者线程。图7-2是这个例子的main函数。
线程间共享的全局变量
4~12 这些变量是各个线程间共享的。我们把它们以及相应的互斥锁收集到一个名为shared的结构中,目的是为了强调这些变量只应该在拥有其互斥锁时访问。nput是buff数组中下一次存放的元素下标,nval是下一次存放的值(0、1、2等)。我们分配这个结构,并初始化其中用于生产者线程间同步的互斥锁。
我们将如本例子所做的那样一直努力地把共享数据和它们的同步变量(互斥锁、条件变量或信号量)收集到一个结构中,这是一个很好的编程技巧。然而在许多情况下共享数据是动态分配的,譬如说一个链表。我们可以把该链表的头以及该链表的同步变量存放到一个结构中(图5-20中的mq_hdr结构就是这么一回事),但是其他共享数据(该链表的其余部分)却不在该结构中。因此这种办法通常是不完善的。
命令行参数
19~22 第一个命令行参数指定生产者存放的条目数,下一个参数指定待创建生产者线程的数目。
设置并发级别
23 我们的set_concurrency函数告诉线程系统我们希望并发运行多少线程。在Solaris 2.6下,该函数只是调用thr_setconcurrency,当我们希望多个生产者线程中每一个都有执行机会时,这个函数是必需的。如果我们在Solaris下省略该调用,那么只有第一个生产者线程运行。在Digital Unix 4.0B下,我们的set_concurrency函数不做任何事(因为默认情况下,一个进程中的各个线程竞争使用处理器)。
Unix 98需要一个名为pthread_setconcurrency的函数执行同样的功能。在把多个用户线程(使用pthread_create创建的对象)复用到较小的一组内核执行实体(例如内核线程)上的线程实现中,该函数是需要的。这些实现经常被称为多对少、两级或M对N实现。[butenhof 1997]的5.6节详细讨论了用户线程和内核实体间的关系。
图7-2 main函数
创建生产者线程
24~28 创建生产者线程,每个线程执行produce。在tid_produce数组中保存每个线程的线程ID。传递给每个生产者线程的参数是指向count数组中某个元素的指针。我们首先把该计数器初始化为0,然后每个线程在每次往缓冲区中存放一个条目时给这个计数器加1。当一切生产完毕时,我们输出这个计数器数组各元素的值,以查看每个生产者线程分别存放了多少条目。
等待生产者线程,然后启动消费者线程
29~36 等待所有生产者线程终止,同时输出每个线程的计数器值,此后才启动单个的消费者线程。这是我们(暂时)避免生产者和消费者之间的同步问题的办法。接着等待消费者完成,然后终止进程。
图7-3给出了这个例子所用的produce和consume函数。
图7-3 produce和consume函数
产生数据条目
42~53 生产者的临界区由用来测试是否一切生产完毕的条件语句
if (shared.nput >= nitems)
和随后的三行
shared.buff[shared.nput] = shared.nval;
shared.nput++;
shared.nval++;
构成。
我们用一个互斥锁保护该临界区,同时保证在一切生产完毕的情况下给该互斥锁解锁。注意count元素的增加(通过指针arg)不属于该临界区,因为每个线程有各自的计数器(main函数中的count数组)。既然如此,我们就不把这行代码包括在由互斥锁锁住的临界区中,因为作为一个通用的编程原则,我们总是应该努力减少由一个互斥锁锁住的代码量。
消费者验证数组的内容
59~62 消费者只是验证buff数组中的每个条目是否正确,若发现错误则输出一个消息。正如我们先前所说,本函数只有一个实例在运行,而且是在所有生产者线程都完成之后,因此不需要任何同步。
指定一百万个条目和5个生产者线程运行上述程序,结果如下:
solaris % prodcons2 1000000 5
count[0] = 167165
count[1] = 249891
count[2] = 194221
count[3] = 191815
count[4] = 196908
正如我们所提,如果在Solaris 2.6下去掉set_concurrency调用,那么count[0]将变为1000000,其余计数器则都是0。
要是我们删除本例子中的互斥锁上锁,那么它将如期地失败。也就是说,消费者将检测出许多buff[i]不等于i的情况。我们还可以验证,如果只有一个生产者线程在运行,那么删除互斥锁上锁并没有影响。
现在展示互斥锁用于上锁(1ocking)而不能用于等待(waiting)。我们把上一节中的生产者-消费者例子改为在所有生产者线程都启动后立即启动消费者线程。这样在生产者线程产生数据的同时,消费者线程就能处理它,而不是像图7-2中那样,消费者线程直到所有生产者线程都完成后才启动。但现在我们必须同步生产者和消费者,以确保消费者只处理已由生产者存放的数据条目。
图7-4给出了main函数。在main声明之前的所有行与图7-2中的一样。
图7-4 main函数:启动生产者后立即启动消费者
24 给并发级别加1,把额外的消费者线程也计算在内。
25~29 创建生产者线程后,立即创建消费者线程。
produce函数没有变化,已在图7-3中给出。
图7-5给出了consume函数,它调用我们新定义的consume_wait函数。
图7-5 consume_wait和consume函数
消息者必须等待
71 consume函数的唯一变动是在从buff数组中取出下一个条目之前调用consume_wait。
等待生产者
57~64 我们的consume_wait函数必须等待到生产者产生了第i个条目。为检查这种条件,先给生产者的互斥锁上锁,再比较i和生产者的nput下标。我们必须在查看nput前获得互斥锁,因为某个生产者线程当时可能正处于更新该变量的过程中。
这里的基本问题是:当期待的条目尚未准备好时,我们能做些什么?图7-5中的做法是一次次地循环,每次给互斥锁解锁又上锁。这称为轮转(spinning)或轮询(polling),是一种对CPU时间的浪费。
我们也许可以睡眠很短的一段时间,但是不知道该睡眠多久。这儿所需的是另一种类型的同步,它允许一个线程(或进程)睡眠到发生某个事件为止。
互斥锁用于上锁,条件变量则用于等待。这两种不同类型的同步都是需要的。
条件变量是类型为pthread_cond_t的变量,以下两个函数使用了这些变量。
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *cptr,pthread_mutex_t *mptr);
int pthread_cond_signal(pthread_cond_t *cptr);
均返回:若成功则为0,若出错则为正的Exxx值
其中第二个函数的名字中的“signal”一词指的不是Unix SIGxxx信号。
这两个函数所等待或由之得以通知的“条件”,其定义由我们选择:我们在代码中测试这种条件。
每个条件变量总是有一个互斥锁与之关联。我们调用pthread_cond_wait等待某个条件为真时,还会指定其条件变量的地址和所关联的互斥锁的地址。
我们通过重新编写上一节中的例子来解释条件变量的使用。图7-6给出了全局变量的声明。
图7-6 使用条件变量的生产者-消费者程序全局变量
把生产者变量和互斥锁收集到一个结构中
7~13 把互斥锁变量mutex以及与之关联的两个变量nput和nval收集到一个名为put的结构中。生产者使用这个结构。
把计数器、条件变量和互斥锁收集到一个结构中
14~20 下一个结构含有一个计数器、一个条件变量和一个互斥锁。我们把条件变量初始化为PTHREAD_COND_INITIALIZER。
main函数没有变动,已在图7-4中给出。
produce和consume函数变动了,在图7-7中给出。
往数组中放置下一个条目
50~58 当生产者往数组buff中放置一个新条目时,我们改用互斥锁put.mutex来为临界区上锁。
图7-7 produce和consume函数
通知消费者
59~64 给用来统计准备好由消费者处理的条目数的计数器nready.nready加1。在加1之前,如果该计数器的值为0,那就调用pthread_cond_signal唤醒可能正在等待其值变为非零的任意线程(如消费者)。现在可以看出与该计数器关联的互斥锁和条件变量的相互作用。该计数器是在生产者和消费者之间共享的,因此只有锁住与之关联的互斥锁(nready.mutex)时才能访问它。与之关联的条件变量则用于等待和发送信号。
消费者等待nready.nready变为非零
72~76 消费者只是等待计数器nready.nready变为非零。既然该计数器是在所有的生产者和消费者之间共享的,那么只有锁住与之关联的互斥锁(nready.mutex)时才能测试它的值。如果在锁住该互斥锁期间该计数器的值为0,我们就调用pthread_cond_wait进入睡眠。该函数原子地执行以下两个动作:
(1)给互斥锁nready.mutex解锁;
(2)把调用线程投入睡眠,直到另外某个线程就本条件变量调用pthread_cond_signal。
pthread_cond_wait在返回前重新给互斥锁nready.mutex上锁。因此当它返回并且我们发现计数器nready.nready不为0时,我们将把该计数器减1(前提是我们肯定已锁住了该互斥锁),然后给该互斥锁解锁。注意,当pthread_cond_wait返回时,我们总是再次测试相应条件成立与否,因为可能发生虚假的(spurious)唤醒:期待的条件尚不成立时的唤醒。各种线程实现都试图最大限度减少这些虚假唤醒的数量,但是仍有可能发生。
总的来说,给条件变量发送信号的代码大体如下:
struct {
pthread_mutex_t mutex;
pthread_cond_t cond;
维护本条件的各个变量
.va...PTHREAD_MUTEX_INITIALIZER,PTHREAD_COND_INITIALIZER,...};
Pthread_mutex_lock(&var.mutex);
设置条件为真
Pthread_cond_signal(&var.cond);
Pthread_mutex_unlock(&var.mutex);
在我们的例子中,用来维护条件的变量是一个整数计数器,设置条件的操作就是给该计数器加1。我们做了优化处理,即只有该计数器从0变为1时才发出条件变量信号。
测试条件并进入睡眠以等待该条件变为真的代码大体如下:
Pthread_mutex_lock(&var.mutex);
while (条件为假)
Pthread_cond_wait(&var.cond,&var.mutex);
修改条件
Pthread_mutex_unlock(&var.mutex);
避免上锁冲突
在刚刚给出的代码片段以及图7-7中,pthread_cond_signal由当前锁住某个互斥锁的线程调用,而该互斥锁是与本函数将给它发送信号的条件变量关联的。我们可以设想下最坏情况,当该条件变量被发送信号后,系统立即调度等待在其上的线程,该线程开始运行,但立即停止,因为它没能获取相应的互斥锁。为避免这种上锁冲突,图7-7中的代码可作如下变动:
int dosignal;
Pthread_mutex_lock(&nready.mutex);
dosignal = (nready.nready == 0);
nready.nready++;
Pthread_mutex_unlock(&neready.mutex);if (dosignal)
Pthread_cond_signal(&nready.cond);
在这儿,我们直到释放互斥锁nready.mutex后才给与之关联的条件变量nready.cond发送信号。Posix明确允许这么做:调用pthread_cond_signal的线程不必是与之关联的互斥锁的当前属主。不过Posix接着说:如果需要可预见的调度行为,那么调用pthread_cond_signal的线程必须锁住该互斥锁。
通常pthread_cond_signal只唤醒等待在相应条件变量上的一个线程。在某些情况下一个线程认定有多个其他线程应被唤醒,这时它可调用pthread_cond_broadcast唤醒阻塞在相应条件变量上的所有线程。
有多个线程应唤醒的情形的例子之一发生在我们将在第8章中讲述的读出者与写入者问题中。当一个写入者完成访问并释放相应的锁后,它希望唤醒所有排着队的读出者,因为允许同时有多个读出者访问。
考虑条件变量信号单播发送与广播发送的一种候选(且更为安全的)方式是坚持使用广播发送。如果所有的等待者代码都编写确切,只有一个等待者需要唤醒,而且唤醒哪一个等待者无关紧要,那么可以使用为这些情况而优化的单播发送。所有其他情况下都必须使用广播发送。
#include <pthread.h>
int pthread_cond_broadcast(pthread_cond_t *cptr);
int pthread_cond_timedwait(pthread_cond_t *cptr,pthread_mutex_t *mptr,
const struct timespec *abstime);
均返回:若成功则为0,若出错则为正的Exxx值
pthread_cond_timedwait允许线程就阻塞时间设置一个限制值。abstime参数是一个timespec结构:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
该结构指定这个函数必须返回时的系统时间,即便当时相应的条件变量还没有收到信号。如果发生这种超时情况,该函数就返回ETIMEDOUT错误。
时间值是绝对时间(absolute time),而不是时间差(time delta)。这就是说,abstime是该函数应该返回时刻的系统时间——自UTC时间1970年1月1日子时以来流逝的秒数和纳秒数。这与select、pselect和poll(UNPvl第6章)不同,它们都指定在将来的某个小数秒数,到时函数应该返回(select指定将来的微秒数,pselect指定将来的纳秒数,poll指定将来的毫秒数)。使用绝对时间而不是时间差的好处是:如果函数过早返回了(也许是因为捕获了某个信号),那么同一函数无需改变其参数中timespec结构的内容就能再次被调用。
本章中的互斥锁和条件变量例子把它们作为一个进程中的全局变量存放,它们用于该进程内各线程间的同步。我们用两个常值PTHREAD_MUTEX_INITIALIZER和PTHREAD_COND_INITIALIZER来初始化它们。由这种方式初始化的互斥锁和条件变量具备默认属性,不过我们还能以非默认属性初始化它们。
首先,互斥锁和条件变量是用以下函数初始化或摧毁的。
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mptr,const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mptr);
int pthread_cond_init(pthread_cond_t *cptr,const pthread_condattr_t *attr);
int pthread_cond_destroy(pthread_cond_t *cptr);
均返回:若成功则为0,若出错则为正的Exxx值
考虑互斥锁情况,mptr必须指向一个已分配的pthread_mutex_t变量,并由pthread_mutex_init函数初始化该互斥锁。由该函数第二个参数attr指向的pthread_mutexattr_t值指定其属性。如果该参数是个空指针,那就使用默认属性。
互斥锁属性的数据类型为pthread_mutexattr_t,条件变量属性的数据类型为pthread_condattr_t,它们由以下函数初始化或摧毁。
#include <pthread.h>
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
均返回:若成功则为0,若出错则为正的Exxx值
一旦某个互斥锁属性对象或某个条件变量属性对象已被初始化,就通过调用不同函数启用或禁止特定的属性。举例来说,我们将在以后各章中使用的一个属性是:指定互斥锁或条件变量在不同进程间共享,而不是只在单个进程内的不同线程间共享。这个属性是用以下函数取得或存入的。
#include <pthread.h>
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *attr,int *valptr);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr,int value);
int pthread_condattr_getpshared(const pthread_condattr_t *attr,int *valptr);
int pthread_condattr_setpshared(pthread_condattr_t *attr,int value);
均返回:若成功则为0,若出错则为正的Exxx值
其中两个get函数返回在由valptr指向的整数中的这个属性的当前值,两个set函数则根据value的值设置这个属性的当前值。value的值可以是PTHREAD_PROCESS_PRIVATE或PTHREAD_PROCESS_SHARED。后者也称为进程间共享属性。
这个特性只在头文件<unistd.h>中定义了常值_POSIX_THREAD_PROCESS_SHARED时才得以支持。它在Posix.1中是可选特性,在Unix 98中却是必需的(图1-5)。
以下代码片段给出初始化一个互斥锁以便它能在进程间共享的过程:
pthread_mutex_t *mptr; /* pointer to the mutex in shared memory */
pthread_mutexattr_t mattr; /* mutex attribute datatype */
. . .
mptr = /* some value that points to shared memory */ ;Pthread_mutexattr_init(&mattr);
#ifdef _POSIX_THREAD_PROCESS_SHARED
Pthread_mutexattr_setpshared(&mattr,PTHREAD_PROCESS_SHARED);
#else
# error this implementation does not support _POSIX_THREAD_PROCESS_SHARED
#endif
Pthread_mutex_init(mptr,&mattr);
我们声明一个名为mattr的pthread_mutexattr_t数据类型的变量,把它初始化成互斥锁的默认属性,然后给它设置PTHREAD_PROCESS_SHARED属性,意思是该互斥锁将在进程间共享。pthread_mutex_init然后照此初始化该互斥锁。必须分配给该互斥锁的共享内存区空间大小为sizeof(pthread_mutex_t)。
用于给存放在共享内存区中供多个进程使用的一个条件变量设置PTHREAD_PROCESS_SHARED属性的一组语句跟用于互斥锁的语句几乎相同,只需把其中的5处mutex替换成cond。
我们已在图5-22中给出这些进程间共享的互斥锁和条件变量的例子。
持有锁期间进程终止
当在进程间共享一个互斥锁时,持有该互斥锁的进程在持有期间终止(也许是非自愿地)的可能总是有的。没有办法让系统在进程终止时自动释放所持有的锁。我们将会看到读写锁和Posix信号量也具备这种属性。进程终止时内核总是自动清理的唯一同步锁类型是fcntl记录锁(第9章)。使用System V信号量时,应用程序可以选择进程终止时内核是否自动清理某个信号量锁(将在11.3节中讨论的SEM_UNDQ特性)。
一个线程也可以在持有某个互斥锁期间终止,起因是被另一个线程取消或自己去调用了pthread_exit。后者没什么可关注的,因为如果该线程调用pthread_exit自愿终止的话,它应该知道自己还持有一个互斥锁。如果是被另一个线程取消的情况,那么该线程可以安装将在被取消时调用的清理处理程序,如8.5节中所展示的那样。对于一个线程来说是致命的条件通常还导致整个进程的终止。举例来说,如果某个线程执行了一个无效指针访问,从而引发了SIGSEGV信号,那么一旦该信号未被捕获,整个进程就被它终止,我们于是回到了先前处理进程终止的条件上。
即使一个进程终止时系统会自动释放某个锁,那也可能解决不了问题。该锁保护某个临界区很可能是为了在执行该临界区代码期间更新某个数据。如果该进程在执行该临界区的中途终止,该数据处于什么状态呢?该数据处于不一致状态的可能性很大:举例来说,一个新条目也许只是部分插入某个链表中,要是该进程终止时内核仅仅把那个锁解开的话,使用该链表的下一个进程就可能发现它已损坏。
然而在某些例子中,让内核在进程终止时清理某个锁(若是信号量情况则为计数器)不成问题。例如,某个服务器可能使用一个System V信号量(打开其SEM_UNDO特性)来统计当前被处理的客户数。每次fork一个子进程时,它就把该信号量加1,当该子进程终止时,它再把该信号量减1。如果该子进程非正常终止,内核仍会把该计数器减1。9.7节给出了一个例子,说明内核在什么时候释放一个锁(不是我们刚讲的计数器)合适。那儿的守护进程一开始就在自己的某个数据文件上获得一个写入锁,然后在其运行期间一直持有该锁。如果有人试图启动该守护进程的另一个副本,那么新的副本将因为无法取得该写入锁而终止,从而确保该守护进程只有一个副本在一直运行。但是如果该守护进程不正常地终止了,那么内核会释放该写入锁,从而允许启动该守护进程的另一个副本。
互斥锁用于保护代码临界区,从而保证任何时刻只有一个线程在临界区内执行。有时候一个线程获得某个互斥锁后,发现自己需要等待某个条件变为真。如果是这样,该线程就可以等待在某个条件变量上。条件变量总是有一个互斥锁与之关联。把调用线程投入睡眠的pthread_cond_wait函数在这么做之前先给所关联的互斥锁解锁,以后某个时刻唤醒该线程前再给该互斥锁上锁。该条件变量由另外某个线程向它发送信号,而这个发送信号的线程既可以只唤醒一个线程(pthread_cond_signal),也可以唤醒等待相应条件变为真的所有线程(pthread_cond_broadcast)。
互斥锁和条件变量可以静态分配并静态初始化。它们也可以动态分配,那要求动态地初始化它们。动态初始化允许我们指定进程间共享属性,从而允许在不同进程间共享某个互斥锁或条件变量,其前提是该互斥锁或条件变量必须存放在由这些进程共享的内存区中。
7.1 去掉图7-3中的互斥锁,验证这个例子在运行不止一个生产者线程的前提下会失败。
7.2 如果把图7-2中对消费者线程的Pthread_join调用去掉,那么会发生什么?
7.3 编写一个程序,在一个无限循环中只调用pthread_mutexattr_init和pthread_condattr_init。使用诸如ps这样的程序观察其进程的内存使用情况。发生了什么?现在加上合适的pthread_mutexattr_destroy和phtread_condattr_destory,再验证没有发生内存遗漏。
7.4 在图7-7中,生产者只在计数器nready.nready由0变为1时才调用pthread_cond_signal。为查看这种优化处理的效果,增设一个计数器,它在每次调用pthread_cond_signal时加1,当消费者完成时,在主线程中输出这个计数器的值。
互斥锁把试图进入我们称之为临界区的所有其他线程都阻塞住。该临界区通常涉及对由这些线程共享的一个或多个数据的访问或更新。然而有时候我们可以在读某个数据与修改某个数据之间作区分。
我们现在讲述读写锁(read-write lock),并在获取读写锁用于读和获取读写锁用于写之间作区分。这些读写锁的分配规则如下:
(1)只要没有线程持有某个给定的读写锁用于写,那么任意数目的线程可以持有该读写锁用于读。
(2)仅当没有线程持有某个给定的读写锁用于读或用于写时,才能分配该读写锁用于写。
换一种说法就是,只要没有线程在修改某个给定的数据,那么任意数目的线程都可以拥有该数据的读访问权。仅当没有其他线程在读或修改某个给定的数据时,当前线程才可以修改它。
某些应用中读数据比修改数据频繁,这些应用可从改用读写锁代替互斥锁中获益。任意给定时刻允许多个读出者存在提供了更高的并发度,同时在某个写入者修改数据期间保护该数据,以免任何其他读出者或写入者的干扰。
这种对于某个给定资源的共享访问也称为共享—独占(shared-exclusive)上锁,因为获取一个读写锁用于读称为共享锁(shared lock),获取一个读写锁用于写称为独占锁(exclusive lock)。有关这种类型问题(多个读出者和一个写入者)的其他说法有读出者与写入者(readers and writers)问题以及多读出者—单写入者(readers-writer)锁。(最后一个说法的英文名称中,“readers”有意是复数,“writer”有意是单数,目的是强调这种问题的多个读出者与单个写入者本性。)
读写锁的一个日常类比是访问银行账户。多个线程可以同时读出某个账户的收支结余,但是一旦有一个线程需要更新某个给定收支结余,该线程就必须等待所有读出者完成该收支结余的读出,然后只允许该更新线程修改这个收支结余。直到更新完之前,任何读出者都不允许读该收支结余。
本章中描述的函数由Unix 98定义,因为读写锁不属于1996年Posix.1标准的一部分。这些函数是在1995年由一个称为Aspen Group的Unix厂家联合体开发的,同时开发的还有Posix.1未定义的其他扩充。有一个Posix工作组(1003.1j)正在开发包括读写锁在内的一组Pthread扩充,它们很有可能与本章中讲述的一样。
读写锁的数据类型为pthread_rwlock_t。如果这个类型的某个变量是静态分配的,那么可通过给它赋常值PTHREAD_RWLOCK_INITIALIZER来初始化它。
pthread_rwlock_rdlock获取一个读出锁,如果对应的读写锁已由某个写入者持有,那就阻塞调用线程。pthread_rwlock_wrlock获取一个写入锁,如果对应的读写锁已由另一个写入者持有,或者已由一个或多个读出者持有,那就阻塞调用线程。pthread_rwlock_unlock释放一个读出锁或写入锁。
#include <pthread.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwptr);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwptr);
int pthread_rwlock_unlock(pthread_rwlock_t *rwptr);
均返回:若成功则为0,若出错则为正的Exxx值
下面两个函数尝试获取一个读出锁或写入锁,但是如果该锁不能马上取得,那就返回一个EBUSY错误,而不是把调用线程投入睡眠。
#include <pthread.h>
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwptr);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwptr);
均返回:若成功则为0,若出错则为正的Exxx值
我们提到过,可通过给一个静态分配的读写锁赋常值PTHREAD_RWLOCK_INITIALIZER来初始化它。读写锁变量也可以通过调用pthread_rwlock_init来动态地初始化。当一个线程不再需要某个读写锁时,可以调用pthread_rwlock_destroy摧毁它。
#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *rwptr,
const pthread_rwlockattr_t *attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwptr);
均返回:若成功则为0,若出错则为正的Exxx值
初始化某个读写锁时,如果attr是个空指针,那就使用默认属性。要赋予它非默认的属性,需使用下面两个函数。
#include <pthread.h>
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
均返回:若成功则为0,若出错则为正的Exxx值
数据类型为pthread_rwlockattr_t的某个属性对象一旦初始化,就通过调用不同的函数来启用或禁止特定属性。当前定义了的唯一属性是PTHREAD_PROCESS_SHARED,它指定相应的读写锁将在不同进程间共享,而不仅仅是在单个进程内的不同线程间共享。以下两个函数分别获取和设置这个属性。
#include <pthread.h>
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *attr,int *valptr);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr,int value);
均返回:若成功则为0,若出错则为正的Exxx值
第一个函数在由valptr指向的整数中返回该属性的当前值。第二个函数把该属性的当前值设置为value,其值或为PTHREAD_PROCESS_PRIVATE,或为PTHREAD_PROCESS_SHARED。
只需使用互斥锁和条件变量就能实现读写锁。本节中我们将查看一种可能的实现。这个实现优先考虑等待着的写入者。这不是必需的,可以有其他实现方案。
本节和本章剩余各节含有高级主题,第一次阅读时你可暂时跳过去。
读写锁的其他实现也值得研究。[Butenhof 1997]的7.1.2节提供了一个优先考虑等待着的读出者的实现,并且包括取消处理(我们将稍后讨论)。[IEEE1996]的B.18.2.3.1节提供了另一个优先考虑等待着的写入者的实现,也包括取消处理。[Kleiman,Shah,and Smaalders 1996]第14章提供了一个优先考虑等待着的写入者的实现。本节给出的实现来自Doug Schmidt的ACE软件包http://www.cs.wustl.edu/~schmidt/ACE.html(适应性通信环境,Adaptive Communications Environment)。以上4个实现都使用互斥锁和条件变量。
8.4.1 pthread_rwlock_t数据类型
图8-1给出我们的pthread_rwlock.h头文件,它定义了基本的pthread_rwlock_t数据类型和操作读写锁的各个函数的函数原型。通常情况下它们是在<pthread.h>头文件中。
图8-1 pthread_rwlock_t数据类型的定义
图8-1(续)
3~13 我们的pthread_rwlock_t数据类型含有一个互斥锁、两个条件变量、一个标志及三个计数器。我们将从接下来给出的函数中看出所有这些成员的用途。无论何时检查或操纵该结构,我们都必须持有其中的互斥锁成员rw_mutex。该结构初始化成功后,标志成员rw_magic就被设置成RW_MAGIC。所有函数都测试该成员,以检查调用者是否向某个已初始化的读写锁传递了指针。该读写锁被摧毁时,这个成员就被置为0。
注意计数器成员之一rw_refcount总是指示着本读写锁的当前状态:-1表示它是一个写入锁(任意时刻这样的锁只能有一个),0表示它是可用的,大于0的值则意味着它当前容纳着那么多的读出锁。
14~17 给该数据类型定义静态初始化常值。
8.4.2 pthread_rwlock_init函数
图8-2给出了我们的第一个函数pthread_rwlock_init,它动态初始化一个读写锁。
图8-2 pthread_rwlock_init函数:初始化一个读写锁
图8-2(续)
7~8 我们不支持使用本函数给读写锁赋属性,因此检查其attr是否为一个空指针。
9~19 初始化由调用者指定其指针的读写锁结构中的互斥锁和两个条件变量成员。所有三个计数器成员都设置为0,rw_magic成员则设置为表示该结构已初始化完毕的值。
20~25 如果互斥锁或条件变量的初始化失败,那么小心地确保摧毁已初始化的对象,然后返回一个错误。
8.4.3 pthread_rwlock_destroy函数
图8-3给出了我们的pthread_rwlock_destroy函数,它在所有线程(包括调用者在内)都不再持有也不试图持有某个读写锁的时候摧毁该锁。8~13 首先检查由调用者指定的读写锁已不在使用中,然后给其中的互斥锁和两个条件变量成员调用合适的摧毁函数。
图8-3 pthread_rwlock_destroy函数:摧毁一个读写锁
8.4.4 pthread_rwlock_rdlock函数
图8-4给出了我们的pthread_rwlock_rdlock函数。
图8-4 pthread_rwlock_rdlock函数:获取一个读出锁
9~10 无论何时操作pthread_rwlock_t类型的结构,都必须给其中的rw_mutex成员上锁。
11~18 如果(a)rw_refcount小于0(意味着当前有一个写入者持有由调用者指定的读写锁),或者(b)有线程正等着获取该读写锁的一个写入锁(rw_nwaitwriters大于0),那么我们无法获取该读写锁的一个读出锁。如果这两个条件中有一个为真,我们就把rw_nwaitreaders加1。并在rw_condreaders条件变量上调用pthread_cond_wait。我们稍后将看到,当给一个读写锁解锁时,首先检查是否有任何等待着的写入者,若没有则检查是否有任何等待着的读出者。如果有读出者在等待,那就向rw_condreaders条件变量广播信号。
19~20 取得读出锁后把rw_refcount加1。互斥锁旋即释放。
该函数中存在一个问题:如果调用线程阻塞在其中的pthread_cond_wait调用上并随后被取消,它就在仍持有互斥锁的情况下终止,于是rw_nwaitreaders计数器的值出错。图8-6中pthread_rwlock_wrlock函数的实现也存在同样的问题。我们将在8.5节纠正这些问题。
8.4.5 pthread_rwlock_tryrdlock函数
图8-5给出我们的pthread_rwlock_tryrdlock函数,它在尝试获取一个读出锁时并不阻塞。
11~14 如果当前有一个写入者持有调用者指定的读写锁,或者有线程在等待该读写锁的一个写入锁,那就返回EBUSY错误。否则,通过把rw_refcount加1获取该读写锁。
图8-5 pthread_rwlock_tryrdlock函数:试图获取一个读出锁
8.4.6 pthread_rwlock_wrlock函数
图8-6给出了我们的pthread_rwlock_wrlock函数。
图8-6 pthread_rwlock_wrlock函数:获取一个写入锁
11~17 只要有读出者持有由调用者指定的读写锁的读出锁,或者有一个写入者持有该读写锁的唯一写入锁(两者都是rw_refcount不为0的情况),调用线程就得阻塞。为此,我们把rw_nwaitwriters加1,然后在rw_condwriters条件变量上调用pthread_cond_wait。我们将看到,向该条件变量发送信号的前提是:它所在的读写锁被释放,并且有写入者正在等待。
18~19 取得写入锁后把rw_refcount置为-1。
8.4.7 pthread_rwlock_trywrlock函数
图8-7给出了非阻塞版本的pthread_rwlock_trywrlock函数。11~14 如果rw_refcount不为0,那么由调用者指定的读写锁或者由一个写入者持有,或者由一个或多个读出者持有(至于由哪个持有则无关紧要),因而返回一个EBUSY错误。否则,获取该读写锁的写入锁,并把rw_refcount置为−1。
图8-7 pthread_rwlock_trywrlock函数:试图获取一个写入锁
8.4.8 pthread_rwlock_unlock函数
图8-8给出了我们的最后一个函数pthread_rwlock_unlock。
11~16 如果rw_refcount当前大于0,那么有一个读出者(即调用线程)准备释放一个读出锁。
如果rw_refcount当前为−1,那么有一个写入者(即调用线程)准备释放一个写入锁。17~22 如果有一个写入者在等待,那么一旦由调用者指定的读写锁变得可用(也就是说它的引用计数变为0),就向rw_condwriters条件变量发送信号。我们知道只有一个写入者能够获取该读写锁,因此调用pthread_cond_signal来唤醒一个线程。如没有写入者在等待,但是有一个或多个读出者在等待,那就在rw_condreaders条件变量上调用pthread_ cond_broadcast,因为所有等待着的读出者都可以获取一个读出锁。注意,一旦有一个写入者在等待,我们就不给任何读出者授予读出锁,否则一个持续的读请求流可能永远阻塞某个等待着的写入者。由于这个原因,我们需要两个分开的if条件测试,而不能写成:
/* give preference to waiting writers over waiting readers */
if (rw->rw_nwaitwriters > 0 && rw->rw_refcount == 0){
result = pthread_cond_signal(&rw->rw_condwriters);
} else if (rw->rw_nwaitreaders > 0)
result = pthread_cond_broadcast(&rw->rw_condreaders);
图8-8 pthread_rwlock_unlock函数:释放一个读出锁或写入锁
我们也可以省略对rw->rw_refcount的测试,不过那会导致在仍分配着读出锁的情况下还调用pthread_cond_signal,从而降低了效率。
我们在随图8-4的说明中暗示了一个问题,即如果pthread_rwlock_rdlock的调用线程阻塞在其中的pthread_cond_wait调用上并随后被取消,它就在仍持有互斥锁的情况下终止。通过由对方调用函数pthread_cancel,一个线程可以被同一进程内的任何其他线程所取消(cancel),pthread_cancel的唯一参数就是待取消线程的线程ID。
#include <pthread.h>
int pthread_cancel(pthread_t tid);
返回:若成功则为0,若出错则为正的Exxx值
举例来说,如果启动了多个线程以执行某个给定任务(譬如说在某个数据库中查找一个记录),那么首先完成任务的线程可使用线程取消功能取消其他线程。另一个例子是,当多个线程开始执行同一个任务时,如果其中某个线程发现一个错误,它和其他线程就有必要终止。
为处理被取消的可能情况,任何线程可以安装(压入)和删除(弹出)清理处理程序。
#include <pthread.h>
void pthread_cleanup_push(void (*function)(void *),void *arg);
void pthread_cleanup_pop(int execute);
这些处理程序就是发生以下情况时被调用的函数:
调用线程被取消(由某个线程调用pthread_cancel完成);
调用线程自愿终止(或者通过调用pthread_exit,或者从自己的线程起始函数返回)。
清理处理程序可以恢复任何需要恢复的状态,例如给调用线程当前持有的任何互斥锁或信号量解锁。
pthread_cleanup_push的function参数是调用线程被取消时所调用的函数的地址,arg是它的单个参数。pthread_cleanup_pop总是删除调用线程的取消清理栈中位于栈顶的函数,而且如果execute不为0,那就调用该函数。
我们将随图15-31再次遇到线程取消,那时我们将看到,在某个过程调用正在处理期间如果客户终止,门服务器就被取消。
例子
说明上一节中我们的实现所存在问题的最简易方法是给出例子。图8-9给出了测试程序的时间线图,图8-10给出了程序本身。
图8-9 图8-10中程序的时间线图
图8-10 展示线程取消的测试程序
创建两个线程
10~13 创建两个进程,第一个线程执行函数threadl,第二个线程执行函数thread2。创建第一个线程后睡眠1秒,以允许它获取一个读出锁。
等待线程终止
14~23 首先等待第二个线程,并验证其状态为PTHREAD_CANCEL。接着待等第一个线程,并验证其状态为一个空指针。然后输出pthread_rwlock_t类型读写锁结构中两个计数器成员的值,最后摧毁该读写锁。
thread1函数
26~36 第一个线程获取一个读出锁后睡眠3秒。这个停顿允许另一个线程(第二个线程)调用pthread_rwlock_wrlock并阻塞在其中的pthread_cond_wait调用中,因为在有一个读出锁活跃期间,是无法提供写入锁的。本线程然后调用pthread_cancel取消另一个线程,再睡眠3秒后释放所持有的读出锁,然后终止。
thread2函数
37~46 第二个线程试图获取一个写入锁(这是不可能取得的,因为第一个线程已经获取了一个读出锁)。本函数的其余部分不应该被执行。
如果使用上一节中给出的函数运行本测试程序,那么我们将得到如下结果:
solaris % testcancel
thread1()got a read lock
thread2()trying to obtain a write lock
而且肯定不返回shell提示符状态。该程序被挂起。发生如下步骤。
(1)第二个线程调用pthread_rwlock_wrlock(图8-6),阻塞在其中的pthread_cond_wait调用中。
(2)第一个线程中的sleep(3)返回,pthread_cancel被接着调用。
(3)第二个线程被取消(这儿就是被终止)。当阻塞在某个条件变量等待中的一个线程被取消时,要再次取得与该条件变量关联的互斥锁,然后调用第一个线程取消清理处理程序。(我们尚未安装任何线程取消清理处理程序,但是所关联的互斥锁仍然在该线程被取消前再次取得。)因此,当第二个线程被取消时,它持有包含在读写锁中的互斥锁,而且图8-6中rw_nwaitwriters的值已被加l。
(4)第一个线程调用pthread_rwlock_unlcok,但它永远阻塞在其中的pthread_mutex_lock调用中(图8-8),因为它想要持有的互斥锁仍然由已被取消的线程锁着。
要是我们去掉thread1函数中的pthread_rwlock_unlock调用,那么主线程将输出如下:
rw_refcount = 1,rw_nwaitreaders = 0,rw_nwaitwriters = 1
pthread_rwlock_destroy error: Device busy
第一个计数器是1,因为我们删除了pthread_rwlock_unlock调用,但是最后一个计数器也是1,因为它是由第二个线程在被取消前加1了的计数器。
这一问题纠正起来很简单。首先,给图8-4中的pthread_rwlock_rdlock函数增加两行代码(前有加号指示),它们把pthread_cond_wait调用括了起来:
rw->rw_nwaitreaders++;
+ prhread_cleanup_push(rwlock_cancelrdwait,(void *)rw);
+ pthread_cleanup_pop(0);
result = pthread_cond_wait(&rw->rw_condreaders,&rw->rw_mutex);
rw->rw_nwaitreaders--;
第一行新代码建立一个清理处理程序(我们的rwlock_cancelrdwait函数),它的单个参数将是读写锁指针rw。如果pthread_cond_wait返回,第二行新代码就删除这个清理处理程序。pthread_cleanup_pop的值为0的单个参数指示不调用该处理程序。要是该参数不为0,那就先调用这个清理处理程序再删除它。
如果pthread_rwlock_rdlock的调用线程在阻塞于该函数中的pthread_cond_wait调用期间被取消,它就不会从该函数返回,而是会调用清理处理程序(在重新获取所关联的互斥锁之后,我们已在前面的第3步中提到过这一点)。
图8-11给出了我们的rwlock_cancelrdwait函数,它是我们为pthread rwlock_rdlock建立的清理处理程序。8~9 把rw_nwaitreaders计数器减1,并给互斥锁解锁。这是在调用pthread_cond_wait前建立的“状态”,其调用线程被取消时必须恢复到该状态。
图8-11 rwlock_cancelrdwait函数:读出锁的清理处理程序
对图8-6中pthread_rwlock_wrlock函数进行类似的修正。首先,在pthread_cond_wait调用前后各增加一行新代码:
rw->rw_nwaitwriters++;
+ prhread_cleanup_push(rwlock_cancelwrwait,(void *)rw);
+ pthread_cleanup_pop(0);
result = pthread_cond_wait(&rw->rw_condwriters,&rw->rw_mutex);
rw->rw_nwaitwriters--;
图8-12给出了我们的rwlcok_cancelwrwait函数,它是清理写入锁请求的清理处理程序。
图8-12 rwlock_cancelwrwait函数:写入锁的清理处理程序
8~9 给rw_nwaitwriters计数器减1,并给互斥锁解锁。
用这些新函数运行图8-10中的测试程序,结果是正确的:
solaris % testcancel
thread1()got a read lock
thread2()trying to obtain a write lock
rw_refcount = 0,rw_nwaitreaders = 0,rw_nwaitwriters = 0
三个计数器的值都是正确的,thread1从它的pthread_rwlock_unlock调用返回,而且pthread_rwlock_destroy不返回EBUSY错误。
本节仅仅是线程取消的一个概貌。它还有许多细节,参见[Butenhof 1997]的5.3节。
与普通的互斥相比,当被保护数据的读访问比写访问更为频繁时,读写锁能提供更高的并发度。本章讲述的由Unix 98定义的读写锁函数或类似函数应出现在某个未来的Posix标准中。这些函数与第7章讲述的互斥锁函数类似。
读写锁可以只通过使用互斥锁和条件变量来实现,我们给出了一个实现例子。这个实现优先考虑等待着的写入者,但是有的实现优先考虑等待着的读出者。
线程可能在阻塞于pthread_cond_wait调用期间被取消,我们的读写锁实现允许看到这种情况的发生。我们使用线程取消清理处理程序解决了这个问题。
8.1 修改8.4节中我们的读写锁实现,优先考虑读出者而不是写入者。
8.2 度量并比较8.4节中我们的读写锁实现和厂家提供的实现的性能。
上一章讲述的读写锁是作为pthread_rwlock_t数据类型的变量在内存中分配的。当读写锁是在单个进程内的各个线程间共享时(默认情况),这些变量可以在那个进程内;当读写锁是在共享某个内存区的进程间共享时(假设初始化它们时指定了PTHREAD_PROCESS_SHARED属性),这些变量应该在该共享内存区中。
本章讲述读写锁的一种扩展类型,它可用于有亲缘关系或无亲缘关系的进程之间共享某个文件的读与写。被锁住的文件通过其描述符访问,执行上锁操作的函数是fcntl。这种类型的锁通常在内核中维护,其属主是由属主的进程ID标识的。这意味着这些锁用于不同进程间的上锁,而不是用于同一进程内不同线程间的上锁。
我们将在本章介绍序列号持续加1的例子。考虑来自Unix打印假脱机处理系统(BSD下使用1pr命令访问,System V下使用lp命令访问)的下述情形。把一个打印作业加到打印队列中(供另一个进程在以后某个时候打印)的进程必须给每个作业赋一个唯一的序列号。只是在该进程运行期间唯一的进程ID不能用作这个序列号,因为一个打印作业可能存在很长时间,期间早先把它加到打印队列中的进程的进程ID可能被重用。另外,一个给定进程可以往某个队列中加入多个打印作业,而每个作业都需要一个唯一的作业号。打印假脱机处理系统使用的技巧是:给每台打印机准备一个文件,它是只有一个单行的ASCII文本文件,其中含有待用的下一个序列号。需要给某个打印作业赋一个序列号的每个进程都得经历以下三个步骤。
(1)读序列号文件。
(2)使用其中的序列号。
(3)给序列号加1并写回文件中。
问题是当某个进程在执行这三个步骤时,另一个进程可能在执行同样的三个步骤。这将导致混乱,如我们将在后面的一些例子中看到的那样。
我们刚刚叙述的是一个互斥问题。它可使用第7章讲述的互斥锁或第8章讲述的读写锁来解决。然而不同的是,我们假设各个进程彼此无亲缘关系,从而让使用这些技巧更为困难。我们可以让这些进程共享某个内存区(如本书第4部分所述),然后在该共享内存区中使用某种类型的同步变量,不过对于无亲缘关系的进程,fcntl记录上锁往往更易使用。另一个因素是,我们随行式打印机假脱机处理系统描述的问题,在互斥锁、条件变量和读写锁的可用之前许多年就存在。记录上锁是在20世纪80年代早期加到Unix中的,先于共享内存区和线程的开发。
我们所需的是:一个进程能够设置某个锁,以宣称没有其他进程能够访问相应的文件,直到第一个进程完成访问为止。图9-2给出了执行上述三个步骤的一个简单程序。函数my_lock和my_unlock分别用于刚开始时给序列号文件上锁以及完成序列号更新时给该文件解锁。我们将给出这两个函数的多种实现。
20 每次循环输出序列号时同时输出正在运行的程序的名字(argv[0]),因为这个main函数与不同版本的上锁函数一块使用,而我们希望看到哪个版本在输出序列号。
输出进程ID需要把类型为pid_t的变量强制转换成long类型,然后使用%ld格式化串输出。其原因是,尽管pid_t是一个整数类型,但我们不知道它的大小(int或long),因此必须假设成最大的类型。要是我们假设它是int类型并使用%d格式化串,但是实际类型却为long,那么代码是错误的。
为展示不上锁的后果,图9-1提供了根本不上锁的两个“上锁”函数。
图9-1 不上锁的函数
如果序列号文件中的序列号初始化为1,而且该程序只有一个副本在运行,那么结果如下:
solaris % locknone
locknone: pid = 15491,seq# = 1
locknone: pid = 15491,seq# = 2
locknone: pid = 15491,seq# = 3
locknone: pid = 15491,seq# = 4
locknone: pid = 15491,seq# = 5
locknone: pid = 15491,seq# = 6
locknone: pid = 15491,seq# = 7
locknone: pid = 15491,seq# = 8
locknone: pid = 15491,seq# = 9
locknone: pid = 15491,seq# = 10
locknone: pid = 15491,seq# = 11
locknone: pid = 15491,seq# = 12
locknone: pid = 15491,seq# = 13
locknone: pid = 15491,seq# = 14
locknone: pid = 15491,seq# = 15
locknone: pid = 15491,seq# = 16
locknone: pid = 15491,seq# = 17
locknone: pid = 15491,seq# = 18
locknone: pid = 15491,seq# = 19
locknone: pid = 15491,seq# = 20
注意main函数(图9-2)是在一个名为lockmain.c的文件中,但是当我们将它与不执行上锁的“上锁”函数(图9-1)一同编译和链接时,称结果的可执行文件为locknone。这是因为我们将提供my_lock和my_unlock这两个函数的其他实现,它们使用不同的上锁技巧,因此我们根据所用的上锁类型命名可执行文件。
图9-2 文件上锁例子的main函数
把序列号重新初始化为1,然后在后台运行该程序两次,其结果如下:
solaris % locknone & locknone &
solaris % locknone: pid = 15498,seq# = 1
locknone: pid = 15498,seq# = 2
locknone: pid = 15498,seq# = 3
locknone: pid = 15498,seq# = 4
locknone: pid = 15498,seq# = 5
locknone: pid = 15498,seq# = 6
locknone: pid = 15498,seq# = 7
locknone: pid = 15498,seq# = 8
locknone: pid = 15498,seq# = 9
locknone: pid = 15498,seq# = 10
locknone: pid = 15498,seq# = 11
locknone: pid = 15498,seq# = 12
locknone: pid = 15498,seq# = 13
locknone: pid = 15498,seq# = 14
locknone: pid = 15498,seq# = 15
locknone: pid = 15498,seq# = 16
locknone: pid = 15498,seq# = 17
locknone: pid = 15498,seq# = 18
locknone: pid = 15498,seq# = 19
locknone: pid = 15498,seq# = 20 到本行为止一切正常
locknone: pid = 15499,seq# = 1 内核切换进程后开始出错locknone: pid = 15499,seq# = 2
locknone: pid = 15499,seq# = 3
locknone: pid = 15499,seq# = 4
locknone: pid = 15499,seq# = 5
locknone: pid = 15499,seq# = 6
locknone: pid = 15499,seq# = 7
locknone: pid = 15499,seq# = 8
locknone: pid = 15499,seq# = 9
locknone: pid = 15499,seq# = 10
locknone: pid = 15499,seq# = 11
locknone: pid = 15499,seq# = 12
locknone: pid = 15499,seq# = 13
locknone: pid = 15499,seq# = 14
locknone: pid = 15499,seq# = 15
locknone: pid = 15499,seq# = 16
locknone: pid = 15499,seq# = 17
locknone: pid = 15499,seq# = 18
locknone: pid = 15499,seq# = 19
locknone: pid = 15499,seq# = 20
我们首先注意到shell提示符在程序输出第一行之前输出。这是正常的,而且在后台运行程序时经常见到。
前20行输出是正常的,它们由该程序的第一个实例(进程ID为15498)输出。但是该程序另一个实例(进程ID为15499)的第一行输出中却出现了问题:它输出一个值为1的序列号,表明它也许是由内核第一个启动,当它读完序列号文件(序列号值为1)后,内核切换另一个进程来运行。该进程直到另一个进程终止时才再次运行,它继续执行所用的值是在内核切换进程前已读出的值1。这不是我们所希望的。每个进程读出、加1然后写入序列号文件20次(从而恰好有40行输出),因此序列号的最终值应该是40。
我们需要某种方法以允许一个进程在执行前述三个步骤期间防止其他进程访问序列号文件。这就是说,考虑到其他进程,这三个步骤应作为一个原子操作(atomic operation)来执行。看这个问题的另一种方式是,图9-2中调用my_lock和调用my_unlock之间的几行代码构成一个临界区(critical region),如第7章中所述。
像刚才所示的那样在后台运行图9-2中程序的两个实例时,其输出是非确定的(nondeterministic)。每次运行该程序的两个实例时,不能保证得到同样的结果。如果早先所列的三个步骤因考虑到其他进程而原子地处理,从而产生40这个最终值,那么结果的不确定性不表示有错。但是如果这三个步骤不是原子地处理,往往会产生一个小于40的最终值,那就不正确了。举例来说,我们并不关心是第一个进程先把序列号从1递增到20,第二个进程接着从21递增到40,还是每个进程都运行刚好足够长时间,从而每次运行把序列号递增2(第一个进程输出1和2,然后第二个进程输出3和4,如此等等)。
非确定性并没有造成不正确。造成程序运行正确与否的是前述三个步骤是否原子地执行。然而非确定性往往使这些类型程序的调试更为困难。
Unix内核没有文件内的记录这一概念。任何关于记录的解释都是由读写文件的应用来进行的。然而Unix内核提供的上锁特性却用记录上锁(record locking)这一术语来描述。不过应用会指定文件中待上锁或解锁部分的字节范围(byte range)。这个字节范围是否跟同一文件内的一个或多个逻辑记录有关联是应用的事。
Posix记录上锁定义了一个特殊的字节范围以指定整个文件,它的起始偏移为0(文件的开头),长度也为0。本章的讨论集中于记录上锁,文件上锁只是它的一个特例。
术语粒度(granularity)用于标记能被锁住的对象的大小。对于Posix记录上锁来说,粒度就是单个字节。通常情况下粒度越小,允许同时使用的用户数就越多。举例来说,假设有五个进程几乎同时访问一个给定的文件,其中三个是读出者,两个是写入者。再假设所有五个进程准备访问该文件中的不同记录,而且这五个请求中的每一个需花的时间几乎相同,譬如说1秒。如果上锁是在文件级别(可能的最粗粒度)上进行的,那么所有三个读出者可以同时访问它们各自的记录,但是那两个写入者必须等到所有读出者完成访问为止。然后其中一个写入者可以修改自己的记录,另一个写入者随后可以这么做。总的时间将大约是3秒。(当然我们在这些定时假设中忽略了许多细节。)但是如果上锁粒度是记录(可能的最细粒度),那么所有五个进程都能同时处理,因为它们各自访问的是不同的记录。于是总的时间只有1秒。
源自Berkeley的Unix实现支持给整个文件上锁或解锁的文件上锁(file locking),但没有给文件内的字节范围上锁或解锁的能力。文件上锁由flock函数提供。
历史
多年来Unix下的文件和记录上锁已应用了各种各样的技巧。诸如UUCP守护进程和行式打印机守护进程之类的早期程序所使用的各种技巧充分利用了文件系统实现上的特色。(我们将在9.8节讨论其中三个文件系统技巧。)然而这些技巧使用起来速度比较慢,因此20世纪80年代早期实现的数据库系统提出了使用更好技巧的要求。
第一个真正的文件和记录上锁是由John Bass于1980年加到Version 7中的,新增的一个系统调用名为locking。它提供强制性记录上锁,并为System III和Xenix的许多版本所沿用。(我们将在本章以后说明强制性上锁和劝告性上锁的区别,以及记录上锁和文件上锁的区别。)
4.2BSD于1983年通过flock函数提供了文件上锁(不是记录上锁)。1984年的/usr/group标准(X/Open的前身之一)定义了lockf函数,它只提供独占锁(即写入锁),而没有提供共享锁(即读出锁)。
System V Release 2(SVR2)于1984年通过fcntl函数提供了劝告性记录上锁。lockf函数也提供了,但它只是一个调用fcntl的库函数,而不是系统调用。(许多当前的系统仍然提供使用fcntl完成的lockf实现。)System V Release 3(SVR3)于1986年给fcntl增加了强制性记录上锁能力,它使用了文件的SGID权限位,我们将在9.5节中讨论。
1988年的Posix.1标准对fcntl函数的劝告性文件和记录上锁功能进行了标准化,这就是本章要讲述的内容。X/Open可移植性指南第3期(X/Open Portability Guide Issue 3,简称XPG3,1988年)也指出记录上锁通过fcntl函数提供。
记录上锁的Posix接口是fcntl函数。
#include <fcntl.h>
in.fcntl(in.fd,in.cmd,.../.struc.floc.*ar.*.);
返回:若成功则取决于cmd,若出错则为-1
用于记录上锁的cmd参数共有三个值。这三个命令要求第三个参数arg是指向某个flock结构的指针:
struct flock {
short l_type; /* F_RDLCK,F_WRLCK,F_UNLCK */
short l_whence; /* SEEK_SET,SEEK_CUR,SEEK_END */
off_t l_start; /* relative starting offset in bytes */
off_t l_len: /* #bytes; 0 means until end-of-file */
pid_t l_pid; /* PID returned by F_GETLK */
};
这三个命令如下。
F_SETLK 获取(1_type成员为F_RDLCK或F_WRLCK)或释放(l_type成员为F_UNLCK)由arg指向的flock结构所描述的锁。
如果无法将该锁授予调用进程,该函数就立即返回一个EACCES或EAGAIN错误而不阻塞。
F_SETLKW 该命令与上一个命令类似,不过如果无法将所请求的锁授予调用进程,调用线程将阻塞到该锁能够授予为止。(该命令的名字中最后一个字母W意思是“等待(wait)”。) [1]
F_GETLK 检查由arg指向的锁以确定是否有某个已存在的锁会妨碍将新锁授予调用进程。如果当前没有这样的锁存在,由arg指向的flock结构的l_type成员就被置为F_UNLCK。否则,关于这个已存在锁的信息将在由arg指向的flock结构中返回(也就是说,该结构的内容由fcntl函数覆写),其中包括持有该锁的进程的进程ID。 [2]
应清楚发出F_GETLK命令后紧接着发出F_SETLK命令不是一个原子操作。这就是说,如果我们发出F_GETLK命令,并且执行该命令的fcntl函数返回时置l_type成员为F_UNLCK,那么跟着立即发出F_SETLK命令不能保证其fcntl函数会成功返回。这两次调用之间可能有另外一个进程运行并获取了我们想要的锁。
提供F_GETLK命令的原因在于:当执行F_SETLK命令的fcntl函数返回一个错误时,导致该错误的某个锁的信息可由F_GETLK命令返回,从而允许我们确定是哪个进程锁住了所请求的文件区,以及上锁方式(读出锁或写入锁)。但是即使是这样的情形,F_GETLK命令也可能返回该文件区已解锁的信息,因为在F_SETLK和F_GETLK命令之间,该文件区可能被解锁。
flock结构描述锁的类型(读出锁或写入锁)以及待锁住的字节范围。跟lseek一样,起始字节偏移是作为一个相对偏移(1_start成员)伴随其解释(l_whence成员)指定的。l_whence成员有以下三个取值。
SEEK_SET:l_start相对于文件的开头解释。
SEEK_CUR:l_start相对于文件的当前字节偏移(即当前读写指针位置)解释。
SEEK_END:l_start相对于文件的末尾解释。
l_len成员指定从该偏移开始的连续字节数。长度为0意思是“从起始偏移到文件偏移的最大可能值”。因此,锁住整个文件有两种方式。
(1)指定l_whence成员为SEEK_SET,l_start成员为0,l_len成员为0。
(2)使用lseek把读写指针定位到文件头,然后指定l_whence成员为SEEK_CUR,l_start成员为0,l_len成员为0。
第一种方式最常用,因为它只需一个函数调用(fcntl)而不是两个(另见习题9.10)。
fcntl记录上锁既可用于读也可用于写,对于一个文件的任意字节,最多只能存在一种类型的锁(读出锁或写入锁)。而且,一个给定字节可以有多个读出锁,但只能有一个写入锁。这跟我们在上一章讲述的读写锁是一致的。自然,当一个描述符不是打开来用于读时,如果我们对它请求一个读出锁,错误就会发生,同样,当一个描述符不是打开来用于写时,如果我们对它请求一个写入锁,错误也会发生。
对于一个打开着某个文件的给定进程来说,当它关闭该文件的所有描述符或它本身终止时,与该文件关联的所有锁都被删除。 [3] 锁不能通过fork由子进程继承。
在进程终止时由内核完成已有锁清理工作的特性只有fcntl记录上锁完全提供了, System V信号量则把它作为一个选项提供。我们讲述的其他同步技巧(互斥锁、条件变量、读写锁、Posix信号量)并不在进程终止时执行清理工作。我们已在7.7节末尾讨论过这一点。
记录上锁不应该同标准I/O函数库一块使用,因为该函数库会执行内部缓冲。当某个文件需上锁时,为避免问题,应对它使用read和write。
9.3.1 例子
现在回到图9-2中的例子,并把图9-1中的两个函数my_lock和my_unlock重新编写成使用Posix记录上锁。图9-3给出了这些函数。
注意,我们必须指定写入锁,以保证任何时刻只有一个进程更新序列号(见习题9.4)。在获取该锁时所指定的命令为F_SETLKW,因为如果该锁不可得,那么我们希望阻塞到它变为可得为止。
有了早先给出的flock结构的定义后,有人可能认为在my_lock中可以如下初始化我们的结构:
static struct flock lock = { F_WRLCK,SEEK_SET,0,0,0 };
然而这是错误的。Posix只定义在一个结构(例如flock)中的必需成员。各个实现可以以任意顺序排列这些成员,还可以增设特定于实现的成员。
图9-3 Posix fcntl上锁
我们不给出结果输出,但它看来是正确的。需认识到运行像图9-2这样的简单程序不足以告诉我们程序是否正常工作。如果输出像我们先前看到的那样是错误的,那么可以断言程序不正确,但是如果只运行它的两个副本,每个副本只循环20次,那么测试是不充分的。内核可能运行一个程序更新序列号20次,再运行另一个程序更新序列号20次。如果这两个进程中途不发生切换,我们就可能永远发现不了错误。更好的测试是:使用另外一个main函数运行图9-3中的函数,这个main函数给序列号加1譬如说1万次,每次循环时不再输出值。如果我们把序列号初始化为1,然后同时运行该程序的20个副本,那么序列号文件的最终值应该是200 001。
9.3.2 例子:简化用的宏
图9-3中,请求或释放一个锁需6行代码。我们必须分配一个结构,填写这个结构,然后调用fcntl。通过定义来自APUE的12.3节的以下7个宏,可以简化我们的程序:
#define read_lock(fd,offset,whence,len)\
lock_reg(fd,F_SETLK,F_RDLCK,offset,whence,len)
#define readw_lock(fd,offset,whence,len)\
lock_reg(fd,F_SETLKW,F_RDLCK,offset,whence,len)
#define write_lock(fd,offset,whence,len)\
lock_reg(fd,F_SETLK,F_WRLCK,offset,whence,len)
#define writew_lock(fd,offset,whence,len)\
lock_reg(fd,F_SETLKW,F_WRLCK,offset,whence,len)
#define un_lock(fd,offset,whence,len)\
lock_reg(fd,F_SETLK,F_UNLCK,offset,whence,len)
#define is_read_lockable(fd,offset,whence,len)\
!lock_test(fd,F_RDLCK,offset,whence,len)
#define is_write_lockable(fd,offset,whence,len)\
!lock_test(fd,F_WRLCK,offset,whence,len)
这些宏使用我们的lock_reg和lock_test函数,它们在图9-4和图9-5中给出。使用这些宏时,不必考虑flock结构和真正调用的函数。这些宏的前三个参数有意安排成跟lseek函数的前三个参数相同。
图9-4 调用fcntl获取或释放一个锁
图9-5 调用fcntl测试一个锁
我们还定义了两个包裹函数Lock_reg和Lock_test,它们在fcntl出错时输出一个错误并终止。另有7个同名但首字母大写的宏,它们调用这两个包裹函数。
使用这些宏,图9-3中的my_lock和my_unlock函数变为:
#define my_lock(fd) (Writew_lock(fd,0,SEEK_SET,0))
#define my_unlock(fd) (Un_lock(fd,0,SEEK_SET,0))
Posix记录上锁称为劝告性上锁(advisory locking)。共含义是内核维护着已由各个进程上锁的所有文件的正确信息,但是它不能防止一个进程写已由另一个进程读锁定的某个文件。类似地,它也不能防止一个进程读已由另一个进程写锁定的某个文件。一个进程能够无视一个劝告性锁而写一个读锁定文件,或者读一个写锁定文件,前提是该进程有读或写该文件的足够权限。
劝告性锁对于协作进程(cooperating processes)是足够了。网络编程中守护程序的编写是协作进程的一个例子:这些程序访问诸如序列号文件之类的共享资源,而且都在系统管理员的控制之下。只要含有序列号的真正文件不是任何进程都可写,那么在该文件被锁住期间,不理会劝告性锁的随意进程无法写它。
例子:非协作进程
通过运行我们的序列号程序的两个实例,就能展示Posix记录上锁是劝告性的,这两个实例是:使用图9-3中函数的lockfcntl,它在给序列号加1前先锁住文件,以及使用图9-1中函数的locknone,它不执行上锁。
solaris % lockfcntl & locknone &
lockfcntl: pid = 18816,seq# = 1
lockfcntl: pid = 18816,seq# = 2
lockfcntl: pid = 18816,seq# = 3
lockfcntl: pid = 18816,seq# = 4
lockfcntl: pid = 18816,seq# = 5
lockfcntl: pid = 18816,seq# = 6
lockfcntl: pid = 18816,seq# = 7
lockfcntl: pid = 18816,seq# = 8
lockfcntl: pid = 18816,seq# = 9
lockfcntl: pid = 18816,seq# = 10
lockfcntl: pid = 18816,seq# = 11
locknone: pid = 18817,seq# = 11 切换进程;出错locknone: pid = 18817,seq# = 12
locknone: pid = 18817,seq# = 13
locknone: pid = 18817,seq# = 14
locknone: pid = 18817,seq# = 15
locknone: pid = 18817,seq# = 16
locknone: pid = 18817,seq# = 17
locknone: pid = 18817,seq# = 18
lockfcntl: pid = 18816,seq# = 12 切换进程;出错lockfcntl: pid = 18816,seq# = 13
lockfcntl: pid = 18816,seq# = 14
lockfcntl: pid = 18816,seq# = 15
lockfcntl: pid = 18816,seq# = 16
lockfcntl: pid = 18816,seq# = 17
lockfcntl: pid = 18816,seq# = 18
lockfcntl: pid = 18816,seq# = 19
lockfcntl: pid = 18816,seq# = 20
locknone: pid = 18817,seq# = 19 切换进程;出错locknone: pid = 18817,seq# = 20
locknone: pid = 18817,seq# = 21
locknone: pid = 18817,seq# = 22
locknone: pid = 18817,seq# = 23
locknone: pid = 18817,seq# = 24
locknone: pid = 18817,seq# = 25
locknone: pid = 18817,seq# = 26
locknone: pid = 18817,seq# = 27
locknone: pid = 18817,seq# = 28
locknone: pid = 18817,seq# = 29
locknone: pid = 18817,seq# = 30
lockfcntl程序首先运行,但是在它执行将序列号从11增加到12的三个步骤期间(此间它持有整个文件的锁),内核切换进程,并且locknone程序运行。该新程序读出的序列号值是lockfcntl程序写回序列号文件之前的11。由lockfcntl程序持有的劝告性记录锁对locknone程序没有影响。
有些系统提供另一种类型的记录上锁,称为强制性上锁(mandatory locking)。使用强制性锁后,内核检查每个read和write请求,以验证其操作不会干扰由某个进程持有的某个锁。对于通常的阻塞式描述符,与某个强制性锁冲突的read或write将把调用进程投入睡眠,直到该锁释放为止。对于非阻塞式描述符,与某个强制性锁冲突的read或write将导致它们返回一个EAGAIN错误。
Posix.1和Unix 98只定义劝告性上锁。然而源自System V的许多实现却同时提供劝告性上锁和强制性上锁。强制性记录上锁是随System V Release 3引入的。
为对某个特定文件施行强制性上锁,应满足:
组成员执行位必须关掉;
SGID位必须打开。
注意,打开某个文件的SUID位而不打开它的用户执行位是没有意义的,同样,打开SGID位而不打开组成员执行位也没有意义。因此,以这种方式加上的强制性锁不会影响任何现有的用户软件。强制性上锁不需要新的系统调用。
在支持强制性记录上锁的系统上,ls命令查找权限位的这种特殊组合,并输出l或L以指示相应文件的强制性上锁是否启用。类似地,chmod命令接受l这个指示符以给某个文件启用强制性上锁。
例子
初看起来,使用强制性上锁应该解决非协作进程的问题,因为非协作进程对被锁住文件的任何read或write调用都将阻塞进程本身,直到该文件的锁被释放为止。不幸的是,定时问题相当复杂,这一点我们很容易展示。
要把我们使用fcntl的例子转换成使用强制性上锁,所需做的是修改seqno文件的权限位。我们还改用另一个版本的main函数,它的for循环次数取自第一个命令行参数(而不是使用常值20),每次循环时不再调用printf。
solaris % cat > seqno 首先把序列号值初始化为1
1
^D Ctrl+D是我们的终端文件结束符
solaris % ls -l seqno
-rw-r--r-- 1 rstevens other1 2 Oct 7 11:24 seqno
solaris % chmod +l seqno 启用强制性上锁
solaris % ls -l seqno
-rw-r-lr-- 1 rstevens other1 2 Oct 7 11:24 seqno
现在在后台启动两个程序:loopfcntl使用fcntl上锁,loopnone不上锁。所指定的命令行参数为10 000,它是每个程序读出、加1再写入序列号的次数。
solaris % loopfcntl 10000 & loopnone 10000 & 在后台同时启动两个程序
solaris % wait 等待这两个后台作业的完成
solaris % cat seqno 然后查看序列号
14378 出错:应该是20 001
每次运行这两个程序,最终的序列号通常在14 000和16 000之间。如果上锁像期望的那样工作的话,最终值应该总是为20 001。为查看错误发生位置,我们需要画出具体到每个步骤的时间线图,如图9-6所示。
图9-6 loopfcntl和loopnone程序的时间线图
我们假设loopfcntl程序首先启动,执行图中所示前8个步骤。然后内核在loopfcntl持有序列号文件的一个记录锁期间切换进程。于是loopnone启动,但是它的第一个read阻塞了,因为它想从中读出序列号的文件有一个由另一个进程持有的未释放强制性锁。我们假设内核把进程切换回第一个程序,由它执行第13、14和15步。这是我们期待的行为:内核阻塞来自非协作进程的read,因为它试图读的文件由另一个进程锁着。
然后内核切换进程到locknone程序,由它执行第17~23步。这些步骤中的read和write是允许的,因为第一个程序已在第15步给序列号文件解锁。然而,当该程序在第23步read到值为5的序列号,接着内核切换到第一个进程时,问题就发生了。第一个进程接着给序列号值加1两次,然后在第二个进程运行第36步前存入一个值为7的序列号。但是第二个进程往序列号文件写入的值却为6,这是错误的。
我们从这个例子中看到的是,尽管强制性上锁阻止了非协作进程读一个已被锁住的文件(第11步),但是仍没有解决问题。问题出在当右边的进程处于更新序列号的三个步骤(第23、36和37步)期间时,左边的进程是允许更新序列号文件的(第25~34步)。如果有多个进程在更新一个文件,那么所有进程必须使用某种上锁形式协作。只要一个进程违规就可能引发大混乱。
在8.4节我们的读写锁实现中,优先考虑的是等待着的写入者而不是等待着的读出者。现在看看由fcntl记录上锁提供的解决读出者与写入者问题的办法的某些细节。我们想看到的是,当一个文件区已被锁住时,待处理的上锁请求是如何处理的,这是Posix未曾说明的。
9.6.1 例子:某个写入锁待处理期间的额外读出锁
我们问的第一个问题是:如果某个资源已经读锁定,并有一个写入锁请求在等待处理,那么是否允许有另一个读出锁?某些解决读出者与写入者问题的办法不允许在已有一个写入者等待着的情况下再增加一个读出者,因为要是不断允许新的读出请求的话,待处理的写入请求存在永远不被允许的可能性。
为测试fcntl记录上锁是如何处理这种情形的,我们编写一个测试程序,它获取某个完整文件的一个读出锁,然后fork两个子进程。第一个子进程首先尝试获取一个写入锁(它将阻塞,因为父进程已持有整个文件的一个读出锁),然后由第二个进程尝试获取一个读出锁。图9-7展示了这些请求的时间线图,图9-8给出了我们的测试程序。
图9-7 确定有一个写入锁待处理期间是否允许有另一个读出锁
图9-8 确定在有一个写入锁待处理期间是否允许有另一个读出锁
父进程打开文件并获取读出锁
6~8 父进程打开文件,并获取整个文件的读出锁。注意,我们调用read_lock(它不阻塞,但当无法取得锁时会返回一个错误)而不是readw_lock(它可能等待),因为预期该锁会立即取得。当取得该锁时,输出带有当前时间的一个消息(使用UNPvl第404页的gf_time函数)。
fork第一个子进程
9~19 创建第一个子进程,它睡眠1秒,然后阻塞,等待整个文件的一个写入锁。当取得该写入锁时,该进程持有它2秒后即释放它,然后终止。
fork第二个子进程
20~30 创建第二个子进程,它睡眠3秒以允许第一个子进程的写入锁处于待处理状态,然后尝试获取整个文件的一个读出锁。到时候我们就能凭readw_lock返回时输出的消息判
定,该读出锁是被排入请求队列了还是立即给予了。该锁持有4秒后被释放。
父进程持有读出锁5秒
31~35 父进程持有读出锁5秒后,释放该锁,然后终止。 [4]
图9-7所示的时间线图是我们在Solaris 2.6、Digital Unix 4.0B和BSD/OS 3.1下看到的情形。也就是说,即使已有来自第一个子进程的一个待处理写入锁请求,第二个子进程请求的读出锁也是立即给予的。这么一来,只要连续不断地发出读出锁请求,写入者就可能因获取不了写入锁而“挨饿”。下面是程序的输出,我们在大的时间事件之间插入些空白行,以改善可读性:
alpha % test2
16:32:29.674453: parent has read lock
16:32:30.709197: first child tries to obtain write lock 16:32:32.725810: second child tries to obtain read lock
16:32:32.728739: second child obtain read lock
16:32:34.722282: parent releases read lock 16:32:36.729738: second child releases read lock
16:32:36.735597: first child obtains write lock
16:32:38.736938: first child releases write lock
9.6.2 例子:等待着的写入者是否比等待着的读出者优先
我们问的下一个问题是:等待着的写入者比等待着的读出者更优先吗?某些解决读出者与写入者问题的办法内置着这样的优先关系。
图9-9是我们的测试程序,图9-10是该测试程序的时间线图。
父进程创建文件并获取写入锁
6~8 父进程创建一个文件,并获取整个文件的一个写入锁。
fork第一个子进程
9~19 派生第一个子进程,它睡眠1秒后请求整个文件的一个写入锁。我们知道这将阻塞,因为父进程已获取整个文件的一个写入锁并且持有它5秒,不过我们期待父进程持有的锁释放时,本请求已排入队。
fork第二个子进程
20~30 派生第二个子进程,它睡眠3秒后请求整个文件的一个读出锁。当父进程释放持有的写入锁时,本请求也将排入队。
在Solaris 2.6和Digital Unix 4.0B下,我们看到第一个子进程的写入锁先于第二个子进程的读出锁取得,如图9-10所示。但是这不足以告诉我们写入锁比读出锁优先,因为其原因可能是内核以FIFO顺序准予上锁请求,而不管它们是读出锁还是写入锁。为验证之,我们创建另外一个与图9-9几乎相同的测试程序,不过读出锁请求是在第1秒发生,写入锁请求是在第3秒发生。前后两个测试程序表明,Solaris和Digital Unix是以FIFO顺序处理上锁请求的,而不管上锁请求的类型。这两个程序还表明,BSD/OS 3.1优先考虑读出请求。
图9-9 测试写入者是否比读出者优先
下面是图9-9中程序的输出,我们就是以此构造出图9-10所示的时间线图的。
alpha % test3
16:34:02.810285: parent has write lock
16:34:03.848166: first child tries to obtain write lock
16:34:05.861082: second child tries to obtain read lock
16:34:07.858393: parent releases write lock
16:34:07.865222: first child obtains write lock
16:34:09.865987: first child releases write lock
16:34:09.872823: second child obtains read lock
16:34:13.873822: secound child releases read lock
图9-10 测试写入者是否比读出者优先
记录上锁的一个常见用途是确保某个程序(例如守护程序)在任何时刻只有一个副本在运行。图9-11给出了一个守护程序启动时将执行的代码片段。
打开一个文件并为其上锁
8~17 守护进程维护一个只有1行文本的文件,其中含有它的进程ID。它打开这个文件,必要的话创建之,然后请求整个文件的一个写入锁。如果没有取得该锁,我们就知道该程序有另一个副本在运行,于是输出一个出错消息并终止。
许多Unix系统让它们的守护进程把各自的进程ID写到一个文件中。Solaris 2.6在/etc目录下存放了其中一些文件。Digital Unix和BSD/OS则在/var/run目录下存放这些文件。
把本进程PID写入文件
18~21 把所打开的文件截为0,然后写入含有本进程PID的一行文本。截短该文件的原因是,该程序先前的副本(譬如说在系统重新自举前执行的副本)可能有一个值为23456的进程ID,而本副本的进程ID为123。要是光写入那一行而不预先截短,那么文件内容将会是123\n6\n。尽管第一行仍然含有本进程的进程ID,避免该文件中出现第二行的可能却更为清晰,更不易引起混淆。
图9-11 确保某个程序只有一个副本在运行
下面是图9-11中程序的一个测试结果:
solaris % onedaemon & 启动第一个副本
solaris % cat pidfile 检查写入文件中的PID
solaris % onedaemon 然后尝试启动第二个副本
[1] 22388 22388
unable to lock pidfile,is onedaemon already running?
一个守护进程还有其他方法防止自身另一个副本启动,譬如说可能使用信号量。本节所示的方法的优势在于,许多守护程序都编写成向某个文件写入本进程ID,而且如果某个守护进程过早崩溃了,那么内核会自动释放它的记录锁。
Posix.1保证,如果以O_CREAT(若文件不存在则创建它)和O_EXCL(独占打开)标志调用open函数,那么一旦该文件已经存在,该函数就返回一个错误。而且考虑到其他进程的存在,检查该文件是否存在和创建该文件(如果它还不存在)必须是原子的。因此,我们可以把以这种技巧创建的文件作为锁使用。Posix.1保证任何时候只有一个进程能够创建这样的文件(也就是获取锁),释放这样的锁只需unlink该文件。
图9-12给出了使用这种技巧的上锁函数的一个版本。如果open成功,我们就持有与所创建文件对应的锁,于是my_lock函数可以返回。返回前还close该文件,因为我们并不需要它的描述符:该文件的存在本身代表锁,至于它是否打开则无关紧要。如果open返回一个EEXIST错误,那么该文件已经存在,于是我们再次尝试open。
图9-12 使用指定O_CREAT和O_EXCL标志的open实现的锁函数
这种技巧存在以下三个问题。
(1)如果当前持有该锁的进程没有释放它就终止,那么其文件名并未删除。对付这个问题有一些特别的技巧,例如检查该文件的最近访问时间,如果它有一段大于某个确定数量的时间未曾访问,那就假设它已被遗忘,不过这些技巧没有一个是完善的。另一个技巧是把持有该锁的进程的进程ID写入其锁文件中,这样其他进程可以读出该进程ID,并检查该进程是否仍在运行。这也是不完善的,因为进程ID在过一段时间后会被重用。
这种情形对fcntl记录上锁而言不成问题,因为当某个进程终止时,由它持有的任何记录锁都自动释放。
(2)如果另外某个进程已打开了锁文件,那么当前进程只是在一个无限循环中一次又一次地调用open。这称为轮询,是对CPU时间的一种浪费。一种替换技巧是sleep 1秒,然后再次尝试open。(我们在图7-5中看到了同样的问题。)
如果使用fcntl记录上锁,这就不成问题,前提是想要持有该锁的进程指定FSETLKW命令。内核将把该进程投入睡眠,直到该锁可用,然后唤醒它。
(3)调用open和unlink创建和删除一个额外的文件涉及文件系统的访问,这通常比调用fcntl两次(一次用于获取锁,一次用于释放锁)所花时间长得多。测量在我们的程序中给序列号加1共1000次的循环所花的执行时间,发现fcntl记录上锁比调用open和unlink快75倍。
Unix文件系统的另外两个技巧也用于提供特殊的上锁。第一个技巧是:如果新链接的名字已经存在,那么link函数将失败。为获取一个锁,首先创建一个唯一的临时文件,其路径名中含有调用进程的进程ID(如果不同进程中的线程间以及同一进程内的线程间都需要上锁,那么所含的是进程ID和线程ID的某种组合)。然后以待建立锁文件的众所周知路径名调用link函数创建这个临时文件的一个链接。如果创建成功,该临时路径名就可以unlink掉。当调用线程使用完该锁时,只需unlink其众所周知的路径名就可以解锁。如果link失败返回EEXIST错误,调用线程就得重新尝试(类似于图9-12中的做法)。这种技巧的要求之一是:临时文件路径名和锁文件众所周知的路径名必须都存在于同一文件系统中,因为多数版本的Unix不允许硬链接(link函数的结果)跨越不同的文件系统。
第二种技巧基于:如果待打开的文件已经存在,打开时指定了O_TRUNC标志,而且调用进程不具备写访问权限,那么open调用将返回一个错误。为获取一个锁,我们在指定O_CREAT|O_WRONLY|O_TRUNC标志并置mode参数为0(即新文件不打开任何权限位)的前提下调用open。如果调用成功,我们就拥有了该锁,以后使用完该锁后只需unlink其路径名。如果open调用失败返回EACCES错误,那么调用线程必须重新尝试(类似于图9-12中的做法)。需要注意的是,这种技巧在调用线程具备超级用户特权时不起作用。
从这些例子得出的教训是:应该使用fcntl记录上锁。然而你有可能碰到使用这些老式上锁技巧的代码,它们通常存在于fcntl上锁尚未广泛得以实现之前编写的程序中。
NFS就是网络文件系统,它在TCPvl第29章中讨论。作为对NFS的一种扩展,NFS的大多数实现支持fcntl记录上锁。Unix系统通常以两个额外的守护进程支持NFS记录上锁,它们是lockd和statd。当某个进程调用fcntl以获取一个锁,而且内核检测出其描述符引用通过NFS安装的某个文件系统上的一个文件时,本地的lockd就向服务器的lockd发送这个请求。statd守护进程跟踪着持有锁的各个客户,它与lockd交互以提供NFS上锁的崩溃恢复功能。
我们可以预期NFS文件的记录上锁比本地文件的记录上锁花的时间长,因为获取与释放每一个锁都需要网络通信。为测试NFS记录上锁,我们只需修改图9-2中由SEQFILE指定的文件名。测量我们的程序使用fcntl记录上锁执行10 000次循环所需的时间,发现本地文件的记录上锁比NFS文件的记录上锁快了约80倍。还要留意的是,当序列号文件在某个通过NFS安装的文件系统上时,记录上锁和序列号的读写都涉及网络通信。
防止误解的说明:NFS记录上锁多年来一直是个问题,它差不多是由不理想的实现所导致的。尽管主要的Unix厂家已最终清理了它们各自的实现,通过NFS使用fcntl记录上锁对于许多实现来说仍然是一个严重的问题。我们不会在这个问题上偏袒一方而贬低另一方,只是指出fcntl记录上锁在NFS上也应该起作用,不过实际成功与否取决于实现的质量,客户端和服务器端都有质量要求。
fcntl记录上锁提供了对一个文件的劝告性或强制性上锁功能,而我们是通过该文件打开着的描述符来访问它的。这些锁用于不同进程间的上锁,而不是同一进程内不同线程间的上锁。术语“记录”是个不确切的名字,因为Unix内核没有文件内记录的概念。更好的称谓是“范围上锁(range locking)”,因为我们上锁或解锁的是文件内的一个字节范围。这类记录上锁几乎都用作协作进程之间的劝告性锁,因为即使是强制性上锁也会导致不一致的数据,正如我们所示。
使用fcntl记录上锁时,等待着的读出者优先还是等待着的写入者优先没有保证,这也是我们在第8章中看到过的读写锁的情形。如果这对于某个应用来说很重要,那就编写并运行9.6节中开发的类似测试程序,或者给该应用提供满足所需优先关系的专用读写锁实现(如我们在8.4节所做的那样)。
9.1 从图9-2和图9-1构造locknone程序,在自己的系统上运行多次。验证这个没有任何上锁能力的程序工作不正确,而且结果是非确定的。
9.2 把图9-2中的程序修改成不对标准输出进行缓冲。这样的修改有什么效果?
9.3 继续上一道习题,这次改为调用putchar逐个输出字符,而不是调用printf。这样的修改有什么效果?
9.4 把图9-3中my_lock函数使用的写入锁改为读出锁。会发生什么?
9.5 把loopmain.c程序中的open调用改为同时指定O_NONBLOCK标志。构造loopfcntlnonb程序,同时运行它的两个实例。结果有什么变化吗?为什么?
9.6 继续上一道习题,这次使用非阻塞版本的loopmain.c构造loopnonenonb程序(使用locknone.c文件,它不进行上锁操作)。启用seqno文件的强制性上锁。同时运行本程序的一个实例以及来自上一道习题的loopfcntlnonb程序的一个实例。会发生什么?
9.7 构造loopfcntl程序,从某个shell脚本在后台运行它10次。这10个实例的每一个应指定一个值为10 000的命令行参数。首先在使用劝告性上锁的前提下给这个shell脚本计时,然后把seqno文件的权限改为启用强制性上锁。强制性上锁对性能有什么影响?
9.8 在图9-8和图9-9中,我们为什么调用fork创建子进程,而不是调用pthread_create创建线程?
9.9 在图9-11中,我们调用ftruncate把文件的大小置为0字节。为什么不改为简单地给open指定O_TRUNC标志?
9.10 如果我们要编写一个使用fcntl记录上锁的线程化应用程序,那么在指定上锁的起始字节偏移量时,应使用SEEK_SET、SEEK_CUR还是SEEK_END呢?为什么?
信号量(semaphore)是一种用于提供不同进程间或一个给定进程的不同线程间同步手段的原语。本书讨论三种类型的信号量。
Posix有名信号量:使用Posix IPC名字(2.2节)标识,可用于进程或线程间的同步。
Posix基于内存的信号量:存放在共享内存区中,可用于进程或线程间的同步。
System V信号量(第11章):在内核中维护,可用于进程或线程间的同步。
我们暂时只考虑不同进程间的同步。首先考虑二值信号量(binary semaphore):其值或为0或为1的信号量。图10-1展示了这种信号量。
图10-1 由两个进程使用的一个二值信号量
图中画出该信号量是由内核来维护的(这对于System V信号量是正确的),其值可以是0或1。
Posix信号量不必在内核中维护。另外,Posix信号量是由可能与文件系统中的路径名对应的名字来标识的。因此,图10-2是Posix有名信号量的更为实际的图示。
图10-2 由两个进程使用的一个Posix有名二值信号量
我们必须就图10-2作一个限定:尽管Posix有名信号量是由可能与文件系统中的路径对应的名字来标识的,但是并不要求它们真正存放在文件系统内的某个文件中。举例来说,嵌入式实时系统可能使用这样的名字来标识信号量,但是真正的信号量值却存放在内核中的某个地方。然而,如果信号量的实现用到了映射文件(我们将在10.15节展示这样的一个实现),那么信号量的真正值确实出现在某个文件中,而该文件是映射到所有让该信号量打开着的进程的地址空间的。
在图10-1和图10-2中,我们注出了一个进程可以在某个信号量上执行的三种操作。
(1)创建(create)一个信号量。这还要求调用者指定初始值,对于二值信号量来说,它通常是1,但也可以是0。
(2)等待(wait)一个信号量。该操作会测试会这个信号量的值,如果其值小于或等于0,那就等待(阻塞),一旦其值变为大于0就将它减1。这个过程可以用如下的伪代码来总结:
while (semaphore_value <= 0)
; /* wait; i.e.,block the thread or process */
semaphore_value--;
/* we have the semaphore */
这里的基本要求是:考虑到访问同一信号量的其他线程或进程,在while语句中测试该信号量的值和其后将它减1(如果该值大于0)这两个步骤必须作为一个原子操作完成。(这是20世纪80年代中期System V信号量在内核中实现的原因之一。这样一来信号量操作成为内核中的系统调用,于是保证相对其他进程的原子性变得容易起来。)
本操作还有其他常用名字:最初Edsger Dijkstra称它为P操作,代表荷兰语单词proberen(意思是尝试)。它也称为递减(down,因为信号量的值被减掉1)或上锁(lock),不过我们使用Posix术语等待(wait)。
(3)挂出(post)一个信号量。该操作将信号量的值加1,可以用如下的伪代码来总结:
semaphore_value++;
如果有一些进程阻塞着等待该信号量的值变为大于0,其中一个进程现在就可能被唤醒。与刚刚给出的等待伪代码一样,考虑到访问同一信号量的其他进程,挂出操作也必须是原子的。
本操作还有其他常用名字:最初称为V操作,代表荷兰语单词verhogen(意思是增加)。它也称为递增(up,因为信号量的值被加上1)、解锁(unlock)或发信号(signal)。我们使用Posix术语挂出(post)。
显而易见,真正的信号量代码比我们给出的等待和挂出操作的伪代码有更多的细节,也就是如何将等待某个给定信号量的所有进程排队,然后如何唤醒一个(可能是很多进程中的一个)正在等待某个给定信号量被挂出的进程。所幸的是这些细节是由实现来处理的。
注意,上面给出的伪代码并没有假定使用其值仅为0或1的二值信号量。它们适用于其值初始化为任意非负值的信号量。这样的信号量称为计数信号量(counting semaphore)。计数信号量通常初始化为某个值N,指示可用的资源(譬如说缓冲区)数。本章我们将同时展示二值信号量和计数信号量的例子。
我们往往在二值信号量和计数信号量之间进行区分,这样做是为了我们自己的教导目的。在实现信号量的代码中,这两者间并没有差别。
二值信号量可用于互斥目的,就像互斥锁一样。图10-3给出了一个例子。
图10-3 比较解决互斥问题的互斥锁和信号量
我们把信号量初始化为1,sem_wait调用等待其值变为大于0,然后将它减1,sem_post调用则将其值加1(从0变为1),然后唤醒阻塞在sem_wait调用中等待该信号量的任何线程。
除可以像互斥锁那样使用外,信号量还有一个互斥锁没有提供的特性:互斥锁必须总是由
222锁住它的线程解锁,信号量的挂出却不必由执行过它的等待操作的同一线程执行。我们可以使用两个二值信号量和第7章中生产者-消费者问题的一个简化版本提供展示这种特性的一个例子。图10-4展示了往某个共享缓冲区中放置一个条目的一个生产者以及取走该条目的一个消费者。为简单起见,假设该缓冲区只容纳一个条目。
图10-4 使用一个共享缓冲区的简单生产者-消费者问题
图10-5给出了生产者和消费者程序的伪代码。
图10-5 简单的生产者-消费者程序的伪代码
信号量put控制生产者是否可以往共享缓冲区中放置一个条目,信号量get控制消费者是否可以从共享缓冲区中取走一个条目。按时间顺序发生的步骤如下所述。
(1)生产者初始化缓冲区和两个信号量。
(2)假设消费者接着运行。它阻塞在sem_wait调用中,因为get的值为0。
(3)一段时间后生产者接着运行。当它调用sem_wait后,put的值由1减为0,于是生产者往缓冲区中放置一个条目,然后它调用sem_post,把get的值由0增为1。既然有一个线程(即消费者)阻塞在该信号量上等待其值变为正数,该线程将被标记成准备好运行(ready_to_run)。但是假设生产者继续运行,生产者随后会阻塞在for循环顶部的sem_wait调用中,因为put的值为0。生产者必须等待到消费者腾空缓冲区。
(4)消费者从sem_wait调用中返回,它将get信号量的值由1减为0。接着它会处理缓冲区中的数据,然后调用sem_post,把put的值由0增为1。既然有一个线程(生产者)阻塞在该信号量上等待其值变为正数,该线程将被标记成准备好运行。但是假设消费者继续运行,消费者随后会阻塞在for循环顶部的sem_wait调用中,因为get的值为0。
(5)生产者从sem_wait调用中返回,于是把数据放入缓冲区中,上述情形循环继续。
我们假设每次调用sem_post时,即使当时有一个进程正在等待并随后被标记成准备好运行,调用者也继续运行。是调用者继续运行还是刚变成准备好状态的线程运行无关紧要(你应该假设对立的情形,并说服自己接受这个事实)。
下面列出信号量、互斥锁和条件变量之间的三个差异。
(1)互斥锁必须总是由给它上锁的线程解锁,信号量的挂出却不必由执行过它的等待操作的同一线程执行。这是我们的例子刚展示过的。
(2)互斥锁要么被锁住,要么被解开(二值状态,类似于二值信号量)。
(3)既然信号量有一个与之关联的状态(它的计数值),那么信号量挂出操作总是被记住。然而当向一个条件变量发送信号时,如果没有线程等待在该条件变量上,那么该信号将丢失。作为这种特性的一个例子,考虑图10-5,不过假设第一次通过生产者的循环时,消费者还没有调用sem_wait。生产者仍可以往缓冲区放置数据条目,然后在get信号量上调用sem_post(把它的值由0增为1),接着阻塞在put信号量上的sem_wait调用中。一段时间以后,消费者可能进入它的for循环,在get信号量上调用sem_wait,其结果是将该信号量的值减1(由1减为0),于是消费者接着处理缓冲区。
Posix.1基本原理一文声称,有了互斥锁和条件变量还提供信号量的原因是:“本标准提供信号量的主要目的是提供一种进程间同步方式。这些进程可能共享也可能不共享内存区。互斥锁和条件变量是作为线程间的同步机制说明的,这些线程总是共享(某个)内存区。这两者都是已广泛使用了多年的同步范式。每组原语都特别适合于特定的问题。”我们将在10.15节看到,使用互斥锁和条件变量实现具有随内核持续性的计数信号量需要约300行C代码,应用程序不应该各自从头编写这300行C代码。尽管信号量的意图在于进程间同步,互斥锁和条件变量的意图则在于线程间同步,但是信号量也可用于线程间,互斥锁和条件变量也可用于进程间。我们应该使用适合具体应用的那组原语。
我们提到过Posix提供两类信号量:有名(named)信号量和基于内存的(memory-based)的信号量,后者也称为无名(unnamed)信号量。图10-6比较了这两类信号量使用的函数。
图10-6 用于Posix信号量的函数调用
图10-2展示了一个Posix有名信号量。图10-7则展示了某个进程内由两个线程共享的一个Posix基于内存的信号量。
图10-7 由一个进程内的两个线程共享的基于内存的信号量
图10-8展示了某个共享内存区(第四部分)中由两个进程共享的一个Posix基于内存的信号量。图中画出该共享内存区同时属于这两个进程的地址空间。
图10-8 由两个进程共享、处于共享内存区中的基于内存的信号量
本章中我们首先讲述Posix有名信号量,然后讲述Posix基于内存的信号量。我们回到7.3节中的生产者-消费者问题,将它扩展成允许多个生产者和一个消费者,最后是多个生产者和多个消费者。我们然后指出,多个缓冲区的常用I/O技巧只是生产者-消费者问题的一个特例。
我们给出Posix有名信号量的三种实现:第一种实现使用FIFO,第二种实现使用内存映射I/O以及互斥锁和条件变量,第三种实现使用System V信号量。
函数sem_open创建一个新的有名信号量或打开一个已存在的有名信号量。有名信号量总是既可用于线程间的同步,又可用于进程间的同步。
#include <semaphore.h>
sem_t *sem_open(const char *name,int oflag,...
/* mode_t mode,unsigned int value */ );
返回:若成功则为指向信号量的指针,若出错则为SEM_FAILED
我们已在2.2节中描述过有关name参数的规则。
oflag参数可以是0、O_CREAT或O_CREAT|O_EXCL,如2.3节所述。如果指定了O_CREAT标志,那么第三个和第四个参数是需要的:其中mode参数指定权限位(图2-4),value参数指定信号量的初始值。该初始值不能超过SEM_VALUE_MAX(这个常值必须至少为32767)。二值信号量的初始值通常为1,计数信号量的初始值则往往大于1。
如果指定了O_CREAT(而没有指定O_EXCL),那么只有当所需的信号量尚未存在时才初始化它。不过所需信号量已存在条件下指定O_CREAT并不是一个错误。该标志的意思仅仅是“如果所需信号量尚未存在,那就创建并初始化它”。但是所需信号量已存在条件下指定O_CREAT |O_EXCL却是一个错误。
sem_open的返回值是指向某个sem_t数据类型的指针。该指针随后用作sem_close、sem_wait、sem_trywait、sem_post以及sem_getvalue的参数。
用SEM_FAILED这个返回值来指示错误比较奇怪。使用空指针也许更为合理。后来形成Posix标准的那些早期草案指定使用−1这个返回值来指示出错,许多实现于是定义
#define SEM_FAILED ((sem_t *)(-1))
当使用sem_open创建或打开某个信号量时,Posix.1未就与该信号量关联的权限位做过多少说明。实际上从图2-3和前面的讨论可注意到,当打开一个有名信号量时,我们甚至没有在oflag参数中指定O_RDONLY、O_WRONLY或O_RDWR标志。本书中的例子所用的两个系统(Digital Unix 4.0B和Solaris 2.6)都要求对某个已存在的信号量具有读访问和写访问权限,这
样对它的sem_open才能成功。其原因也许是信号量的挂出与等待操作都需要读出并修改信号量的值。这两种实现上,不具备读访问或写访问某个已存在信号量的权限都将导致sem_open函数返回一个EACCES错误(“Permission denied(访问权限不符)”)。
使用sem_open打开的有名信号量,使用sem_close将其关闭。
#include <semaphore.h>
int sem_close(sem_t *sem);
返回:若成功则为0,若出错则为-1
一个进程终止时,内核还对其上仍然打开着的所有有名信号量自动执行这样的信号量关闭操作。不论该进程是自愿终止的(通过调用exit或_exit)还是非自愿地终止的(通过向它发送一个Unix信号),这种自动关闭都会发生。
关闭一个信号量并没有将它从系统中删除。这就是说,Posix有名信号量至少是随内核持续的:即使当前没有进程打开着某个信号量,它的值仍然保持。
有名信号量使用sem_unlink从系统中删除。
#include <semaphore.h>
int sem_unlink(const char *name);
返回:若成功则为0,若出错则为-1
每个信号量有一个引用计数器记录当前的打开次数(就像文件一样),sem_unlink类似于文件I/O的unlink函数:当引用计数还是大于0时,name就能从文件系统中删除,然而其信号量的析构(不同于将它的名字从文件系统中删除)却要等到最后一个sem_close发生时为止。
sem_wait函数测试所指定信号量的值,如果该值大于0,那就将它减1并立即返回。如果该值等于0,调用线程就被投入睡眠中,直到该值变为大于0,这时再将它减1,函数随后返回。我们以前提到过,考虑到访问同一信号量的其他线程,“测试并减1”操作必须是原子的。
#include <semaphore.h>
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
均返回:若成功则为0,若出错则为-1
sem_wait和sem_trywait的差别是:当所指定信号量的值已经是0时,后者并不将调用线程投入睡眠。相反,它返回一个EAGAIN错误。
如果被某个信号中断,sem_wait就可能过早地返回,所返回的错误为EINTR。
当一个线程使用完某个信号量时,它应该调用sem_post。就像10.1节中讨论过的那样,本函数把所指定信号量的值加1,然后唤醒正在等待该信号量值变为正数的任意线程。
#include <semaphore.h>
int sem_post(sem_t *sem);
int sem_getvalue(sem_t *sem,int *valp);
均返回:若成功则为0,若出错则为-1
sem_getvalue在由valp指向的整数中返回所指定信号量的当前值。如果该信号量当前已上锁,那么返回值或为0,或为某个负数,其绝对值就是等待该信号量解锁的线程数。
我们现在看到了互斥锁、条件变量和信号量之间的更多差别。首先,互斥锁必须总是由给它上锁的线程解锁。信号量没有这种限制:一个线程可以等待某个给定信号量(譬如说将该信号量的值由1减为0,这跟给该信号量上锁一样),而另一个线程可以挂出该信号量(譬如说将该信号量的值由0增为1,这跟给该信号量解锁一样)。
其次,既然每个信号量有一个与之关联的值,它由挂出操作加1,由等待操作减1,那么任何线程都可以挂出一个信号(譬如说将它的值由0增为1),即使当时没有线程在等待该信号量值变为正数也没有关系。然而,如果某个线程调用了pthread_cond_signal,不过当时没有任何线程阻塞在pthread_cond_wait调用中,那么发往相应条件变量的信号将丢失。
最后,在各种各样的同步技巧(互斥锁、条件变量、读写锁、信号量)中,能够从信号处理程序中安全调用的唯一函数是sem_post。
这三个差异点不应该被解释成作者对于信号量的偏袒。我们已看过的所有同步原语(互斥锁、条件变量、读写锁、信号量以及记录上锁)都有它们各自的位置。对于一个给定应用我们已有很多选择,因而需要了解各种原语之间的差别。还要从刚刚列出的比较中意识到的是,互斥锁是为上锁而优化的,条件变量是为等待而优化的,信号量既可用于上锁,也可用于等待,因而可能导致更多的开销和更高的复杂性。
我们现在提供一些在Posix有名信号量上操作的简单程序,目的是更多地了解它们的功能与实现。由于Posix有名信号量至少具有随内核的持续性,因此我们可以跨多个程序操纵它们。
10.5.1 semcreate程序
图10-9中的程序创建一个有名信号量,允许的命令行选项有指定独占创建的-e和指定一个初始值(默认值1以外的值)的-i。
图10-9 创建一个有名信号量
图10-9(续)
创建信号量
22 既然总是指定O_CREAT标志,我们调用sem_open时必须提供四个参数。不过最后两个参数只有所需信号量尚未存在时才由sem_open使用。
关闭信号量
23 我们调用sem_close,不过要是省掉了这个调用,当相应进程终止时,所创建的信号量也被关闭(所占用的系统资源随之释放)。
10.5.2 semunlink程序
图10-10中的程序删除一个有名信号量的名字。
图10-10 删除一个有名信号量的名字
10.5.3 semgetvalue程序
图10-11中的简单程序打开一个有名信号量,取得它的当前值,然后输出该值。
打开信号量
9 当我们去打开一个一定存在的信号量时,sem_open的第二个参数为0,因为我们不指定O_CREAT,也没有其他O_xxx常值需指定。
图10-11 取得并输出一个信号量的值
10.5.4 semwait程序
图10-12中的程序打开一个有名信号量,调用sem_wait(如果该信号量的当前值小于或等于0,该调用就阻塞,结束阻塞后该调用将信号量的值减1),取得并输出该信号量的当前值,然后永远阻塞在一个pause调用中。
图10-12 等待一个信号量并输出它的值
10.5.5 sempost程序
图10-13中的程序挂出一个有名信号量(即把它的值加1),然后取得并输出该信号量的值。
图10-13 挂出一个信号量
10.5.6 例子
我们首先在Digital Unix 4.0B下创建一个有名信号量,然后输出它的(默认)值。
alpha % semcreate /tmp/test1
alpha % ls -l /tmp/test1
-rw-r--r-- 1 rstevens system 264 Nov 13 08:51 /tmp/test1 alpha % semgetvalue /tmp/test1
value = 1
跟Posix消息队列一样,本系统创建一个位于文件系统中的文件,它对应于我们给所创建的有名信号量指定的名字。
现在等待该信号量,然后中止持有该信号量锁的程序。
alpha % semwait /tmp/test1
pid 9702 has semaphore,value = 0 sem_wait返回后的值
^? 键入中断键以中止程序
value = 0 值仍然为0 alpha % semgetvalue /tmp/test1
本例子展示了我们早先提到过的两个特性。首先,信号量的值是随内核持续的。这就是说,从上一个例子创建该信号量到本例子,尽管期间没有程序打开着该信号量,其值(等于1)却由内核维持着。其次,当我们中止持有信号量锁的semwait程序时,该信号量的值并不改变。这就是说,当持有某个信号量锁的进程没有释放它就终止时,内核并不给该信号量解锁。这跟记录锁不一样,我们在第9章中说过,当持有某个记录锁的进程没有释放它就终止时,内核自动释放它。
我们接着展示Digital Unix的信号量实现使用负的信号量值指示等待该信号量解锁的进程数。
value = 0 值仍然是来自上一个例子的0
alpha % semwait /tmp/test1 & 在后台启动一个semwait程序
[1] 9718 它阻塞,等待信号量
value = -1 有一个进程在等待信号量
alpha % semwait /tmp/test1 & 在后台启动另一个semwait程序
alpha % semgetvalue /tmp/test1
alpha % semgetvalue /tmp/test1
[2] 9727 它也阻塞,等待信号量
value = -2 有两个进程在等待信号量
alpha % sempost /tmp/test1 现在挂出信号量
value = -1 sem_post返回后的值
pid 9718 has semaphore,value = -1 来自第一个semwait程序的输出
alpha % sempost /tmp/test1 再次挂出信号量
pid 9727 has semaphore,value = 0 来自第二个semwait程序的输出
alpha % semgetvalue /tmp/test1
value = 0
当信号量值为−2时,我们执行sempost程序,于是该值经加1后变为−1,同时有一个原本阻塞在sem_wait调用中的进程返回。
现在改为在Solaris 2.6下执行同样的例子,目的是查看它们在信号量实现上的差异。
solaris % semcreate /test2
solaris % ls -l /tmp/.*test2*
-rw-r--r-- 1 rstevens other1 48 Nov 13 09:11 /tmp/.SEMDtest2
-rw-rw-rw- 1 rstevens other1 0 Nov 13 09:11 /tmp/.SEMLtest2 solaris % semgetvalue / test2
value = 1
跟Posix消息队列一样,Solaris系统在/tmp目录下创建若干文件,作为文件名后缀的是所指定信号量的名字。我们看到第一个文件的权限与我们调用sem_open时指定的权限相对应,至于第二个文件,我们猜想其用于上锁。
下面验证当持有某个信号量锁的进程没有释放该锁就终止时,内核没有自动挂出该信号量。
solaris % semwait /test2
pid 4133 has semaphore,value = 0
^? 键入中断键
value = 0 值仍然为0 solaris % semgetvalue /test2
接着展示Solaris的信号量实现在有进程等待着某个信号量的时候如何处理该信号量的值。
solaris % semgetvalue /test2
value = 0 值仍然是来自上一个例子的0
solaris % semwait /test2 & 在后台启动一个semwait程序
[1] 4257 它阻塞,等待信号量
value = 0 本实现不使用负值
solaris % semwait /test2 & 在后台启动另一个semwait程序
value = 0 值仍然为0,不过有两个进程在等着
solaris % sempost /test2 现在挂出信号量
pid 4257 has semaphore,value = 0 来自第一个semwait程序的输出
pid 4263 has semaphore,value = 0 来自第二个semwait程序的输出solaris % semgetvalue /test2
[2] 4263
solaris % semgetvalue /test2
value = 0
solaris % sempost /test2
value = 0
与前面在Digital Unix下的输出相比,这个输出中的一个差别是在挂出信号量的时机:看起来等待着的进程优于挂出信号量的进程运行。
我们在7.3节中讲述了生产者-消费者问题,并展示了一些解决方案,具体解决多个生产者线程填写由单个消费者线程处理的一个数组时的同步问题。
(1)在第一个方案中(7.3节),消费者是在生产者完成后启动的,因此使用单个互斥锁(来同步各个生产者)就能解决同步问题。
(2)在下一个方案中(7.5节),消费者在生产者完成之前启动,因此解决同步问题需要一个互斥锁(来同步各个生产者)加上一个条件变量及其互斥锁(来同步生产者和消费者)。
现在对生产者-消费者问题进行扩展,把共享缓冲区用作一个环绕缓冲区:生产者填写最后一项(buff[NBUFF-1])后,回过头来填写第一项(buff[0]),消费者也同样这么做。这么一来增加了又一个同步问题,即生产者不能走到消费者的前面。我们仍然假设生产者和消费者都是线程,不过它们也可以是进程,前提是存在某种在进程间共享缓冲区的方法(例如我们将在第四部分介绍的共享内存区)。
当共享缓冲区作为一个环绕缓冲区考虑时,必须由代码来维持以下三个条件。
(1)当缓冲区为空时,消费者不能试图从其中去除一个条目。
(2)当缓冲区填满时,生产者不能试图往其中放置一个条目。
(3)共享变量可能描述缓冲区的当前状态(下标、计数和链表指针等),因此生产者和消费者的所有缓冲区操纵都必须保护起来,以避免竞争状态。
接下来我们给出的使用信号量的方案展示了三种不同类型的信号量。
(1)名为mutex的二值信号量保护两个临界区:一个是往共享缓冲区中插入一个数据条目(由生产者执行),另一个是从共享缓冲区中移走一个数据条目(由消费者执行)。用作互斥锁的二值信号量初始化为1。(显然,我们可以使用真正的互斥锁代替这样的二值信号量。见习题10.10。) (2)名为nempty的计数信号量统计共享缓冲区中的空槽位数。该信号量初始化为缓冲区中的槽位数(NBUFF)。
(3)名为nstored的计数信号量统计共享缓冲区中已填写的槽位数。该信号量初始化为0,因为缓冲区一开始是空的。
图10-14展示了程序完成初始化时我们的缓冲区及两个计数信号量的状态。我们给未用的数组元素标以阴影。
图10-14 初始化后的缓冲区和两个计数信号量
在我们的例子中,生产者只是把0~(NLOOP-1)存放到共享缓冲区中(buff[0] = 0,buff[1]= 1,等等),并把该缓冲区用作一个环绕缓冲区。消费者从该缓冲区取出这些整数,并验证它们是正确的,若有错误则输出到标准输出上。
图10-15展示了在生产者往共享缓冲区放置了3个条目之后,但在消息者从该缓冲区取走其中任何条目之前该缓冲区和两个计数信号量的状态。
图10-15 生产者放置3个条目到缓冲区后的缓冲区和计数信号量
我们接着假设消费者从共享缓冲区中移走一个条目,图10-16展示这时的状态。
图10-16 消费者从缓冲区移走第一个条目后的缓冲区和计数信号量
图10-17中的main函数创建前述三个信号量,创建两个线程,等待这两个线程的完成,然后删除这些信号量。
全局变量
6~10 可存放NBUFF个条目的缓冲区以及三个信号量指针是生产者线程和消费者线程共享的全局变量。正如第7章中所述,我们把它们收集到一个结构中,以强调这些信号量是用于同步对共享缓冲区的访问的。
创建信号量
19~25 创建三个信号量,它们的名字首先传递给我们的px_ipc_name函数。指定O_EXCL标志,因为每个信号量都需要初始化为正确的值。如果这三个信号量因先前本程序运行中止而没有全部删除,那么我们可以在创建之前给每个信号量调用sem_unlink,并忽略任何错误。另一种方法是,检查指定了O_EXCL标志的sem_open是否返回一个EEXIST错误,若是则调用sem_unlink,然后再调用一次sem_open,不过这么一来就更复杂了。如果我们需要验证本程序只有一个副本在运行(这一步可在尝试创建任何信号量之前完成),那么可以如9.7节中所述的那样去做。
图10-17 生产者—消费者问题信号量解决方案的main函数
创建两个线程
26~29 创建两个线程,一个作为生产者,一个作为消费者。不给这两个线程传递任何参数。
30~36 主线程然后等待这两个线程的终止,接着删除一开始创建的三个信号量。
我们还可以给每个线程调用sem_close,不过进程终止时这会自动发生。然而删除一个有名信号量的名字却必须显式地完成。
图10-18给出了produce和comsume函数。
图10-18 produce和consume函数
生产者等待到缓冲区中有一个条目的空间
44 生产者在nempty信号量上调用sem_wait,等待缓冲区中有存放另一个条目的可用空间为止。首次执行本行语句时,该信号量的值将从NBUFF变为NBUFF-1。
生产者在缓冲区中存放条目
45~48 在往缓冲区中存放新条目之前,生产者必须获取mutex信号量。在我们的例子中,生产者只是往下标为i % NBUFF的数组元素中存放一个值,因此不需要描述缓冲区状态的共享变量(也就是说我们不使用每次往缓冲区中放置一个条目就得更新状态的链表)。这样的话,获取和释放mutex信号量实际上没有必要。不过我们还是给出了,因为这种类型的问题(更新由多个线程共享的一个缓冲区)通常需要这么做。
往缓冲区中存放当前条目后,释放mutex信号量(其值由0变为1),并挂出nstored信号量。第一次执行这个语句时,nstored的值将从初始值0变为1。
消费者等待nstored信号量
57~62 当nstored信号量的值大于0时,缓冲区中已有那么多的条目待处理。消费者从缓冲区
死锁中取出一个条目并验证它的值是正确的,不过这样的缓冲区访问是用mutex信号量保护起来的。之后消费者挂出nempty信号量,告诉生产者又有一个空槽位可用了。
如果我们错误地对换了消费者函数(图10-18)中两个Sem_wait调用的顺序,那会发生什么呢?假设生产者首先启动(跟习题10.1的解答中所假设的一样),那么它将往缓冲区中存放NBUFF个条目,从而把nempty信号量的值从NBUFF递减为0,把nstored信号量的值从0递增为NBUFF。至此,生产者阻塞在Sem_wait(shared.nempty)调用中,因为缓冲区满,其上没有存放另一个条目的空槽位可用。
消费者启动,验证缓冲区中第一批NBUFF个条目的正确性。这个过程把nstored信号量的值从NBUFF递减为0,把nempty信号量的值从0递增为NBUFF。消费者接着在调用Sem_wait (shared.mutex)之后阻塞在Sem_wait(shared.nstored)调用中。生产者可以恢复执行了,因为nempty的值现已大于0,然而生产者接着调用的是Sem_wait(shared.mutex),于是阻塞。
这种现象就是死锁(dead lock)。生产者在等待mutex信号量,但是消费者却持有该信号量并在等待nstored信号量。然而生产者只有获取了mutex信号量才能挂出nstored信号量。这就是使用信号量的问题之一:要是编写代码时出了差错,程序就不能正确工作。
Posix允许sem_wait检测死锁并返回EDEADLK错误,但是运行本例子所用的系统(Solaris 2.6和Digital Unix 4.0B)都不能检测这种错误。
现在回到第9章中的序列号问题,我们提供了使用Posix有名信号量实现的my_lock和my_unlock函数。图10-19给出了这两个函数。
图10-19 使用Posix有名信号量的文件上锁
这两个函数采用一个作为劝告性文件锁使用的信号量,当首先调用my_lock函数时,该信号量的值被初始化为1。为获取该文件锁,我们调用sem_wait;为释放该锁,我们调用sem_post。
本章此前的内容处理的是Posix有名信号量。这些信号量由一个name参数标识,它通常指代文件系统中的某个文件。然而Posix也提供基于内存的信号量,它们由应用程序分配信号量的内存空间(也就是分配一个sem_t数据类型的内存空间),然后由系统初始化它们的值。
#include <semaphore.h>
int sem_init(sem_t *sem,int shared,unsigned int value);
返回:若出错则为-1
int sem_destroy(sem_t *sem);
返回:若成功则为0,若出错则为-1
基于内存的信号量是由sem_init初始化的。sem参数指向应用程序必须分配的sem_t变量。如果shared为0,那么待初始化的信号量是在同一进程的各个线程间共享的,否则该信号量是在进程间共享的。当shared为非零时,该信号量必须存放在某种类型的共享内存区中,而即将使用它的所有进程都要能访问该共享内存区。跟sem_open一样,value参数是该信号量的初始值。
使用完一个基于内存的信号量后,我们调用sem_destroy摧毁它。
sem_open不需要类似于shared的参数或类似于PTHREAD_PROCESS_SHARED的属性(第7章中讲述的互斥锁和条件变量可使用该属性),因为有名信号量总是可以在不同进程间共享的。
注意,基于内存的信号量不使用任何类似于O_CREAT标志的东西,也就是说,sem_init总是初始化信号量的值。因此,对于一个给定的信号量,我们必须小心保证只调用sem_init一次。(习题10.2展示了对于有名信号量的这个差别。)对一个已初始化过的信号量调用sem_init,其结果是未定义的。
你得确保理解sem_open和sem_init之间的下述基本差异。前者返回一个指向某个sem_t变量的指针,该变量由(sem_open)函数本身分配并初始化。后者的第一个参数是一个指向某个sem_t变量的指针,该变量由调用者分配,然后由(sem_init)函数初始化。
Posix.1警告说,对于一个基于内存的信号量,只有sem_init的sem参数指向的位置可用于访问该信号量,使用它的sem_t数据类型副本访问时结果未定义。
sem_init出错时返回−1,但成功时并不返回0。这确实有些奇怪,Posix.1基本原理一文中有一个注解说,将来的某个修订版可能指定调用成功时返回0。
当不需要使用与有名信号量关联的名字时,可改用基于内存的信号量。彼此无亲缘关系的不同进程需使用信号量时,通常使用有名信号量。其名字就是各个进程标识信号量的手段。
我们在图1-3中说过,基于内存的信号量至少具有随进程的持续性,然而它们真正的持续性却取决于存放信号量的内存区的类型。只要含有某个基于内存信号量的内存区保持有效,该信号量就一直存在。
如果某个基于内存的信号量是由单个进程内的各个线程共享的(sem_init的shared的参数为0),那么该信号量具有随进程的持续性,当该进程终止时它也消失。
如果某个基于内存的信号量是在不同进程间共享的(sem_init的shared参数为1),那么该信号量必须存放在共享内存区中,因而只要该共享内存区仍然存在,该信号量也就继续存在。从图1-3可以看出,Posix共享内存区和System V共享内存区都具有随内核的持续性。这意味着服务器可以创建一个共享内存区,在该共享内存区中初始化一个Posix基于内存的信号量,然后终止。一段时间后,一个或多个客户可打开该共享内存区,访问存放在其中的基于内存的信号量。
小心,下面的代码并不像预期的那样工作。
sem_t mysem;
Sem_init(&mysem,1,0); /* 2nd arg of 1 -> shared between processes */
if (Fork()== 0){ /* child */
. . .
Sem_post(&mysem);
}
Sem_wait(&mysem); /* parent; wait for child */
问题在于信号量mysem不在共享内存区中,正确的代码见10.12节。fork出来的子进程通常不共享父进程的内存空间。子进程是在父进程内存空间的副本上启动的,它跟共享内存区不是一回事。我们将在本书第四部分详细讨论共享内存区。
例子
作为一个例子,我们把图10-17和图10-18中的生产者-消费者例子程序转换成使用基于内存的信号量。图10-20给出了这个程序。
图10-20 使用基于内存信号量的生产者—消费者程序
图10-20(续)
分配信号量
6 本程序所用的三个信号量现在声明为三个sem_t数据类型的变量,而不是以前的三个sem_t数据类型指针。
调用sem_init
16~27 把原来的sem_open调用改为sem_init,sem_unlink调用改为sem_destroy。这几个sem_destroy调用实际上没有必要,因为程序马上就结束了。
其余变动是在所有的sem_wait和sem_post调用中传递指向三个信号量变量的指针。
10.6节中的生产者-消费者方案解决的是经典的单个生产者单个消费者问题。对它作修改后允许有多个生产者和单个消费者。我们从图10-20使用基于内存信号量的方案着手。图10-21给出了全局变量和main函数。
图10-21 创建多个生产者线程的main函数
全局变量
4 全局变量nitems是所有生产者生产的总条目数,nproducers是生产者线程的总数。
它们都是根据命令行参数设置的。
共享的结构
5~10 在shared结构中定义了两个新变量:nput和nputval。nput是下一个待存入值的缓冲区项的下标(按NBUFF求模),nputval则是下一个待存入缓冲区的值。这两个变量来自图7-2和图7-3中的解决方案。它们用于同步多个生产者线程。
新的命令行参数
17~20 两个新的命令行参数分别指定待存人缓冲区的总条目数以及待创建生产者线程的总数。
创建所有线程
21~41 初始化各个信号量,创建所有的生产者线程和唯一的消费者线程。然后等待所有线程终止。这段代码与图7-2几乎相同。
图10-22给出了由每个生产者线程执行的produce函数。
图10-22 所有生产者线程都执行的函数
生产者线程间的互斥
49~53 与图10-18相比的改变是,当所有线程总共往缓冲区中放置了nitems个值后,循环即终止。注意,能同时获取nempty信号量的生产者线程可能有多个,但每个时刻只有一个生产者线程能获取mutex信号量。这么一来,变量nput和nputval就不会同时受不止一个生产者线程的修改。
生产者线程的终止
50~51 我们必须仔细处理生产者线程的终止。最后一个条目生产出来后,每个生产者线程都执行循环顶端的如下一行语句:
Sem_wait(&shared.nempty); /* wait for at least 1 empty slot */
它将nempty信号量减1。然而每个生产者线程在终止前必须给该信号量加1,因为它在最后一次走过循环时并没有往缓冲区中存入一个条目。即将终止的生产者线程还得释放mutex信号量,以允许其他生产者线程继续运行。要是线程终止时我们没有给nempty信号量加1,而且生产者线程数大于缓冲区槽位数(譬如说14个生产者线程和10个缓冲区槽位),那么多余的线程(4个)将永远阻塞,等待nempty信号量,从而永远终止不了。 [5]
图10-23中的consume函数只是验证缓冲区中每个项都是正确的,如果检测到错误就输出一个消息。
图10-23 唯一的消费者线程执行的函数
这个唯一的消费者线程的终止条件非常简单——它只需统计已消费的条目数。
对于生产者-消费者问题的进一步修改是允许多个生产者和多个消费者。具有多个消费者是否有意义取决于具体应用。作者看到过使用这种技巧的两个应用程序。
(1)一个把IP地址转换成对应主机名的程序。每个消费者取一个IP地址,调用gethostbyaddr(UNPvl的9.6节),然后往某个文件中添加得出的主机名。由于每次调用gethostbyaddr所花时间可能不一样,因此缓冲区中IP地址的顺序通常与各个消费者线程存入结果的文件中的主机名顺序不一致。这种情形的优势在于多个gethostbyaddr调用(每个调用可能得花数秒)可以并行地发生:每个消费者线程一个调用。
这里假设gethostbyaddr是一个可重入版本的函数,然而不是所有实现都具备这个属性。要是可重入版本的gethostbyaddr函数不可用,候选方法之一就是将缓冲区存放在共享内存区中,并改用多个进程代替多个线程。
(2)一个读出UDP数据报,对它们进行操作后把结果写入某个数据库的程序。每个数据报由一个消费者线程处理,为重叠可能很花时间的每个数据报的处理,需要有多个消费者线程。尽管由消费者线程们写入数据库中的数据报顺序通常不同于原来的数据报顺序,数据库中的记录排序功能却能处理顺序问题。
图10-24给出了全局变量。
图10-24 全局变量
全局变量和共享的结构
4~12 消费者线程数现在是一个全局变量,它是根据一个命令行参数设置的。我们还往shared结构中加了另外两个变量:nget和ngetval。nget是任意一个消费者线程待取出的下一个条目的编号,ngetval则存放相应的值。
图10-25给出的main函数已修改成创建多个消费者线程。
图10-25 创建多个生产者和多个消费者的main函数
图10-25(续)
19~23 增设一个新的命令行选项,由它指定待创建消费者线程的总数。我们必须分配一个数组(tid_consume)以保存所有消费者的线程ID,再分配一个数组(conscount)以保存每个消费者线程处理的条目数,作为诊断计数。
24~50 创建多个生产者线程和多个消费者线程,然后等待它们的完成。
我们的生产者函数含有图10-22中没有的一个新行。当所有的生产者线程完成生产工作时,前面标以加号的那一行是新加的:
if (shared.nput >= nitems){
+ Sem_post(&shared.nstored); /* let consumers terminate */
Sem_post(&shared.nempty);
Sem_post(&shared.mutex);
return(NULL); /* all done */
}
在处理生产者线程和消费者线程的终止时,我们仍得小心。缓冲区中所有条目都被消费掉之后,每个消费者线程将阻塞在如下调用中:
Sem_wait(&shared.nstored); /* wait for at least 1 stored item */
我们让每个生产者线程给nstored信号量加1以给各个消费者线程解阻塞,以此让消费者们看到生产者们已完成生产工作。
图10-26给出了我们的消费者函数。
图10-26 所有消费者线程都执行的函数
消费者线程的终止
79~83 新的消费者函数必须比较nget和nitems,以确定所有消费者线程完成消费工作的时刻(类似于生产者函数)。缓冲区中最后一个条目被消费掉之后,各个消费者线程阻塞,等待nstored信号量变为大于0。这么一来,每个生产者线程终止时,应给nstored加1以让一个消费者线程终止。 [6]
在处理一些数据的典型程序中,我们可以找到一个如下形式的循环:
while ( (n = read(fdin,buff,BUFFSIZE))> 0){
/* process the data */
write(fdout,buff,n);
}
举例来说,处理文本文件的许多程序读入一行输入,对它进行处理,然后写出一行输出。对于文本文件,read和write调用往往被替换成对标准I/O函数fgets和fputs的调用。
图10-27展示了实现这种操作的一种方法,其中名为reader的函数从输入文件读入数据,名为writer的函数往输出文件写出数据。总共使用一个缓冲区。
图10-27 由一个进程把数据读入某个缓冲区,再从该缓冲区写出
图10-28给出了整个操作的时间线图。我们在时间线的左边按从上到下的时间增长顺序标出了数值,时间单位则是某个任意值。我们假设一个读操作花5个单位时间,一个写操作花7个单位时间,读和写之间的处理花2个单位时间。
我们可以把这个应用修改成在两个线程间分割读写操作,如图10-29所示。这儿我们使用两个线程,因为各个线程自动共享同一个全局缓冲区。我们也可以把复制操作分割到两个进程中,不过那将需要使用还没有讨论的共享内存区。
图10-28 由一个进程把数据读入某个缓冲区,再从该缓冲区写出
图10-29 把文件复制操作分割到两个线程中
把读写操作分割到两个线程(或两个进程)中还需要线程(或进程)间某种形式的通知。当缓冲区准备好写出时,读入者线程必须通知写出者线程;同样,当缓冲区准备好重新读入时,写出者线程必须通知读入者线程。图10-30给出了这种操作的时间线图。
图10-30 把文件复制操作分割到两个线程中
我们假设处理缓冲区中数据以及通知对方线程花两个单位时间。需特别注意的是,把读和写分割到两个线程中并不影响完成整个操作所需时间。我们没有得到任何速度上的优势,只是把整个操作分割到两个线程(或进程)中。
我们在这些时间线图中忽略了许多细微点。例如,大多数Unix内核检测出对一个文件的顺序读后就为读进程执行对下一个磁盘块的异步超前读(read ahead)。这可以改善执行这种类型操作所花的称为“时钟时间(clock time)”的实际时间量。我们还忽略了其他进程对于我们的读入者线程和写出者线程的影响以及内核调度算法的效果。
接下去我们可以把文件复制应用修改成使用两个线程(或进程)和两个缓冲区。这就是经典的双缓冲(double buffering)方案,如图10-31所示。
图中画出读入者线程正在往第一个缓冲区中读入数据,写出者线程正在从第二个缓冲区中写出数据。这两个缓冲区随后就在这两个线程间来回切换。
图10-32展示了双缓冲方案的时间线图。读入者首先读入缓冲区1,然后通知写出者缓冲区1准备好处理。读入者随后开始读入缓冲区2,其间写出者在从缓冲区1中写出数据。
图10-31 使用两个缓冲区把文件复制操作分割到两个线程中
图10-32 双缓冲方案的时间线图
注意,我们不能走得快于最慢的操作,在本例子中就是写操作。服务器完成前两个读操作后,不得不额外等待2个单位时间:写操作所花时间(7)与读操作所花时间(5)之差。然而对于我们这个假想的例子来说,双缓冲方案所花的总时钟时间几乎只有单缓冲方案的一半。
还要注意,写出操作现在是在尽可能快地发生着,每两个写操作间仅有2个单位时间作为分隔,而图10-28和图10-30中却有9个单位时间作为分隔。这种状况有助于某些像磁带驱动器这样的设备,这些设备在尽可能快地往它们写入数据的条件下会动作得更快(这称为鱼贯(streaming)模式)。
有关双缓冲问题需注意的有趣之事是,它仅仅是生产者—消费者问题的一个特例。
现在开始把以前的生产者-消费者程序修改为处理多个缓冲区。我们从图10-20中使用基于内存信号量的方案入手。新方案能处理任意数量的缓冲区(由NBUFF定义),而不只是个双缓冲方案。图10-33给出了全局变量和main函数。
图10-33 全局变量和main函数
声明NBUFF个缓冲区
2~9 新的shared结构含有另一个名为buff的结构的数组,而这个新结构含有一个缓冲区和它的当前字节计数。
打开输入文件
18 命令行参数是我们将把其内容复制到标准输出的文件的路径名。
图10-34给出了produce和consume函数。
图10-34 produce和consume函数
空临界区
40~42 本例子中由mutex锁住的临界区是空的。要是数据缓冲区是在一个链表上维护的,那么这儿就是我们从该链表中移出某个缓冲区的地方,把该操作放在临界区中是为了避免与消费者对该链表的操纵发生冲突。然而在我们的例子中,生产者只是使用下一个缓冲区,而且生产者线程只有一个,因此没有什么东西需保护以避免消费者干扰。我们仍然给出了mutex的上锁和解锁,目的是强调本代码的其他修改版本中也许需要这些。
读入数据并给nstored信号量加1
43~49 生产者每获得一个空闲缓冲区就调用read。read返回后生产者将nstored信号量加1,告诉消费者该缓冲区准备好写出。如果read返回0(文件结束符),生产者就在给nstored信号量加1后返回。
消费者线程
57~68 消费者线程取出缓冲区中数据并把它们的内容写到标准输出。碰到长度为0的一个缓冲区表示已到达文件尾。跟生产者函数一样,由mutex保护着的临界区也是空的。
我们在UNPvl的22.3节中开发了一个使用多个缓冲区的例子。在那个例子中,生产者是SIGIO信号处理程序,消费者是主处理循环(do_echo函数)。在生产者和消费者之间共享的变量是nqueue计数器。消费者每次检查或修改该计数器时都阻塞SIGIO信号以防止它产生。
进程间共享基于内存信号量的规则很简单:信号量本身(其地址作为sem_init第一个参数的sem_t数据类型变量)必须驻留在由所有希望共享它的进程所共享的内存区中,而且sem_init的第二个参数必须为1。
这些规则与进程间共享互斥锁、条件变量或读写锁的规则类似:同步对象本身(pthread_mutex_t变量、pthread_cond_t变量或pthread_rwlock_t变量)必须驻留在由所有希望共享它的进程所共享的内存区中,而且该对象必须以PTHREAD_PROCESS_SHARED属性初始化。
至于有名信号量,不同进程(不论彼此间有无亲缘关系)总是能够访问同一个有名信号量,只要它们在调用sem_open时指定相同的名字就行。即使对于某个给定名字的sem_open调用在每个调用进程中可能返回不同的指针,使用该指针的信号量函数(例如sem_post和sem_wait)所引用的仍然是同一个有名信号量。
如果我们在调用sem_open返回指向某个sem_t数据类型变量的指针后接着调用fork,情况又会怎么样呢?Posix.1中有关fork函数的描述这么说:“在父进程中打开的任何信号量仍应在子进程中打开。”这意味着如下形式的代码是正确的:
sem_t *mutex; /* global pointer that is copied across the fork()*/
...
/* parent creates named semaphore */
mutex = Sem_open(Px_ipc_name(NAME),O_CREAT | O_EXCL,FILE_MODE,0);
if ( (childpid = Fork())== 0){
/* child */
...
Sem_wait(mutex);
}
...
...
/* parent */
...
Sem_post(mutex);
我们必须仔细搞清什么时候可以或者不可以在不同进程间共享某个信号量的原因:一个信号量的状态可能包含在它本身的sem_t数据类型中,但它还可能使用其他信息(例如文件描述符)。我们将在下一章看到,一个进程用于描述一个System V信号量的唯一句柄是由semget返回的它的整数标识符。知道这个标识符的任何进程都可以访问该信号量。System V信号量的所有状态都包含在内核中,它们的整数标识符只是告诉内核具体引用哪个信号量。
Posix定义了两个信号量限制:
SEM_NSEMS_MAX 一个进程可同时打开着的最大信号量数(Posix要求至少为256);
SEM_VALUE_MAX 一个信号量的最大值(Posix要求至少为32767)。
这两个常值通常定义在<unistd.h>头文件中,也可在运行时通过调用sysconf函数获取,如下面的例子所示。
例子:semsysconf程序
图10-35中的程序调用sysconf输出信号量的两个由实现定义的限制值。
图10-35 调用sysconf获取信号量限制值
在我们的两个系统上执行该程序的结果如下:
solaris % semsysconf
SEM_NSEMS_MAX = 2147483647,SEM_VALUE_MAX = 2147483647
alpha % semsysconf
SEM_NSEMS_MAX = 256,SEM_VALUE_MAX = 32767
现在提供一个使用FIFO完成的Posix有名信号量的实现。每个有名信号量作为一个使用同一名字的FIFO来实现,该FIFO中的非负字节数代表该信号量的当前值。sem_post函数往该FIFO中写入1个字节,sem_wait函数则从该FIFO中读出一个字节(我们希望如果该FIFO为空,那就阻塞调用线程)。sem_open函数在指定了O_CREAT标志的前提下创建待打开的FIFO,然后(不论是否指定了O_CREAT)打开该FIFO两次(一次用于只读,一次用于只写),如果该FIFO是新创建的,那就往其中写入作为信号量初始值指定的那个数目的字节。
本节和本章其余各节含有你第一次阅读时可能希望暂时跳过的高级主题内容。
我们首先在图10-36中给出semaphore.h头文件,它定义了基本的sem_t数据类型。
图10-36 semaphore.h头文件
sem_t数据类型
1~5 我们的信号量数据结构包含两个描述符,一个用于从实现它的FIFO中读,一个用于往实现它的FIFO中写。为与管道保持一致,我们将这两个描述符存放在某个仅有两个元素的数组中,第一个元素存放读描述符,第二个元素存放写描述符。
一旦该结构初始化,sem_magic成员将含有常值SEM_MAGIC。接受一个sem_t指针作为参数的每个函数都检查该值,从而判定该指针确实指向一个已初始化的信号量结构。
当关闭一个信号量时,其sem_t数据结构中的这个成员将被置为0。这种技巧尽管不大完善,却有助于检查一些编程错误。
10.14.1 sem_open函数
图10-37给出了我们的sem_open函数,它创建一个新的信号量或打开一个已存在的信号量。
创建一个新的信号量
13~17 如果调用者指定了O_CREAT标志,我们就知道需要的是四个而不是两个参数。我们调用va_start初始化ap变量,让它指向最后一个命名过的参数(oflag)。然后使用ap和系统实现提供的va_arg函数获取第三和第四个参数的值。我们已随图5-21描述过可变参数表和我们的va_mode_t数据类型。
创建新的FIFO
18~23 创建一个新的FIFO,其名字为调用者指定给信号量的名字。跟4.6节中讨论过的一样,如果该FIFO已经存在,mkfifo函数将返回一个EEXIST错误。如果sem_open的调用者没有指定O_EXCL标志,那么这种错误无关紧要,不过我们不想以后在本函数中再次初始化该FIFO,于是关掉O_CREAT标志。
分配sem_t数据类型,打开FIFO分别用于读和写
25~37 为一个sem_t数据类型分配空间,它将含有两个描述符。打开新创建或已存在的FIFO两次,一次用于只读,一次用于只写。我们不想阻塞在这两个open调用的任何一个之中,因此在打开该FIFO只读时指定了O_NONBLOCK标志(回想图4-21)。当打开FIFO只写时,我们也指定O_NONBLOCK标志,不过是为了将来检测溢出(例如试图往该管道中写入多于PIPE_BUF个字节)。打开该FIFO两次之后,关掉只读描述符上的非阻塞标志。
初始化新创建信号量的值
38~42 如果创建了一个新的信号量,我们就通过向实现它的FIFO逐个写入总共value个字节来初始化它的值。如果这个初始值超过了系统实现给定的PIPE_BUF限制,那么该FIFO填满后再次调用的write将返回一个EAGAIN错误。
图10-37 sem_open函数
10.14.2 sem_close函数
图10-38给出了我们的sem_close函数。
图10-38 sem_close函数
11~15 close由调用者指定的信号量结构中的两个FIFO描述符,free分配给这个sem_t数据类型的空间。
10.14.3 sem_unlink函数
图10-39给出了我们的sem_unlink函数,由它删除与某个信号量关联的名字。它只是调用Unix的unlink函数。
图10-39 sem_unlink函数
10.14.4 sem_post函数
图10-40给出了我们的sem_post函数,由它将某个信号量的值加1。
11~12 往与由调用者指定的信号量相关联的FIFO中写入一个任意字节。如果该FIFO先前是空的,那么这个写入操作将唤醒阻塞在该FIFO上的read调用中等待一个数据字节的任意进程。
图10-40 sem_post函数
10.14.5 sem_wait函数
图10-41给出了我们的最后一个函数sem_wait。
图10-41 sem_wait函数
11~12 从与由调用者指定的信号量相关联的FIFO中read 1个字节,如果该FIFO已为空,那就阻塞。
我们没有实现sem_trywait函数,但是通过启用与其信号量关联的FIFO的非阻塞标志后再调用read,就能做到。我们也没有实现sem_getvalue函数。当调用stat或fstat函数时,某些实现会返回当前在某个管道或FIFO中的字节数,它是作为所返回stat结构的st_size成员给出的。然而Posix并不保证这一点,因而是不可移植的。下一节中将给出这两个Posix信号量函数的实现。
我们现在提供一个使用内存映射I/O以及Posix互斥锁和条件变量完成的Posix有名信号量的实现。[IEEE 1996]的B.11.3节(基本原理部分)中也提供了一个类似的实现。
我们将在第12章和第13章中讨论内存映射I/O。你可能希望跳过本节,到阅读完那两章后再返回来。
我们首先在图10-42中给出自己的semaphore.h头文件,它定义了基本的sem_t数据类型。
图10-42 semaphore.h头文件
sem_t数据类型
1~7 我们的信号量数据结构含有一个互斥锁、一个条件变量和包含本信号量当前值的一个无符号整数。正如随图10-36讨论的那样,一旦该结构已被初始化,其sem_magic成员将含有SEM_MAGIC值。
10.15.1 sem_open函数
图10-43给出了我们的sem_open函数的前半部分,它会创建一个新的信号量或打开一个已存在的信号量。
图10-43 sem_open函数:前半部分
图10-43(续)
处理可变参数表
14~23 如果调用者指定了O_CREAT标志,我们就知道需要的是四个而不是两个参数,我们已随图5-21讲述过可变参数表和我们的va_mode_t数据类型。接着关掉mode变量中的用户执行位(S_IXUSR),其原因稍后讲述。
创建一个新的信号量,并处理潜在的竞争状态
24~32 创建一个新文件,其名字为调用者命名信号量所用的名字,同时打开它的用户执行位。在调用者指定了O_CREAT标志的前提下,如果我们只是打开该文件,内存映射其内容,然后初始化sem_t结构的三个成员,那就会碰到一个竞争状态。我们已随图5-21描述过该竞争状态,这儿所用的处理技巧跟那儿给出的一样。在图10-52中我们还会碰到一个类似的竞争状态。
设置文件大小
33~37 通过往其中写入一个用0填充的结构来设置新创建文件的大小。既然知道刚创建的文件的大小为0,于是我们调用write而不是ftruncate来设置文件大小,因为正如我们在13.3节中所注,当一个普通文件的大小有待增长时,Posix并不保证ftruncate能够工作。
内存映射文件
38~42 调用mmap对新创建的文件进行内存映射。该文件将含有所创建信号量sem_t数据结构的当前值,不过既然已内存映射了该文件,我们就只通过由mmap返回的指针来访问它,而从不调用read或write。
初始化sem_t数据结构
3~57 初始化内存映射文件中的sem_t数据结构的三个成员:互斥锁、条件变量、信号量的值。既然Posix有名信号量可由知道其名字并有足够权限的任意进程共享,因此在初始化互斥锁和条件变量成员时,我们必须指定PTHREAD_PROCESS_SHARED属性。对于互斥锁成员的做法是:首先调用pthread_mutexattr_init初始化它的属性,然后调用pthread_mutexattr_setpshared在该属性结构中设置进程间共享属性,最后调用pthread_mutex_init初始化该互斥锁。对于条件变量成员也有几乎相同的做法。我们小心地保证在出错情况下摧毁这些属性对象。
初始化信号量值
58~61 最后存入信号量的初始值,我们还把该值与通过调用sysconf(10.13节)获取的最大允许值作比较。
关掉用户执行位
62~67 完成信号量的初始化后,关掉用户执行位。它指示信号量已初始化完毕的状态。接着close已内存映射了的文件,因为已没有保持它继续打开着的必要了。
图10-44给出了我们的sem_open函数的后半部分。在这里处理前面提到过的竞争状态的技巧与图5-23中所用的技巧相同。
打开已存在的信号量
69~78 我们是在调用者没有指定O_CREAT标志或者尽管指定了但所需信号量已存在的条件下到达这儿的。无论哪种情况下,我们都要打开一个已存在的信号量。我们open含有所需sem_t数据类型的文件用于读和写,并把该文件内存映射到当前进程的地址空间中(使用mmap)。
图10-44 sem_open函数:后半部分
现在可以看出为什么Posix.1声称:“访问信号量的副本将产生未定义的结果”。当使用内存映射I/O来实现有名信号量时,信号量(即sem_t数据类型)会内存映射到让它打开着的所有进程的地址空间中。这是由打开该有名信号量的每个进程调用sem_open来完成的。任意一个进程对该信号量所做的变动(例如改变它的计数值)由所有其他进程通过内存映射当场看到。要是我们去制造某个sem_t数据结构的私用副本,那么该副本将不再为所有进程所共享。即使我们可能认为它在工作(针对它的信号量函数也许不会给出错误,至少在调用sem_close之前有这种可能,而sem_close是要撤销映射的,因此关闭私用副本肯定失败),本进程与其他进程间也不会发生同步。然而从图1-6可注意到,父进程中的内存映射区穿越fork后继续在子进程中存留,因此由内核完成的从父进程到子进程穿越fork进行的信号量复制是可行的。
确保信号量已初始化
79~96 我们必须等待刚才打开的信号量完成初始化(以防多个线程几乎同时尝试创建同一个信号量)。为此,我们调用stat查看内存映射文件的权限(stat结构的st_mode成员)。如果它的用户执行位已关掉,那么该信号量已完成初始化。
出错返回
97~108 当出错时,我们小心地保证不改变errno的值。
10.15.2 sem_close函数
图10-45给出了我们的sem_close函数,它只是对先前映射的内存区调用munmap。要是调用者继续使用由以前的sem_open调用返回并作为参数传递给本函数的指针,那么它将收到一个SIGSEGV信号。
图10-45 sem_close函数
10.15.3 sem_unlink函数
图10-46给出了我们的sem_unlink函数,由它删除与某个信号量关联的名字。它仅仅调用Unix的unlink函数。
图10-46 sem_unlink函数
10.15.4 sem_post函数
图10-47给出了我们的sem_post函数,它会给某个信号量的值加1,如果此前该信号量的值为0,那就唤醒因等待该信号量而阻塞的任意线程。
图10-47 sem_post函数
11~18 在修改由调用者指定的信号量的值之前,首先得获取它的互斥锁。如果该信号量的值将从0增为1,那就调用pthread_cond_signal唤醒等待它的任意一个线程。
10.15.5 sem_wait函数
图10-48给出了我们的sem_wait函数,它等待某个信号量的值超过0。
图10-48 sem_wait函数
11~18 在修改由调用者指定的信号量的值之前,首先得获取它的互斥锁。如果该信号量的值为0,那就让调用线程睡眠在一个pthread_cond_wait调用中,等待另外某个线程为该信号量的条件变量调用pthread_cond_signal,到那时该信号量的值将由那个线程从0增为1。该值一旦大于0,本函数就将它减1,然后释放关联的互斥锁。
10.15.6 sem_trywait函数
图10-49给出了我们的sem_trywait函数,它是sem_wait函数的非阻塞版本。
图10-49 sem_trywait函数
11~22 获取由调用者指定的信号量的互斥锁后检查它的值。如果该值大于0,那就将它减1并返回0。否则返回值为−1,同时置errno为EAGAIN。
10.15.7 sem_getvalue函数
图10-50给出了我们的最后一个函数sem_getvalue,它会返回某个信号量的当前值。
图10-50 sem_getvalue函数
图10-50(续)
11~16 获取由调用者指定的信号量的互斥锁后返回它的值。
从本节提供的实现可以看出,使用信号量比使用互斥锁和条件变量要简单。
我们现在提供使用System V信号量完成的Posix有名信号量的又一个实现。既然较早的System V信号量实现与较新的Posix信号量实现相比更为普遍,那么本实现允许应用程序在操作系统还不支持Posix信号量的情况下就开始使用它们。
我们将在第11章中讨论System V信号量。你可能希望暂时跳过本节,到阅读完那一章之后再返回来。
我们首先在图10-51中给出semaphore.h头文件,它定义了基本的sem_t数据类型。
图10-51 semaphore.h头文件
sem_t数据类型
1~5 我们使用仅由一个成员构成的System V信号量集来实现Posix有名信号量。这个信号量数据结构包含对应的System V信号量ID和一个魔数(我们已随图10-36讨论过魔数的用途)。
10.16.1 sem_open函数
图10-52给出了我们的sem_open函数的前半部分,该函数创建一个新的信号量或打开一个已存在的信号量。
图10-52 sem_open函数:前半部分
创建一个新的信号量,处理可变长度参数表
20~24 如果调用者指定了O_CREAT标志,我们就知道需要的是四个而不是两个参数。我们已随图5-21描述过可变长度参数表的处理和我们的va_mode_t数据类型。
创建辅助文件,将其路径名映射成System V IPC键
25~30 创建一个普通文件,其路径名为调用者命名Posix信号量所用的名字。创建该文件的目的只是为了有一个路径名可供ftok用来标识该信号量。调用者给该信号量指定的oflag参数可以是O_CREAT或O_CREAT | O_EXCL,它用在打开辅助文件的open调用中。这样,如果该文件尚未存在,那就创建它;如果该文件已存在而且调用者指定了O_EXCL标志,那就返回一个错误。接着关闭该文件的描述符,因为该文件的唯一用途是作为ftok的参数,由ftok将其路径名转换成一个System V IPC键(3.2节)。
创建仅有一个成员的System V信号量集
31~33 把O_CREAT和O_EXCL这两个常值转换成对应的System V IPC_xxx常值后,调用semget创建一个仅由单个成员构成的System V信号量集。我们总是指定IPC_EXCL标志,目的是为了确定该System V信号量是否存在。
初始化信号量
34~50 11.2节将叙述初始化System V信号量时存在的一个基本问题,11.6节将给出避免其中潜在的竞争状态的代码。这儿使用类似的技巧。尝试创建那个System V信号量的第一个线程(回想一下,我们调用semget时总是指定IPC_EXCL)将使用semctl的SETVAL命令将它初始化为0,然后调用semop把它设置成由调用者指定的初始值。该信号量的sem_otime值保证由semget初始化为0,然后由创建者的semop调用设置成某个非零值。这样一来,发现该信号量已经存在的任何其他线程一旦看到该信号量的sem_otime值不为0,就可以肯定它已完成初始化。
检查初始值
40~44 我们检查由调用者指定的初始值,因为System V信号量通常作为unsigned short整数存放(见11.1节中的sem结构),有一个32767的最大值(11.7节),Posix信号量则通常作为普通整数存放,允许的值可能更大(10.13节)。有些System V实现把常值SEMVMX定义成最大的信号量值,如果系统没有定义该常值,我们就在图10-51中把它定义为32767。
51~53 如果所需的System V信号量已经存在,而且调用者没有指定O_EXCL,那就不算出错。
这种情形下,代码将落入用于打开(而不是创建)已存在信号量的那部分。
图10-53给出了我们的sem_open函数的后半部分。
打开已存在的信号量
55~63 对于已存在的待打开Posix信号量(调用者未指定O_CREAT标志或者尽管指定了该标志,但所需信号量已存在),我们使用semget打开与之对应的System V信号量。注意,当O_CREAT标志未指定时,sem_open并没有mode参数,然而即使待打开的是一个已存在的信号量,semget也需要一个与mode参数等价的参数。在本函数的开始处,我们给变量semflag赋了个默认值(来自我们的unpipc.h头文件的SVSEM_MODE常值),它在调用者未指定O_CREAT标志时传递给semget。
等待信号量完成初始化
64~72 接着通过以IPC_STAT命令循环调用semctl,等待该信号量的sem_otime值变为非零来验证它已初始化完毕。
图10-53 sem_open函数:后半部分
出错返回
73~78 发生错误时,我们小心地保证不改变errno的值。
分配sem_t数据类型
79~84 为一个sem_t数据类型分配空间,把所创建或打开的System V信号量的ID存放到该结构中。本函数的返回值就是指向该sem_t数据类型的指针。
10.16.2 sem_close函数
图10-54给出了我们的sem_close函数,它仅仅调用free释放早先为sem_t数据类型动态分配的内存空间。
图10-54 sem_close函数
图10-54(续)
10.16.3 sem_unlink函数
图10-55给出了我们的sem_unlink函数,它删除与我们的某个Posix信号量关联的辅助文件和System V信号量。
图10-55 sem_unlink函数
获取与路径名关联的System V键
8~16 ftok把调用者指定的路径名转换成一个System V IPC键。unlink函数删除与该路径名同名的辅助文件。(我们现在就删除该文件,这样可避免另外某个函数返回一个错误。 [7] ) semget打开关联的System V信号量,接着由semctl的IPC_RMID命令删除该信号量。
10.16.4 sem_post函数
图10-56给出了我们的sem_post函数,它会给某个信号量的值加1。
11~16 以单一操作调用semop,把由调用者指定的信号量的值加1。
图10-56 sem_post函数
10.16.5 sem_wait函数
图10-57给出了我们的sem_wait函数,它等待某个信号量的值超过0。
图10-57 sem_wait函数
11~16 以单一操作调用semop,把由调用者指定的信号量的值减1。
10.16.6 sem_trywait函数
图10-58给出了我们的sem_trywait函数,它是sem_wait函数的非阻塞版本。
13 与图10-57中sem_wait函数的唯一差别是把sem_ftg成员指定为IPC_NOWAIT。如果不阻塞调用线程就完成不了所指定的操作,那么semop的返回值将是EAGAIN错误,这也是sem_trywait非得阻塞才能完成等待操作时必须返回的错误。
图10-58 sem_trywait函数
10.16.7 sem_getvalue函数
图10-59给出了我们的最后一个函数sem_getvalue,它返回某个信号量的当前值。
图10-59 sem_getvalue函数
11~14 由调用者指定的信号量的当前值使用semctl的GETVAL命令获取。
Posix信号量是计数信号量,它提供以下三种基本操作:
(1)创建一个信号量;
(2)等待一个信号量的值变为大于0,然后将它的值减1;
(3)给一个信号量的值加1,并唤醒等待该信号量的任意线程,以此挂出该信号量。
Posix信号量可以是有名的,也可以是基于内存的。有名信号量总是能够在不同进程间共享,基于内存的信号量则必须在创建时指定成是否在进程间共享。这两类信号量的持续性也有差别:有名信号量至少有随内核的持续性,基于内存的信号量则具有随进程的持续性。
生产者-消费者问题是演示信号量的经典例子。本章中,解决这个问题的第一个方案只有一个生产者线程和一个消费者线程,下一个方案允许多个生产者线程和单个消费者线程,最后一个方案则允许多个消费者线程。我们接着指出,双缓冲这个经典问题仅仅是生产者-消费者问题的一个特例,该问题涉及的生产者和消费者都是单个的。
本章最后提供了Posix信号量的三种示例实现。使用FIFO完成的第一种实现是最简单的,因为内核提供的read和write函数处理了不少同步需求。第二种实现使用内存映射I/O完成,这与5.8节中提供的Posix消费队列的实现类似,其中的同步使用互斥锁和条件变量进行。最后一种实现使用System V信号量完成,它同时提供了访问System V信号量的一个更简单的接口。
10.1 如下修改10.6节中的produce和consume函数。首先,对换消费者程序中两个Sem_wait的顺序以引发死锁(正如10.6节中讨论的那样)。其次,在每个Sem_wait调用之前加一个printf调用,以指出哪个线程(生产者或消费者)在等待哪个信号量。在这些Sem_wait调用之后再加一个printf调用,以指出取得了相应信号量的线程。把缓冲区的数目减少到2,然后构造并运行该程序,以验证它会导致死锁。
10.2 假设启动图9-2中的程序的4个副本,而该程序调用的my_lock函数来自图10-19:
% lockpxsem & lockpxsem & lockpxsem & lockpxsem &
这4个进程都以值为0的initflag启动,因此每个进程调用sem_open时都指定了O_CREAT标志。这样可行吗?
10.3 上一道习题中,要是有一个进程在调用my_lock之后但在调用my_unlock之前终止,那么会发生什么?
10.4 在图10-37中,要是我们没有把那两个描述符都初始化为-1,那么会发生什么?
10.5 在图10-37中,我们为什么先保存errno的值,稍后再恢复,而不是把那两个close调用改为:
if (sem->fd[0] >= 0)
close(sem->fd[0]);
if (sem->fd[1] >= 0)
close(sem->fd[1]);
10.6 如果有两个进程几乎同时调用我们的sem_open的FIFO实现版本(图10-37),而且都指定O_CREAT标志和初始值5,那么会发生什么?相应的FIFO有可能被(不正确地)初始化为10吗?
10.7 对于图10-43和图10-44,我们描述过当有两个进程几乎同时尝试创建一个信号量时可能存在的一种竞争状态。然而在上一道习题的解答中,我们说图10-37不存在竞争状态。请解释。
10.8 Posix.1规定了sem_wait的一个可选功能:检测自己已被一个捕获的信号中断并返回EINTR错误。编写一个测试程序,判定你的系统上的实现是否进行这种检测。
再针对我们的几种实现运行你的测试程序,它们有使用FIFO的(10.14节)、使用内存映射I/O的(10.15节)和使用System V信号量的(10.16节)。
10.9 我们的3种sem_post实现中,哪种是异步信号安全的(图5-10)?
10.10 修改10.6节中使用的生产者-消费者解决方案,将mutex变量改为使用pthread_mutex_t数据类型,而不是使用信号量。在性能上发生了可测量到的变化了吗?
10.11 比较有名信号量(图10-17和图10-18)和基于内存的信号量(图10-20)的定时结果。
我们在第10章中讲述信号量的概念时,首先讨论的是
二值信号量(binary semaphore):其值或为0或为1的信号量。这与互斥锁(第7章)类似,若资源被锁住则信号量值为0,若资源可用则信号量值为1。
接着把这种信号量扩展为
计数信号量(counting semaphore):其值在0和某个限制值(对于Posix信号量,该值必须至少为32767)之间的信号量。我们使用这些信号量在生产者-消费者问题中统计资源,信号量的值就是可用资源数。
这两种类型的信号量中,等待(wait)操作都等待信号量的值变为大于0,然后将它减1。挂出(post)操作则只是将信号量的值加1,从而唤醒正在等待该信号量值变为大于0的任意线程。
System V信号量通过定义如下概念给信号量增加了另外一级复杂度。
? 计数信号量集(set of counting semaphores):一个或多个信号量(构成一个集合),其中每个都是计数信号量。每个集合的信号量数存在一个限制,一般在25个的数量级上(11.7节)。当我们谈论“System V信号量”时,所指的是计数信号量集。当我们谈论“Posix信号量”时,所指的是单个计数信号量。
对于系统中的每个信号量集,内核维护一个如下的信息结构,它定义在<sys/sem.h>头文件中。
struct semid_ds {
struct ipc_perm sem_perm; /* operation permission struct */
struct sem *sem_base; /* ptr to array of semaphores in set */
ushort sem_nsems; /* # of semaphores in set */
time_t sem_otime; /* time of last semop()*/
time_t sem_ctime; /* time of creation or last IPC_SET */
};
其中的ipc_perm结构已在3.3节描述过,它含有当前这个特定信号量的访问权限。
sem结构是内核用于维护某个给定信号量的一组值的内部数据结构。一个信号量集的每个成员由如下这个结构描述:
struct sem {
ushort_t semval; /* semaphore value,nonnegative */
short sempid; /* PID of last successful semop(),SETVAL,SETALL */
ushort_t semncnt; /* # awaiting semval > current value */
ushort_t semzcnt; /* # awaiting semval = 0 */
};
注意sem_base含有指向某个sem结构数组的指针:当前信号量集中的每个信号量对应其中一个数组元素。
除维护一个信号量集内每个信号量的实际值之外,内核还给该集合中每个信号量维护另外三个信息:对其值执行最后一次操作的进程的进程ID、等待其值增长的进程数计数以及等待其值变为0的进程数计数。
Unix 98声称上述这个结构是匿名的。我们给出的名字sem来自历史上的System V实现。
我们可以把内核中的某个特定信号量图解成指向一个sem结构数组的一个semid_ds结构。如果该信号量在其集合中有两个成员,那么我们将有图11-1所示的图解。该图中变量sem_nsems的值为2,另外该集合的两个成员分别用下标[0]和[1]标记。
图11-1 由两组值构成的某个信号量集的内核数据结构
semget函数创建一个信号量集或访问一个已存在的信号量集。
#include <sys/sem.h>
int semget(key_t key,int nsems,int oflag);
返回:若成功则为非负标识符,若出错则为−1
返回值是一个称为信号量标识符(semaphore identifier)的整数,semop和semctl函数将使用它。
nsems参数指定集合中的信号量数。如果我们不创建一个新的信号量集,而只是访问一个已存在的集合,那就可以把该参数指定为0。一旦创建完一个信号量集,我们就不能改变其中的信号量数。
oflag值是图3-6中给出的SEM_R和SEM_A常值的组合。其中R代表“读(read)”,A代表“改(alter)”。它们还可以与IPC_CREAT或IPC_CREAT | IPC_EXCL按位或,如随图3-4所作的讨论。
当实际操作为创建一个新的信号量集时,相应的semid_ds结构的以下成员将被初始化。
sem_perm结构的uid和cuid成员被置为调用进程的有效用户ID,gid和cgid成员被置为调用进程的有效组ID。
oflag参数中的读写权限位存入sem_perm.mode。
sem_otime被置为0,sem_ctime则被置为当前时间。
sem_nsems被置为nsems参数的值。
与该集合中每个信号量关联的各个sem结构并不初始化。这些结构是在以SET_VAL或SETALL命令调用semctl时初始化的。
信号量值的初始化
出现在本书1990年版所含源代码中的注释不正确地声称,当实际操作为创建一个新的信号量集时,semget会将该集合中各个信号量的值初始化为0。尽管有些系统确实把新的信号量集内各个信号量的值初始化为0,这一点却不能保证做到。实际上早期的System V实现根本不对信号量值进行初始化,存放新创建信号量集的那部分内存空间最近一次使用时的值就是各个信号量的初始值。
semget的大多数手册页面根本不就实际操作为创建一个新的信号量集时各个信号量的初始值应为多少说些什么。X/Open XPG3可移植性指南(1989年)和Unix 98纠正了这个忽略行为,明确地陈述semget并不初始化各个信号量的值,这个初始化必须通过以SET_VAL命令(设置集合中一个值)或SETALL命令(设置集合中所有值)调用semctl(我们稍后描述)来完成。
System V信号量的设计中,创建一个信号量集(semget)并将它初始化(semctl)需两次函数调用是一个致命的缺陷。一个不完备的解决方案是:在调用semget时指定IPC_CREAT |IPC_EXCL标志,这样只有一个进程(首先调用semget的那个进程)创建所需信号量,该进程随后初始化该信号量,其他进程会收到来自semget的一个EEXIST错误,于是再次调用semget,不过这次调用既不指定IPC_CREAT标志,也不指定IPC_EXCL标志。
然而竞争状态依然存在。假设有两个进程几乎同时尝试创建并初始化一个只有单个成员的信号量集,两者都执行如下几行标了号的代码:
1 oflag = IPC_CREAT | IPC_EXCL | SVSEM_MODE;
2 if ( (semid = semget(key,1,oflag))>= 0){
/* success,we are the first,so initialize */
3 arg.val = 1;
4 Semctl(semid,0,SETVAL,arg);
5 } else if (errno == EEXIST){
/* already exists,just open */
6 semid = Semget(key,1,SVSEM_MODE);
7 } else
8 err_sys("semget error");
9 Semop(semid,...); /* decrement the semaphore by 1 */
那么可能发生如下情形:
(1)第一个进程执行第1~3行,然后被内核阻止执行;
(2)内核启动第二个进程,它执行第1、2、5、6、9行。
尽管成功创建该信号量的第一个进程将是初始化该信号量的唯一进程,但是由于它完成创建和初始化操作需花两个步骤,因此内核有可能在这两个步骤之间把上下文切换到另一个进程。这个新切换来运行的进程随后可以使用该信号量(上述代码片段的第9行),但是该信号量的值尚未由第一个进程初始化。当第二个进程执行第9行时,该信号量的值是不确定的。
幸运的是存在绕过这个竞争状态的方法。当semget创建一个新的信号量集时,其semid_ds结构的sem_otime成员保证被置为0。(System V手册已陈述这个事实很长时间,XPG3和Unix 98标准也这么说。)该成员只是在semop调用成功时才被设置为当前值。因此,上面例子中的第二个进程再次成功地调用semget(上述代码片段的第6行)后,必须以IPC_STAT命令调用semctl。它然后等待sem_otime变为非零值,到时就可断定该信号量已被初始化,而且对它进行初始化的那个进程已成功地调用semop。这意味着创建该信号量的那个进程必须初始化它的值,而且必须在任何其他进程可以使用该信号量之前调用semop。我们在图10-52和图11-7中展示了使用这种技巧的例子。
Posix有名信号量通过让单个函数(sem_open)创建并初始化信号量来避免上述问题。而且即使指定O_CREAT标志,信号量也只是在尚未存在的前提下才被初始化。
这个潜在的竞争状态是否构成问题还取决于应用程序。有些应用程序(例如图10-21中的生产者—消费者程序)由单个进程创建并初始化信号量。这种情形下不会存在竞争状态。但是在其他应用程序(例如图10-19中的文件上锁例子程序)中,创建并初始化信号量的并不是单个进程:第一个打开信号量的进程必须创建并初始化它,而且必须避免竞争状态。
使用semget打开一个信号量集后,对其中一个或多个信号量的操作就使用semop函数来执行。
#include <sys/sem.h>
int semop(int semid,struct sembuf *opsptr,size_t nops);
返回:若成功则为0,若出错则为−1
其中opsptr指向一个如下结构的数组:
struct sembuf {
short sem_num; /* semaphore number: 0,1,...,nsems-1 */
short sem_op; /* semaphore operation: <0,0,>0 */
short sem_flg; /* operation flags: 0,IPC_NOWAIT,SEM_UNDO */
};
nops参数指出由opsptr指向的sembuf结构数组中元素的数目。该数组中的每个元素给目标信号量集内某个特定的信号量指定一个操作。这个特定的信号量由sem_num指定,0代表第一个元素,1代表第二个元素,依次类推,直到nsems-1,其中nsems是目标信号量集内成员信号量的数目(也就是创建该集合时传递给semget的第二个参数)。
我们仅仅保证sembuf结构含有所给出的三个成员。它还可能含有其他成员,而且各成员并不保证以我们给出的顺序排序。这意味着我们绝不能静态地初始化这种结构,例如:
struct sembuf ops[2] = {
0,0,0, /* wait for [0] to be 0 */
0,1,SEM_UNDO /* then increment [0] by 1 */
};
而是必须使用运行时初始化方法,例如:
struct sembuf ops[2];
ops[0].sem_num = 0; /* wait for [0] to be 0 */
ops[0].sem_op = 0;
ops[0].sem_flg = 0;
ops[1].sem_num = 0; /* then increment [0] by 1 */
ops[1].sem_op = 1;
ops[1].sem_flg = SEM_UNDO;
由内核保证传递给semop函数的操作数组(opsptr)被原子地执行。内核或者完成所有指定的操作,或者什么操作都不做。我们将在11.5节中给出这个特性的一个例子。
每个特定的操作是由sem_op的值确定的,它可以是负数、0或正数。在稍后给出的讨论中,我们将使用如下术语。
semval:信号量的当前值(图11-1)。
semncnt:等待semval变为大于其当前值的线程数(图11-1)。
semzcnt:等待semval变为0的线程数(图11-1)。
semadj:所指定信号量针对调用进程的调整值。只有在对应本操作的sembuf结构的sem_flg成员中指定SEM_UNDO标志后,semadj才会更新。这是一个概念性的变量,它由内核为在其某个信号量操作中指定了SEM_UNDO标志的各个进程维护,不必存在名为semadj的结构成员。 [8]
使得一个给定信号量操作非阻塞的方法是,在对应的sembuf结构的sem_flg成员中指定IPC_NOWAIT标志。在指定了该标志,并且如果不把调用线程投入睡眠就完成不了这个给定操作的情况下,semop将返回一个EAGAIN错误。
当一个线程被投入睡眠以等待某个信号量操作完成之时(我们将看到该线程既可等待这个信号量的值变为0,也可等待它变为大于0),如果它捕获了一个信号,那么其信号处理程序的返回将中断引起睡眠的semop函数,该函数于是返回一个EINTR错误。按照UNPvl第124页的术语定义,semop是需被所捕获的信号中断的慢系统调用(slow system call)。
当一个线程被投入睡眠以等待某个信号量操作完成之时,如果该信号量被另外某个线程或进程从系统中删除,那么引起睡眠的semop函数将返回一个EIDRM错误,表示“identifier removed(标识符已删除)”。
现在我们基于每个具体指定的sem_op操作的三类可能值——正数、0或负数——来描述semop的操作。
(1)如果sem_op是正数,其值就加到semval上。这对应于释放由某个信号量控制的资源。
如果指定了SEM_UNDO标志,那就从相应信号量的semadj值中减掉sem_op的值。
(2)如果sem_op是0,那么调用者希望等待到semval变为0。如果semval已经是0,那就立即返回。
如果semval不为0,相应信号量的semzcnt值就加1,调用线程则被阻塞到semval变为0(到那时,相应信号量的semzcnt值再减1)。前面已经提到,如果指定了IPC_NOWAIT标志,调用线程就不会被投入睡眠。如果某个被捕获的信号中断了引起睡眠的semop函数,或者相应的信号量被删除了,那么该函数将过早地返回一个错误。
(3)如果sem_op是负数,那么调用者希望等待semval变为大于或等于sem_op的绝对值。这对应于分配资源。
如果semval大于或等于sem_op的绝对值,那就从semval中减掉sem_op的绝对值。如果指定了SEM_UNDO标志,那么sem_op的绝对值就加到相应信号量的semadj值上。
如果semval小于sem_op的绝对值,相应信号量的semncnt值就加1,调用线程则被阻塞到semval变为大于或等于sem_op的绝对值。到那时该线程将被解阻塞,还将从semval中减掉sem_op的绝对值,相应信号量的semncnt值将减1。如果指定了SEM_UNDO标志,那么sem_op的绝对值将加到相应信号量的semadj值上。前面已经提到,如果指定了IPC_NOWAIT标志,调用线程就不会被投入睡眠。另外,如果某个被捕获的信号中断了引起睡眠的sem_op函数,或者相应的信号量被删除了,那么该函数将过早地返回一个错误。
比较一下这些操作和Posix信号量允许的操作,可看到Posix信号量只允许−1(sem_wait)和+1(sem_post)这两个操作。System V信号量允许信号量的值增长或减少不光是1,而且允许等待信号量的值变为0。与较为简单的Posix信号量相比,这些更为一般化的操作以及System V信号量可以有一组值的事实造成了System V信号量的复杂性。
semctl函数对一个信号量执行各种控制操作。
#include <sys/sem.h>
in.semctl(in.semid,in.semnum,in.cmd,.../.unio.semu.ar.*.);
返回:若成功则为非负值(见正文),若出错则为−1
第一个参数semid标识其操作待控制的信号量集,第二个参数semnum标识该信号量集内的某个成员(0、1等,直到nsems−1)。semnum值仅仅用于GETVAL、SETVAL、GETNCNT、GETZCNT和GETPID命令。
第四个参数是可选的,取决于第三个参数cmd(参见下面给出的联合中的注释)。
union semun {
int val; /* used for SETVAL only */
struct semid_ds *buf; /* used for IPC_SET and IPC_STAT */
ushort *array; /* used for GETALL and SETALL */
};
这个联合并没有出现在任何系统头文件中,因而必须由应用程序声明。(我们在图C.1中给出的unpipc.h头文件中定义了它。)它是按值传递的,而不是按引用传递的。也就是说作为参数的是这个联合的真正值,而不是指向它的指针。
不幸的是,有些系统(FreeBSD和Linux)在<sys/sem.h>头文件中定义了这个联合,从而使编写可移植代码变得困难。尽管由这个系统头文件来声明semun联合确实有理由,Unix 98还是声称它必须由应用程序显式声明。
System V支持下列cmd值。除非另外声明,否则返回值为0表示成功,返回值为−1表示出错。
GETVAL 把semval的当前值作为函数返回值返回。既然信号量决不会是负数(semval被声明成一个unsigned short整数),那么成功的返回值总是非负数。
SETVAL 把semval值设置为arg.val。如果操作成功,那么相应信号量在所有进程中的信号量调整值(semadj)将被置为0。
GETPID 把sempid的当前值作为函数返回值返回。
GETNCNT 把semncnt的当前值作为函数返回值返回。
GETZCNT 把semzcnt的当前值作为函数返回值返回。
GETALL 返回所指定信号量集内每个成员的semval值。这些值通过arg.array指针返回,函数本身的返回值则为0。注意,调用者必须分配一个unsigned short整数数组,该数组要足够容纳所指定信号量集内所有成员的semval值的,然后把arg.array设置成指向这个数组。
SETALL 设置所指定信号量集中每个成员的semval值。这些值是通过arg.array指针指定的。
IPC_RMID 把由semid指定的信号量集从系统中删除掉。
IPC_SET 设置所指定信号量集的semid_ds结构中的以下三个成员:sem_perm.uid、sem_perm.gid和sem_perm.mode,这些值来自由arg.buf 参数指向的结构中的相应成员。semid_ds结构中的sem_ctime成员也被设置成当前时间。
IPC_STAT (通过arg.buf 参数)返回所指定信号量集当前的semid_ds结构。注意,调用者必须首先分配一个semid_ds结构,并把arg.buf 设置成指向这个结构。
既然System V信号量具有随内核的持续性,于是我们可以通过编写一组操纵它们的程序并查看这些程序的运行结果来展示它们的用法。信号量的值将由内核从我们的某个程序维持到下一个程序。
11.5.1 semcreate程序
图11-2给出了我们的第一个程序,它只是创建一个System V信号量集。-e命令行选项指定IPC_EXCL标志,该集合中信号量的数目必须由最后一个命令行参数指定。
图11-2 semcreate程序
11.5.2 semrmid程序
图11-3给出的下一个程序会从系统中删除一个信号量集。删除该集合通过调用semctl函数执行IPC_RMID命令完成。
图11-3 semrmid函数
11.5.3 semsetvalues程序
图11-4给出的semsetvalues程序设置某个信号量集中的所有值。
图11-4 semsetvalues函数
取得集合中信号量的数目
11~15 使用semget获取所指定信号量集的信号量ID之后,发出一个semctl的IPC_STAT命令取得该信号量的semid_ds结构。其中sem_nsems成员就是该集合中信号量的数目。
设置所有的值
19~24 分配一个unsigned short整数数组的内存空间,每个集合成员对应一个数组元素,然后把它们的值从命令行复制到数组中。接着由一个semctl的SETALL命令设置该信号量集内所有成员信号量的值。
11.5.4 semgetvalues程序
图11-5给出的semgetvalues程序取得并输出某个信号量集中的所有值。
图11-5 semgetvalues程序
取得集合中信号量的数目
11~15 使用semget获取所指定信号量集的信号量ID之后,发出一个semctl的IPC_STAT命令取得该信号量的semid_ds结构。其中sem_nsems成员就是该集合中信号量的数目。
取得所有的值
16~22 分配一个unsigned short整数数组的内存空间,每个集合成员对应一个数组元素,然后发出一个semctl的GETALL命令获取该信号量集内所有成员信号量的值。接着输出所有的值。
11.5.5 semops程序
图11-6给出的semops程序对某个信号量集执行一数组的操作。
图11-6 semops程序
命令行选项
7~19 -n选项给每个操作指定IPC_NOWAIT标志,-u选项给每个操作指定SEM_UNDO标志。注意,semop函数允许我们给sembuf结构的每个成员(也就是针对信号量集内每个成员的操作)指定一组不同的标志,但是为了简单起见,我们让这些命令行选项给所有成员信号量各自的指定操作统一指定相应标志。
给各个操作分配内存空间
20~29 使用semget打开所指定的信号量集后,分配一个sembuf结构数组,命令行中指定的每个操作对应一个数组元素。与前两个程序不同,本程序允许用户指定少于相应信号量集内成员数目的操作个数。
执行各个操作
30 semop在所指定信号量集上执行刚才创建的操作数组。
11.5.6 例子
现在演示刚刚给出的5个程序,以查看System V信号量的某些特性。
solaris % touch /tmp/rich
solaris % semcreate -e /tmp/rich 3
solaris % semsetvalues /tmp/rich 1 2 3
solaris % semgetvalues /tmp/rich
semval[0] = 1
semval[1] = 2
semval[2] = 3
我们首先创建一个名为/tmp/rich的文件,它将(通过ftok)用于标识本例子所用的信号量集。semcreate创建一个共有三个成员的信号量集。semsetvalues把它们的值分别设置为1、2和3,这些值随后由semgetvalues输出。
我们接着演示在一个信号量集上执行一组操作的原子性。
solaris % semops -n /tmp/rich -1 -2 -4
semctl error: Resource temporarily unavailable
solaris % semgetvalues /tmp/rich
semval[0] = 1
semval[1] = 2
semval[2] = 3
我们指定了非阻塞标志(-n)和三个操作,每个操作分别减少刚创建信号量集内的某个值。第一个操作可以执行(我们可以从该集合值为1的第一个成员中减掉1),第二个操作也可以执行(我们可以从该集合值为2的第二个成员中减掉2),但是第三个操作却无法执行(我们不能从该集合值为3的第三个成员中减掉4)。既然最后一个操作不能执行,而且指定了非阻塞标志,那么将返回一个EAGAIN错误。(要是未曾指定非阻塞标志,我们的程序就只是阻塞。)我们接着验证该集合中没有值变动过。尽管前两个操作可以执行,但是由于最后一个操作不能执行,因此这三个操作都不执行。semop的原子性意味着要么所有操作都执行,要么一个操作都不执行。
下面演示System V信号量的SEM_UNDO属性。
solaris % semsetvalues /tmp/rich 1 2 3 设置成已知值
solaris % semops -u /tmp/rich -1 -2 -3 给每个操作指定SEM_UNDO标志solaris % semgetvalues /tmp/rich
semval[0] = 1 当semops终止时所有变动都被取消
semval[1] = 2
semval[2] = 3
solaris % semops /tmp/rich -1 -2 -3 不指定SEM_UNDO标志solaris % semgetvalues /tmp/rich
semval[0] = 0 变动未被取消semval[1] = 0
semval[2] = 0
我们首先使用semsetvalues把三个信号量值重新置为1、2和3,然后使用semops指定−1、−2和−3三个操作。这导致所有三个值都变为0,但是既然我们给semops程序指定了-u选项,那么所有三个操作都被指定了SEM_UNDO标志。这么一来,这三个成员信号量的semadj值就分别被置为1、2和3。后来当semops程序终止时,这三个semadj值就分别加到三个成员信号量的当前值(全为0)上,导致它们的最终值分别变为1、2和3,这一点我们用semgetvalues程序验证了。我们接着再次执行semops程序,不过这次不指定-u选项,其结果是当semops程序终止时,所有三个成员信号量的值都保持为0,而不回复到开始执行semops程序时的值。
我们可以提供图10-19中my_lock和my_unlock函数的另一个版本,它们使用System V信号量实现。图11-7给出了这个版本。
图11-7 使用System V信号量实现的文件上锁
首先尝试独占创建
13~17 我们必须保证只有单个进程初始化文件上锁信号量,因此给semget指定了IPC_CREAT|IPC_EXCL标志。如果创建成功,当前进程就调用semctl将该信号量的值初始化为1。如果有多个进程几乎同时调用我们的my_lock函数,那么只有一个进程会创建出文件上锁信号量(假设它尚未存在),接着初始化该信号量的也是这个进程。
信号量已存在,那就打开它
18~20 对于其他进程来说,第一个semget调用将返回一个EEXIST错误,它们于是再次调用semget,不过这次不指定IPC_CREAT|IPC_EXCL标志。
等待信号量被初始化
21~28 我们遇到了11.2节中讲解System V信号量的初始化时讨论过的同一竞争状态。为避免该竞争状态,发现文件上锁信号量已存在的任何进程都必须以IPC_STAT命令调用semctl,以查看该信号量的sem_otime值。如果该值不为0,我们就知道创建该信号量的进程已对它初始化,并已调用semop(semop调用在本函数末尾)。如果该信号量的sem_otime值仍为0(这种情况应该非常罕见),我们就sleep 1秒再尝试。我们限制了尝试次数,避免发生永久睡眠。
初始化sembuf结构
33~38 我们早先提及,sembuf结构中各成员的排列顺序没有保证,因此不能静态地初始化它们。当一个进程首次调用my_lock时,我们分配两个这样的结构,并在运行时填写它们。我们指定了SEM_UNDO标志,这样的话如果某个进程在持有锁期间终止了,内核会释放该锁(见习题10.3)。
在首次使用时创建一个信号量很容易(每个进程尝试创建它,但是如果它已经存在,那就忽略所产生的错误),然而在所有进程都完成后将它删除要困难得多。在使用序列号文件分配作业号的打印机守护进程例子中,信号量将一直存在下去。但是其他应用程序可能希望一旦需要上锁的文件被删除,其信号量也被删除。对于这种情况,使用记录锁也许比使用信号量更好。
跟System V消息队列一样,System V信号量也有特定的系统限制,其中大部分源自最初的System V实现(3.8节)。图11-8展示了这些限制。第一栏是含有相应限制值的内核变量的传统System V名字。
图11-8 System V信号量的典型限制值
①每个复旧(undo)结构对应一个进程。——译者注
Digital Unix显然不存在semmnu限制。
例子
图11-9中的程序确定图11-8中给出的各个限制值。
图11-9 确定System V信号量上的系统限制值
图11-9(续)
图11-9(续)
从Posix信号量到System V信号量发生了如下变动。
(1)System V信号量由一组值构成。当指定应用到某个信号量集的一组信号量操作时,要么所有操作都执行,要么一个操作都不执行。
(2)可应用到一个信号量集的每个成员的操作有三种:测试其值是否为0、往其值加一个整数以及从其值中减掉一个整数(假设结果值仍然非负)。Posix信号量所允许的操作只是将其值加1或减1(假设结果值仍然非负)。
(3)创建一个System V信号量集需要技巧,因为创建该集合并随后初始化其各个值需要两个操作,从而可能导致竞争状态。
(4)System V信号量提供“复旧”特性,该特性保证在进程终止时逆转某个信号量操作。
11.1 图6-8是对图6-6的修改,它接受用于指定消息队列的标识符而不是路径名。从中看出访问一个System V消息队列只需知道其标识符(前提是有足够的权限)。对图11-6进行类似的修改,以展示同样的特性也适用于System V信号量。
11.2 如果LOCK_PATH文件不存在,那么图11-7中的函数会发生什么?
[1]. 一个进程可以对某个文件的特定字节范围多次发出F_SETLK或F_SETLKW命令,每次成功与否取决于其他进程当时是否锁住该字节范围以及锁的类型,而与本进程先前是否锁住该字节范围无关。也就是说,后执行的F_SETLK或F_SETLKW命令覆盖先执行的针对同一字节范围的同样两个命令。另外,文件能否读写与相应的记录是否被其他进程锁住无关(前提是劝告性上锁),前者由文件访问权限完全决定。这就是说,一个进程有可能访问已被另一个进程独占地锁住的文件中的记录,不过彼此协作的进程应自觉地不去执行违反上锁要求的访问。——译者注
[2]. 调用进程已持有的针对同一字节范围的锁不会妨碍它获取新锁,因为同一进程内,后执行的获取锁命令覆盖先执行的命令。举例来说,如果一个进程针对同一字节范围先后执行两个命令:F_SETLK(l_type成员为F_WRLCK)和F_GETLK(l_type成员为F_RDLCK),这两个命令之间无其他进程干扰,而且它们都执行成功,那么由F_GETLK返回的l_type成员是F_UNLCK而不是F_WRLCK。——译者注
[3]. 确实如此,甚至于所关闭的描述符先前是在其文件已由本进程(通过该文件的另一个描述符)上锁后才打开也不例外。看来删除锁时关键的是进程ID,而不是引用同一文件的描述符数目及打开目的(只读、只写、读写)。既然锁跟进程ID紧密关联,它不能通过fork由子进程继承也就顺理成章,因为父子进程有不同的进程ID。——译者注
[4]. 为避免在子进程的输出中出现shell提示符,可以在第34行和第35行之间插入一行“wait(NULL); wait(NULL);”以等待两个子进程终止。图9-9、图12-10和图12-12中的程序也有类似情况。
[5]. 试想,消费者消费掉生产者产生的所有数据时,nempty和nstore的值肯定恢复成生产-消费过程尚未开始时的值(分别为NBUFF和0)。也就是说生产-消费过程的结束状态与开始状态是一致的。此后所有生产者线程都执行第47行的Sem_wait,其中有nempty个线程返回,其余线程则阻塞。未阻塞的线程要是不执行第50行的Sem_post,已阻塞的线程将永远阻塞下去。永远阻塞的线程数于是为生产-消费过程结束时的nempty=nproducers-NBUFF。——译者注
[6]. 生产-消费过程刚结束时,nempty和nstored恢复为生产-消费过程尚未开始时的值(分别为NBUFF和0)。生产者函数produce中新增的Sem_post行使得只要有一个生产者线程终止,nstored的值就大于0(当所有生产者线程都终止时,nstored的值将变为nproducers)。由于消费者函数consume中第77行的Sem_wait和第80行的Sem_post匹配成对,因此只要nstored大于0,不论有多少个消费者线程,都能一个也不阻塞地全部终止。所有消费者线程都终止时,所有生产者线程不一定都已终止,但至少有一个已终止。——译者注
[7]. 在调用线程执行本函数期间,系统可能切换针对同一信号量调用sem_open函数的另一个线程来运行。要是把本函数改为先删除关联的System V信号量,再删除辅助文件,那么一旦线程切换发生在这两个删除操作之间,执行sem_open的线程将发现所需的Posix信号量已存在(因为其辅助文件尚未删除),但是与之关联的System V信号量却打不开(因为已被删除了),于是出错。——译者注
[8]. 调用进程终止时,semadj加到相应信号量的semval之上。要是调用进程对某个信号量的全部操作都指定SEM_UNDO标志,那么该进程终止后,该信号量的值就会变得像根本没有运行过该进程一样,这就是复旧(undo)的本意。——译者注