图 1:本章导图——进程之间如何”对话”

这一篇在干嘛?

第 5 章讲同步时,主角是内核控制路径;本章轮到用户态进程。进程之间要协作就必须同步和交换数据,而这全靠内核牵线。本章覆盖四大机制:管道与 FIFO(生产者/消费者场景)、System V IPC(信号量、消息队列、共享内存)、以及 POSIX 消息队列。其中 IPC 共享内存还与第 17 章的交换子系统和第 16 章的内存映射紧密联动,是全书内容的精彩汇合点。

通信机制总览 | 管道 | FIFO | System V IPC 框架 | IPC 信号量 | IPC 消息 | IPC 共享内存 | POSIX 消息队列

通信机制总览

用户态进程当然可以靠文件锁(/linux内核/lk12)同步:建一个文件、用 VFS 系统调用加解锁;也可以靠临时文件共享数据。但这套做法要反复访问磁盘文件系统,代价太高。所以所有 Unix 内核都内置了一组不经过文件系统的进程通信系统调用,库函数再把它们包装得更顺手。

按用途分,Unix 提供这些基本机制:

  • 管道与 FIFO(命名管道):最适合生产者/消费者模式——一些进程往里写数据,另一些进程从里读数据。
  • 信号量:第 5 章内核信号量(/linux内核/lk05)的用户态版本。
  • 消息:进程通过预定义的消息队列读写”短数据块”。Linux 有两套:System V IPC 消息和 POSIX 消息。
  • 共享内存区:多个进程读写同一块内存。需要交换大量数据时,这是最高效的进程通信形式。
  • 套接字:跨机器通过网络交换数据,也可以用于同一台主机上的进程间通信(比如 X Window 的客户端与 X 服务器之间)。

选型口诀:小数据流式传递用管道;无关进程之间通信用 FIFO;简单的互斥/计数用信号量;结构化的消息传递用消息队列;大量数据高性能共享用共享内存。

管道

管道是所有 Unix 都有的进程通信机制:一个单向数据流——一个进程写进管道的数据,被内核路由给另一个进程读。

shell 里用 | 操作符创建管道。ls | more 让 shell 创建两个由管道连接的进程:ls 的标准输出重定向进管道,more 从管道读输入。等价做法是 ls > tempmore < temp——但用管道更好:命令更短,还不用事后清理临时文件。

使用管道

可以把管道看作在已挂载文件系统中没有磁盘映像的已打开文件。进程用 pipe() 系统调用创建管道,得到一对文件描述符;通过 fork() 把描述符传给子孙进程共享。用第一个描述符 read(),用第二个描述符 write()。

POSIX 只定义了半双工管道:虽然 pipe() 返回两个描述符,每个进程用其中一个之前必须关掉另一个;需要双向传输就得调两次 pipe() 建两条管道。System V Release 4 等系统实现全双工管道(两个描述符都可读写,两条双向通道)。Linux 折中:每个描述符仍然单向,但不必先关掉另一个再使用。

回顾 ls | more 的完整流程,shell 做三件事:pipe() 假设返回 3(读端)和 4(写端)→ fork() 两次 → close() 两次释放自己的 3 和 4。第一个子进程(要跑 ls):dup2(4,1) 把 4 号复制成 1 号(标准输出从此指向管道写端)→ close(3)、close(4) → execve() 执行 ls——ls 往标准输出(1 号描述符)写,实际写进了管道。第二个子进程(要跑 more):dup2(3,0) 把 3 号复制成 0 号(标准输入指向读端)→ close(3)、close(4) → 执行 more——more 从标准输入(0 号)读,实际读了管道。

一个管道实际可以被任意多个进程使用,但多个进程读写同一管道时必须自己用文件锁或 IPC 信号量同步访问。

C 库还提供 popen()/pclose() 包装函数包办脏活。popen(命令路径, 类型) 的流程:pipe() 建管道 → fork 子进程,子进程按 type 把管道相应端的描述符复制到 1(type 为 r 时复制写端)或 0(type 为 w 时复制读端)、关掉 pipe 返回的原始描述符、execve 执行指定程序 → 父进程关掉不用的那一端 → 返回指向 FILE 结构的指针。之后父进程就能用 fprintf()/fscanf() 等高层 I/O 与子进程交换数据。pclose() 只是调 wait4() 等 popen 创建的进程终止。

管道的数据结构

管道通过 read()/write() VFS 系统调用访问,所以内核为每个管道创建一个 inode 对象 + 两个文件对象(读一个、写一个)。

inode 的 i_pipe 字段指向 pipe_inode_info 结构:

