Bootstrap

Linux 网络编程学习笔记——十三、多进程编程

目录

进程是Linux操作系统环境的基础,它控制着系统上几乎所有的活动。本章从系统程序员的角度来讨论 Linux 多进程编程,包括如下内容:

  • 复制进程映像的 fork 系统调用和替换进程映像的 exec 系列系统调用;
  • 僵尸进程以及如何避免僵尸进程;
  • 进程间通信(Inter-Process Communication,IPC)最简单的方式:管道;
  • 3 种 System V 进程间通信方式:信号量、消息队列和共享内存。它们都是由 AT&T System V2 版本的 UNIX 引入的,所以统称为 System V IPC ;
  • 在进程间传递文件描述符的通用方法:通过 UNIX 本地域 socket 传递特殊的辅助数据。

一、fork 系统调用

Linux 下创建新进程的系统调用是 fork 。其定义如下:

#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);

该函数的每次调用都返回两次,在父进程中返回的是子进程的 PID ,在子进程中则返回 0 。该返回值是后续代码判断当前进程是父进程还是子进程的依据。fork 调用失败时返回 -1 ,并设置 errno 。

fork 函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的 PPID 被设置成原进程的 PID ,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。

子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。即便如此,如果我们在程序中分配了大量内存,那么使用 fork 时也应当十分谨慎,尽量避免没必要的内存分配和数据复制。

此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加 1 。不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加 1 。

二、exec 系列系统调用

有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用如下 exec 系列函数之一:

#include<unistd.h>
extern char**environ; 
int execl(const char*path,const char*arg,...); 
int execlp(const char*file,const char*arg,...); 
int execle(const char*path,const char*arg,...,char*const envp[]); 
int execv(const char*path,char*const argv[]); 
int execvp(const char*file,char*const argv[]); 
int execve(const char*path,char*const argv[],char*const envp[]);
  • path:指定可执行文件的完整路径;
  • file:可以接受文件名,该文件的具体位置则在环境变量 PATH 中搜寻;
  • arg:接受可变参数,argv 则接受参数数组,它们都会被传递给新程序(path 或 file 指定的程序) 的 main 函数;
  • envp:用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量 environ 指定的环境变量。

一般情况下,exec 函数是不返回的,除非出错。它出错时返回 -1 ,并设置 errno 。如果没出错,则原程序中 exec 调用之后的代码都不会执行,因为此时原程序已经被 exec 的参数指定的程序完全替换(包括代码和数据)。

exec 函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似 SOCK_CLOEXEC 的属性。

三、处理僵尸进程

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。另外一种使子进程进入僵尸态的情况是:父进程结束或者异常终止,而子进程继续运行。此时子进程的 PPID 将被操作系统设置为 1 ,即 init 进程。init 进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。

由此可见,无论哪种情况,如果父进程没有正确地处理子进程的返回信息,子进程都将停留在僵尸态,并占据着内核资源。这是绝对不能容许的,毕竟内核资源有限。下面这对函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束:

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*stat_loc); 
pid_t waitpid(pid_t pid,int*stat_loc,int options);

wait 函数将阻塞进程,直到该进程的某个子进程结束运行为止。它返回结束运行的子进程的 PID ,并将该子进程的退出状态信息存储于 stat_loc 参数指向的内存中。sys/wait.h 头文件中定义了几个宏来帮助解释子进程的退出状态信息:

含义
WIFEXITED(stat_val)如果子进程正常结束,它就返回一个非 0 值
WEXITSTATUS(stat_val)如果 WIFEXITED 非 0 ,它返回子进程的退出码
WIFSIGNALED(stat_val)如果子进程是因为一个未捕获的信号而终止,它就返回一个非 0 值
WTERMSIG(stat_val)如果 WIFSIGNALED 非 0 ,它返回一个信号值
WIFSTOPPED(stat_val)如果子进程意外终止,它就返回一个非 0 值
WSTOPSIG(stat_val)如果 WIFSTOPPED 非 0 ,它返回一个信号值

wait 函数的阻塞特性显然不是服务器程序期望的,而 waitpid 函数解决了这个问题。waitpid 只等待由 pid 参数指定的子进程。如果 pid 取值 为 -1 ,那么它就和 wait 函数相同,即等待任意一个子进程结束。stat_loc 参数的含义和 wait 函数的 stat_loc 参数相同。options 参数可以控制 waitpid 函数的行为。该参数最常用的取值是 WNOHANG 。当 options 的取值是 WNOHANG 时,waitpid 调用将是非阻塞的:如果 pid 指定的目标子进程还没有结束或意外终止,则 waitpid 立即返回 0 ;如果目标子进程确实正常退出了,则 waitpid 返回该子进程的 PID 。waitpid 调用失败时返回 -1 并设置 errno 。

8.3 节曾提到,要在事件已经发生的情况下执行非阻塞调用才能提高程序的效率。对 waitpid 函数而言,最好在某个子进程退出之后再调用它。那么父进程从何得知某个子进程已经退出了呢?这正是 SIGCHLD 信号的用途。当一个进程结束时,它将给其父进程发送一个 SIGCHLD 信号。因此,可以在父进程中捕获 SIGCHLD 信号,并在信号处理函数中调用 waitpid 函数以“彻底结束”一个子进程:

static void handle_child(int sig) { 
	pid_t pid; 
	int stat; 
	while((pid = waitpid(-1, &stat, WNOHANG)) > 0) 
	{ 
		/*对结束的子进程进行善后处理*/ 
	} 
}

四、管道

管道能在父、子进程间传递数据,利用的是 fork 调用之后两个管道文件描述符(fd[0] 和 fd[1])都保持打开。一对这样的文件描述符只能保证父、子进程间一个方向的数据传输,父进程和子进程必须有一个关闭 fd[0] ,另一个关闭 fd[1] 。比如,我们要使用管道实现从父进程向子进程写数据,就应该按照下图所示来操作:
1
显然,如果要实现父、子进程之间的双向数据传输,就必须使用两个管道。socket 编程接口提供了一个创建全双工管道的系统调用:socketpair 。squid 服务器程序就是利用 socketpair 创建管道,以实现在父进程和日志服务子进程之间传递日志信息。

管道只能用于有关联的两个进程(比如父、子进程)间的通信。而下面要讨论的 3 种 System V IPC 能用于无关联的多个进程之间的通信,因为它们都使用一个全局唯一的键值来标识一条信道。不 过,有一种特殊的管道称为 FIFO(First In First Out,先进先出),也叫命名管道。它也能用于无关联进程之间的通信。因为 FIFO 管道在网络编程中使用不多,所以不讨论它。

五、信号量

1. 信号量原语

当多个进程同时访问系统上的某个资源的时候,比如同时写一个数据库的某条记录,或者同时修改某个文件,就需要考虑进程的同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。 通常,程序对共享资源的访问的代码只是很短的一段,但就是这一段代码引发了进程之间的竞态条件。我们称这段代码为关键代码段,或者临界区。对进程同步,也就是确保任一时刻只有一个进程能进入关键代码段。

