Linux 进程间通信:匿名管道原理与实现
Linux 进程间通信中,匿名管道是最基础的 IPC 方式。本文解析了管道的定义、核心特性及 pipe 函数创建方法,通过 fork 实现父子进程共享文件描述符进行数据交互。从内核视角阐述了管道缓冲区、file 与 inode 结构的关系,对比了管道与普通文件的异同,并提供了子写父读的实战代码示例。

Linux 进程间通信中,匿名管道是最基础的 IPC 方式。本文解析了管道的定义、核心特性及 pipe 函数创建方法,通过 fork 实现父子进程共享文件描述符进行数据交互。从内核视角阐述了管道缓冲区、file 与 inode 结构的关系,对比了管道与普通文件的异同,并提供了子写父读的实战代码示例。

在 Linux 系统中,进程是资源分配的基本单位,各个进程拥有独立的地址空间,进程间的隔离性让数据无法直接互通,而进程间通信(IPC)就是打破这种隔离、实现进程数据交互和协同工作的核心技术。管道作为 Unix 系统中最古老的 IPC 方式,也是 Linux 下最基础、最常用的进程间通信手段,其设计贴合 Linux 一切皆文件的核心思想,简单易用且能满足亲缘进程间的通信需求。本文将从管道的基础概念出发,深入解析匿名管道的创建、工作原理,从文件描述符和内核视角带你吃透匿名管道的底层逻辑。
在学习管道之前,我们需要先明确进程间通信的核心目的和分类,建立对 IPC 技术的整体认知,这能帮助我们更好地理解管道的设计初衷和应用场景。
进程间通信的本质是实现进程间的数据交互、资源共享和事件协同,具体可分为四个方面:

Linux 的 IPC 技术从 Unix 继承并不断发展,整体可分为三大类,管道是其中最基础的一类:
匿名管道(pipe) 和 命名管道(FIFO),是最基础的 IPC 方式,基于文件系统实现;管道作为最原始的 IPC 方式,虽然功能简单,但却是理解 Linux 进程间通信和文件系统的关键,也是实现其他复杂 IPC 的基础。

管道是一种半双工的数据流通信方式,本质是内核中的一块缓冲区,它将一个进程的标准输出与另一个进程的标准输入相连,形成一条单向的数据流通道。我们可以把管道理解为进程间的 '一根水管',数据从一端写入,从另一端读出,实现单向的通信。
在 Linux 命令行中,我们经常使用的管道符 | 就是管道的典型应用,例如 who | wc -l:
who 进程的标准输出被重定向到管道的写端;
wc -l 进程的标准输入被重定向到管道的读端;
内核中的管道缓冲区作为中间介质,完成两个进程间的数据传递。
从图中我们可以看出最后他们三个指令的父进程都是 bash 的,他们之间是具有血缘关系的进程

管道的设计贴合 Linux 一切皆文件的思想,其核心特性可总结为:



匿名管道是最基础的管道类型,通过系统调用 pipe 创建,其核心 API 简单且易用,是实现亲缘进程间通信的首选。
#include <unistd.h>
int pipe(int pipefd[2]);
函数参数
pipefd:整型数组,是输出型参数,用于保存管道的读、写文件描述符:
pipefd[0]:管道的读端,仅用于读取管道中的数据;pipefd[1]:管道的写端,仅用于向管道中写入数据。返回值
errno 表示错误原因。注意:调用 pipe 函数的进程会同时持有管道的读端和写端,若要实现两个进程间的单向通信,需要在进程创建后关闭各自无用的文件描述符,避免数据读写异常

下面的示例实现了一个基础的匿名管道通信:从键盘读取数据写入管道,再从管道读取数据输出到屏幕,直观展示管道的读写操作。
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
int main() {
int fds[2];
char buf[100];
int len;
if (pipe(fds) == -1) {
std::perror("make pipe");
std::exit(1);
}
while (std::fgets(buf, 100, stdin)) {
len = std::strlen(buf);
if (write(fds[1], buf, len) != len) {
std::perror("write to pipe");
break;
}
std::memset(buf, 0x00, sizeof(buf));
if ((len = read(fds[0], buf, 100)) == -1) {
std::perror("read from pipe");
break;
}
if (write(1, buf, len) != len) {
std::perror("write to stdout");
break;
}
}
return 0;
}
该示例中,进程自身同时完成管道的写和读操作,虽然未实现跨进程通信,但清晰展示了管道的基本读写流程:通过 fd[1] 写,通过 fd[0] 读,操作接口与普通文件完全一致。
匿名管道本身由单个进程创建,要实现跨进程通信,需要借助 fork 函数创建子进程 —— 子进程会继承父进程的文件描述符表,从而与父进程共享同一个管道的读、写端,这是匿名管道实现亲缘进程通信的核心原理。