字段含义
wait管道/FIFO 等待队列
nrbufs含有待读数据的缓冲个数
curbuf第一个含待读数据的缓冲的下标
bufs[16]管道缓冲描述符数组
tmp_page缓存页框指针
start当前管道缓冲内的读位置
readers / writers读/写进程标志(或数量)
waiting_writers睡在等待队列里的写进程数
r_counter / w_counter类似 readers/writers,供等待读写方时使用
fasync_readers / fasync_writers用于信号的异步 I/O 通知

核心是管道缓冲(pipe buffer):一个装着”已写入、尚未读出”数据的页框。直到 Linux 2.6.10,每个管道只有一个管道缓冲;2.6.11 内核大改之后每个管道有 16 个,大幅提升了向管道写大块数据的应用性能。

pipe_buffer 对象描述每个缓冲:

字段含义
page缓冲页框的页描述符地址
offset页框内有效数据的当前位置
len管道缓冲内有效数据长度
ops管道缓冲方法表地址(空缓冲为 NULL)

ops 指向 anon_pipe_buf_ops 表,含三个方法:map(访问数据前调,对页框执行 kmap()——万一是高端内存页框呢,见 /linux内核/lk08)、unmap(访问结束执行 kunmap())、release(释放缓冲时调,实现了一个单页内存缓存:被释放的不是存数据的那个页框,而是把它和 tmp_page 指向的缓存页框互换——回收腾出来的页框先存着,下次写管道时直接用,省一次分配)。

16 个管道缓冲可以看作一个全局循环缓冲:写进程不断往里添数据,读进程不断取走。当前已写未读的总字节数叫管道大小(pipe size)。为效率起见,待读数据可以分散在多个半满的缓冲里——写操作发现上一个缓冲装不下新数据时,直接启用一个新空缓冲。内核要追踪两处:下一个待读字节所在的缓冲及其页内偏移(curbuf 字段 + 对应 pipe_buffer 的 offset 字段);第一个空缓冲的下标(curbuf + nrbufs,模 16)。管道数据结构的并发保护靠 inode 内置的 i_sem 信号量

pipefs 特殊文件系统

管道是一组没有磁盘映像的 VFS 对象。Linux 2.6 把它们组织进 pipefs 特殊文件系统/linux内核/lk12)统一管理。它在系统目录树里没有挂载点,用户永远看不到它;但有了 pipefs,管道就完全融入 VFS 层,内核可以用处理 FIFO 的同一套方式处理它。

init_pipe_fs() 在内核初始化时注册并挂载 pipefs:

struct file_system_type pipe_fs_type;
pipe_fs_type.name = "pipefs";
pipe_fs_type.get_sb = pipefs_get_sb;
pipe_fs.kill_sb = kill_anon_super;
register_filesystem(&pipe_fs_type);
pipe_mnt = do_kern_mount("pipefs", 0, "pipefs", NULL);

pipefs 根目录对应的已挂载文件系统对象存在 pipe_mnt 变量里。

创建与销毁管道

pipe() 系统调用由 sys_pipe() 服务,后者调 do_pipe(),步骤:get_pipe_inode() 在 pipefs 里分配并初始化 inode——分配新 inode、分配 pipe_inode_info 存进 i_pipe、curbuf/nrbufs 清零、bufs 数组清零、r_counter/w_counter 置 1、readers/writers 置 1 → 为读端分配文件对象和描述符(f_flag = O_RDONLY,f_op 指向 read_pipe_fops 表)→ 为写端同样处理(O_WRONLY,write_pipe_fops 表)→ 分配 dentry 对象把两个文件对象和 inode 联系起来,把新 inode 插进 pipefs → 返回两个描述符。

发起 pipe() 的进程起初是唯一能访问该管道的进程(读、写都算),所以 readers/writers 都是 1。这两个字段只在对应文件对象仍被打开时为 1,文件对象被释放就置 0。fork 不会让它们增值——它们永远不超过 1;fork 增加的是所有文件对象的使用计数,所以即使父进程死了,对象也不释放,管道对子进程保持可用。

进程对管道描述符调 close() 时,内核对文件对象执行 fput() 递减使用计数;计数归 0 就调用文件操作的 release 方法——按读/写端分别是 pipe_read_release()/pipe_write_release(),都转入 pipe_release():把 readers 或 writers 置 0;若两者都为 0,对所有管道缓冲调 release 方法(页框归还伙伴系统)并释放 tmp_page 缓存页;否则唤醒睡在管道等待队列上的进程,让它们察觉管道状态变化。

从管道读

POSIX 对读操作规定了细致的行为。设管道大小为 p(待读字节数)、请求读 n 字节:

