
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介
文章目录
- 前言
- 一、进程为什么不能直接通信
-
- 1.1 进程为什么不能直接把数据交给对方
- 1.2 IPC 的含义
- 1.3 操作系统为什么要提供系统调用
- 二、匿名管道到底是什么
-
- 2.1 什么是管道
- 2.2 `pipe()` 返回的两个文件描述符
- 2.3 `fork()` 后为什么父子进程能访问同一条管道
- 2.4 为什么必须关闭不需要的端
- 三、先把最小 Demo 跑起来
-
- 3.1 创建管道和子进程
- 3.2 数据为什么是“读后消费”
- 四、管道真正容易踩坑的读写规则
-
- 4.1 匿名管道的几个特点
-
- 1. 常用于具有亲缘关系的进程
- 2. 管道自带同步关系
- 3. 管道面向字节流
- 4. 一条管道只提供一个数据方向
- 5. 生命周期由所有引用共同决定
- 4.2 四种典型情况
-
- 情况一:写端正常,但暂时没有数据
- 情况二:读端正常,但管道已经写满
- 情况三:所有写端关闭,读端继续读取
- 情况四:所有读端关闭,写端继续写
- 五、管道容量和 `PIPE_BUF` 别混在一起
-
- 5.1 管道容量不是固定写死的结论
- 5.2 `ulimit` 中的 pipe size 不等于总容量
- 5.3 `PIPE_BUF` 解决的是写入原子性
- 总结
前言
前面学习进程时,我们一直强调进程具有独立性。
每个进程都有自己的 PCB、虚拟地址空间和页表。fork() 创建子进程以后,父子进程虽然起点相同,但仍然是两个独立的进程;一方修改普通变量,并不会直接改到另一方的地址空间。
隔离带来了安全性,也带来了新的问题:两个彼此独立的进程,数据到底怎么传?
终端里这条命令就是最直观的例子:
ls -l | wc -l
Shell 会分别创建 ls 和 wc 两个进程,再把 ls 的标准输出接到 wc 的标准输入。两个进程没有共享普通变量,却可以通过内核提供的管道协作。
wc -l 统计的是 ls -l 输出的行数,不等于“当前目录中准确的文件数量”。这里用它,只是为了观察两个进程怎样通过管道连接起来。
这篇先把匿名管道本身讲透:pipe() 做了什么、fork() 后 fd 怎样继承、为什么必须关闭无关端,以及管道为空、写满、关闭读端或写端时会发生什么。进程池放到下篇单独展开。
一、进程为什么不能直接通信
1.1 进程为什么不能直接把数据交给对方
假设进程 A 中有一个全局变量:
int data = 10;
即使进程 B 是通过 fork() 创建出来的子进程,B 修改自己的 data,也不会把 A 中的 data 一起改掉。
原因很直接:
- A 和 B 有各自的虚拟地址空间;
- 它们有各自的页表;
- 进程看到的变量属于自己的用户空间;
- 写入时会按照写时拷贝机制完成隔离。
所以,进程 A 不能把自己堆区中的一块地址直接告诉进程 B,然后让 B 当成自己的地址使用。
这也是进程协作比线程协作更麻烦的地方。
1.2 IPC 的含义
IPC 是 Inter-Process Communication 的缩写,也就是进程间通信。
进程通信通常有下面几类目的:
- 数据传输:一个进程把处理结果交给另一个进程;
- 资源共享:多个进程共同使用某类资源;
- 事件通知:一个进程通知另一个进程某件事已经发生;
- 进程控制:调试器等控制进程观察或干预目标进程。
既然进程彼此隔离,通信数据就不能放在某个进程自己的堆或栈里。它需要一个双方都能访问、同时又不属于任何一方普通用户空间的位置。
核心:进程间通信的前提,是让通信双方都能访问同一个由内核管理的通信资源。
这份资源不属于某个进程的普通堆区或栈区,而是由操作系统创建和维护。进程再通过文件描述符等方式拿到访问入口。
对于匿名管道来说,这个共同资源就是内核中的管道对象及其缓冲区。
通信过程可以概括成:
子进程用户空间
│ write
▼
内核管道缓冲区
│ read
▼
父进程用户空间
写进程先把数据从自己的用户空间复制到内核管道,读进程再把数据从管道复制到自己的用户空间。
父子进程的地址空间仍然是独立的,它们只是借助内核中的同一条管道完成数据传递。
1.3 操作系统为什么要提供系统调用
既然通信资源由操作系统管理,操作系统就需要负责:
用户进程不能直接操作内核对象,只能通过系统调用完成这些动作。
匿名管道对应的核心接口就是:
#include <unistd.h>
int pipe(int pipefd[2]);
接下来就从这条接口开始。
二、匿名管道到底是什么
2.1 什么是管道
管道是 Unix 中非常早的一种进程间通信方式。
可以把它理解成一条位于内核中的数据通道:一端负责写入,另一端负责读取,写进去的数据按照字节流的方式向前流动。

