14.1 信号量

当我们编写的程序使用了线程时,不管它是运行在多用户系统上、多进程系统上,还是运行在多用户多进程系统上,我们通常会发现,程序中存在着一部分临界代码,我们需要确保只有一个进程(或一个执行线程)可以进入这个临界代码并拥有对资源独占式的访问权。

信号量有着复杂的编程接口,但幸运的是,我们可以很轻松地为自己提供一个更简单的接口,它足够应付大多数信号量编程的问题。

第7章的第一个示例程序用dbm来访问数据库。如果有多个程序试图在同一时间更新这个数据库,数据就可能会遭到破坏。两个不同的程序要求不同的用户向数据库输入数据,这本身并没有错,问题只可能出现在对数据库进行更新的那部分代码上。这部分真正执行数据更新的代码需要独占式地执行,它们被称为临界区域。它们通常只在一个大型程序中占据一小段的代码。

为了防止出现因多个程序同时访问一个共享资源而引发的问题,我们需要有一种方法,它可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域。在第12章我们简单介绍了一些线程特定的方法,我们可以在使用线程的程序中通过互斥量或信号量来控制对临界区域的访问。在本章中,我们又回到信号量的主题上,但将对它们如何在不同的进程之间使用做更具普遍意义地介绍。

我们在本章介绍的信号量函数比在第12章看到的用于线程的信号量函数要更通用,所以请不要把这两者混淆。

要想编写通用的代码,以确保程序对某个特定的资源具有独占式的访问权是非常困难的。虽然有一个名为Dekker算法的解决方法,但这个算法依赖于“忙等待”或“自旋锁”。也就是说,一个进程要持续不断地运行以等待某个内存位置被改变。在像Linux这样的多任务环境中,人们并不愿意使用这种浪费CPU资源的处理方法。但如果硬件支持独占式访问(一般是通过特定的CPU指令的形式),那么情况就变得简单多了。一个硬件支持的例子就是,用一条指令以原子方式访问并增加寄存器的值,在这个读取/增加/写入操作执行的过程中不会有其他指令(甚至一个中断)发生。

我们前面见过的一种可能的解决方法是,使用带O_EXCL标志的open函数来创建锁文件,它提供了原子化的文件创建方法。它允许一个进程通过获取一个令牌(即新创建的文件)来取得成功。这个方法比较适合于处理简单的问题,但对于更复杂的例子,它就显得比较杂乱且缺乏效率。

荷兰计算机科学家Edsger Dijkstra提出的信号量概念是在并发编程领域迈出的重要一步。正如我们在第12章所讨论的,信号量是一个特殊的变量,它只取正整数值,并且程序对其访问都是原子操作。在本章中,我们将对这个较早的简化定义做进一步的解释。我们将详细说明信号量是如何工作的,如何在不同进程之间使用具备更通用功能的函数,而不是像我们在第12章中看到的那个多线程程序的特例。

信号量的一个更正式的定义是:它是一个特殊变量,只允许对它进行等待(wait)和发送信号(signal)这两种操作。因为在Linux编程中,“等待”和“发送信号”都已具有特殊的含义,所以我们将用原先定义的符号来表示这两种操作。

❑ P(信号量变量):用于等待。

❑ V(信号量变量):用于发送信号。

这两个字母分别来自于荷兰语单词passeren(传递,就好像位于进入临界区域之前的检查点)和vrijgeven(给予或释放,就好像放弃对临界区域的控制权)。在与信号量关联的内容中,你可能还会看到术语“开”(up)和“关”(down),它们取自开、关信号标志的用法。

14.1.1 信号量的定义

最简单的信号量是只能取值0和1的变量,即二进制信号量。这也是信号量最常见的一种形式。可以取多个正整数值的信号量被称为通用信号量。在本章后面的内容中,我们将集中讨论二进制信号量。

PV操作的定义非常简单。假设有一个信号量变量sv,则这两个操作的定义如表14-1所示。

表 14-1

14.1 信号量 - 图1