条件阻塞读非阻塞读
p = 0,有写进程且有睡眠的写者等待数据到来后拷贝并返回其大小返回 -EAGAIN
p = 0,有写进程无睡眠写者拷贝 n 字节并返回 n,缓冲空时等待数据返回 -EAGAIN
p = 0,无写进程返回 0返回 0
0 < p < n拷贝 p 字节并返回 p(管道被读空)同左
p ≥ n拷贝 n 字节并返回 n(管道剩 p-n 字节)同左

两种情况会阻塞当前进程:系统调用开始时管道是空的;管道里数据不足 n 字节且先前有写进程在等待缓冲空间。读可以是非阻塞的——管道没法用 open() 的 O_NONBLOCK(管道打不开),要靠对描述符调 fcntl() 设 O_NONBLOCK。read() 返回 0 当且仅当管道为空且已没有进程持有写端文件对象——这正是”读端判断写端已关闭”的依据。

pipe_read() 的步骤:拿 inode 的 i_sem → nrbufs 为 0(全空)时按上表决定返回还是阻塞——阻塞则 prepare_to_wait() 排队 → 放信号量 → schedule() → 醒来 finish_wait()、重新拿信号量、回到开头 → 从 curbuf 取当前缓冲下标 → 调缓冲的 map 方法 → 把请求字节数(或缓冲里剩余字节数,取小者)拷进用户地址空间 → unmap → 更新 offset 和 len → 缓冲被读空(len 为 0)则调 release 方法释放页框、ops 置 NULL、curbuf 前进、nrbufs 减一 → 没拷够且 nrbufs 非 0 就回第 3 步继续读下一个缓冲 → 缓冲全空但有睡眠的写进程且读是阻塞式,唤醒等待队列上所有进程后回头重试 → 放 i_sem → 唤醒所有睡眠的写进程 → 返回拷贝的字节数。

向管道写

POSIX 对写操作同样有规定。设缓冲空闲字节数为 u、请求写 n 字节:

条件阻塞写非阻塞写
u < n ≤ 4,096,有读进程等 n-u 字节被腾出后拷 n 字节返回 n返回 -EAGAIN
n > 4,096,有读进程拷 n 字节(必要时等待)返回 nu>0 时拷 u 字节返回 u;u=0 返回 -EAGAIN
u ≥ n拷 n 字节返回 n同左
无读进程发 SIGPIPE 信号,返回 -EPIPE同左

关键规则:少量字节的写必须是原子的——两个以上进程并发写同一管道时,每次不超过 4,096 字节(管道缓冲大小)的写必须完整完成、不能被其他进程的写穿插;超过 4,096 字节的写可以不原子、也可能让进程睡眠。

另一条铁律:管道没有读进程时写必须失败——内核给写进程发 SIGPIPE 信号,write() 以 -EPIPE 错误码结束(这就是经典的 “Broken pipe” 报错来源)。

pipe_write() 的步骤:拿 i_sem → 检查有无读进程,没有就发 SIGPIPE、放信号量、返回 -EPIPE → 由 curbuf + nrbufs - 1 算出最后写入的缓冲下标;它装得下全部数据则 map → 拷贝 → unmap → 更新 len,收工 → nrbufs 等于 16(没有空缓冲)时:非阻塞写直接返回 -EAGAIN;阻塞写则 waiting_writers 加一、prepare_to_wait() 排队、放信号量、schedule(),醒来后 finish_wait()、重新拿锁、waiting_writers 减一、回头重试 → 有空缓冲了:由 curbuf + nrbufs 定位第一个空缓冲 → 伙伴系统分配新页框(除非 tmp_page 缓存页可用)→ 从用户地址空间拷至多 4,096 字节进页框(必要时临时映射到内核线性地址空间)→ 填 pipe_buffer:page 指向页描述符、ops 指向 anon_pipe_buf_ops、offset = 0、len = 写入字节数 → nrbufs 加一 → 没写完就回第 4 步 → 放 i_sem → 唤醒所有睡眠的读进程 → 返回写入字节数。

常见坑:Broken pipe 与 read 返回 0

写端常见的 “Broken pipe” 是因为所有读进程都退出了(readers 为 0),内核主动发 SIGPIPE 并返回 -EPIPE——不是 bug,是设计。反过来,读端收到 read() 返回 0(EOF)意味着”管道空且写端已全部关闭”;写端还在但暂时没数据时,阻塞读会等待而不是返回 0。分不清这两者,生产者/消费者程序的退出逻辑就写不对。

FIFO

管道有一个致命短板:已经存在的管道无法被”打开”——两个任意进程要共享管道,除非管道由它们的共同祖先创建。对很多应用这是硬伤:比如数据库服务器持续响应各客户端的查询,服务器和按需被 shell 创建的客户端之间根本没有共同管道可用。

