图 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 > temp 再 more < 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 字节(必要时等待)返回 n | u>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():
- 拿 inode 的 i_sem 信号量。
- i_pipe 为 NULL(FIFO 尚未打开过)则分配并初始化新的 pipe_inode_info,动作与管道创建的 1b~1e 步相同。
- 按访问模式给文件对象的 f_op 设相应操作表:
| 访问类型 | 文件操作表 | 读方法 | 写方法 |
|---|---|---|---|
| 只读 | read_fifo_fops | pipe_read() | bad_pipe_w() |
| 只写 | write_fifo_fops | bad_pipe_r() | pipe_write() |
| 读/写 | rdwr_fifo_fops | pipe_read() | pipe_write() |
- 只读或读/写模式:readers 和 r_counter 加一;若是只读且此前没有读进程,唤醒等待队列里睡眠的写进程。
- 只写或读/写模式:writers 和 w_counter 加一;若是只写且此前没有写进程,唤醒睡眠的读进程。
- 没有读方或没有写方时,按 POSIX 规则决定阻塞还是报错:
| 访问类型 | 阻塞 | 非阻塞 |
|---|---|---|
| 只读,有写者 | 成功返回 | 成功返回 |
| 只读,无写者 | 等待写者出现 | 成功返回 |
| 只写,有读者 | 成功返回 | 成功返回 |
| 只写,无读者 | 等待读者出现 | 返回 -ENXIO |
| 读/写 | 成功返回 | 成功返回 |
- 放信号量,返回 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 信号量比内核信号量复杂,原因有二:
- 一个 IPC 信号量是一组(一个或多个)信号量值,而非单个值——同一个 IPC 资源可以保护多个独立的共享数据结构。组内基本信号量(primitive semaphore)的个数在 semget() 创建时指定。IPC 信号量资源数上限默认 128,单个资源内基本信号量数上限默认 250,管理员可通过 /proc/sys/kernel/sem 调整。
- **可撤销操作(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():
- 沿 VFS 对象的指针链找到共享内存资源的 inode 对象。
- 由内存区描述符的 vm_start 和出错地址算出段内逻辑页号。
- 页在页缓存里?是就返回页描述符地址。
- 页在交换缓存里且是最新的?是就返回页描述符地址。
- shmem_inode_info 里存着该逻辑页号的换出页标识符?是就调
read_swap_cache_async()执行换入(/linux内核/lk17),等数据传输完成后返回页描述符地址。 - 都不是——页从未存在或已彻底丢弃:从伙伴系统分配新页、插入页缓存、返回其地址。
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 多了什么?——全部答出,本章过关。
ls | more 的 shell 语句背后发生了什么?为什么 fork 之后 readers/writers 还是 1?
shell 依次:pipe() 得到读写两端描述符(如 3、4)→ fork 两次 → close 自己的 3 和 4。第一个子进程 dup2(4,1) 让标准输出指向写端、关掉 3 和 4、execve 执行 ls;第二个子进程 dup2(3,0) 让标准输入指向读端、关 3 和 4、执行 more。fork 不增加 pipe_inode_info 的 readers/writers 计数(它们表示”文件对象是否仍被打开”,最大为 1),增加的是文件对象的使用计数——所以父进程死后文件对象因子进程持有而存活,管道保持打开。
IPC 资源用固定键约定可能出什么问题?内核用什么办法降低"拿错资源"的风险?
固定键可能被无关程序撞上:semget/msgget/shmget 成功返回的却是别人创建的资源的标识符,数据悄悄串了。内核的对策是不立即回收释放的标识符——新标识符几乎总大于同类型前一个资源的标识符(公式 s×M+i,s 为随分配递增的槽位使用序号,M = 32,768)。刚释放的资源被新进程用”旧公式值”重新命中的概率因此极低。更彻底的方案是用 IPC_PRIVATE 创建资源再传递标识符。
一个进程带着 SEM_UNDO 对信号量做了 +1 和 -2 后异常退出,内核如何恢复?
内核在 sem_undo 结构里为该进程维护一个整数数组,记录它对各基本信号量可撤销操作的净变化量:+1 使数组第 1 项变 -1,-2 使第 2 项变 +2。进程退出时 exit_sem() 沿”按进程链表”遍历所有 sem_undo,把非零数组项的值直接加回对应信号量计数——被中止进程的改动被回退,其他进程的改动原样保留,等待者不再被无限期阻塞。带 CLONE_SYSVSEM 的 clone 进程共享同一条链表。
为什么说 IPC 共享内存页"可交换但不可同步"?它的两段式换出是怎么回事?
它们映射的 tmpfs/shm 特殊 inode 在磁盘上没有映像,无从”同步”,回收前必须写入交换区;又因为资源持久(无人挂接时页也必须保留),不能直接丢弃。两段式换出:第一次 PFRA 扫到时,shmem_writepage() 给页分配交换区页槽、把它从页缓存挪进交换缓存、记录换出页标识符,但故意重新置上 PG_dirty——shrink_list 看到脏位只好把页留在 inactive 链表;第二次被扫到时页的 owner 已是 swapper_space,writepage 变成 swap_writepage(),真正写盘,然后页被摘出交换缓存、页框归还伙伴系统。
shmat() 不改页表,进程第一次访问共享内存时缺页处理走什么路径?
缺页异常发现地址在进程地址空间内且页表项为空,调 do_no_page();它调内存区的 nopage 方法——共享内存的内存区总是定义为 shmem_nopage()。该函数沿 vm_file → dentry → inode 指针链找到 shm 资源的 inode,算出段内逻辑页号,依次查页缓存 → 交换缓存 → shmem_inode_info 里记录的换出页标识符(有则 read_swap_cache_async 换入并等待);三者皆无才从伙伴系统分配新页插入页缓存。返回的页描述符地址被 do_no_page 写进页表项,多个进程的页表项就此指向同一页框,共享达成。