还可以这样看信号量:当临界区域可用时,信号量变量sv的值是true,然后P(sv)操作将它减1使它变为false以表示临界区域正在被使用;当进程离开临界区域时,使用V(sv)操作将它加1,使临界区域再次变为可用。注意,只用一个普通变量进行类似的加减法是不行的,因为在C、C++、C#或几乎任何一个传统的编程语言中,都没有一个原子操作可以满足检测变量是否为true,如果是再将该变量设置为false的需要。这也是信号量操作如此特殊的原因。

14.1.2 一个理论性的例子

我们用一个简单的理论性的例子来说明其工作原理。假设有两个进程proc1和proc2,这两个进程都需要在其执行过程中的某一时刻对一个数据库进行独占式的访问。我们定义一个二进制信号量sv,该变量的初始值为1,两个进程都可以访问它。要想对代码中的临界区域进行访问,这两个进程都需要执行相同的处理步骤,事实上,这两个进程可以只是同一个程序的两个不同执行实例。

两个进程共享信号量变量sv。一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进入临界区域。而第二个进程将被阻止进入临界区域,因为当它试图执行P(sv)操作时,它会被挂起以等待第一个进程离开临界区域并执行V(sv)操作释放信号量。

需要的伪代码对两个进程都是相同的,如下所示:

14.1 信号量 - 图2

这段代码相当简单,这是因为PV操作的功能非常强大。图14-1显示了PV操作是如何把守代码中的临界区域的。

14.1 信号量 - 图3

图 14-1

14.1.3 Linux的信号量机制

现在,我们已了解了信号量的含义及其工作原理,接下来我们来看看,在Linux系统中是如何实现这些功能的。Linux系统中的信号量接口经过了精心设计,它提供了比通常所需更多的机制。所有的Linux信号量函数都是针对成组的通用信号量进行操作,而不是只针对一个二进制信号量。乍看起来,这好像把事情弄得更复杂了,但在一个进程需要锁定多个资源的复杂情况中,这种能够对一组信号量进行操作的能力是一个巨大的优势。在本章中,我们将集中讨论单个信号量的使用,因为在绝大多数情况下,使用它就足够了。

信号量函数的定义如下所示:

14.1 信号量 - 图4

头文件sys/sem.h通常依赖于另两个头文件sys/types.h和sys/ipc.h。一般情况下,它们都会被sys/sem.h自动包含,因此不需要为它们明确添加相应的#include语句。

在逐个介绍这些函数时,请记住,这些函数都是用来对成组的信号量值进行操作的。这使得,对它们的操作要比单个信号量所需要的操作复杂得多。

参数key的作用很像一个文件名,它代表程序可能要使用的某个资源,如果多个程序使用相同的key值,它将负责协调工作。与此类似,由semget函数返回的并用在其他共享内存函数中的标识符也与fopen返回的FILE*文件流很相似,进程需要通过它来访问共享文件。此外,类似于文件的使用情况,不同的进程可以用不同的信号量标识符来指向同一个信号量。对于我们将在本章讨论的所有IPC机制来说,这种一个键加上一个标识符的用法是很常见的,尽管每个机制都使用独立的键和标识符。

1.semget函数

semget函数的作用是创建一个新信号量或取得一个已有信号量的键:

14.1 信号量 - 图5

第一个参数key是整数值,不相关的进程可以通过它访问同一个信号量。程序对所有信号量的访问都是间接的,它先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才直接使用信号量键,所有其他的信号量函数都是使用由semget函数返回的信号量标识符。

有一个特殊的信号量键值IPC_PRIVATE,它的作用是创建一个只有创建者进程才可以访问的信号量,但这个键值很少有实际的用途。在创建新的信号量时,你需要给键提供一个唯一的非零整数。

num_sems参数指定需要的信号量数目。它几乎总是取值为1。