解决方案是命名管道(named pipe)或 FIFO(first in, first out——先写入的字节先被读出)。FIFO 像管道:打开后由内核缓冲暂存交换的数据,不占磁盘块;但因为有磁盘 inode、文件名出现在系统目录树里,任何进程都能打开它。前面数据库的例子:服务器启动时创建一个 FIFO 供客户端发请求;每个客户端在连接前创建另一个 FIFO 供服务器写回答,并把 FIFO 名字附在首条请求里。

Linux 2.6 里 FIFO 和管道几乎一样,用同一套 pipe_inode_info 结构,读写也是同一个 pipe_read()/pipe_write()。只有两处实质差别:

  • FIFO 的 inode 出现在系统目录树里,而不是 pipefs 里;
  • FIFO 是双向通信通道——可以以读/写模式打开。

创建与打开 FIFO

用 mknod() 系统调用创建,参数是路径名和 S_IFIFO(0x10000)与权限位掩码的按位或;POSIX 的 mkfifo() 在 Linux 里就是个调 mknod() 的 C 库函数。创建后用寻常的 open()/read()/write()/close() 访问,但 VFS 特殊处理它——FIFO 的 inode 和文件操作是定制的,与它存放于哪个文件系统无关。

打开 FIFO 时 VFS 走设备文件的流程(/linux内核/lk13):文件系统相关的 read_inode 方法发现磁盘 inode 代表特殊文件,调 init_special_inode() 把 inode 的 i_fop 设为 def_fifo_fops 表;内核随后执行该表的 open 方法,即 fifo_open()

  1. 拿 inode 的 i_sem 信号量。
  2. i_pipe 为 NULL(FIFO 尚未打开过)则分配并初始化新的 pipe_inode_info,动作与管道创建的 1b~1e 步相同。
  3. 按访问模式给文件对象的 f_op 设相应操作表:
访问类型文件操作表读方法写方法
只读read_fifo_fopspipe_read()bad_pipe_w()
只写write_fifo_fopsbad_pipe_r()pipe_write()
读/写rdwr_fifo_fopspipe_read()pipe_write()
  1. 只读或读/写模式:readers 和 r_counter 加一;若是只读且此前没有读进程,唤醒等待队列里睡眠的写进程。
  2. 只写或读/写模式:writers 和 w_counter 加一;若是只写且此前没有写进程,唤醒睡眠的读进程。
  3. 没有读方或没有写方时,按 POSIX 规则决定阻塞还是报错:
访问类型阻塞非阻塞
只读,有写者成功返回成功返回
只读,无写者等待写者出现成功返回
只写,有读者成功返回成功返回
只写,无读者等待读者出现返回 -ENXIO
读/写成功返回成功返回
  1. 放信号量,返回 0。

三张文件操作表的差别全在读写方法:允许读的用 pipe_read(),否则用只会返回错误码的 bad_pipe_r();写同理。

System V IPC 框架

System V IPC 是一组让用户态进程完成三件事的机制:用信号量互相同步、互相收发消息、共享一块内存。它最早出现在”Columbus Unix”中,后被 AT&T System III 采纳,如今几乎所有 Unix 系统都有,Linux 也不例外。

三个关键特性:IPC 资源(信号量、消息队列、共享内存区)在进程请求时动态创建;资源是持久的——除非进程显式删除,它一直驻留内存直到系统关机;资源可被任何进程使用,包括与创建者毫无亲缘关系的进程。

IPC 键与 IPC 标识符

因为同类资源可以有多个,每个资源由 32 位 IPC 键标识(类似文件的路径名),并拥有 32 位 IPC 标识符(类似打开文件的描述符)。标识符由内核分配、全系统唯一;键由程序员自由选择。通信各方都引用资源的标识符

semget()/msgget()/shmget() 三个函数分别创建信号量、消息队列、共享内存区,核心目标都是从传入的 IPC 键推出对应的 IPC 标识符;键没有对应资源时就新建。出错时返回:

错误码含义
EACCESS进程没有相应访问权限
EEXIST试图用已存在的键创建资源
EINVAL参数无效
ENOENT键对应的资源不存在且未要求创建
ENOMEM存储不足
ENOSPC超出资源数量上限

两个独立进程共享一个 IPC 资源有两种方式:约定固定键——简单,但撞键风险真实存在:别的程序可能恰好选了同一个键,IPC 调用成功返回的却是错误资源的标识符;IPC_PRIVATE 键——一个进程用它创建新资源,再把标识符传给对方(或直接 fork 出对方),保证资源不会被无关应用误用。