fork 函数创建的子进程会复制父进程的文件描述符表,包括父进程创建的管道读、写端文件描述符,因此父子进程会共享同一个内核管道缓冲区,实现数据互通。其核心步骤分为三步:
pipe 创建管道,持有 fd[0](读)和 fd[1](写)两个文件描述符;例如要实现父进程读、子进程写,则:
fd[1],仅保留读端 fd[0];fd[0],仅保留写端 fd[1]。
从文件描述符的角度,我们可以更清晰地看到父子进程共享管道的过程,以父读子写为例:
✅️ 步骤 1:父进程创建管道
父进程的文件描述符表中,0、1、2 分别为标准输入、标准输出、标准错误,pipe 创建的管道分配到 3(读端 fd[0])和 4(写端 fd[1])。
父进程:0(tty) 1(tty) 2(tty) 3(pipe 读) 4(pipe 写)
✅️ 步骤 2:父进程 fork 创建子进程
子进程复制父进程的文件描述符表,此时父子进程的文件描述符 3、4 均指向同一个内核管道缓冲区。
父进程:0(tty) 1(tty) 2(tty) 3(pipe 读) 4(pipe 写)
子进程:0(tty) 1(tty) 2(tty) 3(pipe 读) 4(pipe 写)
核心关键点:父子进程的文件描述符指向同一个内核管道缓冲区,这是进程间能通过管道通信的根本原因;关闭无用描述符则是为了保证通信的单向性,避免出现数据读写的混乱。
✅️ 步骤 3:关闭无用文件描述符
父进程关闭写端 4,子进程关闭读端 3,此时管道形成单向的'子写父读'通道,数据只能从子进程写入,父进程读出。
父进程:0(tty) 1(tty) 2(tty) 3(pipe 读)
子进程:0(tty) 1(tty) 2(tty) 4(pipe 写)
核心关键点:父子进程的文件描述符指向同一个内核管道缓冲区,这是进程间能通过管道通信的根本原因;关闭无用描述符则是为了保证通信的单向性,避免出现数据读写的混乱。
下面的示例实现了子进程向管道写入字符串,父进程从管道读取并打印的功能,是'子写父读'的标准实现:
#include <iostream>
#include <string>
#include <unistd.h>
// 子进程写
void WriteData(int wfd) {
int cnt = 1;
pid_t id = getpid();
while (true) {
sleep(1);
std::string message = "hello father process, ";
message += "cnt: " + std::to_string(cnt++) + ", my pid is: " + std::to_string(id);
write(wfd, message.c_str(), message.size());
}
}
// 父进程:读
void ReadData(int rfd) {
char inbuffer[1024];
while (true) {
ssize_t n = read(rfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0) {
sleep(5);
inbuffer[n] = '\0';
std::cout << getpid() << "# " << inbuffer << std::endl;
}
}
}
int main() {
// 1. 创建管道成功
int pipefd[2] = {0};
int n = pipe(pipefd);
(void)n;
// 2. 创建子进程
pid_t id = fork();
if (id == 0) {
// 3. 形成单向通信的信道
// 子进程:写
close(pipefd[0]); // 关掉我不用的这个读
WriteData(pipefd[1]);
close(pipefd[1]); // 可以关掉
exit(0);
} else {
// 3. 形成单向通信的信道
// 父进程:读
close(pipefd[1]); // 关掉我不用的这个写
ReadData(pipefd[0]);
close(pipefd[0]); // 可以关掉
}
// 0->read 1->write
// 0->嘴巴->读 1->笔->写
// std::cout << "pipefd[0]: " << pipefd[0] << std::endl;
// std::cout << "pipefd[1]: " << pipefd[1] << std::endl;
return 0;
}
通过对上述案例进行一定程度上的修改,有一想 4 种情况,大家注意看一下,也可以根据我的截图自己改了去试一下。




从文件描述符视角,我们理解了管道的使用流程,而从内核视角,我们能看透管道的底层实现 —— 管道的本质是内核中的一块缓冲区,由两个 file 结构体指向同一个 inode,贴合 Linux'一切皆文件'的设计思想。
在 Linux 内核中,管道的底层实现涉及三个核心数据结构:
file 结构体:进程的文件描述符表中的每个项都指向一个 file 结构体,记录文件的操作方式、当前偏移量等信息;inode 结构体:用于描述文件的物理属性,管道的 inode 中保存了管道缓冲区的地址、大小、读写位置等核心信息;对于匿名管道,父子进程的 fd[0] 和 fd[1] 会分别指向不同的 file 结构体,但这两个 file 结构体最终会指向同一个 inode 结构体,而该 inode 指向内核中的管道缓冲区。

当进程对管道执行 read/write 操作时,内核的处理逻辑如下:
write(fd[1], data, len):内核将数据从进程地址空间复制到管道缓冲区,并更新 inode 中的写位置;read(fd[0], buf, len):内核将管道缓冲区中的数据复制到进程地址空间,并更新 inode 中的读位置;简单来说,管道的读写操作本质是进程地址空间与内核缓冲区之间的数据拷贝,而两个进程共享同一个内核缓冲区,就实现了数据的跨进程传递。

管道的操作接口与普通文件一致,但二者在底层实现和使用上有明显区别,核心对比如下:
| 特性 | 管道 | 普通文件 |
|---|---|---|
| 存储介质 | 内核缓冲区 | 磁盘 / 块设备 |
| 生命周期 | 随进程(进程退出释放) | 随文件系统(需手动删除) |
| 数据读写 | 流式读写,不可随机访问 | 支持随机访问(lseek) |
| 共享方式 | 仅亲缘进程通过文件描述符共享 | 所有进程可通过路径 / 文件描述符共享 |
| 数据持久化 | 不持久化(读出即删除) | 持久化(数据保存在磁盘) |
但二者的核心共性是都遵循 Linux 的文件操作模型,通过文件描述符、file 结构体、inode 结构体实现操作,这也是管道能复用文件读写接口的根本原因。
本文从进程间通信的基础出发,详细解析了 Linux 匿名管道的核心概念、创建 API、基于 fork 的跨进程通信实现,并从文件描述符和内核两个视角深入剖析了匿名管道的底层逻辑,核心要点可总结为:
read/write 接口操作;pipe 函数创建,返回读、写两个文件描述符,需借助 fork 实现亲缘进程间通信,核心是子进程继承父进程的文件描述符表,共享同一个内核管道缓冲区;file 结构体指向同一个 inode 的内核缓冲区,读写操作是进程与内核缓冲区之间的数据拷贝;

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online