sem_flags参数是一组标志,它与open函数的标志非常相似。它低端的9个比特是该信号量的权限,其作用类似于文件的访问权限。此外,它们还可以和值IPC_CREAT做按位或操作,来创建一个新信号量。即使在设置了IPC_CREAT标志后给出的键是一个已有信号量的键,也不会产生错误。如果函数用不到IPC_CREAT标志,该标志就会被悄悄地忽略掉。我们可以通过联合使用标志IPC_CREAT和IPC_EXCL来确保创建出的是一个新的、唯一的信号量。如果该信号量已存在,它将返回一个错误。

semget函数在成功时返回一个正数(非零)值,它就是其他信号量函数将用到的信号量标识符。如果失败,则返回-1。

2.semop函数

semop函数用于改变信号量的值,它的定义如下所示:

14.1 信号量 - 图6

第一个参数sem_id是由semget返回的信号量标识符。第二个参数sem_ops是指向一个结构数组的指针,每个数组元素至少包含以下几个成员:

14.1 信号量 - 图7

第一个成员sem_num是信号量编号,除非你需要使用一组信号量,否则它的取值一般为0。sem_op成员的值是信号量在一次操作中需要改变的数值(你可以用一个非1的数值来改变信号量的值)。通常只会用到两个值,一个是-1,也就是P操作,它等待信号量变为可用;一个是+1,也就是V操作,它发送信号表示信号量现在已可用。

最后一个成员sem_flg通常被设置为SEM_UNDO。它将使得操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。除非你对信号量的行为有特殊的要求,否则应该养成设置sem_flg为SEM_UNDO的好习惯。如果决定使用一个非SEM_UNDO的值,那就一定要注意保持设置的一致性,否则你很可能会搞不清楚内核是否会在进程退出时清理信号量。

semop调用的一切动作都是一次性完成的,这是为了避免出现因使用多个信号量而可能发生的竞争现象。semop的处理细节可以在手册页中找到。

3.semctl函数

semctl函数用来直接控制信号量信息,它的定义如下所示:

14.1 信号量 - 图8

第一个参数sem_id是由semget返回的信号量标识符。sem_num参数是信号量编号,当需要用到成组的信号量时,就要用到这个参数,它一般取值为0,表示这是第一个也是唯一的一个信号量。command参数是将要采取的动作。如果还有第四个参数,它将会是一个union semun结构,根据X/OPEN规范的定义,它至少包含以下几个成员:

14.1 信号量 - 图9

虽然X/Open规范中指出,semun联合结构必须由程序员自己定义,但大多数Linux版本会在某个头文件(一般是sem.h)中给出该结构的定义。如果你发现确实需要自己来定义该结构,请查阅semctl的手册页,看手册中是否已给出了定义。如果有,我们建议使用手册中给出的定义,即使它与这里给出的定义不一致也应该如此。

semctl函数中的command参数可以设置许多不同的值,但只有下面介绍的两个值最常用。semctl函数的完整细节请查阅它的手册页。

❑ SETVAL:用来把信号量初始化为一个已知的值。这个值通过union semun中的val成员设置。其作用是在信号量第一次使用之前对它进行设置。

❑ IPC_RMID:用于删除一个已经无需继续使用的信号量标识符。

semctl函数将根据command参数的不同而返回不同的值。对于SETVAL和IPC_RMID,成功时返回0,失败时返回-1。

14.1.4 使用信号量

从上一节的介绍可以看出,信号量的操作相当复杂。这可不是一个好消息,因为编写包含临界区域的多进程或多线程程序本身就是一件非常困难的事情,再加上一个如此复杂的编程接口,这就更增添了编程者的精神负担。

幸运的是,大部分需要使用信号量来解决的问题只需使用一个最简单的二进制信号量即可。在下面的例子中,我们将用完整的编程接口为二进制信号量创建一个简单得多的PV类型接口,然后用这个非常简单的接口来演示信号量是如何工作的。

我们将用程序sem1.c来试验信号量,该程序可以被多次调用。我们通过一个可选的参数来指定程序是负责创建信号量还是负责删除信号量。

