linux下进程间通信的几种主要手段简介:
为了提供与其它系统的兼容性,Linux也支持三种system Ⅴ的进程间通信机制:消息、信号量(semaphores)和共享内存,Linux对这些机制的实施大同小异。我们把信号量、消息和共享内存统称System V IPC的对象,每一个对象都具有同样类型的接口,即系统调用。就像每个文件都有一个打开文件号一样,每个对象也都有唯一的识别号,进程可以通过系统调用传递的识别号来存取这些对象,与文件的存取一样,对这些对象的存取也要验证存取权限,System V IPC可以通过系统调用对对象的创建者设置这些对象的存取权限。
在Linux内核中,System V IPC的所有对象有一个公共的数据结构pc_perm结构,它是IPC对象的权限描述,在linux/ipc.h中定义如下:
struct ipc_perm |
在这个结构中,要进一步说明的是键(key)。键和识别号指的是不同的东西。系统支持两种键:公有和私有。如果键是公有的,则系统中所有的进程通过权限检查后,均可以找到System V IPC 对象的识别号。如果键是公有的,则键值为0,说明每个进程都可以用键值0建立一个专供其私用的对象。注意,对System V IPC对象的引用是通过识别号而不是通过键,从后面的系统调用中可了解这一点。
7.3.1信号量
信号量及信号量上的操作是E.W.Dijkstra 在1965年提出的一种解决同步、互斥问题的较通用的方法,并在很多操作系统中得以实现, Linux改进并实现了这种机制。
信号量(semaphore )实际是一个整数,它的值由多个进程进行测试(test)和设置(set)。就每个进程所关心的测试和设置操作而言,这两个操作是不可中断的,或称“原子”操作,即一旦开始直到两个操作全部完成。测试和设置操作的结果是:信号量的当前值和设置值相加,其和或者是正或者为负。根据测试和设置操作的结果,一个进程可能必须睡眠,直到有另一个进程改变信号量的值。
信号量可用来实现所谓的“临界区”的互斥使用,临界区指同一时刻只能有一个进程执行其中代码的代码段。为了进一步理解信号量的使用,下面我们举例说明。
假设你有很多相互协作的进程,它们正在读或写一个数据文件中的记录。你可能希望严格协调对这个文件的存取,于是你使用初始值为1的信号量,在这个信号量上实施两个操作,首先测试并且给信号量的值减1,然后测试并给信号量的值加1。当第一个进程存取文件时,它把信号量的值减1,并获得成功,信号量的值现在变为0,这个进程可以继续执行并存取数据文件。但是,如果另外一个进程也希望存取这个文件,那么它也把信号量的值减1,结果是不能存取这个文件,因为信号量的值变为-1。这个进程将被挂起,直到第一个进程完成对数据文件的存取。当第一个进程完成对数据文件的存取,它将增加信号量的值,使它重新变为1,现在,等待的进程被唤醒,它对信号量的减1操作将获得成功。
上述的进程互斥问题,是针对进程之间要共享一个临界资源而言的,信号量的初值为1。实际上,信号量作为资源计数器,它的初值可以是任何正整数,其初值不一定为0或1。另外,如果一个进程要先获得两个或多个的共享资源后才能执行的话,那么,相应地也需要多个信号量,而多个进程要分别获得多个临界资源后方能运行,这就是信号量集合机制,Linux 讨论的就是信号量集合问题。
1. 信号量的数据结构
Linux中信号量是通过内核提供的一系列数据结构实现的,这些数据结构存在于内核空间,对它们的分析是充分理解信号量及利用信号量实现进程间通信的基础,下面先给出信号量的数据结构(存在于include/linux/sem.h中),其它一些数据结构将在相关的系统调用中介绍。
(1)系统中每个信号量的数据结构(sem)
1)系统中每个信号量的数据结构(sem) 2)系统中表示信号量集合(set)的数据结构(semid_ds) |
(4)几个主要数据结构之间的关系
从7.3图可以看出,semid_ds结构的sem_base指向一个信号量数组,允许操作这些信号量集合的进程可以利用系统调用执行操作。注意,信号量信号量集合的区别,从上面可以看出,信号量用“sem” 结构描述,而信量集合用“semid_ds"结构描述,实际上,在后面的讨论中,我们以信号量集合为讨论的主要对象。下面我们给出这几个结构之间的关系,如图7.3所示。
Linux对信号量的这种实现机制,是为了与消息和共享内存的实现机制保持一致,但信号量是这三者中最难理解的,因此我们将结合系统调用做进一步的介绍,通过对系统调用的深入分析,我们可以较清楚地了解内核对信号量的实现机制。
2. 系统调用:semget()
为了创建一个新的信号量集合,或者存取一个已存在的集合,要使用segget()系统调用,其描述如下:
原型: int semget ( key_t key, int nsems, int semflg );
返回值: 如果成功,则返回信号量集合的IPC识别号
如果为-1,则出现错误:
semget()中的第一个参数是键值,这个键值要与已有的键值进行比较,已有的键值指在内核中已存在的其它信号量集合的键值。对信号量集合的打开或存取操作依赖于semflg参数的取值:IPC_CREAT :如果内核中没有新创建的信号量集合,则创建它。
IPC_EXCL :当与IPC_CREAT一起使用时,但信号量集合已经存在,则创建失败。如果IPC_CREAT单独使用,semget()为一个新建的集合返回标识号,或者返回具有相同键值的已存在集合的标识号。如果IPC_EXCL与IPC_CREAT一起使用,要么创建一个新的集合,要么对已存在的集合返回-1。IPC_EXCL单独是没有用的,当与IPC_CREAT结合起来使用时,可以保证新创建集合的打开和存取。作为System V IPC的其它形式,一种可选项是把一个八进制与掩码或,形成信号量集合的存取权限。第三个参数nsems指的是在新创建的集合中信号量的个数。其最大值在“linux/sem.h”中定义:
#define SEMMSL 250 /* <= 8 000 max num of semaphores per id */ |
注意:如果你是显式地打开一个现有的集合,则nsems参数可以忽略。
下面举例说明。
int open_semaphore_set( key_t keyval, int numsems ) |
注意,这个例子显式地用了0660权限。这个函数要么返回一个集合的标识号,要么返回-1而出错。键值必须传递给它,信号量的个数也传递给它,这是因为如果创建成功则要分配空间。
3. 系统调用: semop()
原型: int semop ( int semid, struct sembuf *sops, unsignednsops);
返回: 如果所有的操作都执行,则成功返回0。
如果为-1,则出错。
semop()中的第一个参数(semid)是集合的识别号(可以由semget()系统调用得到)。第二个参数(sops)是一个指针,它指向在集合上执行操作的数组。而第三个参数(nsop)是在那个数组上操作的个数。sops参数指向类型为sembuf的一个数组,这个结构在/inclide/linux/sem.h 中声明,是内核中的一个数据结构,描述如下:
struct sembuf { |
如果sem_op为负数,那么就从信号量的值中减去sem_op的绝对值,这意味着进程要获取资源,这些资源是由信号量控制或监控来存取的。如果没有指定IPC_NOWAIT,那么调用进程睡眠到请求的资源数得到满足(其它的进程可能释放一些资源)。
如果sem_op是正数,把它的值加到信号量,这意味着把资源归还给应用程序的集合。
最后,如果sem_op为0,那么调用进程将睡眠到信号量的值也为0,这相当于一个信号量到达了100%的利用。
综上所述,Linux 按如下的规则判断是否所有的操作都可以成功:操作值和信号量的当前值相加大于 0,或操作值和当前值均为 0,则操作成功。如果系统调用中指定的所有操作中有一个操作不能成功时,则 Linux 会挂起这一进程。但是,如果操作标志指定这种情况下不能挂起进程的话,系统调用返回并指明信号量上的操作没有成功,而进程可以继续执行。如果进程被挂起,Linux 必须保存信号量的操作状态并将当前进程放入等待队列。为此,Linux 内核在堆栈中建立一个 sem_queue 结构并填充该结构。新的 sem_queue 结构添加到集合的等待队列中(利用 sem_pending 和 sem_pending_last 指针)。当前进程放入 sem_queue 结构的等待队列中(sleeper)后调用调度程序选择其他的进程运行。
为了进一步解释semop()调用,让我们来看一个例子。假设我们有一台打印机,一次只能打印一个作业。我们创建一个只有一个信号量的集合(仅一个打印机),并且给信号量的初值为1(因为一次只能有一个作业)。
每当我们希望把一个作业发送给打印机时,首先要确定这个资源是可用的,可以通过从信号量中获得一个单位而达到此目的。让我们装载一个sembuf数组来执行这个操作:
struct sembuf sem_lock = { 0, -1, IPC_NOWAIT }; |
从这个初始化结构可以看出,0表示集合中信号量数组的索引,即在集合中只有一个信号量,-1表示信号量操作(sem_op),操作标志为IPC_NOWAIT,表示或者调用进程不用等待可立即执行,或者失败(另一个进程正在打印)。下面是用初始化的sembuf结构进行semop()系统调用的例子:
if((semop(sid, &sem_lock, 1) == -1) |
第三个参数(nsops)是说我们仅仅执行了一个操作(在我们的操作数组中只有一个sembuf结构),sid参数是我们集合的IPC识别号。
当我们使用完打印机,我们必须把资源返回给集合,以便其它的进程使用。
struct sembuf sem_unlock = { 0, 1, IPC_NOWAIT }; |
上面这个初始化结构表示,把1加到集合数组的第0个元素,换句话说,一个单位资源返回给集合。
4. 系统调用 : semctl()
原型: int semctl ( int semid, int semnum, int cmd, unionsemun arg );
返回值: 成功返回正数,出错返回-1。
注意:semctl()是在集合上执行控制操作。
semctl()的第一个参数(semid)是集合的标识号,第二个参数(semnn)是将要操作的信号量个数,从本质上说,它是集合的一个索引,对于集合上的第一个信号量,则该值为0。
·cmd参数表示在集合上执行的命令,这些命令及解释如表7.2所示:
·arg参数的类型为semun,这个特殊的联合体在include/linux/sem.h中声明,对它的描述如下:
/* arg for semctl system calls. */ |
这个联合体中,有三个成员已经在表7-1中提到,剩下的两个成员_buf 和_pad用在内核中信号量的实现代码,开发者很少用到。事实上,这两个成员是Linux操作系统所特有的,在UINX中没有。这个系统调用比较复杂,我们举例说明。
下面这个程序段返回集合上索引为semnum对应信号量的值。当用GETVAL命令时,最后的参数(semnum)被忽略。
int get_sem_val( int sid, int semnum ) |
关于信号量的三个系统调用,我们进行了详细的介绍。从中可以看出,这几个系统调用的实现和使用都和系统内核密切相关,因此,如果在了解内核的基础上,再理解系统调用,相对要简单地多,也深入地多。
5. 死锁
和信号量操作相关的概念还有“死锁”。当某个进程修改了信号量而进入临界区之后,却因为崩溃或被“杀死(kill)"而没有退出临界区,这时,其他被挂起在信号量上的进程永远得不到运行机会,这就是所谓的死锁。Linux 通过维护一个信号量数组的调整列表(semadj)来避免这一问题。其基本思想是,当应用这些“调整”时,让信号量的状态退回到操作实施前的状态。
关于调整的描述是在sem_undo数据结构中,在include/linux/sem.h描述如下:
/*每一个任务都有一系列的恢复(undo)请求,当进程退出时,自动执行undo请求*/ |
sem_undo结构也出现在task_struct数据结构中。
每一个单独的信号量操作也许要请求得到一次“调整”,Linux将为每一个信号量数组的每一个进程维护至少一个sem_undo结构。如果请求的进程没有这个结构,当必要时则创建它,新创建的sem_undo数据结构既在这个进程的task_struct数据结构中排队,也在信号量数组的semid_ds结构中排队。当对信号量数组上的一个信号量施加操作时,这个操作值的负数与这个信号量的“调整”相加,因此,如果操作值为2,则把-2加到这个信号量的“调整”域。
当进程被删除时,Linux完成了对sem_undo数据结构的设置及对信号量数组的调整。如果一个信号量集合被删除,sem_undo结构依然留在这个进程的task_struct结构中,但信号量集合的识别号变为无效。
7.3.2 消息队列
一个或多个进程可向消息队列写入消息,而一个或多个进程可从消息队列中读取消息,这种进程间通讯机制通常使用在客户/服务器模型中,客户向服务器发送请求消息,服务器读取消息并执行相应请求。在许多微内核结构的操作系统中,内核和各组件之间的基本通讯方式就是消息队列。例如,在 MINIX 操作系统中,内核、I/O 任务、服务器进程和用户进程之间就是通过消息队列实现通讯的。
Linux中的消息可以被描述成在内核地址空间的一个内部链表,每一个消息队列由一个IPC的标识号唯一的标识。Linux 为系统中所有的消息队列维护一个 msgque 链表,该链表中的每个指针指向一个 msgid_ds 结构,该结构完整描述一个消息队列。
1. 数据结构
(1)消息缓冲区(msgbuf)
我们在这里要介绍的第一个数据结构是msgbuf结构,可以把这个特殊的数据结构看成一个存放消息数据的模板,它在include/linux/msg.h中声明,描述如下:
/* msgsnd 和msgrcv 系统调用使用的消息缓冲区*/ |
注意:对于消息数据元素(mtext),不要受其描述的限制。实际上,这个域(mtext)不仅能保存字符数组,而能保存任何形式的任何数据。这个域本身是任意的,因为这个结构本身可以由应用程序员重新定义:
struct my_msgbuf { |
我们看到,消息的类型还是和前面一样,但是结构的剩余部分由两个其它的元素代替,而且有一个是结构。这就是消息队列的优美之处,内核根本不管传送的是什么样的数据,任何信息都可以传送。
但是,消息的长度还是有限制的,在Linux中,给定消息的最大长度在include/linux/msg.h中定义如下:
#define MSGMAX 8192 /* max size of message (bytes) */ |
消息总的长度不能超过8192字节,包括mtype域,它是4字节长。
(2)消息结构(msg)
内核把每一条消息存储在以msg结构为框架的队列中,它在include/linux/msg.h中定义如下:
struct msg { |
注意:msg_next是指向下一条消息的指针,它们在内核地址空间形成一个单链表。
(3)消息队列结构(msgid_ds)
当在系统中创建每一个消息队列时,内核创建、存储及维护这个结构的一个实例。
/* 在系统中的每一个消息队列对应一个msqid_ds 结构 */ |
2. 系统调用: msgget()
为了创建一个新的消息队列,或存取一个已经存在的队列,要使用msgget()系统调用。
原型: int msgget ( key_t key, int msgflg );
返回: 成功,则返回消息队列识别号,失败,则返回-1,
semget()中的第一个参数是键,这个键值要与现有的键值进行比较,现有的键值指在内核中已存在的其它消
息队列的键值。对消息队列的打开或存取操作依赖于msgflg参数的取值:
IPC_CREAT :如果这个队列在内核中不存在,则创建它。
IPC_EXCL :当与IPC_CREAT一起使用时,如果这个队列已存在,则创建失败。
如果IPC_CREAT单独使用,semget()为一个新创建的消息队列返回标识号,或者返回具有相同键值的已存在队列的标识号。如果IPC_EXCL与IPC_CREAT一起使用,要么创建一个新的队列,要么对已存在的队列返回-1。
IPC_EXCL单独是没有用的,当与IPC_CREAT结合起来使用时,可以保证新创建队列的打开和存取。
与文件系统的存取权限一样,每一个IPC对象也具有存取权限,因此,可以把一个八进制与掩码或,形成对消息队列的存取权限。
让我们来创建一个打开或创建消息队列的函数:
int open_queue( key_t keyval ) |
注意,这个例子显式地用了0660权限。这个函数要么返回一个消息队列的标识号,要么返回-1而出错。键值作为唯一的参数必须传递给它。
3. 系统调用: msgsnd()
一旦我们有了队列识别号,我们就可以在这个队列上执行操作。要把一条消息传递给一个队列,你必须用msgsnd()系统调用。
原型:int msgsnd ( int msqid, struct msgbuf *msgp, int msgsz,int msgflg );
返回:成功为0,失败为-1。
msgsnd()的第一个参数是队列识别号,由msgget()调用返回。第二个参数msgp是一个指针,指向我们重新声和装载的消息缓冲区。msgsz参数包含了消息以字节为单位的长度,其中包括了消息类型的4个字节。
msgflg参数可以设置成0(忽略),或者:
IPC_NOWAIT :如果消息队列满,消息不写到队列中,并且控制权返回给调用进程(继续执行)。如果不指定IPC_NOWAIT,调用进程将挂起(阻塞)直到消息被写到队列中。
让我们来看一个发送消息的简单函数:
int send_message( int qid, struct mymsgbuf *qbuf ) |
这个小函数试图把缓冲区qbuf中的消息,发送给队列识别号为qid的消息队列。
现在,我们在消息队列里有了一条消息,可以用ipcs命令来看你队列的状态。如何从消息队列检索消息,可以用msgrcv()系统调用。
4.系统调用:msgrcv()
原型:int msgrcv ( int msqid, struct msgbuf *msgp, int msgsz,long mtype, int msgflg );
返回值:成功,则为拷贝到消息缓冲区的字节数,失败为-1。
很明显,第一个参数用来指定要检索的队列(必须由msgget()调用返回),第二个参数(msgp)是存放检索到消息的缓冲区的地址,第三个参数(msgsz)是消息缓冲区的大小,包括消息类型的长度(4字节)。
第四个参数(mtype)指定了消息的类型。内核将搜索队列中相匹配类型的最早的消息,并且返回这个消息的一个拷贝,返回的消息放在由msgp参数指向的地址。这里存在一个特殊的情况,如果传递给mytype参数的值为0,就可以不管类型,只返回队列中最早的消息。
如果传递给参数msgflg的值为IPC_NOWAIT,并且没有可取的消息,那么给调用进程返回ENOMSG错误消息,否则,调用进程阻塞,直到一条消息到达队列并且满足msgrcv()的参数。如果一个客户正在等待消息,而队列被删除,则返回EIDRM。如果当进程正在阻塞,并且等待一条消息到达但捕获到了一个信号,则返回EINTR。
让我们来看一个从我们已建的消息队列中检索消息的例子
int read_message( int qid, long type, struct mymsgbuf *qbuf ) |
当从队列中成功地检索到消息后,这个消息将从队列删除。
7.3.3 共享内存
共享内存可以被描述成内存一个区域(段)的映射,这个区域可以被更多的进程所共享。这是IPC机制中最快的一种形式,因为它不需要中间环节,而是把信息直接从一个内存段映射到调用进程的地址空间。一个段可以直接由一个进程创建,随后,可以有任意多的进程对其读和写。但是,一旦内存被共享之后,对共享内存的访问同步需要由其他 IPC 机制,例如信号量来实现。象所有的System V IPC 对象一样,Linux 对共享内存的存取是通过对访问键和访问权限的检查来控制的。
1. 数据结构
与消息队列和信号量集合类似,内核为每一个共享内存段(存在于它的地址空间)维护着一个特殊的数据结构shmid_ds,这个结构在include/linux/shm.h中定义如下:
/* 在系统中 每一个共享内存段都有一个shmid_ds数据结构. */ |
2. 共享内存的处理过程
某个进程第一次访问共享虚拟内存时将产生缺页异常。这时,Linux 找出描述该内存的 vm_area_struct 结构,该结构中包含用来处理这种共享虚拟内存段的处理函数地址。共享内存缺页异常处理代码对shmid_ds 的页表项表进行搜索,以便查看是否存在该共享虚拟内存的页表项。如果没有,系统将分配一个物理页并建立页表项,该页表项加入 shmid_ds 结构的同时也添加到进程的页表中。这就意味着当下一个进程试图访问这页内存时出现缺页异常,共享内存的缺页异常处理代码则把新创建的物理页给这个进程。因此说,第一个进程对共享内存的存取引起创建新的物理页面,而其它进程对共享内存的存取引起把那个页加添加到它们的地址空间。
当某个进程不再共享其虚拟内存时,利用系统调用将共享段从自己的虚拟地址区域中移去,并更新进程页表。当最后一个进程释放了共享段之后,系统将释放给共享段所分配的物理页。
当共享的虚拟内存没有被锁定到物理内存时,共享内存也可能会被交换到交换区中。
3. 系统调用:shmget()
原型:int shmget ( key_t key, int size, int shmflg );
返回:成功,则返回共享内存段的识别号, 失败返回-1
shmget()系统调用类似于信号量和消息队列的系统调用,在此不进一步赘述。
4. 系统调用:shmat()
原型: int shmat ( int shmid, char *shmaddr, int shmflg);
返回:成功,则返回附加到进程的那个段的地址,失败返回-1。
其中shmid是由shmget()调用返回的共享内存段识别号,shmaddr是你希望共享段附加的地址,shmflag允许你规定希望所附加的段为只读(利用SHM_RDONLY)以代替读写。通常,并不需要规定你自己的shmaddr,可以用传递参数值零使得系统为你取得一个地址。
这个调用可能是最简单的,下面看一个例子,把一个有效的识别号传递给一个段,然后返回这个段被附加到内存的内存地址。
char *attach_segment( int shmid ) |
一旦一个段适当地被附加,并且一个进程有指向那个段起始地址的一个指针,那么,对那个段的读写就变得相当容易。
5. 系统调用: shmctl()
原型: int shmctl ( int shmqid, int cmd, struct shmid_ds *buf);
返回:成功为 0 ,失败为-1
这个特殊的调用和semctl()调用几乎相同,因此,这里不进行详细的讨论。有效命令的值是:
IPC_STAT :检索一个共享段的shmid_ds结构,把它存到buf参数的地址中。
IPC_SET :对一个共享段来说,从buf 参数中取值设置shmid_ds结构的ipc_perm域的值。
IPC_RMID :把一个段标记为删除
IPC_RMID 命令实际上不从内核删除一个段,而是仅仅把这个段标记为删除,实际的删除发生在最后一个进程离开这个共享段时。
当一个进程不再需要共享内存段时,它将调用shmdt()系统调用取消这个段,但是,这并不是从内核真正地删除这个段,而是把相关shmid_ds结构的 shm_nattch域的值减1,当这个值为0时,内核才从物理上删除这个共享段。