管道本身不负责把数据保存到磁盘。
普通文件中的数据最终需要落到存储设备,而匿名管道中的数据只是进程通信期间的临时数据。数据被读取以后,就从管道中消费掉;所有相关文件描述符关闭后,内核会释放这条管道。
2.2 pipe() 返回的两个文件描述符
调用:
int pipefd[2] = {0};
int ret = pipe(pipefd);
成功时返回 0,失败时返回 -1。
两个输出参数的含义固定:
pipefd[0]:读端
pipefd[1]:写端
如果当前进程的 0、1、2 已经分别被标准输入、标准输出和标准错误占用,那么第一次调用 pipe() 时,经常会得到:
pipefd[0] = 3
pipefd[1] = 4
这两个 fd 都在当前进程的文件描述符表中,但它们指向管道的不同端。

匿名管道没有路径,也没有可供其他无关进程重新打开的文件名,所以叫“匿名”管道。
它最常见的使用场景,就是先由父进程调用 pipe(),再调用 fork() 创建子进程。
2.3 fork() 后为什么父子进程能访问同一条管道
父进程先创建管道:
int pipefd[2];
pipe(pipefd);
再创建子进程:
pid_t id = fork();
fork() 以后,子进程会得到一份自己的文件描述符表。表是子进程自己的,但其中已有 fd 对应的内核引用会被继承。
也就是说:
父进程 pipefd[0] ─┐
├── 同一条管道的读端
子进程 pipefd[0] ─┘
父进程 pipefd[1] ─┐
├── 同一条管道的写端
子进程 pipefd[1] ─┘
这里需要纠正一个很容易写错的说法。
这里最容易写错:fork() 不会重新复制一份 struct file、inode 和管道缓冲区给子进程。
更准确的理解是:
- 子进程得到自己的 fd 表;
- fd 表中的条目继续引用已有的内核打开文件对象;
- 父子进程因此能够通过各自的 fd 访问同一个管道对象;
- 内核通过引用计数判断还有多少描述符正在使用这些对象。
所以父子进程共享的是内核管道资源的访问关系,不是共享彼此用户空间中的普通变量。
2.4 为什么必须关闭不需要的端
pipe() 创建以后,父子进程最开始都同时持有读端和写端。
如果我们设计成“子进程写,父进程读”,那么应该这样处理:
pid_t id = fork();
if (id == 0)
{
// 子进程只负责写
close(pipefd[0]);
// 写数据
// …
close(pipefd[1]);
_exit(0);
}
// 父进程只负责读
close(pipefd[1]);
// 读数据
// …
close(pipefd[0]);
关闭无用端不只是为了让代码好看,它还会直接影响管道的读写规则。
例如,父进程读完现有数据后,只有当所有写端引用都已经关闭,read() 才会返回 0,表示读到了文件结尾。
如果某个进程还意外持有写端,即使它从来不写数据,读进程也可能一直等不到 EOF。
三、先把最小 Demo 跑起来
下面设计成:
子进程:向管道写消息
父进程:从管道读消息
3.1 创建管道和子进程
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
void ChildWrite(int wfd)
{
char buffer[1024];
int count = 0;
while (count < 5)
{
int len = snprintf(
buffer,
sizeof(buffer),
"I am child, pid: %d, count: %d\\n",
getpid(),
count++
);
write(wfd, buffer, len);
sleep(1);
}
}
void FatherRead(int rfd)
{
char buffer[1024];
while (true)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) – 1);
if (n > 0)
{
buffer[n] = '\\0';
std::cout << "child say: " << buffer;
}
else if (n == 0)
{
std::cout << "写端已经全部关闭,父进程读取结束" << std::endl;
break;
}
else
{
perror("read");
break;
}
}
}
int main()
{
int pipefd[2] = {0};
if (pipe(pipefd) < 0)
{
perror("pipe");
return 1;
}
std::cout << "read fd: " << pipefd[0] << std::endl;
std::cout << "write fd: " << pipefd[1] << std::endl;
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 2;
}
else if (id == 0)
{
close(pipefd[0]);
ChildWrite(pipefd[1]);
close(pipefd[1]);
_exit(0);
}
close(pipefd[1]);
FatherRead(pipefd[0]);
close(pipefd[0]);
waitpid(id, nullptr, 0);
return 0;
}
运行时,子进程每隔一秒向写端写一条消息,父进程从读端取出数据。