我们用两个不同字符的输出来表示进入和离开临界区域。如果程序启动时带有一个参数,它将在进入和退出临界区域时打印字符x;而程序的其他运行实例将在进入和退出临界区域时打印字符o。因为在任一给定时刻,只能有一个进程可以进入临界区域,所以字符x和o应该是成对出现的。

实 验 信号量

(1)在包含了必需的系统头文件之后,我们包含了头文件semun.h。如果系统头文件sys/sem.h没有定义X/OPEN规范所需的联合semun,这个头文件包含了对它的定义。然后是函数原型的声明和全局变量的定义,接着就到了main函数的定义。我们调用semget来创建一个信号量,该函数将返回一个信号量标识符。如果程序是第一个被调用的(也就是说它在被调用时带有一个参数,使得argc>1),就调用set_semvalue初始化信号量并将op_char设置为x:

14.1 信号量 - 图10

(2)接下来是一个循环,它进入和离开临界区域10次。在每次循环的开始,首先调用semaphore_p函数,它在程序将进入临界区域时设置信号量以等待进入:

14.1 信号量 - 图11

(3)在临界区域之后,调用semaphore_v来将信号量设置为可用,然后等待一段随机的时间,再进入下一次循环。在整个循环语句执行完毕后,调用del_semvalue函数来清理代码:

14.1 信号量 - 图12

(4)函数set_semvalue通过将semctl调用的command参数设置为SETVAL来初始化信号量。在使用信号量之前必须要这样做:

14.1 信号量 - 图13

(5)函数del_semvalue的形式与上面的函数几乎一样,只不过它通过将semctl调用的command设置为IPC_RMID来删除信号量ID:

14.1 信号量 - 图14

(6)semaphore_p对信号量做减1操作(等待):

14.1 信号量 - 图15

(7)semaphore_v和semaphore_p类似,不同的是它将sembuf结构中的sem_op设置为1。这是一个“释放”操作,它使信号量变为可用:

14.1 信号量 - 图16

注意,这个简单的程序只允许每个程序有一个二进制信号量。虽然我们可以通过传递信号量变量的方法来扩展它以支持更多的信号量,但通常一个二进制信号量即已足够。

我们可以通过多次启动这个程序的方法来对它进行测试。第一次启动时加上一个参数,表示应该由它来负责创建和删除信号量。其他的调用实例不使用参数。

下面是两个程序调用实例时的一些样本输出:

14.1 信号量 - 图17

请记住,字符“O”和“X”分别代表程序的第一个和第二个调用实例。因为每个程序都在其进入和离开临界区域时打印一个字符,所以每个字符都应该成对出现。如你所见,字符o和x是成对出现的,这表明对临界区域的处理是正确的。如果这个程序在你的系统上不能正常工作,你可能需要在启动程序之前执行命令stty -tostop,以确保产生tty输出的后台程序不会引发系统生成一个信号。

实验解析

在程序的开始,我们用semget函数通过一个(随意选取的)键来取得一个信号量标识符。IPC_CREAT标志的作用是:如果信号量不存在,就创建它。

如果程序带有一个参数,它就负责信号量的初始化工作,这是通过set_semvalue函数来完成的,该函数是针对更通用的semctl函数的简化接口。程序还将根据是否带有参数来决定需要打印哪个字符。sleep函数的作用是,让我们有时间在这个程序实例执行太多次循环之前调用其他的程序实例。我们用函数srand和rand来为程序引入一些伪随机形式的时间分配。

接下来程序循环10次,在临界区域和非临界区域会分别暂停一段随机的时间。临界区域由semaphore_p和semaphore_v函数前后把守,它们是更通用的semop函数的简化接口。

删除信号量之前,带有参数启动的程序会进入等待状态,以允许其他调用实例都执行完毕。如果不删除信号量,它将继续在系统中存在,即使没有程序在使用它也是如此。在实际的编程中,我们需要特别小心,不要无意之中在执行结束之后还留下信号量未删除。它可能会在你下次运行此程序时引发问题,而且信号量也是一种有限的资源,需要大家节约使用。