进程间通信的机制,它们最初由AT&T System V.2版本的UNIX引IPC (Inter-Process Communication,进程间通信)机制,或被更常见的称为System V IPC。正如我们所看到的,它们并不是进程间通信的唯一方法,但人们通常把这些特定的机制称为System V IPC。
信号量
当我们编写的程序使用了线程时,不管它是运行在多用户系统上、多进程系统上,还是运行在多用户多进程系统上,我们通常会发现,程序中存在着一部分临界代码,我们需要确保只有一个进程(或一个执行线程)可以进入这个临界代码并拥有对资源独占式的访问权。信号量有着复杂的编程接口,但幸运的是,我们可以很轻松地为自己提供一个更简单的接口,它足够应付大多数信号量编程的问题。
荷兰计算机科学家Edsger Dijkstra提出的信号量概念是在并发编程领域迈出的重要一步。信号量是一个特殊的变量,它只取正整数值,并且程序对其访问都是原子操作。
信号量的一个更正式的定义是:它是一个特殊变量,只允许对它进行等待( wait)和发送信号( signal)这两种操作。因为在Linux编程中,“等待”和“发送信号”都已具有特殊的含义,所以我们将用原先定义的符号来表示这两种操作。
- P(信号量变量):用于等待。
- V(信号量变量):用于发送信号。
这两个字母分别来自于荷兰语单词passeren(传递,就好像位于进入临界区域之前的检查点)和vrijigeven(给予或释放,就好像放弃对临界区域的控制权)。在与信号量关联的内容中,你可能还会看到术语“开”(up)和“关”( down),它们取自开、关信号标志的用法。
信号量的定义
最简单的信号量是只能取值0和1的变量,即二进制信号量。这也是信号量最常见的一种形式。可以取多个正整数值的信号量被称为通用信号量。 PV操作的定义非常简单。假设有一个信号量变量sv,则这两个操作的定义如表所示。
还可以这样看信号量:当临界区域可用时,信号量变量sv的值是true,然后P(sv)操作将它减1使它变为false以表示临界区域正在被使用:当进程离开临界区域时,使用V(sv)操作将它加1,使临界区域再次变为可用。注意,只用一个普通变量进行类似的加减法是不行的,因为在C、C++、C#或几乎任何一个传统的编程语言中,都没有一个原子操作可以满足检测变量是否为true,如果是再将该变量设置为false的需要。这也是信号量操作如此特殊的原因。
一个理论性的例子
我们用一个简单的理论性的例子来说明其工作原理。假设有两个进程proc1和proc2,这两个进程都需要在其执行过程中的某一时刻对一个数据库进行独占式的访问。我们定义一个二进制信号量sv,该变量的初始值为1,两个进程都可以访问它。要想对代码中的临界区域进行访问,这两个进程都需要执行相同的处理步骤,事实上,这两个进程可以只是同一个程序的两个不同执行实例。
两个进程共享信号量变量sv。一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进入临界区域。而第二个进程将被阻止进入临界区域,因为当它试图执行P(sv)操作时,它会被挂起以等待第一个进程离开临界区域并执行v(sv)操作释放信号量。 需要的伪代码对两个进程都是相同的,如下所示:
semaphore sv = 1;
loop forever
{
P(sv) ;
critical code section ;
V(sv) ;
noncritical code section;
}
这段代码相当简单,这是因为PV操作的功能非常强大。下图显示了Pv操作是如何把守代码中的临界区域的。
Linux的信号量机制
现在,我们已了解了信号量的含义及其工作原理,接下来我们来看看,在Linux系统中是如何实现这些功能的。Linux系统中的信号量接口经过了精心设计,它提供了比通常所需更多的机制。所有的Linux信号量函数都是针对成组的通用信号量进行操作,而不是只针对一个二进制信号量。乍看起来,这好像把事情弄得更复杂了,但在一个进程需要锁定多个资源的复杂情况中,这种能够对一组信号量进行操作的能力是一个巨大的优势。在本章中,我们将集中讨论单个信号量的使用,因为在绝大多数情况下,使用它就足够了。 信号量函数的定义如下所示:
#include <sys/sem.h>
int semctl(int sem_id,int sem_num,int command,...);
int semget(key_t key,int num_sems,int sem_flags);
int semop(int sem_id,struct sembuf *sem_ops, size_t num_sem_ops);
头文件sys/sem.h通常依赖于另两个头文件sys/types.h和sys/ipc.h。一般情况下,它们都会被sys/sem.h自动包含,因此不需要为它们明确添加相应的#include语句。
在逐个介绍这些函数时,请记住,这些函数都是用来对成组的信号量值进行操作的。这使得,对它们的操作要比单个信号量所需要的操作复杂得多。 参数key的作用很像一个文件名,它代表程序可能要使用的某个资源,如果多个程序使用相同的key值,它将负责协调工作。与此类似,由semget函数返回的并用在其他共享内存函数中的标识符也与fopen返回的FILE*文件流很相似,进程需要通过它来访问共享文件。此外,类似于文件的使用情况,不同的进程可以用不同的信号量标识符来指向同一个信号量。对于我们将在本章讨论的所有IPC机制来说,这种一个键加上一个标识符的用法是很常见的,尽管每个机制都使用独立的键和标识符。
semget函数
semget函数的作用是创建一个新信号量或取得一个已有信号量的键:
int semget (key_t key, int num_sems, int sem_flags);
第一个参数key是整数值,不相关的进程可以通过它访问同一个信号量。程序对所有信号量的访问都是间接的,它先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才直接使用信号量键,所有其他的信号量函数都是使用由semget函数返回的信号量标识符。
有一个特殊的信号量键值IPC_PRIVATE,它的作用是创建一个只有创建者进程才可以访问的信号量,但这个键值很少有实际的用途。在创建新的信号量时,你需要给键提供一个唯一的非零整数。
第二个参数num_sems参数指定需要的信号量数目。它几乎总是取值为1. 第三个参数sem_flags参数是一组标志,它与open函数的标志非常相似。它低端的9个比特是该信号量的权限,其作用类似于文件的访问权限。此外,它们还可以和值IPC_CREAT做按位或操作,来创建一个新信号量。即使在设置了IPC_CREAT标志后给出的键是一个已有信号量的键,也不会产生错误。如果函数用不到IPC_CREAT标志,该标志就会被悄悄地忽略掉。我们可以通过联合使用标志IPC_CREAT和IPC_EXCL来确保创建出的是一个新的、唯一的信号量。如果该信号量已存在,它将返回一个错误。 semget函数在成功时返回一个正数(非零)值,它就是其他信号量函数将用到的信号量标识符。如果失败,则返回-1。
semop函数
semop函数用于改变信号量的值,它的定义如下所示:
int semop(int sem_id,struct sembuf *sem_ops,size_t num_sem_ops);
第一个参数sem_id是由semget返回的信号量标识符。 第二个参数sem_ops是指向一个结构数组的指针,每个数组元素至少包含以下几个成员:
struct sembuf
{
short sem_num;
short sem_op;
short sem_f1g;
)
第一个成员sem_num是信号量编号,除非你需要使用一组信号量,否则它的取值一般为0。sem_op成员的值是信号量在一次操作中需要改变的数值(你可以用一个非1的数值来改变信号量的值)。通常只会用到两个值,一个是-1,也就是p操作,它等待信号量变为可用;一个是+1,也就是v操作,它发送信号表示信号量现在已可用。 最后一个成员sem_flg通常被设置为SEM_UNDO。它将使得操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。除非你对信号量的行为有特殊的要求,否则应该养成设置sem_flg为SEM_UNDO的好习惯。如果决定使用一个非SEM_UNDO的值,那就一定要注意保持设置的一致性,否则你很可能会搞不清楚内核是否会在进程退出时清理信号量。 semop调用的一切动作都是一次性完成的,这是为了避免出现因使用多个信号量而可能发生的竞争现象。semop的处理细节可以在手册页中找到。
semctl函数
semctl函数用来直接控制信号量信息,它的定义如下所示:
int semctl(int sem_ia, int sem_num, int command, ...);
第一个参数sem_id是由semget返回的信号量标识符。 第二个参数sem_num参数是信号量编号,当需要用到成组的信号量时,就要用到这个参数,它一般取值为0,表示这是第一个也是唯一的一个信号量。 第三个command参数是将要采取的动作。如果还有第四个参数,它将会是一个union semun结构,根据X/OPEN规范的定义,它至少包含以下几个成员:
union semun
{
int val;
struct semid_ds *buf;
unsigned short *array;
)
虽然X/Open规范中指出,semun联合结构必须由程序员自己定义,但大多数Linux版本会在某个头文件(一般是sem.h)中给出该结构的定义。如果你发现确实需要自己来定义该结构,请查阅semctl的手册页,看手册中是否已给出了定义。如果有,我们建议使用手册中给出的定义,即使它与这里给出的定义不一致也应该如此。
semctl函数中的cormmand参数可以设置许多不同的值,但只有下面介绍的两个值最常用。semctl函数的完整细节请查阅它的手册页。 SETVAL:用来把信号量初始化为一个已知的值。这个值通过union semun中的val成员设置。其作用是在信号量第一次使用之前对它进行设置。 IPC_RMID:用于删除一个已经无需继续使用的信号量标识符。 semctl函数将根据command参数的不同而返回不同的值。对于SETVAL和IPC_RMID,成功时返回0,失败时返回-1。
信号量的使用
从上一节的介绍可以看出,信号量的操作相当复杂。这可不是一个好消息,因为编写包含临界区域的多进程或多线程程序本身就是一件非常困难的事情,再加上一个如此复杂的编程接口,这就更增添了编程者的精神负担。
幸运的是,大部分需要使用信号量来解决的问题只需使用一个最简单的二进制信号量即可。在下面的例子中,我们将用完整的编程接口为二进制信号量创建一个简单得多的PV类型接口,然后用这个非常简单的接口来演示信号量是如何工作的。
我们用两个不同字符的输出来表示进入和离开临界区域。如果程序启动时带有一个参数,它将在进入和退出临界区域时打印字符X;而程序的其他运行实例将在进入和退出临界区域时打印字符O。因为在任一给定时刻,只能有一个进程可以进入临界区域,所以字符X和O应该是成对出现的。
(1)我们首先对上述函数进行简单的封装,只是头文件,我所使用的系统并没有给我提供semun联合体,所以我自己定义了个,主要用来给信号量赋初值,并且由于我这个实验只使用了一个简单的二值信号量,所以将其设置了为全局变量,方便其他函数调用。
#include<sys/sem.h>
#include<unistd.h>
#include<stdio.h>
union semun
{
int val;
};
static int sem_id = 0;
voidO sem_init();
void sem_p();
void sem_v();
void sem_destroy();
具体实现:
void sem_init()
{
sem_id = semget((key_t)1234,1,IPC_CREAT|IPC_EXCL|0600);
if(sem_id == -1)
{
sem_id = semget((key_t)1234,1,0600);
}
else
{
union semun a;
a.val = 1;
if(semctl(sem_id,0,SETVAL,a) == -1 )
{
printf("error set\n");
}
}
}
void sem_p()
{
struct sembuf buf;
buf.sem_num = 0;
buf.sem_op = -1;
buf.sem_flg = SEM_UNDO;
if(semop(sem_id,&buf,1) == -1)
{
printf("error p\n");
}
}
void sem_v()
{
struct sembuf buf;
buf.sem_num = 0;
buf.sem_op = 1;
buf.sem_flg = SEM_UNDO;
if(semop(sem_id,&buf,1) == -1)
{
printf("error v\n");
}
}
void sem_destroy()
{
if( semctl(sem_id,0,IPC_RMID) == -1 )
{
printf("error remove");
}
}
主程序:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include "sem.h"
int main(int argc , char *argv[])
{
char out = 'O';
if(argc > 1)
{
out = 'X';
}
sem_init();
for(int i=0;i<10;i++)
{
sem_p();
printf("%c",out);
fflush(stdout);
sleep(1);
printf("%c",out);
fflush(stdout);
sem_v();
sleep(1);
}
if(argc > 1)
{
sleep(10);
sem_destroy();
}
return 0;
}
结果:
在程序的开始,我们用semget函数(sem_init封装的)通过一个(随意选取的)键来取得一个信号量标识符。IPC_CREAT标志的作用是:如果信号量不存在,就创建它 并赋初值。
如果程序带有一个参数,它就负责信号量的销毁工作。程序还将根据是否带有参数来决定需要打印哪个字符。sleep函数的作用是,让我们有时间在这个程序实例执行太多次循环之前调用其他的程序实例。
接下来程序循环10次,在临界区域和非临界区域会分别暂停一段随机的时间。临界区域由sem_p和sem_v函数前后把守,它们是更通用的semop函数的简化接口。
删除信号量之前,带有参数启动的程序会进入等待状态,以允许其他调用实例都执行完毕。如果不删除信号量,它将继续在系统中存在,即使没有程序在使用它也是如此。在实际的编程中,我们需要特别小心,不要无意之中在执行结束之后还留下信号量未删除。它可能会在你下次运行此程序时引发问题,而且信号量也是一种有限的资源,需要大家节约使用。
|