要编写具有通用目的的代码,以确保关键代码段的独占式访问是非常困难的。有两个名为 Dekker 算法和 Peterson 算法的解决方案,它们试图从语言本身(不需要内核支持)解决并发问题。但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变。这种方式下 CPU 利用率太低,显然是不可取的。

Dijkstra 提出的信号量(Semaphore)概念是并发编程领域迈出的重要一步。信号量是一种特殊的变量,它只能取自然数值并且只支持两种操作:等待(wait)和信号(signal)。不过在 Linux/UNIX 中,“等待”和“信号”都已经具有特殊的含义,所以对信号量的这两种操作更常用的称呼是 P 、V 操作。这两个字母来自于荷兰语单词 passeren(传递, 就好像进入临界区)和 vrijgeven(释放,就好像退出临界区)。假设有信号量 SV ,则对它的 P 、V 操作含义如下:

  • P(SV),如果 SV 的值大于 0 ,就将它减 1 ;如果 SV 的值为 0 ,则挂起进程的执行;
  • V(SV),如果有其他进程因为等待 SV 而挂起,则唤醒之;如果没有,则将 SV 加 1 。

信号量的取值可以是任何自然数。但最常用的、最简单的信号量是二进制信号量,它只能取 0 和 1 这两个值。典型例子如下:
2
当关键代码段可用时,二进制信号量 SV 的值为 1 ,进程 A 和 B 都有机会进入关键代码段。如果此时进程 A 执行了 P(SV) 操作将 SV 减 1 ,则进程 B 若再执行 P(SV) 操作就会被挂起。直到进程 A 离开关键代码段,并执行 V(SV) 操作将 SV 加 1 ,关键代码段才重新变得可用。如果此时进程 B 因为等待 SV 而处于挂起状态,则它将被唤醒,并进入关键代码段。同样,这时进程 A 如果再执行 P(SV) 操作,则也只能被操作系统挂起以等待进程 B 退出关键代码段。

注意:使用一个普通变量来模拟二进制信号量是行不通的,因为所有高级语言都没有一个原子操作可以同时完成如下两步操作:检测变量是否为 true/false ,如果是则再将它设置为 false/true 。

Linux 信号量的 API 都定义在 sys/sem.h 头文件中,主要包含 3 个系统调用:semget 、semop 和 semctl 。它们都被设计为操作一组信号量,即信号量集,而不是单个信号量,因此这些接口看上去要复杂一点。

2. semget 系统调用

semget 系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集。其定义如下:

#include<sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
  • key:是一个键值,用来标识一个全局唯一的信号量集,就像文件名全局唯一地标识一个文件一样。要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量;
  • num_sems:指定要创建/获取的信号量集中信号量的数目。如果是创建信号量,则该值必须被指定;如果是获取已经存在的信号量,则可以把它设置为 0 ;
  • sem_flags:指定一组标志。它低端的 9 个比特是该信号量的权限,其格式和含义都与系统调用 open 的 mode 参数相同。此外,它还可以和 IPC_CREAT 标志做按位“或”运算以创建新的信号量集。此时即使信号量已经存在,semget 也不会产生错误。我们还可以联合使用 IPC_CREAT 和 IPC_EXCL 标志来确保创建一组新的、唯一的信号量集。在这种情况下,如果信号量集已经存在,则 semget 返回错误并设置 errno 为 EEXIST 。这种创建信号量的行为与用 O_CREAT 和 O_EXCL 标志调用 open 来排他式地打开一个文件相似。
  • semget 成功时返回一个正整数值,它是信号量集的标识符;semget 失败时返回 -1 ,并设置 errno 。

如果 semget 用于创建信号量集,则与之关联的内核数据结构体 semid_ds 将被创建并初始化:

#include <sys/sem.h>
/*该结构体用于描述IPC对象(信号量、共享内存和消息队列)的权限*/
struct ipc_perm
{
    key_t key;   /*键值*/
    uid_t uid;   /*所有者的有效用户ID*/
    gid_t gid;   /*所有者的有效组ID*/
    uid_t cuid;  /*创建者的有效用户ID*/
    gid_t cgid;  /*创建者的有效组ID*/
    mode_t mode; /*访问权限*/
    /*省略其他填充字段*/
} struct semid_ds
{
    struct ipc_perm sem_perm;    /*信号量的操作权限*/
    unsigned long int sem_nsems; /*该信号量集中的信号量数目*/
    time_t sem_otime;            /*最后一次调用semop的时间*/
    time_t sem_ctime;            /*最后一次调用semctl的时间*/
    /*省略其他填充字段*/
};

semget 对 semid_ds 结构体的初始化包括:

  • 将 sem_perm.cuid 和 sem_perm.uid 设置为调用进程的有效用户 ID ;
  • 将 sem_perm.cgid 和 sem_perm.gid 设置为调用进程的有效组 ID ;
  • 将 sem_perm.mode 的最低 9 位设置为 sem_flags 参数的最低 9 位;
  • 将 sem_nsems 设置为 num_sems ;
  • 将 sem_otime 设置为 0 ;
  • 将 sem_ctime 设置为当前的系统时间。

3. semop 系统调用

semop 系统调用改变信号量的值,即执行 P 、V 操作。在讨论 semop 之前,需要先介绍与每个信号量关联的一些重要的内核变量:

unsigned short semval; 
/*信号量的值*/ 
unsigned short semzcnt; 
/*等待信号量值变为0的进程数量*/ 
unsigned short semncnt; 
/*等待信号量值增加的进程数量*/ 
pid_t sempid; 
/*最后一次执行semop操作的进程ID*/

semop 对信号量的操作实际上就是对这些内核变量的操作。semop 的定义如下:

#include<sys/sem.h>
struct sembuf{ 
	unsigned short int sem_num;
	short int sem_op; 
	short int sem_flg; 
}
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
  • sem_id 参数是由 semget 调用返回的信号量集标识符,用以指定被操作的目标信号量集;
  • sem_num:是信号量集中信号量的编号,0 表示信号量集中的第一个信号量;
  • sem_op:指定操作类型,其可选值为正整数、0 和负整数。每种类型的操作的行为又受到 sem_flg 成员的影响;
  • sem_flg:可选值是 IPC_NOWAIT 和 SEM_UNDO:
    • IPC_NOWAIT 的含义是,无论信号量操作是否成功,semop 调用都将立即返回,这类似于非阻塞 I/O 操作;
    • SEM_UNDO 的含义是,当进程退出时取消正在进行的 semop 操作。
  • 具体来说,sem_op 和 sem_flg 将按照如下方式来影响 semop 的行为:
    • 如果 sem_op 大于 0 ,则 semop 将被操作的信号量的值 semval 增加 sem_op 。该操作要求调用进程对被操作信号量集拥有写权限。此时若设置了 SEM_UNDO 标志,则系统将更新进程的 semadj 变量(用以跟踪进程对信号量的修改情况);
    • 如果 sem_op 等于 0 ,则表示这是一个“等待 0 ”(wait-for-zero)操作。该操作要求调用进程对被操作信号量集拥有读权限:
      • 如果此时信号量的值是 0 ,则调用立即成功返回;
      • 如果信号量的值不是 0 ,则 semop 失败返回或者阻塞进程以等待信号量变为 0 。在这种情况下,当 IPC_NOWAIT 标志被指定时,semop 立即返回一个错误,并设置 errno 为 EAGAIN 。如果未指定 IPC_NOWAIT 标志,则信号量的 semzcnt 值加 1 ,进程被投入睡眠直到下列 3 个条件之一发生:信号量的值 semval 变为 0 , 此时系统将该信号量的 semzcnt 值减 1 ;被操作信号量所在的信号量集被进程移除,此时 semop 调用失败返回,errno 被设置为 EIDRM ;调用被信号中断,此时 semop 调用失败返回,errno 被设置为 EINTR ,同时系统将该信号量的 semzcnt 值减 1 ;
    • 如果 sem_op 小于0,则表示对信号量值进行减操作,即期望获得信号量。该操作要求调用进程对被操作信号量集拥有写权限:
      • 如果信号量的值 semval 大于或等于 sem_op 的绝对值,则 semop 操作成功,调用进程立即获得信号量,并且系统将该信号量的 semval 值减去 sem_op 的绝对值。此时如果设置了 SEM_UNDO 标志,则系统将更新进程的 semadj 变量;
      • 如果信号量的值 semval 小于 sem_op 的绝对值,则 semop 失败返回或者阻塞进程以等待信号量可用。在这种情况下,当 IPC_NOWAIT 标志被指定时,semop 立即返回一个错误,并设置 errno 为 EAGAIN 。如果未指定 IPC_NOWAIT 标志,则信号量的 semncnt 值加 1 ,进程被投入睡眠直到下列 3 个条件之一发生:信号量的值 semval 变得大于或等于 sem_op 的绝对值,此时系统将该信号量的 semncnt 值减 1 ,并将 semval 减去 sem_op 的绝对值,同时,如果 SEM_UNDO 标志被设置,则系统更新 semadj 变量;被操作信号量所在的信号量集被进程移除,此时 semop 调用失败返回,errno 被设置为 EIDRM ;调用被信号中断,此时 semop 调用失败返回,errno 被设置为 EINTR ,同时系统将该信号量的 semncnt 值减 1 。
  • num_sem_ops:指定要执行的操作个数,即 sem_ops 数组中元素的个数。semop 对数组 sem_ops 中的每个成员按照数组顺序依次执行操作,并且该过程是原子操作,以避免别的进程在同一时刻按照不同的顺序对该信号集中的信号量执行 semop 操作导致的竞态条件;
  • semop 成功时返回 0 ,失败则返回 -1 并设置 errno 。失败的时候,sem_ops 数组中指定的所有操作都不被执行。

4. semctl 系统调用

semctl 系统调用允许调用者对信号量进行直接控制。其定义如下:

#include<sys/sem.h>
int semctl(int sem_id, int sem_num, int command,...);
  • sem_id:由 semget 调用返回的信号量集标识符,用以指定被操作的信号量集;
  • sem_num:指定被操作的信号量在信号量集中的编号;
  • command:指定要执行的命令:
    命令含义semctl 成功时的返回值
    IPC_STAT将信号量集关联的内核数据结构复制到 semun.buf 中0
    IPC_SET将 semun.buf 中的部分成员复制到信号量集关联的内核数据结构中,同时内核数据中的 semid_ds.sem_ctime 被更新0
    IPC_RMID立即移除信号量集,唤醒所有等待该信号量集的进程(semop 返回错误,并设置 errno 为 EIDRM)0
    IPC_INFO获取系统信号量资源配置信息,将结果存储在 semun.__buf 中。这些信息的含义见结构体 seminfo 的注释部分内核信号集数组中已经被使用的项的最大索引值
    SEM_INFO与 IPC_INFO 类似,不过 semun.__buf.semusz 被设置为系统目前拥有的信号量集数目,而 semnu.__buf.semaem 被设置为系统目前拥有的信号量数目同 IPC_INFO
    SEM_STAT与 IPC_STAT 类似,不过此时 sem_id 参数不是用来表示信号量集标识符,而是内核中信号量集数组的索引(系统的所有信号量集都是该数组中的一项)内核信号量集数组中的索引值为 sem_id 的信号量集的标识符
    GETALL将由 sem_id 标识的信号量集中的所有信号量的 semval 值导出到 semun.array 中0
    GETNCNT获取信号量的 semncnt 值信号量的 semncnt 值
    GETPID获取信号量的 sempid 值信号量的 sempid 值
    GETVAL获取信号量的 semval 值信号量的 semval 值
    GETZCNT获取信号量的 semzcnt 值信号量的 semzcnt 值
    SETALL用 semun.array 中的数据填充由 sem_id 标识的信号量集中的所有信号量的 semval 值,同时内核数据中的 semid_ds.sem)ctime 被更新0
    SETVAL将信号量的 semval 值设置为 semun.val ,同时内核数据中的 semid_ds.sem_ctime 被更新0
  • 有的命令需要调用者传递第 4 个参数。第 4 个参数的类型由用户自己定义,但 sys/sem.h 头文件给出了它的推荐格式,具体如下:
    union semun { 
    	int val;/*用于SETVAL命令*/ 
    	struct semid_ds*buf;/*用于IPC_STAT和IPC_SET命令*/ 
    	unsigned short*array;/*用于GETALL和SETALL命令*/ 
    	struct seminfo*__buf;/*用于IPC_INFO命令*/ 
    }; 
    struct seminfo {
    	int semmap;/*Linux内核没有使用*/ 
    	int semmni;/*系统最多可以拥有的信号量集数目*/ 
    	int semmns;/*系统最多可以拥有的信号量数目*/ 
    	int semmnu;/*Linux内核没有使用*/ 
    	int semmsl;/*一个信号量集最多允许包含的信号量数目*/ 
    	int semopm;/*semop一次最多能执行的sem_op操作数目*/ 
    	int semume;/*Linux内核没有使用*/ 
    	int semusz;/*sem_undo结构体的大小*/ 
    	int semvmx;/*最大允许的信号量值*/ 
    	/*最多允许的UNDO次数(带SEM_UNDO标志的semop操作的次数)*/ 
    	int semaem; 
    };
    
    注意 这些操作中,GETNCNT 、GETPID 、GETVAL 、GETZCNT 和 SETVAL 操作的是单个信号量,它是由标识符 sem_id 指定的信号量集中的第 sem_num 个信号量;而其他操作针对的是整个信号量集,此时 semctl 的参数 sem_num 被忽略。
  • semctl 成功时的返回值取决于 command 参数。semctl 失败时返回 -1 ,并设置 errno 。

5. 特殊键值 IPC_PRIVATE

semget 的调用者可以给其 key 参数传递一个特殊的键值 IPC_PRIVATE(其值为 0),这样无论该信号量是否已经存在,semget 都将创建一个新的信号量。使用该键值创建的信号量并非像它的名字 声称的那样是进程私有的。其他进程,尤其是子进程,也有方法来访问这个信号量。所以 semget 的 man 手册的 BUGS 部分上说,使用名字 IPC_PRIVATE 有些误导(历史原因),应该称为 IPC_NEW。下面的代码在父、子进程间使用一个 IPC_PRIVATE 信号量来同步:

// IPC_PRIVATE.cpp
#include <sys/sem.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
union semun
{
    int val;              // 信号量大小
    struct semid_ds *buf; // 信号量的数据结构,包含操作权限、数目、最后一次分别调用semop和semctl的时间
    unsigned short int *array;
    struct seminfo *__buf;
};
// op为-1时执行P操作,op为1时执行V操作
void pv(int sem_id, int op)
{
    struct sembuf sem_b;
    sem_b.sem_num = 0;
    sem_b.sem_op = op;
    sem_b.sem_flg = SEM_UNDO;
    semop(sem_id, &sem_b, 1);
}
int main(int argc, char *argv[])
{
    int sem_id = semget(IPC_PRIVATE, 1, 0666); // 创建信号量集,1表示无论信号量是否存在,都将新建,且并非进程私有,2表示信号量集中的信号量个数
    union semun sem_un;
    sem_un.val = 1;
    semctl(sem_id, 0, SETVAL, sem_un); // 对信号量直接进行控制,1信号量集标识符,2信号量编号,3命令(设置值为sem_un)
    pid_t id = fork();                 // 创建进程
    if (id < 0)
    {
        return 1;
    }
    else if (id == 0) // 子进程
    {
        printf("child try to get binary sem\n");
        // 在父、子进程间共享IPC_PRIVATE信号量的关键就在于二者都可以操作该信号量的标识符sem_id
        pv(sem_id, -1); // P操作
        printf("child get the sem and would release it after 5 seconds\n");
        sleep(5);
        pv(sem_id, 1); // v操作
        exit(0);
    }
    else
    {
        printf("parent try to get binary sem\n");
        pv(sem_id, -1);
        printf("parent get the sem and would release it after 5 seconds\n");
        sleep(5);
        pv(sem_id, 1);
    }
    waitpid(id, NULL, 0);
    semctl(sem_id, 0, IPC_RMID, sem_un);
    // 删除信号量
    return 0;
}

六、共享内存

共享内存是最高效的 IPC 机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。因此,共享内存通常和其他进程间通信方式一起使用。

Linux 共享内存的 API 都定义在 sys/shm.h 头文件中,包括 4 个系统调用:shmget 、shmat 、shmdt 和 shmctl 。

1. shmget 系统调用

shmget 系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。其定义如下:

#include<sys/shm.h>
int shmget(key_t key,size_t size,int shmflg);

和 semget 系统调用一样,key 参数是一个键值,用来标识一段全局唯一的共享内存。size 参数指定共享内存的大小,单位是字节。如果是创建新的共享内存,则 size 值必须被指定。如果是获取已经存在的共享内存,则可以把 size 设置为 0 。

shmflg 参数的使用和含义与 semget 系统调用的 sem_flags 参数相同。不过 shmget 支持两个额外的标志:SHM_HUGETLB 和 SHM_NORESERVE :

  • SHM_HUGETLB:类似于 mmap 的 MAP_HUGETLB 标志,系统将使用“大页面”来为共享内存分配空间;
  • SHM_NORESERVE:类似于 mmap 的 MAP_NORESERVE 标志,不为共享内存保留交换分区(swap 空间)。这样,当物理内存不足的时候,对该共享内存执行写操作将触发 SIGSEGV 信号;
  • shmget 成功时返回一个正整数值,它是共享内存的标识符。shmget 失败时返回 -1 ,并设置 errno 。

如果 shmget 用于创建共享内存,则这段共享内存的所有字节都被初始化为 0 ,与之关联的内核数据结构 shmid_ds 将被创建并初始化。shmid_ds 结构体的定义如下:

struct shmid_ds { 
	struct ipc_perm shm_perm;/*共享内存的操作权限*/ 
	size_t shm_segsz;/*共享内存大小,单位是字节*/ 
	__time_t shm_atime;/*对这段内存最后一次调用shmat的时间*/ 
	__time_t shm_dtime;/*对这段内存最后一次调用shmdt的时间*/ 
	__time_t shm_ctime;/*对这段内存最后一次调用shmctl的时间*/ 
	__pid_t shm_cpid;/*创建者的PID*/ 
	__pid_t shm_lpid;/*最后一次执行shmat或shmdt操作的进程的PID*/ 
	shmatt_t shm_nattach;/*目前关联到此共享内存的进程数量*/ 
	/*省略一些填充字段*/ };

shmget 对 shmid_ds 结构体的初始化包括:

  • 将 shm_perm.cuid 和 shm_perm.uid 设置为调用进程的有效用户 ID ;
  • 将 shm_perm.cgid 和 shm_perm.gid 设置为调用进程的有效组 ID ;
  • 将 shm_perm.mode 的最低 9 位设置为 shmflg 参数的最低 9 位;
  • 将 shm_segsz 设置为 size;
  • 将 shm_lpid 、shm_nattach 、shm_atime 、shm_dtime 设置为 0 ;
  • 将 shm_ctime 设置为当前的时间。

2. shmat 和 shmdt 系统调用

共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,我们也需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:

#include<sys/shm.h>
void*shmat(int shm_id,const void*shm_addr,int shmflg); 
int shmdt(const void*shm_addr);
  • shm_id:是由 shmget 调用返回的共享内存标识符;
  • shm_addr:指定将共享内存关联到进程的哪块地址空间,最终的效果还受到 shmflg 参数的可选标志 SHM_RND 的影响:
    • 如果 shm_addr 为 NULL ,则被关联的地址由操作系统选择。这是推荐的做法,以确保代码的可移植性;
    • 如果 shm_addr 非空,并且 SHM_RND 标志未被设置,则共享内存被关联到 addr 指定的地址处;
    • 如果 shm_addr 非空,并且设置了 SHM_RND 标志,则被关联的地址是 [shm_addr-(shm_addr%SHMLBA)] 。SHMLBA的含义是“段低端边界地址倍数”(Segment Low Boundary Address Multiple),它必须是内存页面大小(PAGE_SIZE)的整数倍。现在的 Linux 内核中,它等于一个内存页大小。SHM_RND 的含义是圆整(round),即将共享内存被关联的地址向下圆整到离 shm_addr 最近的 SHMLBA 的整数倍地址处。
  • 除了 SHM_RND 标志外,shmflg 参数还支持如下标志:
    • SHM_RDONLY:进程仅能读取共享内存中的内容。若没有指定该标志,则进程可同时对共享内存进行读写操作(当然,这需要在创建共享内存的时候指定其读写权限);
    • SHM_REMAP:如果地址 shmaddr 已经被关联到一段共享内存上,则重新关联。
    • SHM_EXEC:它指定对共享内存段的执行权限。对共享内存而言,执行权限实际上和读权限是一样的。
  • hmat 成功时返回共享内存被关联到的地址,失败则返回 (void*)-1 并设置 errno 。shmat 成功时,将修改内核数据结构 shmid_ds 的部分字段,如下:
    • 将 shm_nattach 加 1 ;
    • 将 shm_lpid 设置为调用进程的 PID ;
    • 将 shm_atime 设置为当前的时间。
  • shmdt 函数将关联到 shm_addr 处的共享内存从进程中分离。它成功时返回 0 ,失败则返回 -1 并设置 errno 。shmdt 在成功调用时将修改内核数据结构 shmid_ds 的部分字段,如下:
    • 将 shm_nattach 减 1 ;
    • 将 shm_lpid 设置为调用进程的 PID ;
    • 将 shm_dtime 设置为当前的时间。