三个函数的最后一个参数可含三个标志:IPC_CREAT(不存在则创建)、IPC_EXCL(配合 IPC_CREAT,资源已存在则失败)、IPC_NOWAIT(访问资源时不阻塞)。注意即使 IPC_CREAT 和 IPC_EXCL 都用了,也无法独占资源——别的进程总能凭标识符访问它。

为降低”拿错资源”的风险,内核不立即回收刚释放的标识符:每个新标识符几乎总大于同类型前一个资源的标识符(仅当 32 位溢出时例外)。公式:

IPC identifier = s × M + i

其中 s 是槽位使用序号(从 0 起,每次分配加一,到阈值后归零),M 是可分配资源数上限(Linux 2.6 中为 IPCMNI = 32,768),i 是槽位下标(0 ≤ i < M)。

每类 IPC 资源各有一个 ipc_ids 结构(in_use 已分配数、max_id 最大槽位下标、seq/seq_max 序号、sem 保护信号量、entries 指向 ipc_id_ary)。ipc_id_ary 的 p 字段是指向 kern_ipc_perm 结构的指针数组(每类资源的数组初始大小分别为:共享内存 1、消息队列 16、信号量 128,太小会动态扩容),size 是数组大小。管理员可通过 /proc/sys/kernel/sem、/proc/sys/kernel/msgmni、/proc/sys/kernel/shmmni 调整各类资源的数量上限。

kern_ipc_perm 是每个资源的权限描述符:lock 自旋锁、deleted 标志、key(IPC 键)、uid/gid(当前属主)、cuid/cgid(创建者)、mode(六位权限位——属主/组/其他 各有读写位,没有执行位)、seq(该资源的槽位使用序号)、security(SELinux 用)。

semctl()/msgctl()/shmctl() 管理资源:IPC_SET 改属主和权限位,IPC_STAT/IPC_INFO 查信息,IPC_RMID 删除资源;另有多类型专属子命令。资源创建后,操作它的专属函数是:semop()(获取/释放信号量)、msgsnd()/msgrcv()(发/收消息)、shmat()/shmdt()(挂接/摘除共享内存)。

ipc() 系统调用

80×86 上所有 IPC 函数都走一个系统调用 ipc()。进程调 msgget() 实际调的是 C 库包装函数,它把 msgget 的全部参数加上子命令码 MSGGET 传给 ipc();sys_ipc() 服务例程按子命令码分发到对应内核函数。这个”多路复用器”是历史遗留:当年 System V IPC 可编译成动态模块(见附录 B),为一个可能不存在的内核组件在 system_call 表里占多个表目不值,于是用一个入口。如今 IPC 不能再作为模块编译,复用器失去意义——HP 的 Alpha 和 Intel 的 IA-64 上每个 IPC 函数都有独立系统调用。

IPC 信号量

IPC 信号量与第 5 章的内核信号量(/linux内核/lk05)思想相同:用计数器为多个进程提供对共享数据结构的受控访问。资源可用时信号量为正,不可用时为 0;进程想访问资源就尝试递减计数,内核会把进程阻塞到操作产生正值为止;进程释放资源就递增计数,顺带唤醒等待者。

但 IPC 信号量比内核信号量复杂,原因有二:

  1. 一个 IPC 信号量是一组(一个或多个)信号量值,而非单个值——同一个 IPC 资源可以保护多个独立的共享数据结构。组内基本信号量(primitive semaphore)的个数在 semget() 创建时指定。IPC 信号量资源数上限默认 128,单个资源内基本信号量数上限默认 250,管理员可通过 /proc/sys/kernel/sem 调整。
  2. **可撤销操作(undoable operations)**的失效保险机制:进程半路死掉时来不及撤销自己已做的信号量操作(比如已预留未释放),选择该机制后,进程死亡时内核自动把信号量恢复到”该进程从未操作过”的值,防止其他进程因它而无限期阻塞。

典型使用流程:semget() 拿信号量标识符(要新建就带 IPC_CREAT 或 IPC_PRIVATE 并指明基本信号量个数)→ semop() 原子地测试并递减所有相关基本信号量(全部成功则进程获准访问资源;有信号量被占用则通常挂起等待)→ 用完再调 semop() 原子递增 → 可选地 semctl(IPC_RMID) 删除。semop() 接收标识符、一个描述各操作的整数数组、操作个数;可指定 SEM_UNDO 标志启用可撤销语义。

数据结构

图 2:IPC 信号量数据结构——sem_ids 到 sem_array 再到 sem 数组与等待队列

sem_ids 变量存信号量类型的 ipc_ids;其 ipc_id_ary 的数组形式上存 kern_ipc_perm 指针,实际每个结构就是 sem_array 的第一个字段。sem_array 的字段:sem_perm(权限)、sem_otime/sem_ctime(最近 semop()/变更时间戳)、sem_base(指向 sem 数组)、sem_pending/sem_pending_last(挂起操作队列)、undo(可撤销请求链表)、sem_nsems(基本信号量个数)。