这里变化的 count 是子进程自己的普通变量。
它的变化只能说明子进程在持续生成不同的消息,不能用来证明父子进程共享了变量,也不能用来证明写时拷贝是否发生。
真正完成通信的是下面这条链路:
子进程 buffer
│ write
▼
内核管道
│ read
▼
父进程 buffer
3.2 数据为什么是“读后消费”
管道是字节流。
子进程调用 write() 后,字节进入内核管道;父进程调用 read() 取走一部分字节后,这部分数据就不再留在管道中等待第二次读取。
例如子进程写入整数 14:
所以匿名管道不是“父子进程共同拿着同一个用户变量修改”,而是一个典型的生产者—消费者模型。
四、管道真正容易踩坑的读写规则
4.1 匿名管道的几个特点
1. 常用于具有亲缘关系的进程
匿名管道没有文件名。
父进程先拿到 fd,再通过 fork() 把 fd 的访问关系继承给子进程,因此它通常用于父子进程,或者拥有共同祖先的进程之间。
2. 管道自带同步关系
当管道为空,但写端仍然存在时,阻塞模式下的 read() 会等待数据。
当管道已经写满,但读端仍然存在时,阻塞模式下的 write() 会等待空间。
这里的“同步”可以直接理解成:
一个进程的执行条件,要等待另一个进程的行为才能满足。
3. 管道面向字节流
管道不保存“消息边界”。
假设子进程连续写入十条字符串,父进程一次 read():
- 可能只读到一条;
- 可能读到多条拼在一起;
- 也可能读到某条消息的一部分。
最终读多少,取决于:
- 管道当前有多少数据;
- read() 传入的缓冲区大小;
- 内核当次返回的字节数。
所以不能认为一次 write() 必然对应一次 read()。
下面这种“写得快、读得慢”的程序,很容易先把管道写满:
void ChildWrite(int wfd)
{
char buffer[1024];
int count = 0;
while (true)
{
int len = snprintf(
buffer,
sizeof(buffer),
"I am child, pid: %d, count: %d\\n",
getpid(),
count++
);
write(wfd, buffer, len);
}
}
void FatherRead(int rfd)
{
char buffer[1024];
while (true)
{
sleep(5);
ssize_t n = read(rfd, buffer, sizeof(buffer) – 1);
if (n > 0)
{
buffer[n] = '\\0';
std::cout << buffer;
}
}
}
当管道没有剩余空间时,子进程会阻塞在 write(),等待父进程读走一部分数据。


反过来,如果子进程每隔一秒写一条,父进程一直读取,就会表现为写一条、读一条:

4. 一条管道只提供一个数据方向
一条匿名管道有读端和写端,数据从写端流向读端。
如果 A 要给 B 发数据,同时 B 也要给 A 发数据,通常需要创建两条管道:
管道 1:A 写,B 读
管道 2:B 写,A 读
所以这里更准确的说法是:一条管道是单向字节流通道。需要双向通信时,要额外建立另一条通道。
5. 生命周期由所有引用共同决定
某一个进程退出,并不一定代表管道立即消失。
只有当所有指向管道读端、写端的文件描述符都关闭后,内核才会真正释放对应资源。
4.2 四种典型情况
情况一:写端正常,但暂时没有数据
如果写端仍然存在,管道又是空的:
read(rfd, buffer, sizeof(buffer));
阻塞模式下,读进程会等待写进程写入。
这就是“读空则等”。

