Linux 服务器高阶编程(10):管道、共享映射与进程生命周期
一、匿名管道:先创建通道,再让父子共享端点
1.1 管道不是普通数组
用队列解释管道:数据写入内核管理的缓冲区,再由读端取走。它具有传输方向、暂存能力,不是用来长期保存数据的磁盘文件。
用户程序中的 fds 只是两个文件描述符的容器,并不是管道缓冲区本身。
核心写法:
int fds[2];pipe(fds);| 数组元素 | 对应端点 | 操作 |
|---|---|---|
fds[0] | 读端 | read(fds[0], ...) |
fds[1] | 写端 | write(fds[1], ...) |
pipe() 成功返回 0,失败返回 -1;两个描述符通过参数数组返回。不要把函数返回值误当成读端或写端。
1.2 为什么放在 fork() 前面
顺序是:父进程先创建管道,再创建子进程。
父进程 pipe(fds) | +-- fork() --> 父进程:保留一份读、写描述符 --> 子进程:继承一份读、写描述符
两边的描述符共同引用原来的管道,而不是各自创建一条新管道。如果父、子在不同分支里分别调用 pipe(),得到的是不同管道,不能仅凭两个数组都叫 fds 就认为它们能互相通信。
主要使用父子进程继承端点的模型。这里的“匿名管道用于亲缘进程”应结合这个使用方式理解:它没有 FIFO 那样可供独立进程通过 open() 查找的路径名。
1.3 父写子读时,谁关闭哪一端
| 进程 | 保留端点 | 立即关闭的端点 | 完成后 |
|---|---|---|---|
| 父进程 | fds[1],负责写 | fds[0] | 关闭写端,等待子进程 |
| 子进程 | fds[0],负责读 | fds[1] | 关闭读端,退出 |
“确认单工”,在这个示例里就是通过关闭不需要的端点,明确父进程只发送、子进程只接收。
不能只把 close() 当成最后清理资源的一步。多余端点不关闭,还会改变 EOF 与 SIGPIPE 出现的条件,进而影响程序是否能够正常结束。
1.4 父写子读示例的核心片段
父进程分支:
close(fds[0]);//确认单工write(fds[1],msg,strlen(msg));close(fds[1]);zpid = wait(NULL);printf("parent wait success\n");exit(0);子进程分支:
close(fds[1]); //确认单工char buffer[1024];bzero(buffer,sizeof(buffer));read(fds[0],buffer,sizeof(buffer));close(fds[0]);printf("child id %d , read msg = %s",getpid(),buffer);exit(0);//变成僵尸消息为
#define msg "hi son can u hear me ?\n"阅读这段示例时,把调用分成三组:
pipe()、fork():建立通信关系。write()、read():传递数据。close()、wait()、exit():结束使用并回收进程。
父进程通过 wait(NULL) 等待并回收子进程。因此,不能只看子进程分支的“变成僵尸”注释,就判断这个程序会长期遗留僵尸进程。子进程结束与父进程取得退出状态是前后两个步骤。
二、管道的四种状态:空、满、写端全关、读端全关
2.1 四种情形不要混淆
下表按阻塞读写场景整理,并把“关闭”补充为关键的“全部相关端点关闭”。
| 情形 | 本端操作 | 结果或含义 |
|---|---|---|
| 管道为空,仍有写端存在 | 读端 read() | 等待数据,可能阻塞 |
| 管道已满,仍有读端存在 | 写端 write() | 等待读端腾出空间,可能阻塞 |
| 所有写端已关闭 | 读端继续读取 | 先读完剩余数据,之后返回 0,表示 EOF |
| 所有读端已关闭 | 写端继续写非零长度数据 | 产生 SIGPIPE;默认处理通常终止写进程 |
关键不是“某一个写进程退出了”,而是“是否还存在任何写端引用”。读端关闭的判断同理。若 SIGPIPE 被忽略,则写操作会以 EPIPE 失败,不能再期待其默认终止效果。
2.2 为什么写进程退出了,读进程还一直等
典型错误是子进程虽然只负责读,却忘了关闭自己继承到的 fds[1]。
此时即使父进程已关闭写端,内核仍能看到子进程手中还有一个写端。子进程把已有数据读完后,再读时就可能继续等待,而不是得到 EOF。
排查顺序:
- 列出所有经过
fork()或描述符复制得到的端点。 - 标出每个进程真正使用的端点。
- 检查所有不需要的端点是否已关闭。
- 最后再看循环条件,不要先用
sleep()掩盖问题。
同样,若子进程保留了一个根本不使用的读端,父进程的读端关闭后,子进程写入也未必会触发预期的 SIGPIPE。这直接关系到最后的练习。
2.3 描述符和资源释放
以父子各持有一份读、写端点的方式解释“共有 4 个引用”。
真正应该落实到代码的是:每个进程只保留自己需要的描述符;用完关闭;进程结束也会释放其打开的描述符。
2.4 单工、半双工和双工
通信方向区分这三个术语:
- 单工:固定一个方向,例如父写子读。
- 半双工:双方可以轮流发送,但不同时发送。
- 双工:双方能够同时发送和接收。
2.5 字节流不自带消息边界
匿名管道传输无格式字节流,消息格式需要程序自己约定。
三、与 Socket 联系起来理解 SIGPIPE
客户端发送请求后退出,服务端处理完再发送响应,服务端为什么可能异常结束?
通信对端已经无法接收时,发送操作可能触发 SIGPIPE,默认信号处理会终止进程。不能一看到程序消失就只排查数组越界,也要检查发送操作和对端连接状态。
send(sockfd, buffer, len, MSG_NOSIGNAL);MSG_NOSIGNAL 抑制的是这次 Socket 发送产生的 SIGPIPE,不是让失效连接重新可用。发送仍可能失败并返回 EPIPE,应检查返回值。另外,“客户端退出后,服务端第一次响应一定立即被杀死”并非普遍保证,还与连接关闭过程有关。
四、有名管道 FIFO:用路径连接非亲缘进程
4.1 相比匿名管道,解决了什么问题
匿名管道示例依赖父子进程继承已有描述符。有名管道给通信对象提供一个文件系统路径,独立启动的进程只要访问同一个 FIFO,并有相应权限,就可以建立通信。
进程 A -- open 同一路径,写入 --> FIFO 的内核缓冲区进程 B <-- open 同一路径,读取 -- FIFO 的内核缓冲区这里文件名的作用是“找到对象”。不要把 FIFO 想成一个不断追加内容的普通文本文件。
4.2 创建和删除
mkfifo Namerm Name -rf第一条创建名为 Name 的 FIFO
删除自己练习用的单个 FIFO 不需要递归参数,确认路径后使用 rm -- Name 即可。不要将示意名称替换成不确定的目录后照抄 -rf。
mkfifo() 与 unlink()。其中 mkfifo(char * name, 0664) 是把参数说明与调用混写的示意,不是可以直接复制的完整 C 声明。
#include <sys/types.h>#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);例如把实际路径传入 mkfifo("Name", 0664)。0664 是八进制权限值,最终创建权限还会受到 umask 影响。成功返回 0,失败返回 -1。
4.3 为什么停在 open(),还没走到 read()
“必须满足读写两个权限,否则 open() 阻塞”。这里应区分文件权限与通信端点。
open()前后的日志是否都有输出?- 另一端是否已启动并尝试打开?
- 两端路径是否确实指向同一个 FIFO?
- 目录和文件访问权限是否满足要求?
- 是否无意间用
O_RDWR保留了自己的读、写端,改变了 EOF 判断?
4.4 FIFO 文件与缓冲区不是同一种寿命
强调 FIFO 数据不按普通文件内容那样持久化,这个理解方向很重要。但“管道文件不删除,通信数据就一直保留”的理解是错误的。
FIFO 路径是访问入口,实际通信数据由内核处理。路径可能继续存在,而所有端点关闭后,原来那轮通信的缓冲数据并不会作为磁盘文件内容保存下来。FIFO 是特殊文件类型,不是普通数据文件。
五、文件映射 mmap:从“传递字节”转向“访问共享区域”
5.1 与管道的核心区别
把文件映射描述为:多个进程映射同一个普通文件,通过访问映射区域交换数据。
管道中,一端写入、另一端读走;映射区域中,一端修改数据,另一端访问该区域。读取映射区域不会像读取队列那样把数据“取走”,数据会继续存在,直到被修改、覆盖或相关对象结束。
- 管道像等待被消费的数据流。
- 共享映射像双方都能访问的一块数据区。
共享数据区仍需要程序约定数据结构、更新时机和访问协调,不能把“能看到同一块数据”当成“所有并发问题已经自动解决”。
5.2 调用框架
#include <sys/mman.h>
void * ptr = NULL;if((ptr = mmap(NULL,size,prot,type,fd,offset))==MAP_FAILED) perror("mmap call failed");
munmap(ptr , size);这里保留 size、prot、type、fd、offset。这些参数必须由实际程序定义;该片段不是独立程序。错误分支之后不能继续把失败返回值当成有效指针使用。
5.3 六个参数分别在说明什么
按示意参数名整理:
| 位置 | 参数 | 阅读含义 |
|---|---|---|
| 1 | NULL | 让系统选择映射地址 |
| 2 | size | 本次需要映射的字节数 |
| 3 | prot | 映射区域的访问保护,如读、写 |
| 4 | type | 映射方式,重点区分 MAP_SHARED 与 MAP_PRIVATE |
| 5 | fd | 已打开的映射文件描述符 |
| 6 | offset | 从文件哪个字节位置开始映射 |
PROT_READ、PROT_WRITE、PROT_EXEC、PROT_NONE,以及 MAP_SHARED、MAP_PRIVATE。不要把访问保护标志与映射方式标志混写成同一种参数。
length 必须大于零,文件偏移需要满足页大小对齐要求;失败判断是 MAP_FAILED,而不是普通的空指针判断。映射成功后关闭 fd 不会自动解除映射,解除映射使用 munmap()。
5.4 “没有有效数据”不等于“文件长度为零”
若把文件作为通信介质,希望开始时没有旧消息,但仍要有足够长度支持访问。
先打开,再通过 ftruncate() 调整长度:
int fd = open("file",O_RDWR);随后调用 ftruncate(fd, 需要的字节数)。
ftruncate() 把文件调整到指定长度;缩小时会丢弃超出的内容,扩展出的区域读出为零字节。它不是“把原有文件所有字节清零”的函数,文件描述符也必须可写。
由此理解准备工作:
- 明确共享数据需要多少字节。
- 准备一个允许相应读写的普通文件。
- 检查文件长度足以覆盖需要访问的区域。
- 必要时调整长度并检查结果。
- 再建立映射并检查返回值。
5.5 使用结构体组织共享数据
typedef struct{ int id; char date[4096];}shared_t; //共享数据类型后面还有:
shared_t * ptr = mmap(NULL, sizeof(shared_t), x);这里的 x 是省略剩余参数的占位,不是实际接口允许只传三个参数。它要表达的是:把映射区域当成 shared_t 类型的数据使用。
说明:
- 用
sizeof(shared_t)表达结构体实际大小,不要只按4096计算整个结构体。 - 写入者修改成员,读取者按同一个结构解释这些字节。
- 两端必须对结构布局和数据含义达成一致。
- 一个普通指针变量的值不能直接当成“另一进程可用的地址”传过去;双方使用各自
mmap()返回的地址访问对应区域。
5.6 MAP_SHARED 与 MAP_PRIVATE
需要共享修改或更新原文件时使用共享映射;希望私下修改、而不把修改写回原文件时使用私有映射。
| 映射方式 | 对映射区域进行修改后的含义 | 重点 |
|---|---|---|
MAP_SHARED | 修改可对映射同一区域的其他进程可见,并可写回底层文件 | 共享修改 |
MAP_PRIVATE | 修改属于私有的写时复制结果,不作为共享修改写回原文件 | 私下修改 |
私有映射不是调用时就把整个文件完整备份一遍,也不能当成保证不受外部文件修改影响的可靠快照;共享映射的内存可见性与文件何时落盘也不是同一个问题。
5.7 sync 的表述怎样理解
对映射区域需要明确控制写回时,可了解 msync();MS_SYNC 请求更新并等待完成。但它解决的是映射内容写回的问题,不是读写双方互斥,也不能代替“这一条消息已经写完整了”的进程间协调。
5.8 三类常见问题:参数、权限、范围
映射长度为零、映射与打开权限冲突、访问超出文件有效范围。
| 现象 | 首先检查 |
|---|---|
mmap() 返回 MAP_FAILED | 参数、文件描述符、访问模式、映射长度、偏移 |
| 返回了地址,但访问时出现总线错误 | 实际访问位置与文件长度是否匹配,文件是否被截短 |
| 一端修改了,另一端没得到预期内容 | 是否同一文件、同一区域,是否使用共享映射,访问时机是否协调 |
munmap() 后仍访问旧指针 | 是否继续使用已经解除的地址范围 |
空文件不代表 mmap() 调用必定立即失败;建立映射与随后访问是两个阶段。访问文件末尾以外的整页区域可能产生 SIGBUS。
5.9 关于“mmap 一定更快”
映射可能减少传统读入用户缓冲区的复制开销,这是理解其用途的一个方向。
六、亲缘关系、进程组、会话
6.1 先区分四个标识
| 标识 | 表示什么 | 用来回答什么问题 |
|---|---|---|
| PID | 当前进程编号 | 这是哪个进程? |
| PPID | 父进程编号 | 当前由哪个进程作为父进程? |
| PGID | 进程组编号 | 它属于哪一组? |
| SID | 会话编号 | 它属于哪个会话? |
6.2 亲缘关系强调创建、继承和回收
Bash -> fork 创建子进程 -> 子进程执行 execl 一类接口 -> 运行 ./app -> app 还可以继续 fork 创建自己的子进程阅读时还应区分:fork() 负责创建子进程;exec 类接口用于替换当前进程运行的程序映像。 execl() 是流程占位,并没有给出可直接调用的参数列表。
七、进程组:把多个进程组织成一个管理单位
7.1 组长与组员
进程组组长:PID == PGID进程组的存在不依赖组长始终活着:即使组长已经结束,只要组内仍有其他成员,不能仅凭组长不在就断言组已经消失。
“最后一个成员终止或转移时组消失”和“进程组一般随系统持续”。前一句才是判断具体进程组寿命时应抓住的条件;不能把所有进程组都理解为一直活到系统关闭。
7.2 查询进程组
pgid = getpgrp();它用于取得调用进程的进程组编号。
7.3 创建新组与转移进程组
setpgid(getpid(),getpid());含义是把当前进程放到一个以自身 PID 为组号的进程组。
转移目标组通常必须存在并属于同一会话;可操作的目标也受“自身或符合条件的子进程”等限制,不能任意移动其他进程。不能简单记成“组长一律不能转移”;会话首进程不能改变所属进程组,setsid() 对组长的限制则是另一条规则。检查返回值 0 或 -1,不要只看调用已经写上了。
7.4 分组不会取代父子回收
一个进程成为组长,不代表它会自动成为所有组员的父进程;一个子进程换了组,也不因此免除退出后的回收需求。
所以排查时分开看:PPID 用来理解父子与回收,PGID 用来理解分组。不要只观察其中一个字段。
八、会话与控制终端:setpgid() 不等于 setsid()
8.1 会话与进程组的层次
可以把关系整理为:
一个会话 SID +-- 进程组 A | +-- 组长 | +-- 其他成员 +-- 进程组 B +-- 组长 +-- 其他成员这张图描述管理层次,不是父子树。父子关系需要另外观察 PPID。
8.2 查询会话与创建会话
sid = getsid(getpid());setsid();第一行查询当前进程所属会话;第二行尝试创建新会话。两者不能因为名字相近而混用。
setsid() 要求调用进程不是进程组组长。成功后,它成为新会话首进程和新组组长,新会话起初没有控制终端;失败返回 -1。这也是常见流程先 fork()、再让子进程调用 setsid() 的原因。
8.3 为什么先 fork() 再在子进程里创建会话
- 当前进程创建子进程。
- 父分支按设计退出。
- 子分支尝试
setsid()。 - 检查是否成功,再继续需要独立运行的工作。
容易写错的顺序是先在子进程里调用 setpgid(getpid(), getpid()) 把自己变成组长,再调用 setsid()。这会与 setsid() 的非组长条件冲突。
8.4 “换组”为什么不等于“脱离终端”
setpgid():调整组关系,不直接创建新会话。setsid():建立新会话及新进程组,并使新会话起初没有控制终端。
所以不能仅用 setpgid(getpid(), getpid()) 就断言已经彻底脱离控制终端。也不能把“从终端启动的进程”当成一种永久无法脱离终端的特殊进程类型。
8.5 终端关闭不等于无条件杀死所有后代
具体行为涉及终端挂断、会话、前台进程组及信号处理,不能简化成“关闭 Bash 必然杀死所有子孙”。会话首进程终止时,与其控制终端相关的前台进程组会收到 SIGHUP;程序怎样处理信号仍影响最终结果。
8.6 用 ps ajx 观察
ps ajx重点观察 PPID、PID、PGID、SID:
- 先用 PID 定位自己的测试程序。
- 用 PPID 找到它的父进程。
- 比较 PGID,看是否同组及谁是组长。
- 比较 SID,看是否仍在原会话。
- 在创建子进程、调整组或创建会话前后对照,不能只凭程序的输出文字判断调用成功。
九、孤儿进程与僵尸进程:不同问题,分别处理
9.1 孤儿进程是什么
父进程先于子进程退出,仍存活的子进程成为孤儿进程。
这里要看退出顺序:消失的是原父进程,子进程可能还在执行任务。不能把“孤儿”理解为“这个子进程已经退出”。
9.2 与僵尸进程对照
| 对比项 | 孤儿进程 | 僵尸进程 |
|---|---|---|
| 关注的问题 | 原父进程已结束,子进程仍存活 | 子进程已结束,但退出状态尚未被回收 |
| 是否还在执行原程序 | 可以继续运行 | 不再执行原用户程序 |
| 典型现象 | PPID 发生变化,由其他进程接管 | 保留少量退出相关信息,常见状态为 Z |
| 想解决什么 | 不需要继续工作的子进程应随父进程结束 | 结束后的状态应由适当父进程等待回收 |
不应把所有孤儿进程都说成天然有害。风险在于它是否脱离预期管理继续工作、占用资源。僵尸进程则已经停止执行,主要遗留的是等待回收的信息,两者处理思路不同。
9.3 谁接管孤儿进程
/upstart-user 和 PID 1000,这是特定环境下的描述,不能作为通用配置值。
Linux 可以由最近的存活祖先 subreaper 接管孤儿后代,否则通常由相应 PID 命名空间的 init 接管。不要在程序中写死“孤儿的 PPID 必须是 1000”。应在自己的运行环境中观察。
接管并不代表接管者会立即终止孤儿进程。通常是当它后来结束时,由接管者负责取得退出状态;是否应该继续运行,仍是应用的生命周期设计问题。
9.4 方法一:kill(pid, 0) 检测
kill(pid, 0);信号编号为 0 时不会实际发送信号,而是做存在性与权限检查。失败不一定表示进程不存在:ESRCH 与 EPERM 含义不同。不要写成“返回值不是 0 就一定说明父进程死了”。它也不是无竞争的可靠父子监管协议。
9.5 方法二:利用管道的读端关闭
- 父进程只保留读端。
- 子进程只保留写端。
- 子进程定时向父进程发送少量消息。
- 父进程退出后,它持有的读端随之关闭。
- 当所有读端确实都消失,子进程下一次写入触发
SIGPIPE。 - 使用默认信号处理时,子进程因此结束。
这是“以通信端点的存在性,间接感知父进程是否还存在”的办法。
两个重要限定:
- 不是父进程一退出,内核就无条件立刻杀死这几个子进程;还需要发生后续写操作。
- 不是父进程关闭自己的读端就一定够;其他进程不能仍保留同一管道的读端。
这正是前面“关闭不用端点”的实际用途,而不是一条无关紧要的代码习惯。
如果这篇文章对你有帮助,欢迎分享给更多人!