每个 sem 结构对应一个基本信号量,只有两个字段:semval(计数器值)和 sempid(最近访问该信号量的进程 PID,可用 semctl() 查询)。

可撤销操作:sem_undo 双链表

进程通过在 semop() 里指定 SEM_UNDO 声明可撤销操作。帮助内核撤销的信息存在 sem_undo 结构里:信号量的 IPC 标识符 + 一个整数数组,数组每项记录该进程对此基本信号量所有可撤销操作的净变化量

举例:进程操作一个含四个基本信号量的资源,semop() 把第一个计数加 1、第二个减 2,并带 SEM_UNDO——则 sem_undo 数组第 1 项减 1、第 2 项加 2、其余不动。进程退出时,数组中任何非零值都对应着未平衡的操作,内核直接把该值加回对应信号量计数——被中止进程的改动被回退,其他进程的改动原样保留

内核用两条链表高效管理(名字是笔者起的):

  • 按进程链表:进程做过的所有可撤销操作对应的 sem_undo。进程描述符的 sysvsem.undo_list 字段指向 sem_undo_list 结构,其指针指向链首;每个 sem_undo 的 proc_next 字段串起链表。注意:带 CLONE_SYSVSEM 标志的 clone 进程共享同一个 sem_undo_list,因此共享可撤销操作链。
  • 按信号量链表:对某信号量做过可撤销操作的所有进程的 sem_undo。sem_array 的 undo 字段指向链首,sem_undo 的 id_next 串链。

按进程链表在进程终止时用:exit_sem()(由 do_exit() 调用,/linux内核/lk03)遍历链表,撤销该进程在每个信号量上的未平衡操作。按信号量链表在两个场合用:进程用 semctl() 强制设定某基本信号量的值时——内核把所有相关 sem_undo 数组中对应元素清零(此前的可撤销操作已无意义,撤销它们反而会破坏强制值);信号量资源被销毁时——把相关 sem_undo 的 semid 置 -1 使其失效。

挂起请求队列

每个 IPC 信号量有一条挂起请求队列,记录在一个或多个基本信号量上等待的进程。队列是 sem_queue 结构的双向链表,sem_array 的 sem_pending 和 sem_pending_last 分别指向队首队尾——尾插新请求,按 FIFO 顺序服务。重要字段:nsops(涉及的基本信号量个数)、sops(操作数组)、sleeper(发起请求的睡眠进程描述符地址)、undo(指向对应 sem_undo,操作不可撤销时为 NULL)、alter(操作是否修改信号量数组)。

IPC 消息

进程发出的每条消息被送进一个 IPC 消息队列,在那里滞留到被别的进程读走。

消息由固定大小的头部 + 变长正文组成,还可以贴一个整数消息类型标签——接收方可以按类型有选择地取消息。消息一旦被读出,内核就销毁它:每条消息只能被一个进程接收

发消息用 msgsnd():参数是目标队列的 IPC 标识符、正文大小、用户态缓冲区地址(缓冲里是消息类型紧跟消息正文)。收消息用 msgrcv():参数是队列标识符、接收缓冲区指针(类型和正文拷到这里)、缓冲大小、选择值 t——t 为 0 返回队首消息;t 为正返回队列中第一条类型等于 t 的消息;t 为负返回队列中类型值最小且 ≤ |t| 的第一条消息。

为防资源耗尽设限:消息队列资源数上限默认 16、单条消息大小上限默认 8,192 字节、单个队列消息总大小上限默认 16,384 字节;管理员可通过 /proc/sys/kernel/msgmni、msgmnb、msgmax 调整。

数据结构

图 3:IPC 消息队列数据结构——msg_queue 汇集消息链表与收发双方

msg_ids 存消息队列类型的 ipc_ids;数组形式上存 kern_ipc_perm 指针,实际是 msg_queue 的第一个字段。msg_queue 的字段:q_perm(权限)、q_stime/q_rtime/q_ctime(最近发送/接收/变更时间)、q_qcbytes/q_qnum(队列当前字节数/消息数)、q_qbytes(队列字节上限)、q_lspid/q_lrpid(最近发送/接收进程 PID)、以及三条链表头——q_messages(队列中的消息)、q_receivers(阻塞的接收进程)、q_senders(阻塞的发送进程)。

每条消息被拆进一或多个动态分配的页:第一页开头是 msg_msg 头部(m_list 链表指针、m_type 消息类型、m_ts 正文大小、next 下一页指针、security)。正文紧跟描述符之后;超过 4,072 字节(页大小减去 msg_msg 大小)的部分继续放下一页——第二页开头是 msg_msgseg 描述符(只有 next 指针),如此链下去。