3. shmctl 系统调用

shmctl 系统调用控制共享内存的某些属性。其定义如下:

#include<sys/shm.h>
int shmctl(int shm_id,int command,struct shmid_ds*buf);

其中,shm_id 参数是由 shmget 调用返回的共享内存标识符。command 参数指定要执行的命令。 shmctl 支持的所有命令如下表所示:

命令含义shmctl 成功时的返回值
IPC_STAT将共享内存相关的内核数据结构复制到 buf 中0
IPC_SET将 buf 中的部分成员复制到共享内存相关的内核数据机构中,同时内核数据中的 shmid_ds.shm_ctime 被更新0
IPC_RMID将共享内存打上删除的标记。这样当最后一个使用它的进程调用 shmdt 将它从进程中分离时,该共享内存就被删除了0
IPC_INFO获取系统共享内存资源配置信息,将结果存储在 buf 中。应用程序需要将 buf 转换成 shminfo 结构体类型来读取这些系统信息。shminfo 结构体与 seminfo 类似内核共享内存信息数组中已经被使用的项的最大索引值
SHM_INFO与 IPC_INFO 类似,不过返回的是已经分配的共享内存占用的资源信息。应用程序需要将 buf 转换成 shm_info 结构体类型来读取这些信息。shn_info 结构体与 shminfo 类似同 IPC_INFO
SHM_STAT与 IPC_STAT 类似,不过此时 shm_id 参数不是用来标识共享内存标识符,而是内核中共享内存信息数组的索引(每个共享内存的信息都是该数组中的一项)内核共享内存信息数组中索引值为 shm_id 的共享内存的标识符
SHM_LOCK禁止共享内存被移动至交换分区0
SHM_UNLOCK允许共享内存被移动至交换分区0

shmctl 成功时的返回值取决于 command 参数,失败时返回 -1 ,并设置 errno 。

4. 共享内存的 POSIX 方法

mmap 函数的 MAP_ANONYMOUS 标志可以实现父、子进程之间的匿名内存共享。通过打开同一个文件,mmap 也可以实现无关进程之间的内存共享。Linux 提供了另外一种利用 mmap 在无关进程之间共享内存的方式。这种方式无须任何文件的支持,但它需要先使用如下函数来创建或打开一个 POSIX 共享内存对象:

#include<sys/mman.h>
#include<sys/stat.h>
#include<fcntl.h>
int shm_open(const char*name,int oflag,mode_t mode);

shm_open 的使用方法与 open 系统调用完全相同。

  • name:指定要创建/打开的共享内存对象。从可移植性的角度考虑,该参数应该使用“/somename”的格式:以“/”开始,后接多个字符,且这些字符都不是“/”;以“\0”结尾,长度不超过 NAME_MAX(通常 是255);
  • oflag:指定创建方式。它可以是下列标志中的一个或者多个的按位或:
    • O_RDONLY:以只读方式打开共享内存对象;
    • O_RDWR:以可读、可写方式打开共享内存对象;
    • O_CREAT:如果共享内存对象不存在,则创建之。此时 mode 参数的最低 9 位将指定该共享内存对象的访问权限。共享内存对象被创建的时候,其初始长度为 0 ;
    • O_EXCL:和 O_CREAT 一起使用,如果由 name 指定的共享内存对象已经存在,则 shm_open 调用返回错误,否则就创建一个新的共享内存对象;
    • O_TRUNC:如果共享内存对象已经存在,则把它截断,使其长度为 0 。
  • shm_open 调用成功时返回一个文件描述符。该文件描述符可用于后续的 mmap 调用,从而将共享内存关联到调用进程。shm_open 失败时返回 -1 ,并设置 errno 。

和打开的文件最后需要关闭一样,由 shm_open 创建的共享内存对象使用完之后也需要被删除。这个过程是通过如下函数实现的:

#include<sys/mman.h>
#include<sys/stat.h>
#include<fcntl.h>
int shm_unlink(const char*name);

该函数将 name 参数指定的共享内存对象标记为等待删除。当所有使用该共享内存对象的进程都使用 ummap 将它从进程中分离之后,系统将销毁这个共享内存对象所占据的资源。

如果代码中使用了上述 POSIX 共享内存函数,则编译的时候需要指定链接选项 -lrt 。

5. 共享内存实例

9.6 小节中,介绍过一个聊天室服务器程序,现将它修改为一个多进程服务器:一个子进程处理一个客户连接,同时将所有客户 socket 连接的读缓存设计为一块共享内存:

// charRoomServer.cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#define USER_LIMIT 5
#define BUFFER_SIZE 1024
#define FD_LIMIT 65535
#define MAX_EVENT_NUMBER 1024
#define PROCESS_LIMIT 65536
// 处理一个客户连接必要的数据
struct client_data
{
    sockaddr_in address; // 客户端的socket地址
    int connfd;          // socket文件描述符
    pid_t pid;           // 处理这个连接的子进程的PID
    int pipefd[2];       // 和父进程通信用的管道
};
static const char *shm_name = "/my_shm";
int sig_pipefd[2];
int epollfd;
int listenfd;
int shmfd;
char *share_mem = 0;
// 客户连接数组。进程用客户连接的编号来索引这个数组,即可取得相关的客户连接数 据
client_data *users = 0;
// 子进程和客户连接的映射关系表。用进程的PID来索引这个数组,即可取得该进程所处 理的客户连接的编号
int *sub_process = 0;
// 当前客户数量
int user_count = 0;
bool stop_child = false;
int setnonblocking(int fd)
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}
void addfd(int epollfd, int fd)
{
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}
void sig_handler(int sig) // 检测到信号后处理信号的方法
{
    int save_errno = errno;
    int msg = sig;
    send(sig_pipefd[1], (char *)&msg, 1, 0); // 将信号传递给管道sig_pipefd
    errno = save_errno;
}
void addsig(int sig, void (*handler)(int), bool restart = true)
{
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = handler;
    if (restart)
    {
        sa.sa_flags |= SA_RESTART;
    }
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}
void del_resource()
{
    close(sig_pipefd[0]);
    close(sig_pipefd[1]);
    close(listenfd);
    close(epollfd);
    shm_unlink(shm_name);
    delete[] users;
    delete[] sub_process;
}
// 停止一个子进程
void child_term_handler(int sig) { stop_child = true; }
// 子进程运行的函数。参数idx指出该子进程处理的客户连接的编号,users是保存所有 客户连接数据的数组,参数share_mem指出共享内存的起始地址
int run_child(int idx, client_data *users, char *share_mem) // 接收了客户数据存储区和共享内存区
{
    epoll_event events[MAX_EVENT_NUMBER];
    // 子进程使用I/O复用技术来同时监听两个文件描述符:客户连接socket、与父进程通信的管道文件描述符
    int child_epollfd = epoll_create(5);
    assert(child_epollfd != -1);
    int connfd = users[idx].connfd;
    addfd(child_epollfd, connfd);
    int pipefd = users[idx].pipefd[1];
    addfd(child_epollfd, pipefd);
    int ret;
    // 子进程需要设置自己的信号处理函数
    addsig(SIGTERM, child_term_handler, false); // 中断信号
    while (!stop_child)
    {
        int number = epoll_wait(child_epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR))
        {
            printf("epoll failure\n");
            break;
        }
        for (int i = 0; i < number; i++)
        {
            int sockfd = events[i].data.fd;
            // 本子进程负责的客户连接有数据到达
            if ((sockfd == connfd) && (events[i].events & EPOLLIN))
            {
                memset(share_mem + idx * BUFFER_SIZE, '\0', BUFFER_SIZE);
                // 将客户数据读取到对应的读缓存中。该读缓存是共享内存的一段,它开始于idx*BUFFER_SIZE处,长度为BUFFER_SIZE字节。因此,各个客户连接的读缓存是共享的
                ret = recv(connfd, share_mem + idx * BUFFER_SIZE, BUFFER_SIZE - 1, 0);
                if (ret < 0)
                {
                    if (errno != EAGAIN)
                    {
                        stop_child = true;
                    }
                }
                else if (ret == 0)
                {
                    stop_child = true;
                }
                else
                {
                    // 成功读取客户数据后就通知主进程(通过管道)来处理
                    send(pipefd, (char *)&idx, sizeof(idx), 0);
                }
            }
            // 主进程通知本进程(通过管道)将第client个客户的数据发送到本进程负责的客户端
            else if ((sockfd == pipefd) && (events[i].events & EPOLLIN))
            {
                int client = 0;
                // 接收主进程发送来的数据,即有客户数据到达的连接的编号
                ret = recv(sockfd, (char *)&client, sizeof(client), 0);
                if (ret < 0)
                {
                    if (errno != EAGAIN)
                    {
                        stop_child = true;
                    }
                }
                else if (ret == 0)
                {
                    stop_child = true;
                }
                else
                {
                    send(connfd, share_mem + client * BUFFER_SIZE, BUFFER_SIZE, 0); // 将数据发送给客户
                }
            }
            else
            {
                continue;
            }
        }
    }
    close(connfd);
    close(pipefd);
    close(child_epollfd);
    return 0;
}
int main(int argc, char *argv[])
{
    // 处理地址、监听
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    ret = listen(listenfd, 5);
    assert(ret != -1);
    user_count = 0;                          // 用户个数
    users = new client_data[USER_LIMIT + 1]; // 用户资源区
    sub_process = new int[PROCESS_LIMIT];    // 子进程区
    for (int i = 0; i < PROCESS_LIMIT; ++i)  // 初始化为-1
    {
        sub_process[i] = -1;
    }
    epoll_event events[MAX_EVENT_NUMBER]; // 内核事件区
    epollfd = epoll_create(5);            // 创建内核事件表
    assert(epollfd != -1);
    addfd(epollfd, listenfd);                              // 添加监听事件
    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, sig_pipefd); // 创建双向管道
    assert(ret != -1);
    setnonblocking(sig_pipefd[1]);                      // 设置写端非阻塞
    addfd(epollfd, sig_pipefd[0]);                      // 添加读端事件
    addsig(SIGCHLD, sig_handler);                       // 子进程状态发生变化(退出或暂停)
    addsig(SIGTERM, sig_handler);                       // 终止进程,kill命令默认发送的信号就是SIGTERM
    addsig(SIGINT, sig_handler);                        // 中断信号
    addsig(SIGPIPE, SIG_IGN);                           // 往读端被关闭的管道或者socket连接中写数据
    bool stop_server = false;                           // 停止服务器
    bool terminate = false;                             // 中断
    shmfd = shm_open(shm_name, O_CREAT | O_RDWR, 0666); // 创建共享内存,作为所有客户socket连接的读缓存
    assert(shmfd != -1);
    ret = ftruncate(shmfd, USER_LIMIT * BUFFER_SIZE); // 将shmfd指定的文件大小改为USER_LIMIT * BUFFER_SIZE
    assert(ret != -1);
    // 申请空间,1系统自动分配,2内存段长度,3访问权限(读写),4内存段内容被修改后程序的行为(共享内存段),5文件描述符fd,6文件偏移量
    share_mem = (char *)mmap(NULL, USER_LIMIT * BUFFER_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shmfd, 0);
    assert(share_mem != MAP_FAILED);
    close(shmfd);
    while (!stop_server)
    {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if ((number < 0) && (errno != EINTR))
        {
            printf("epoll failure\n");
            break;
        }
        for (int i = 0; i < number; i++)
        {
            int sockfd = events[i].data.fd;
            // 新的客户连接到来
            if (sockfd == listenfd)
            {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                if (connfd < 0)
                {
                    printf("errno is: %d\n", errno);
                    continue;
                }
                if (user_count >= USER_LIMIT)
                {
                    const char *info = "too many users\n";
                    printf("%s", info);
                    send(connfd, info, strlen(info), 0);
                    close(connfd);
                    continue;
                }
                // 保存第user_count个客户连接的相关数据
                users[user_count].address = client_address;
                users[user_count].connfd = connfd;
                // 在主进程和第i个子进程间建立管道,以传递必要的数据
                ret = socketpair(PF_UNIX, SOCK_STREAM, 0, users[user_count].pipefd);
                assert(ret != -1);
                pid_t pid = fork(); // 创建新进程
                if (pid < 0)        // 错误
                {
                    close(connfd);
                    continue;
                }
                else if (pid == 0) // 返回0表示该函数调用在子进程
                {
                    // 关闭主进程相关的操作,交给子进程处理
                    close(epollfd);
                    close(listenfd);
                    close(users[user_count].pipefd[0]);
                    close(sig_pipefd[0]);
                    close(sig_pipefd[1]);
                    run_child(user_count, users, share_mem);             // 交给子进程处理,由于子进程会复制父进程的文件描述符
                    munmap((void *)share_mem, USER_LIMIT * BUFFER_SIZE); // 释放内存
                    exit(0);
                }
                else
                {
                    close(connfd);
                    close(users[user_count].pipefd[1]);
                    addfd(epollfd, users[user_count].pipefd[0]); // 监听可读管道
                    users[user_count].pid = pid;
                    // 记录新的客户连接在数组users中的索引值,建立进程pid和该索引值之间的映射关系
                    sub_process[pid] = user_count; // 子进程pid对应用户编号
                    user_count++;
                }
            }
            // 处理信号事件
            else if ((sockfd == sig_pipefd[0]) && (events[i].events & EPOLLIN)) // 检测到管道可读信号
            {
                int sig;
                char signals[1024];
                ret = recv(sig_pipefd[0], signals, sizeof(signals), 0); // 接收管道数据,返回实际读取到的数据长度
                if (ret == -1)
                {
                    continue;
                }
                else if (ret == 0)
                {
                    continue;
                }
                else
                {
                    for (int i = 0; i < ret; ++i) // 逐字节处理信号
                    {
                        switch (signals[i])
                        {
                            // 子进程退出,表示有某个客户端关闭了连接
                        case SIGCHLD:
                        {
                            pid_t pid;
                            int stat;
                            while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) // 等待子进程退出,并获得子进程的返回信息,从而避免僵尸进程,非0表示正常退出
                            {
                                // 用子进程的pid取得被关闭的客户连接的编号
                                int del_user = sub_process[pid];
                                sub_process[pid] = -1;
                                if ((del_user < 0) || (del_user > USER_LIMIT))
                                {
                                    continue;
                                }
                                // 清除第del_user个客户连接使用的相关数据
                                epoll_ctl(epollfd, EPOLL_CTL_DEL, users[del_user].pipefd[0], 0); // 修改标志位:删除相关数据
                                close(users[del_user].pipefd[0]);                                // 关闭管道的可读端
                                users[del_user] = users[--user_count];                           // 将最后一个客户放到前面
                                sub_process[users[del_user].pid] = del_user;                     // 记录关闭连接的用户的进程号
                            }
                            if (terminate && user_count == 0) // 无客户,且触发中断信号
                            {
                                stop_server = true;
                            }
                            break;
                        }
                        case SIGTERM:
                        case SIGINT:
                        {
                            // 结束服务器程序
                            printf("kill all the clild now\n");
                            if (user_count == 0)
                            {
                                stop_server = true;
                                break;
                            }
                            for (int i = 0; i < user_count; ++i)
                            {
                                int pid = users[i].pid;
                                kill(pid, SIGTERM);
                            }
                            terminate = true;
                            break;
                        }
                        default:
                        {
                            break;
                        }
                        }
                    }
                }
            }
            else if (events[i].events & EPOLLIN) // 某个子进程向父进程写入了数据
            {
                int child = 0;
                // 读取管道数据,child变量记录了是哪个客户连接有数据到达
                ret = recv(sockfd, (char *)&child, sizeof(child), 0);
                printf("read data from child accross pipe\n");
                if (ret == -1)
                {
                    continue;
                }
                else if (ret == 0)
                {
                    continue;
                }
                else
                {
                    // 向除负责处理第child个客户连接的子进程之外的其他子进程发送消息,通知它们有客户数据要写
                    for (int j = 0; j < user_count; ++j)
                    {
                        if (users[j].pipefd[0] != sockfd)
                        {
                            printf("send data to child accross pipe\n");
                            send(users[j].pipefd[0], (char *)&child, sizeof(child), 0);
                        }
                    }
                }
            }
        }
    }
    del_resource();
    return 0;
}

