Lazy loaded image
learn-linux
Linux 网络编程学习笔记——十三、多进程编程
字数 17378阅读时长≈ 44 分钟
2026-10-16
2026-10-21

BeZer0 SpaceBeZer0 SpaceLinux 网络编程学习笔记 | BeZer0 Space

进程是Linux操作系统环境的基础,它控制着系统上几乎所有的活动。本章从系统程序员的角度来讨论 Linux 多进程编程,包括如下内容:
  • 复制进程映像的 fork 系统调用和替换进程映像的 exec 系列系统调用;
  • 僵尸进程以及如何避免僵尸进程;
  • 进程间通信(Inter-Process Communication,IPC)最简单的方式:管道;
  • 3 种 System V 进程间通信方式:信号量、消息队列和共享内存。它们都是由 AT&T System V2 版本的 UNIX 引入的,所以统称为 System V IPC ;
  • 在进程间传递文件描述符的通用方法:通过 UNIX 本地域 socket 传递特殊的辅助数据。

一、fork 系统调用

Linux 下创建新进程的系统调用是 fork 。其定义如下:
该函数的每次调用都返回两次,在父进程中返回的是子进程的 PID ,在子进程中则返回 0 。该返回值是后续代码判断当前进程是父进程还是子进程的依据。fork 调用失败时返回 -1 ,并设置 errno 。
fork 函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的 PPID 被设置成原进程的 PID ,信号位图被清除(原进程设置的信号处理函数不再对新进程起作用)。
子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是所谓的写时复制(copy on writte),即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(先是缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。即便如此,如果我们在程序中分配了大量内存,那么使用 fork 时也应当十分谨慎,尽量避免没必要的内存分配和数据复制。
此外,创建子进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加 1 。不仅如此,父进程的用户根目录、当前工作目录等变量的引用计数均会加 1 。

二、exec 系列系统调用

有时我们需要在子进程中执行其他程序,即替换当前进程映像,这就需要使用如下 exec 系列函数之一:
  • path:指定可执行文件的完整路径;
  • file:可以接受文件名,该文件的具体位置则在环境变量 PATH 中搜寻;
  • arg:接受可变参数,argv 则接受参数数组,它们都会被传递给新程序(path 或 file 指定的程序) 的 main 函数;
  • envp:用于设置新程序的环境变量。如果未设置它,则新程序将使用由全局变量 environ 指定的环境变量。
一般情况下,exec 函数是不返回的,除非出错。它出错时返回 -1 ,并设置 errno 。如果没出错,则原程序中 exec 调用之后的代码都不会执行,因为此时原程序已经被 exec 的参数指定的程序完全替换(包括代码和数据)。
exec 函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似 SOCK_CLOEXEC 的属性。

三、处理僵尸进程

对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。在子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程处于僵尸态。另外一种使子进程进入僵尸态的情况是:父进程结束或者异常终止,而子进程继续运行。此时子进程的 PPID 将被操作系统设置为 1 ,即 init 进程。init 进程接管了该子进程,并等待它结束。在父进程退出之后,子进程退出之前,该子进程处于僵尸态。
由此可见,无论哪种情况,如果父进程没有正确地处理子进程的返回信息,子进程都将停留在僵尸态,并占据着内核资源。这是绝对不能容许的,毕竟内核资源有限。下面这对函数在父进程中调用,以等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程的僵尸态立即结束:
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 函数以“彻底结束”一个子进程:

四、管道

管道能在父、子进程间传递数据,利用的是 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 系统调用创建一个新的信号量集,或者获取一个已经存在的信号量集。其定义如下:
  • 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 将被创建并初始化:
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 之前,需要先介绍与每个信号量关联的一些重要的内核变量:
semop 对信号量的操作实际上就是对这些内核变量的操作。semop 的定义如下:
  • 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 操作。
  • 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 。
  • 如果 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 ;
  • 如果此时信号量的值是 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 。
  • 如果信号量的值 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 系统调用允许调用者对信号量进行直接控制。其定义如下:
  • 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
命令
含义
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 头文件给出了它的推荐格式,具体如下:
    • 注意:这些操作中,GETNCNT 、GETPID 、GETVAL 、GETZCNT 和 SETVAL 操作的是单个信号量,它是由标识符 sem_id 指定的信号量集中的第 sem_num 个信号量;而其他操作针对的是整个信号量集,此时 semctl 的参数 sem_num 被忽略。
注意:这些操作中,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 机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。因此,共享内存通常和其他进程间通信方式一起使用。
Linux 共享内存的 API 都定义在 sys/shm.h 头文件中,包括 4 个系统调用:shmget 、shmat 、shmdt 和 shmctl 。

1. shmget 系统调用

shmget 系统调用创建一段新的共享内存,或者获取一段已经存在的共享内存。其定义如下:
和 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 结构体的定义如下:
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 系统调用

共享内存被创建/获取之后,我们不能立即访问它,而是需要先将它关联到进程的地址空间中。使用完共享内存之后,我们也需要将它从进程地址空间中分离。这两项任务分别由如下两个系统调用实现:
  • 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_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:它指定对共享内存段的执行权限。对共享内存而言,执行权限实际上和读权限是一样的。
  • SHM_RDONLY:进程仅能读取共享内存中的内容。若没有指定该标志,则进程可同时对共享内存进行读写操作(当然,这需要在创建共享内存的时候指定其读写权限);
  • SHM_REMAP:如果地址 shmaddr 已经被关联到一段共享内存上,则重新关联。
  • SHM_EXEC:它指定对共享内存段的执行权限。对共享内存而言,执行权限实际上和读权限是一样的。
  • hmat 成功时返回共享内存被关联到的地址,失败则返回 (void*)-1 并设置 errno 。shmat 成功时,将修改内核数据结构 shmid_ds 的部分字段,如下:
    • 将 shm_nattach 加 1 ;
    • 将 shm_lpid 设置为调用进程的 PID ;
    • 将 shm_atime 设置为当前的时间。
  • 将 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 设置为当前的时间。
  • 将 shm_nattach 减 1 ;
  • 将 shm_lpid 设置为调用进程的 PID ;
  • 将 shm_dtime 设置为当前的时间。

3. shmctl 系统调用

shmctl 系统调用控制共享内存的某些属性。其定义如下:
其中,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 共享内存对象:
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 。
  • 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 创建的共享内存对象使用完之后也需要被删除。这个过程是通过如下函数实现的:
该函数将 name 参数指定的共享内存对象标记为等待删除。当所有使用该共享内存对象的进程都使用 ummap 将它从进程中分离之后,系统将销毁这个共享内存对象所占据的资源。
如果代码中使用了上述 POSIX 共享内存函数,则编译的时候需要指定链接选项 -lrt 。

5. 共享内存实例

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

七、消息队列

消息队列是在两个进程之间传递二进制块数据的一种简单有效的方式。每个数据块都有一个特定的类型,接收方可以根据类型来有选 择地接收数据,而不一定像管道和命名管道那样必须以先进先出的方式接收数据。
Linux 消息队列的 API 都定义在 sys/msg.h 头文件中,包括 4 个系统调用:msgget 、msgsnd 、msgrcv 和 msgctl。

1. msgget 系统调用

msgget 系统调用创建一个消息队列,或者获取一个已有的消息队列。其定义如下:
和 semget 系统调用一样,key 参数是一个键值,用来标识一个全局唯一的消息队列;msgflg 参数的使用和含义与 semget 系统调用的 sem_flags 参数相同。msgget 成功时返回一个正整数值,它是消息队列的标识符。msgget 失败时返回 -1 ,并设置 errno 。
如果 msgget 用于创建消息队列,则与之关联的内核数据结构 msqid_ds 将被创建并初始化。msqid_ds 结构体的定义如下:

2. msgsnd 系统调用

msgsnd 系统调用把一条消息添加到消息队列中。其定义如下:
  • 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 调用将立即返回并设置 errno 为 EIDRM ;
  • 程序接收到信号:此时 msgsnd 调用将立即返回并设置 errno 为 EINTR ;
  • msgsnd 成功时返回 0 ,失败则返回 -1 并设置 errno 。msgsnd 成功时将修改内核数据结构 msqid_ds 的部分字段,如下所示:
    • 将 msg_qnum 加 1 ;
    • 将 msg_lspid 设置为调用进程的 PID ;
    • 将 msg_stime 设置为当前的时间。 ## 3. msgrcv 系统调用 msgrcv 系统调用从消息队列中获取消息。其定义如下:
  • 将 msg_qnum 加 1 ;
  • 将 msg_lspid 设置为调用进程的 PID ;
  • 将 msg_stime 设置为当前的时间。 ## 3. msgrcv 系统调用 msgrcv 系统调用从消息队列中获取消息。其定义如下:
  • msqid:是由 msgget 调用返回的消息队列标识符;
  • msg_ptr:用于存储接收的消息;
  • msg_sz:指的是消息数据部分的长度;
  • msgtype:指定接收何种类型的消息。可以使用如下几种方式来指定消息类型:
    • msgtype 等于 0 :读取消息队列中的第一个消息;
    • msgtype 大于 0 :读取消息队列中第一个类型为 msgtype 的消息 (除非指定了标志 MSG_EXCEPT);
    • msgtype 小于 0 :读取消息队列中第一个类型值比 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 ,就将它截断。
  • 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 系统调用控制消息队列的某些属性。其定义如下:
  • 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 在进程间传递特殊的辅助数据,以实现文件描述符的传递。下述代码给出了一个实例,它在子进程中打开一个文件描述符,然后将它传递给父进程,父进程则通过读取该文件描述符来获得文件的内容:
 
原创存证 · 本地哈希 · 54419ad3b0ea...
算法:SHA-256
时间:2026-10-21
哈希:54419ad3b0ea30b2ed7054e3947b3cc1fd8254a995d93e6b26ddd53fb8661255

该信息用于辅助证明当前内容版本,不等同于法律公证或版权登记。

上一篇
Linux 网络编程学习笔记——十二、高性能 I_O 框架库 Libevent
下一篇
Linux 网络编程学习笔记——十一、定时器

评论
Loading...