031 进程间通信 —— 匿名管道篇

031 进程间通信 —— 匿名管道篇
小米里的大麦进程间通信 —— 匿名管道篇
1. 什么是管道
管道就是一个内核缓冲区,允许一个进程写数据,另一个进程从中读数据。 它像一根水管:一头写入,一头读取,中间是内核帮我们传递数据。
2. 管道的直接原理
1. 底层本质
管道就是操作系统在内核空间里开辟的一块 内存缓冲区,这个缓冲区由内核维护,进程不能直接访问,只能通过 文件描述符 进行读写。
管道使用了 环形缓冲区(循环队列结构),读写两端由内核控制。
当我们调用
pipe(fd)
,操作系统会:在内核里创建一个缓冲区。
返回两个文件描述符:
fd[0]
:读端读进程:read(fd[0], buf, size);
从管道中读取数据(从内核缓冲区读)。fd[1]
:写端写进程:write(fd[1], data, size);
把数据写入管道(进入内核缓冲区)。
pipefd[0]
→0
→ 嘴巴 → 读书 → 读端pipefd[1]
→1
→ 钢笔 → 写字 → 写端
3. 匿名管道的接口
1. pipe()
函数原型
在 Linux 中,pipe()
是用于创建 匿名管道 的系统调用,原型如下:
1 |
|
参数解释:
pipefd[2]
是一个 整型数组,用来返回两个文件描述符:
pipefd[0]
:读端。pipefd[1]
:写端。
返回值:
- 成功:返回
0
。 - 失败:返回
-1
,并设置errno
。
pipe()
创建的是匿名管道。也就是说:匿名管道不能跨无亲缘关系的进程通信,通常用于 父子进程 或 具有共同祖先进程的兄弟进程 间的通信。
2. demo 示例
1 |
|
运行结果:
- 代码中
int pipefd[2] = { 0 };
将数组初始化为[0, 0]
,但这只是 临时状态。 pipe()
是 系统调用,它的核心功能是由操作系统内核实现的。调用时,内核会:- 忽略 我们传入的初始值(
pipefd
只是用于接收结果的缓冲区)。 - 动态分配 两个可用的文件描述符(通常是当前未用的最小数值),并写入到
pipefd
中。
- 忽略 我们传入的初始值(
程序输出了:pipefd[0]: 3 , pipefd[1]: 4
。原因:Linux 中,0/1/2 已默认分配给 stdin
/stdout
/stderr
,就不过多赘述了。如果重复创建管道(例如在循环中),描述符会如何变化?
解释:
1 | 3, 4 // 上一次分配 3(读), 4(写),关闭 3,但 4 还在 |
实现一个最基础的 匿名管道通信模型,用于父子进程之间传输数据:
1 |
|
snprintf
函数原型
1
2 >
>int snprintf(char *str, size_t size, const char *format, ...);参数解释:
参数名 含义 str
输出缓冲区(目标字符串),用于保存格式化后的字符串 size
str
缓冲区的最大容量(包括结尾的\0
)format
格式字符串(类似 printf
)...
可变参数,对应 format
中的格式说明符返回值:
- 如果成功:返回 欲写入的字符串长度(不包括结尾的
\0
)。- 如果返回值 ≥
size
:说明输出被截断(因为目标缓冲区太小)。- 如果返回值 <
size
:说明字符串成功写入,结尾自动加上了\0
。作用总结:
snprintf
是一种 安全版本 的sprintf
,能防止内存溢出。常用于 格式化字符串写入缓冲区。相比sprintf
,它加了一个 长度限制参数size
,从而更安全:
1
2 >sprintf(buf, "%d-%s", id, name); // ⚠️ 可能溢出
>snprintf(buf, sizeof(buf), "%d-%s", id, name); // 安全
上面的代码的运行结果就不演示了,从关闭父子进程的读端和写端就可以发现:
站在文件描述符角度深入理解管道
站在内核角度理解管道的本质
4. 重新认识管道
1. 管道也是文件吗?
是的,在 Linux 中,管道是“特殊类型的文件”,非磁盘文件(准确说,是一种特殊的 I/O 通道),完全符合:「一切皆文件」:键盘、鼠标、终端、套接字、管道、设备,全都是文件,统一用文件描述符(int fd
)访问。
匿名管道没有名字,但有文件描述符。 它在内核中创建一个缓冲区,并返回两个文件描述符指向它,但它 没有路径名,在 /proc/[pid]/fd/
中也只表现为:
1 | 3 -> pipe:[12345] |
2. 管道有没有固定大小?可以写多少内容?
有:内核缓冲区大小是有限的: 默认大小一般是 65536 字节(64KB),不同系统下可以通过命令查看:
1 | cat /proc/sys/fs/pipe-max-size # 管道最大容量 |
3. 匿名管道的 5 个特征
1. 具有血缘关系的进程进行进程间通信
有血缘关系的进程通信:匿名管道(pipe()
)只支持 父子或兄弟 这种有“血缘”的进程通信(还存在爷孙关系,非常少见)。原因:匿名管道没有文件路径,只能靠 fork()
时继承文件描述符传递给子进程。
2. 匿名管道只能单向通信,双向可以使用多管道
pipefd[0]
是读端,pipefd[1]
是写端,本质就是单向数据流。父子进程需要互相通信 → 开两个 pipe
。
3. 父子进程协同通信 = 同步 + 互斥(保护管道文件的数据安全)
管道通信是 阻塞 I/O 的体现,天然就是同步机制。详见下方的匿名管道中的 4 中情况。
- 读阻塞(没有数据时) ⇒ 读线程自动挂起,等待写线程唤醒(即写入数据)。
- 写阻塞(写满时) ⇒ 写线程自动挂起,等待读线程消费。
这是典型的 生产者-消费者模型,操作系统自动帮我们实现了互斥和同步。
4. 管道是面向字节流的
管道是“字节流”接口,read
和 write
都是面向字节,没有结构、没有分隔、没有消息边界。
5. 管道是基于文件的,但不落盘,生命周期跟随进程
匿名管道 = 临时文件 = 内核缓冲区,使用文件描述符访问。pipe()
创建的管道,不存在磁盘上,进程退出后自动销毁。
4. 匿名管道中的 4 中情况
编号 | 场景 | 阻塞? | 原因 & 说明 |
---|---|---|---|
1 | 管道没数据,读端阻塞 | ✅ 是 | 等待写端写数据 |
2 | 管道满了,写端阻塞 | ✅ 是 | 等待读端消费数据 |
3 | 写端关闭,读端读取 | ❌ 否 | read() 返回 0,表示 EOF,不会阻塞 |
4 | 读端关闭,写端写入 | ❌ 失败 | 写端收到 SIGPIPE 信号 → 默认会被杀死(转到情况 3) |
深度解析:第 4 种情况为什么会崩?
示例代码:
1 |
|
运行结果并验证:确实收到了 SIGPIPE
信号。
当我们关闭读端后,系统就会发现没人接收了,就:
- 向当前进程发送
SIGPIPE
。 - 默认行为是 终止进程(kill)。
实际开发中一般这么做防御:
1 | signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE 信号 |
然后再手动检查 write()
的返回值:
1 | ssize_t n = write(wfd, buffer, len); |
操作系统哲学:“操作系统不做无意义、不必要、低效的工作。如果做了,就是操作系统的 BUG!”
比如:
- 没有写端了 → 管道也就没用了,读再久也不会有数据 → 不如直接返回 0
- 没有读端了 → 继续写是浪费 → 直接 kill 写入进程(SIGPIPE)
如果非要系统继续阻塞读,那就是设计缺陷(不会给无意义的等待)。
5. 匿名管道的应用场景
- shell 命令中的管道符 ——
ps aux | grep nginx | wc -l
等。 - 实时数据处理 —— 监控系统、日志系统、流式数据预处理。
- 后端开发的进程管理 —— 主进程 + 子进程池(进程池模型)、数据库连接池等。
5. Shell 的管道符 |
Shell 的管道符 |
是一种 把一个命令的标准输出(stdout)传递给下一个命令的标准输入(stdin) 的方式。形式: 命令1 | 命令2 | 命令3
。作用:实现多个命令之间的 数据流式传递,将它们组合成 处理流水线(pipeline)。
1. 常见用法示例
1.
grep
—— 文本搜索利器
grep
用于在文本中按行查找 符合正则表达式的内容,是日志分析、文本处理的核心工具。基本语法:
1 >grep [选项] "模式" 文件名
常用选项 含义 -i
忽略大小写(ignore case) -v
反向匹配(只显示不包含模式的行) -r
递归搜索目录 -n
显示匹配行的行号 --color=auto
高亮显示匹配部分 示例:
1
2
3 >grep error server.log # 查找包含 "error" 的行
>grep -i http access.log # 忽略大小写搜索
>dmesg | grep -i usb # 在内核日志中查找 usb 相关信息2.
nginx
—— 高性能 Web 服务器此时还不涉及,留个悬念,以后再讲,示例:
1 >ps aux | grep nginx # 查看 nginx 是否正在运行
统计当前登录用户数量:
1
who | wc -l # who: 显示当前登录的用户,wc -l: 统计行数
查看系统中以 nginx 运行的进程数量:
1
ps aux | grep nginx | wc -l # ps aux: 列出所有进程,grep nginx: 过滤包含 nginx 的行,wc -l: 统计行数(即进程数)
显示
/etc/passwd
中包含 “bash” 的用户名:1
cat /etc/passwd | grep bash | cut -d: -f1 # cut -d: -f1: 用冒号分割字段,提取第一列(用户名)
2. 底层原理(深入理解)
当你写下:
1 | A | B |
Shell 做了如下事情:
- 调用
pipe()
创建一个匿名管道(两个文件描述符:读和写)。 - 使用
fork()
创建两个子进程。 - 子进程 A:将
stdout
重定向到管道的写端(dup2(pipefd[1], STDOUT_FILENO)
)。 - 子进程 B:将
stdin
重定向到管道的读端(dup2(pipefd[0], STDIN_FILENO)
)。 - A 执行命令 A,输出写入管道。
- B 执行命令 B,从管道读取数据。