队列满(消息数或总字节数到顶)时,试图入队的进程可能被阻塞——q_senders 链表收拢所有阻塞的发送进程描述符。队列空(或进程要的消息类型不在队列里)时接收进程也可能被阻塞——q_receivers 链表挂着每个阻塞接收进程的 msg_receiver 结构(含进程描述符指针、目标 msg_msg 指针、请求的消息类型)。

IPC 共享内存

共享内存是最有用的 IPC 机制:让多个进程把公共数据结构放进一个 IPC 共享内存区来访问。想访问的进程通过 shmat() 把一个新内存区(/linux内核/lk09)加进自己的地址空间,映射共享内存区的页框;之后这些页框由内核按请求调页(demand paging)轻松管理。

三个函数:shmget() 拿共享内存区的 IPC 标识符(必要时创建);shmat() 把共享内存区”挂接”(attach)到进程——它往进程地址空间加一个共享内存映射,进程可以指定起始线性地址,但地址通常无关紧要,各进程可以用各自不同的地址;shmat() 不改页表,页表要等进程真正访问时按需填充。shmdt() 把共享内存区”摘除”(detach)——从进程地址空间移除对应内存区。切记:IPC 共享内存资源是持久的——即使没有任何进程在用它,它的页也不能丢弃(但可以换出到交换区)。

资源限额:共享内存区数量上限默认 4,096、每段大小上限默认 32 MB、所有段总大小上限默认 8 GB;管理员可通过 /proc/sys/kernel/shmmni、shmmax、shmall 调整。

数据结构:shm 特殊文件系统

图 4:IPC 共享内存数据结构——借道 VFS:shm 文件系统的 inode 串联起一切

shm_ids 存共享内存类型的 ipc_ids;数组元素实际是 shmid_kernel 结构:shm_perm(权限)、shm_file(段的特殊文件对象)、id(槽位下标)、shm_nattach(当前挂接数)、shm_segsz(段大小字节)、shm_atim/shm_dtim/shm_ctim(最近挂接/摘除/变更时间)、shm_cprid/shm_lprid(创建者/最近访问进程 PID)、mlock_user(把段锁进内存的用户)。

最关键的是 shm_file——它体现了 Linux 2.6 中 IPC 共享内存与 VFS 层的深度整合:每个 IPC 共享内存区对应 shm 特殊文件系统里的一个文件。shm 文件系统在目录树里没有挂载点,没人能用常规 VFS 系统调用打开它的文件;但进程挂接段时,内核调 do_mmap() 在进程地址空间建立该文件的共享内存映射(/linux内核/lk16)。shm 文件的对象只有 mmap 一个方法,由 shm_mmap() 实现。

联动关系如图 4:对应共享内存区的内存区是 vm_area_struct 对象,其 vm_file 字段指向 shm 文件的文件对象,文件对象引用 dentry 和 inode;inode 的 i_ino(inode 号)就是共享内存区的槽位下标,所以 inode 间接引用着 shmid_kernel 描述符。和所有共享内存映射一样,页框通过内嵌在 inode 里的 address_space 对象进页缓存(/linux内核/lk15);共享内存页的 address_space 方法存放在全局变量 shmem_aops 里。

共享内存页的换出

共享内存区的页是可交换的但不可同步的(见 /linux内核/lk17 表 17-1)——它们映射的 inode 在磁盘上没有映像,所以回收前必须写进交换区。又因为资源是持久的(页面在段未被挂接时也必须保留),内核不能像普通页那样直接丢弃它们。

PFRA 回收共享内存页的旅程:一切照常走到 shrink_list() → try_to_unmap() 清掉所有用户地址空间里的页表项 → shrink_list() 检查 PG_dirty 调 pageout()(共享内存页在分配时就被标记为脏,所以 pageout 总会被调用)→ pageout() 调用映射文件 address_space 的 writepage 方法,即 shmem_writepage():在交换区分配页槽、把页从页缓存挪进交换缓存(只是换一个 owner address_space 而已)、把换出页标识符存进内嵌 inode 的 shmem_inode_info 结构、再把 PG_dirty 置回去 → shrink_list() 看到 PG_dirty 又置位了,中止本次回收,把页留在 inactive 链表 → 迟早 PFRA 再次处理这页,再次 shrink_list() → 再次 pageout()——这次页已在交换缓存里,owner 是交换子系统的 swapper_space,writepage 方法变成了 swap_writepage(),真正把页写进交换区(/linux内核/lk17)→ 写完后 shrink_list() 确认页已干净,从交换缓存摘除、页框归还伙伴系统。