需要注意的是:

  • 虽然我们使用了共享内存,但每个子进程都只会往自己所处理的客户连接所对应的那一部分读缓存中写入数据,所以我们使用共享内存的目的只是为了“共享读”。因此,每个子进程在使用共享内存的时候都无须加锁。这样做符合“聊天室服务器”的应用场景,同时提高了程序性能。
  • 我们的服务器程序在启动的时候给数组 users 分配了足够多的空间,使得它可以存储所有可能的客户连接的相关数据。同样,我们一次性给数组 sub_process 分配的空间也足以存储所有可能的子进程的相关数据。这是牺牲空间换取时间的又一例子。

七、消息队列

消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式。每个数据块都有一个特定的类型,接收方可以根据类型来有选 择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。

Linux 消息队列的 API 都定义在 sys/msg.h 头文件中,包括 4 个系统调用:msgget 、msgsnd 、msgrcv 和 msgctl。

1. msgget 系统调用

msgget 系统调用创建一个消息队列,或者获取一个已有的消息队列。其定义如下:

#include<sys/msg.h>
int msgget(key_t key, int msgflg);

和 semget 系统调用一样,key 参数是一个键值,用来标识一个全局唯一的消息队列;msgflg 参数的使用和含义与 semget 系统调用的 sem_flags 参数相同。msgget 成功时返回一个正整数值,它是消息队列的标识符。msgget 失败时返回 -1 ,并设置 errno 。

如果 msgget 用于创建消息队列,则与之关联的内核数据结构 msqid_ds 将被创建并初始化。msqid_ds 结构体的定义如下:

struct msqid_ds { 
	struct ipc_perm msg_perm;/*消息队列的操作权限*/ 
	time_t msg_stime;/*最后一次调用msgsnd的时间*/ 
	time_t msg_rtime;/*最后一次调用msgrcv的时间*/ 
	time_t msg_ctime;/*最后一次被修改的时间*/ 
	unsigned long__msg_cbytes;/*消息队列中已有的字节数*/ 
	msgqnum_t msg_qnum;/*消息队列中已有的消息数*/ 
	msglen_t msg_qbytes;/*消息队列允许的最大字节数*/ 
	pid_t msg_lspid;/*最后执行msgsnd的进程的PID*/ 
	pid_t msg_lrpid;/*最后执行msgrcv的进程的PID*/ };

2. msgsnd 系统调用

msgsnd 系统调用把一条消息添加到消息队列中。其定义如下:

#include<sys/msg.h>
struct msgbuf { 
	long mtype;/*消息类型*/ 
	char mtext[512];/*消息数据*/ 
};
int msgsnd(int msqid,const void*msg_ptr,size_t msg_sz,int msgflg);
  • msqid:是由 msgget 调用返回的消息队列标识符;
  • mtype:指定消息的类型,它必须是一个正整数;
  • mtext:是消息数据;
  • msg_sz:是消息的数据部分(mtext)的长度。这个长度可以为 0 ,表示没有消息数据。
  • msgflg:控制 msgsnd 的行为。它通常仅支持 IPC_NOWAIT 标志,即以非阻塞的方式发送消息。默认情况下,发送消息时如果消息队列满了,则 msgsnd 将阻塞。若 IPC_NOWAIT 标志被指定,则 msgsnd 将立 即返回并设置 errno 为 EAGAIN 。处于阻塞状态的 msgsnd 调用可能被如下两种异常情况所中断:
    • 消息队列被移除:此时 msgsnd 调用将立即返回并设置 errno 为 EIDRM ;
    • 程序接收到信号:此时 msgsnd 调用将立即返回并设置 errno 为 EINTR ;
  • msgsnd 成功时返回 0 ,失败则返回 -1 并设置 errno 。msgsnd 成功时将修改内核数据结构 msqid_ds 的部分字段,如下所示:
    • 将 msg_qnum 加 1 ;
    • 将 msg_lspid 设置为调用进程的 PID ;
    • 将 msg_stime 设置为当前的时间。

3. msgrcv 系统调用

msgrcv 系统调用从消息队列中获取消息。其定义如下:

#include<sys/msg·h>
int msgrcv(int msqid,void*msg_ptr,size_t msg_sz,long int msgtype,int msgflg);
  • msqid:是由 msgget 调用返回的消息队列标识符;
  • msg_ptr:用于存储接收的消息;
  • msg_sz:指的是消息数据部分的长度;
  • msgtype:指定接收何种类型的消息。可以使用如下几种方式来指定消息类型:
    • msgtype 等于 0 :读取消息队列中的第一个消息;
    • msgtype 大于 0 :读取消息队列中第一个类型为 msgtype 的消息 (除非指定了标志 MSG_EXCEPT);
    • msgtype 小于 0 :读取消息队列中第一个类型值比 msgtype 的绝对值小的消息。
  • 参数 msgflg 控制 msgrcv 函数的行为。它可以是如下一些标志的按位或:
    • IPC_NOWAIT:如果消息队列中没有消息,则 msgrcv 调用立即返回并设置 errno 为 ENOMSG ;
    • MSG_EXCEPT:如果 msgtype 大于 0 ,则接收消息队列中第一个非 msgtype 类型的消息;
    • MSG_NOERROR:如果消息数据部分的长度超过了 msg_sz ,就将它截断。
  • 处于阻塞状态的 msgrcv 调用还可能被如下两种异常情况所中断:
    • 消息队列被移除:此时 msgrcv 调用将立即返回并设置 errno 为 EIDRM ;
    • 程序接收到信号:此时 msgrcv 调用将立即返回并设置 errno 为 EINTR 。
  • msgrcv 成功时返回 0 ,失败则返回 -1 并设置 errno 。msgrcv 成功时将修改内核数据结构 msqid_ds 的部分字段,如下所示:
    • 将 msg_qnum 减 1 ;
    • 将 msg_lrpid 设置为调用进程的 PID ;
    • 将 msg_rtime 设置为当前的时间。

4. msgctl 系统调用

msgctl 系统调用控制消息队列的某些属性。其定义如下:

#include<sys/msg.h>
int msgctl(int msqid,int command,struct msqid_ds*buf);
  • msqid:是由 msgget 调用返回的共享内存标识符;
  • command:指定要执行的命令;msgctl 支持的所有命令如下:
    命令含义msgctl 成功时的返回值
    IPC_STAT将消息队列关联的内核数据结构复制到 buf 中0
    IPC_SET将 buf 中的部分成员复制到消息队列关联的内核数据结构中,同时内核数据中的 msqid_ds.msg_ctime 被更新0
    IPC_RMID立即移除消息队列,唤醒所有等待读消息和写消息的进程(这些调用立即返回并设置 errno 为 EIDRM)0
    IPC_INFO获取系统消息队列资源配置信息,将结果存储在 buf 中。应用程序需要将 buf 转换成 msginfo 结构体类型来读取这些系统信息。msginfo 结构体与 seminfo 类似内核消息队列信息数组中已经被使用的项的最大索引值
    MSG_INFO与 IPC_INFO 类似,不过返回的是已经分配的消息队列占用的资源信息同 IPC_INFO
    MSG_STAT与 IPC_STAT 类似,不过此时 msqid 参数不是用来表示消息队列标识符,而是内核消息队列信息数组的索引(每个消息队列的信息都是该数组中的一项)内核消息队列信息数组中索引值为 msqid 的消息队列的标识符
  • msgctl 成功时的返回值取决于 command 参数。 msgctl 函数失败时返回 -1 并设置 errno 。

八、IPC 命令

上述 3 种 System V IPC 进程间通信方式都使用一个全局唯一的键值(key)来描述一个共享资源。当程序调用 semget 、shmget 或者 msgget 时,就创建了这些共享资源的一个实例。Linux 提供了 ipcs 命令,以观察当前系统上拥有哪些共享资源实例。

九、在进程间传递文件描述符

由于 fork 调用之后,父进程中打开的文件描述符在子进程中仍然保持打开,所以文件描述符可以很方便地从父进程传递到子进程。需要注意的是,传递一个文件描述符并不是传递一个文件描述符的值,而是要在接收进程中创建一个新的文件描述符,并且该文件描述符和发送进程中被传递的文件描述符指向内核中相同的文件表项。

那么如何把子进程中打开的文件描述符传递给父进程呢?或者更通俗地说,如何在两个不相干的进程之间传递文件描述符呢?在 Linux 下,我们可以利用 UNIX 域 socket 在进程间传递特殊的辅助数据,以实现文件描述符的传递。下述代码给出了一个实例,它在子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件的内容:

// fdTransfer.cpp
#include <sys/socket.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
static const int CONTROL_LEN = CMSG_LEN(sizeof(int));
// 发送文件描述符,fd参数是用来传递信息的UNIX域socket,fd_to_send参数是待发送的文件描述符
void send_fd(int fd, int fd_to_send)
{
    struct iovec iov[1];
    struct msghdr msg;
    char buf[0];
    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;
    cmsghdr cm;
    cm.cmsg_len = CONTROL_LEN;
    cm.cmsg_level = SOL_SOCKET;
    cm.cmsg_type = SCM_RIGHTS;
    *(int *)CMSG_DATA(&cm) = fd_to_send;
    msg.msg_control = &cm;
    // 设置辅助数据
    msg.msg_controllen = CONTROL_LEN;
    sendmsg(fd, &msg, 0);
}
// 接收目标文件描述符
int recv_fd(int fd)
{
    struct iovec iov[1];
    struct msghdr msg;
    char buf[0];
    iov[0].iov_base = buf;
    iov[0].iov_len = 1;
    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = iov;
    msg.msg_iovlen = 1;
    cmsghdr cm;
    msg.msg_control = &cm;
    msg.msg_controllen = CONTROL_LEN;
    recvmsg(fd, &msg, 0);
    int fd_to_read = *(int *)CMSG_DATA(&cm);
    return fd_to_read;
}
int main()
{
    int pipefd[2];
    int fd_to_pass = 0;
    // 创建父、子进程间的管道,文件描述符pipefd[0]和pipefd[1]都是UNIX域socket
    int ret = socketpair(PF_UNIX, SOCK_DGRAM, 0, pipefd);
    assert(ret != -1);
    pid_t pid = fork();
    assert(pid >= 0);
    if (pid == 0)
    {
        close(pipefd[0]);
        fd_to_pass = open("test.txt", O_RDWR, 0666);
        // 子进程通过管道将文件描述符发送到父进程。如果文件test.txt打开失败,则子进程将标准输入文件描述符发送到父进程
        send_fd(pipefd[1], (fd_to_pass > 0) ? fd_to_pass : 0);
        close(fd_to_pass);
        exit(0);
    }
    close(pipefd[1]);
    fd_to_pass = recv_fd(pipefd[0]);
    // 父进程从管道接收目标文件描述符
    char buf[1024];
    memset(buf, '\0', 1024);
    read(fd_to_pass, buf, 1024);
    // 读目标文件描述符,以验证其有效性
    printf("I got fd %d and data %s\n", fd_to_pass, buf);
    close(fd_to_pass);
}
;