
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇 前面我们把进程从创建、程序替换、地址空间到 ELF 加载完整讲透了。每个进程拥有独立的虚拟地址空间,互相隔离、互不干扰,这是进程安全的基础,但也带来了一个问题:进程之间怎么传递数据、协同工作?
这就需要 进程间通信(IPC,Inter-Process Communication) ------ 由内核提供公共通道,让隔离的进程可以交换数据。
IPC 是 Linux 系统编程的核心板块,包含管道、消息队列、共享内存、信号量、套接字等。我们从最基础、最经典的 匿名管道(pipe) 讲起,结合完整可运行代码,从内核原理到逐行实现,把管道的本质、特性、读写行为彻底讲透。
一、为什么进程通信需要专门机制?
在讲管道之前,我们先回到最基础的问题:两个进程为什么不能直接互相传数据?
答案就在我们之前讲的进程地址空间隔离 :
每个进程有自己独立的虚拟地址空间,有独立的页表映射到不同的物理内存。进程 A 里的一个内存地址,对进程 B 来说毫无意义,也根本访问不到 B 的内存。这是进程保护的核心设计,但也成了进程通信的天然屏障。
所以所有 IPC 机制的本质都是:
内核出面提供一块公共的缓冲区 / 通道,两个进程通过读写这个公共区域,间接完成数据交换。
匿名管道,就是最简单、最古老的一种 IPC:内核里开辟一块字节流缓冲区,两个进程通过文件描述符来读写这块缓冲区,实现数据传递。
二、匿名管道的本质与原理
2.1 什么是匿名管道
匿名管道(Anonymous Pipe) 本质是内核中的一块字节流缓冲区,它没有文件名、没有磁盘实体,只存在于内核内存中,所以叫「匿名」。
管道会返回两个文件描述符:
fd[0]:读端,只能从管道里读取数据fd[1]:写端,只能往管道里写入数据
💡 这里完美呼应 Linux「一切皆文件」的设计思想
管道在内核中被抽象成一个特殊的文件对象,操作它用的就是我们学过的普通文件系统调用:
read、write、close。接口和操作磁盘文件完全一致,不用额外学习新的函数。
2.2 为什么能在父子进程间通信?------ 结合 fork 与文件描述符
很多初学者会问:管道是父进程创建的,子进程怎么也能用?
这就要结合我们之前讲的 fork 继承文件描述符 的知识点:
- 父进程先调用
pipe()创建管道,拿到两个文件描述符,记录在父进程的文件描述符表中。 - 调用
fork()创建子进程,子进程会完整复制父进程的文件描述符表。 - 所以子进程也拥有这两个 fd,并且指向同一个内核管道对象。
这样一来,父子进程就有了共同的通信通道:子进程往写端写数据,父进程从读端读数据,反过来也可以。
2.3 半双工特性
管道默认是 半双工 的:同一时间数据只能往一个方向流动。
要么父读子写,要么子读父写,不能同时双向传输。
如果需要双向通信,通常的做法是创建两条管道,各自负责一个方向。
三、代码逐段精讲:父子进程管道通信
我们拿一段完整的「子进程写、父进程读」的代码为例,逐段拆解每一行的作用,以及背后的原理。
完整代码总览
c
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <cstdio>
using namespace std;
// 子进程:负责向管道写数据
void ChildWrite(int wfd)
{
char buffer[1024];
int cnt = 0;
while (true)
{
snprintf(buffer, sizeof(buffer),
"I am child, pid: %d, cnt: %d",
getpid(), cnt++);
write(wfd, buffer, strlen(buffer));
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';
cout << "child say: " << buffer << endl;
}
else if (n == 0)
{
cout << "pipe closed!" << endl;
break;
}
else
{
perror("read");
break;
}
}
}
int main()
{
int fd[2];
// ① 创建管道
if (pipe(fd) < 0)
{
perror("pipe");
return 1;
}
// ② 创建子进程
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
// =========================
// 子进程
// =========================
if (id == 0)
{
close(fd[0]);
ChildWrite(fd[1]);
close(fd[1]);
return 0;
}
// =========================
// 父进程
// =========================
else
{
close(fd[1]);
FatherRead(fd[0]);
close(fd[0]);
waitpid(id, nullptr, 0);
}
return 0;
}
3.1 头文件与作用
表格
| 头文件 | 作用 | 对应知识点 |
|---|---|---|
iostream |
C++ 标准输入输出,cout 打印用 |
- |
unistd.h |
系统调用:pipe、fork、read、write、sleep、getpid |
系统调用层 |
sys/types.h |
类型定义,比如 pid_t |
- |
sys/wait.h |
waitpid 进程回收 |
僵尸进程回收 |
cstring / cstdio |
字符串操作:strlen、snprintf |
- |
3.2 子进程写函数:ChildWrite
c
void ChildWrite(int wfd)
{
char buffer[1024];
int cnt = 0;
while (true)
{
// 格式化要发送的数据
snprintf(buffer, sizeof(buffer),
"I am child, pid: %d, cnt: %d",
getpid(), cnt++);
// 写入管道
write(wfd, buffer, strlen(buffer));
// 每秒写一次
sleep(1);
}
}
逐行讲解
- 用户态缓冲区
buffer:先在用户态把数据拼接好,再一次性写入内核管道,减少系统调用次数,呼应我们之前讲的缓冲区思想。 snprintf格式化:把进程 PID、计数器拼成字符串,模拟实际业务数据。write系统调用 :把用户态 buffer 里的数据,拷贝到内核的管道缓冲区中。- 第一个参数是管道写端文件描述符
- 第三个参数是写入的字节长度
sleep(1):每秒写一次,模拟持续发送的场景。
💡 对应知识点
write是系统调用,会触发用户态到内核态的切换,把数据从用户空间拷贝到内核的管道缓冲区。
3.3 父进程读函数:FatherRead(核心重点)
c
void FatherRead(int rfd)
{
char buffer[1024];
while (true)
{
ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
① read 的返回值是关键
read 有三种返回值,对应三种情况,这是管道读写最重要的特性:
表格
| 返回值 n | 含义 |
|---|---|
n > 0 |
成功读到数据,n 是实际读到的字节数 |
n == 0 |
管道所有写端都已关闭,没有新数据了,读到末尾 |
n < 0 |
读取出错 |
情况 1:读到数据(n > 0)
c
if (n > 0)
{
buffer[n] = '\0';
cout << "child say: " << buffer << endl;
}
- 重点细节 :
read是系统调用,只会老老实实读字节,不会自动给字符串补\0。
所以我们手动在buffer[n]的位置补一个结束符,才能当成 C 字符串用cout打印。 - 字节流特性:管道是字节流,没有消息边界。写端写了 30 字节,读端可能一次读 30,也可能分两次读,有多少读多少。应用层需要自己处理数据边界。
情况 2:写端全部关闭(n == 0)
else if (n == 0)
{
cout << "pipe closed!" << endl;
break;
}
这是管道非常重要的特性:
只有当管道所有的写端文件描述符都被关闭时,read 才会返回 0,表示读到了末尾。
只要还有一个写端开着,哪怕暂时没数据,read 也会阻塞等待,不会返回 0。
这也是我们后面要「关闭不用的文件描述符」的核心原因之一。
情况 3:读取出错(n < 0)
c
else
{
perror("read");
break;
}
系统调用出错,用 perror 打印错误信息。
② 默认阻塞特性
默认情况下,如果管道是空的,read 会阻塞等待 ,挂起当前进程,直到有数据进来才返回。
所以父进程不会空转消耗 CPU,会安静等子进程写数据,这是内核自带的同步机制。
3.4 main 函数:完整流程
① 创建管道
c
int fd[2];
if (pipe(fd) < 0)
{
perror("pipe");
return 1;
}
pipe()系统调用,创建一个匿名管道- 成功返回 0,两个文件描述符填入
fd数组:fd[0]读端,fd[1]写端 - 失败返回 -1,比如文件描述符耗尽
② fork 创建子进程
c
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}
调用一次,返回两次。子进程返回 0,父进程返回子进程 PID,这是我们非常熟悉的 fork 特性。
③ 子进程分支
c
if (id == 0)
{
close(fd[0]); // 关闭不用的读端
ChildWrite(fd[1]);
close(fd[1]);
return 0;
}
关键问题:为什么要先关闭 fd 0 读端?
两个原因:
- 资源节省:子进程只负责写,不需要读端,关闭释放文件描述符资源。
- 管道语义正确:只有所有写端都关闭,read 才会返回 0。如果子进程保留读端,哪怕父进程把写端关了,子进程这边还有一个写端引用吗?不,子进程保留的是读端。准确说:如果所有写端都没关,读就不会结束。反过来,如果我们要让读端能正确收到结束信号,就要保证没用的写端都关掉。这里子进程写,所以关掉读端,父进程读,关掉写端,让管道的两端各只有一个使用者,语义最清晰。
④ 父进程分支
c
else
{
close(fd[1]); // 关闭不用的写端
FatherRead(fd[0]);
close(fd[0]);
waitpid(id, nullptr, 0); // 回收子进程
}
- 父进程只负责读,所以先关闭写端
fd[1]。 - 读完之后关闭读端,调用
waitpid等待子进程退出,回收 PCB,避免产生僵尸进程。
这里呼应我们之前讲的僵尸进程回收知识点。
四、匿名管道的核心特性总结
4.1 半双工通信
数据只能单向流动。如果需要双向通信,需要创建两条管道。
4.2 仅用于亲缘进程
匿名管道没有名字,只能通过 fork 继承文件描述符来获取管道两端。所以只能在父子进程、兄弟进程等有亲缘关系的进程之间使用。
后面要讲的命名管道(FIFO) 就是为了解决这个问题,让任意进程都能通信。
4.3 字节流服务,无消息边界
写进去的是连续的字节流,读端不知道消息的边界。可能一次读半条消息,也可能一次读多条拼在一起。
需要应用层自己约定协议、处理分包。
我们代码里每次写一条、每次读一条,是因为每秒写一次、读的速度快,刚好每次读到完整的一条,这是时序巧合,不是管道保证的。
4.4 内置阻塞同步
默认阻塞模式:
- 管道为空,
read阻塞,直到有数据 - 管道为满,
write阻塞,直到有空间
内核自带同步,不需要用户自己加锁。管道缓冲区大小有限制(通常 4KB~64KB),写满之后再写就会阻塞,等读端把数据读走。
4.5 生命周期随引用
所有指向管道的文件描述符都关闭了,内核才会释放管道资源。
五、串联之前的知识体系
学到这里你会发现,管道不是孤立的知识点,它把我们前面学的很多内容都串起来了:
- fork 继承文件描述符:这是管道能在父子间通信的基础,子进程拿到和父进程一样的 fd,指向同一个内核管道对象。
- 一切皆文件 :管道在内核抽象成 file 对象,用
read/write/close操作,和普通文件接口完全统一。 - 进程地址空间隔离:正是因为进程不能直接访问对方内存,才需要 IPC 这种内核中介。
- 系统调用 :
pipe、fork、read、write都是系统调用,都需要切换到内核态完成。 - 僵尸进程回收 :通信结束父进程调用
waitpid回收子进程,呼应进程生命周期。
六、优缺点与适用场景
优点
- 简单易用:接口和文件操作一致,学习成本低
- 内核保证原子性:读写操作不会互相穿插
- 自带阻塞同步:不用额外加锁
缺点
- 只能单向通信
- 仅限有亲缘关系的进程
- 字节流无边界,需要应用层分包
- 缓冲区大小有限,不适合大量数据传输
典型应用:Shell 管道符
我们天天用的 shell 管道符 |,本质就是匿名管道。
比如 ls | grep txt:
- shell 创建一个匿名管道
- fork 两个子进程
- ls 进程的标准输出重定向到管道写端
- grep 进程的标准输入重定向到管道读端
和我们代码里的逻辑完全一致。
全文总结
匿名管道是 IPC 的入门,也是最基础的进程通信方式。它的本质就是内核里的一块字节流缓冲区,通过 fork 继承文件描述符,让父子进程拥有共同的通信通道。
它的限制也很明显:只能单向、只能亲缘进程、无消息边界。下一篇我们讲 命名管道 FIFO ,解决匿名管道的这些限制,实现任意进程之间的通信。