情况二:读端正常,但管道已经写满
如果还有读端存在,只是读得比较慢,写进程会在管道满时阻塞。
等读进程取走数据、管道重新出现空间后,写操作才能继续。
结论:管道写满后不会把旧数据直接覆盖掉,阻塞写会等待读端释放空间。
情况三:所有写端关闭,读端继续读取
当所有写端都关闭,并且管道中剩余数据已经读完后:
ssize_t n = read(rfd, buffer, sizeof(buffer));
返回:
n == 0
这个 0 表示 EOF。

注意是所有写端引用都关闭,而不只是你认为的“那个负责写的子进程”关闭。
情况四:所有读端关闭,写端继续写
如果所有读端都已经关闭,写进程继续调用 write(),默认情况下会收到 SIGPIPE 信号。
SIGPIPE 在 Linux 中通常是 13 号信号,默认动作是终止进程。

注意:不要把它理解成“操作系统一定直接杀掉写进程”。更准确地说,是内核产生 SIGPIPE;如果进程没有捕获或忽略该信号,就会按照默认动作退出。若信号被忽略,write() 会失败并设置 errno = EPIPE。
五、管道容量和 PIPE_BUF 别混在一起
5.1 管道容量不是固定写死的结论
可以让子进程每次写一个字节,父进程暂时不读,观察写入计数什么时候停止:
void ChildWrite(int wfd)
{
char ch = 'A';
int count = 0;
while (true)
{
ssize_t n = write(wfd, &ch, 1);
if (n == 1)
{
std::cout << count++ << std::endl;
}
}
}
某些 Ubuntu 环境中可能观察到大约 65536 字节,也就是 64 KiB:

但是文章中不能把“64 KiB”写成所有 Linux 环境永远不变的固定值。
管道容量可能受到:
- 内核版本;
- 系统页大小;
- 管道动态扩容策略;
- fcntl() 调整;
- 系统资源限制
等因素影响。
实验只能说明当前机器、当前环境中的结果。
5.2 ulimit 中的 pipe size 不等于总容量
执行:
ulimit -a
可能会看到与 pipe size 有关的项目:

这里显示的值不能直接和“管道当前总容量”画等号。
文章中最容易混淆的是两个概念:
| 管道容量 | 管道缓冲区总共能容纳多少数据 |
| PIPE_BUF | 多进程写同一条管道时,内核保证原子写的上限 |
5.3 PIPE_BUF 解决的是写入原子性
可以通过:
man 7 pipe
查看管道规则。

当一次 write() 的数据量不大于 PIPE_BUF 时,Linux 保证这次写入不会和其他进程的小块写入交叉混合。
例如两个进程同时写:
进程 A:AAAA
进程 B:BBBB
如果每次写入都不超过 PIPE_BUF,可能得到:
AAAABBBB
也可能得到:
BBBBAAAA
但不会把两次写入撕裂成:
AABABBAB
这里保证的是同一次小块写入的完整性,不是保证谁先写,也不是保证 read() 一次恰好读到一条完整消息。
总结
匿名管道真正需要记住的,不是某一段固定代码,而是父子进程和内核资源之间的关系:
写进程用户空间
↓ write
内核管道缓冲区
↓ read
读进程用户空间
pipefd[0] 是读端,pipefd[1] 是写端。fork() 以后,父子进程有各自的 fd 表,但这些 fd 可以继续引用同一条内核管道。
后面很多现象都由“还有没有读端、还有没有写端”决定:
- 管道为空且写端仍存在,阻塞读会等待;
- 管道写满且读端仍存在,阻塞写会等待;
- 所有写端关闭并且数据读完,read() 返回 0;
- 所有读端关闭,继续写会触发 SIGPIPE;
- 管道容量决定能装多少数据,PIPE_BUF 解决的是小块写入原子性,两者不是一回事。
把这些规则理清以后,下篇的进程池就不再只是“照着代码敲”,而是能看懂为什么每个子进程都必须关闭那些看起来无关的 fd。
资源分享: 进程间通信相关代码 【Linux】没有公网 IP 也能 SSH 回内网:反向 SSH、ProxyJump 与 systemd 保活实战
【Linux】系统彻底进不去怎么办?initramfs、recovery 与 chroot 救援指南
网硕互联帮助中心




评论前必须登录!
注册