共享内存的请求调页

shmat() 添加的内存区是”空壳”:只加了内存区、没改页表,而且页可能已被换出——所以一切靠请求调页驱动。

进程访问共享内存区中尚未分配页框的位置时发生缺页:异常处理程序发现地址在进程地址空间内且页表项为空,调用 do_no_page()/linux内核/lk09);该函数检查内存区的 nopage 方法并调用它,把返回的地址填进页表项。共享内存的内存区总是定义 nopage 方法,实现为 shmem_nopage()

  1. 沿 VFS 对象的指针链找到共享内存资源的 inode 对象。
  2. 由内存区描述符的 vm_start 和出错地址算出段内逻辑页号。
  3. 页在页缓存里?是就返回页描述符地址。
  4. 页在交换缓存里且是最新的?是就返回页描述符地址。
  5. shmem_inode_info 里存着该逻辑页号的换出页标识符?是就调 read_swap_cache_async() 执行换入(/linux内核/lk17),等数据传输完成后返回页描述符地址。
  6. 都不是——页从未存在或已彻底丢弃:从伙伴系统分配新页、插入页缓存、返回其地址。

do_no_page() 把进程页表中对应出错地址的表项指向返回的页框——此后各进程访问同一逻辑页就汇聚到同一页框,共享达成。

POSIX 消息队列

POSIX 标准(IEEE Std 1003.1-2001)定义了基于消息队列的 IPC,通常称 POSIX 消息队列。它与 System V 消息队列很像,但有四大优势:更简单的基于文件的接口原生支持消息优先级(优先级直接决定消息在队列中的位置);原生支持到达异步通知(信号或创建线程);阻塞的收发操作支持超时

库函数一览:

函数说明
mq_open()打开(可选创建)一个 POSIX 消息队列
mq_close()关闭队列(不销毁)
mq_unlink()销毁队列
mq_send() / mq_timedsend()发消息;带时限版为发送限定等待时间
mq_receive() / mq_timedreceive()取消息;带时限版为接收限定等待时间
mq_notify()为空队列建立消息到达的异步通知机制
mq_getattr() / mq_setattr()获取/设置队列属性(主要是收发是否阻塞)

典型用法:mq_open() 打开队列——第一个参数是队列名字符串,形似文件名且必须以斜杠开头;接受的标志是 open() 的子集:O_RDONLY、O_WRONLY、O_RDWR、O_CREAT、O_EXCL、O_NONBLOCK(非阻塞收发);指定 O_CREAT 即创建新队列。返回的队列描述符类似 open() 返回的文件描述符。之后用 mq_send()/mq_receive()(或带时限版)收发,用 mq_notify() 免去阻塞等待或轮询——可要求”消息插入空队列时”给选定进程发信号或创建新线程。用完后 mq_close() 关闭——注意它不销毁队列,正如 close() 不删除文件;销毁要用 mq_unlink()。

Linux 2.6 的实现简单直接:引入 mqueue 特殊文件系统/linux内核/lk12),每个现存队列对应其中一个 inode。内核提供的系统调用与库函数大体对应:mq_open()、mq_unlink()、mq_timedsend()、mq_timedreceive()、mq_notify()、mq_getsetattr()——它们透明地操作 mqueue 文件系统的文件,大部分工作由 VFS 层完成。比如内核没有 mq_close() 系统调用:库函数拿到的”队列描述符”其实就是文件描述符,mq_close() 直接执行 close() 系统调用即可。

mqueue 文件系统不一定要挂载到目录树;但如果挂载了,用户可以用 touch 在其根目录创建消息队列、读对应文件了解队列信息,应用还能用 select()/poll() 感知队列状态变化。

每个队列由 mqueue_inode_info 描述符描述,内嵌 mqueue 文件系统中那个文件的 inode 对象。系统调用收到队列描述符后:fget() 由描述符推出文件对象地址 → 取 mqueue 文件的 inode → 得到内嵌该 inode 的 mqueue_inode_info。待处理消息收拢在描述符为根的单链表里,每条消息用 msg_msg 描述符表示——与 System V IPC 消息用的是同一个描述符。

通关标准

不看书答出:管道为什么必须有共同祖先、FIFO 怎么解决?管道 16 个缓冲如何组成循环队列,curbuf/nrbufs 各管什么?读端何时读到 0、写端何时吃 SIGPIPE?IPC 键与 IPC 标识符的区别,标识符公式 s×M+i 防什么?IPC 信号量的可撤销操作如何由两条 sem_undo 链表保障?共享内存页从”页缓存”到”交换缓存”再到落盘的两段式换出流程?POSIX 消息队列比 System V 多了什么?——全部答出,本章过关。