
观众老爷们大家好 这里是邪修KING的独家频道 本文属于系列Linux系统篇 ------动静态库 一起学Linux的小伙伴可订阅专栏: Linux系统篇
前言
匿名管道有一个天生的局限:只能用于具有亲缘关系的进程(父子、兄弟)之间通信。原因很简单 ------ 它没有名字,只能靠 fork 继承文件描述符来传递管道入口,两个毫不相干的进程根本找不到同一条管道。
那如果想让两个完全独立的进程通信呢?比如一个服务端进程和一个客户端进程,它们既不是父子,也没有共同祖先。
这就是我们今天的主角:命名管道(Named Pipe),也叫 FIFO。
和上一篇一样,我们依然会从「先描述,再组织」这个内核核心思想出发,讲透原理、讲清规则、写透代码。本文只聚焦命名管道,文末会预告下一篇 System V 共享内存的内容。
0. 再谈「先描述,再组织」:命名管道也逃不开这个套路
在上一篇里我们反复强调:Linux 内核管理任何资源,都是先用结构体描述清楚「它是什么」,再用数据结构组织好「它们怎么放」。
- 进程:
task_struct(PCB)描述 + 链表 / 哈希表组织 - 匿名管道:
struct pipe_inode_info描述缓冲区 + 文件描述符表组织入口 - 文件:
struct inode描述属性 + 目录树组织层级
到了命名管道,这个思路依然成立,而且体现得更明显:
0.1 描述:内核里还是那套管道结构
很多人以为命名管道和匿名管道是两种完全不同的东西,其实不是。在内核层面,它们共用同一套缓冲区结构 struct pipe_inode_info,读写逻辑、环形缓冲区、等待队列全都是一样的。
换句话说:命名管道 = 匿名管道 + 一个文件系统中的名字。
0.2 组织:从「fd 继承」变成「文件系统索引」
匿名管道的组织方式是「靠文件描述符表 + fork 继承」,只有亲缘进程能拿到;
命名管道的组织方式是「靠文件系统的目录树」,在磁盘上有一个真实的文件名,任何进程只要知道路径,就能通过 open 找到同一条管道。
划重点:变的是「入口组织方式」,不变的是「内核描述结构」。 这就是为什么我们说「创建和打开方式不同,但语义完全一致」。
理解了这一点,你学命名管道就会非常轻松 ------ 读写规则、原子性、半双工、SIGPIPE 这些特性,和匿名管道一模一样,你只需要学「怎么创建、怎么打开、有什么特殊规则」就够了。
1. 为什么需要命名管道?匿名管道的天花板
在讲命名管道之前,我们先把匿名管道的局限说透,这样你才能明白 FIFO 诞生的意义。
1.1 匿名管道的核心限制:只能亲缘进程通信
匿名管道没有名字,它的入口就是两个文件描述符。一个进程创建了管道之后,只有它 fork 出来的子进程能继承到这两个 fd,才能访问同一条管道。
父进程创建管道
│
fork → 子进程继承 fd → 父子通信
↘
fork → 子进程继承 fd → 兄弟通信
两个完全独立的进程,比如你手动启动的 server 和 client,既没有 fork 关系,也没有共同祖先,根本没法拿到对方管道的文件描述符,匿名管道就用不了。
1.2 解决方案:给管道起个名字
既然问题出在「没有全局唯一标识」,那解决方案就很直接:给管道在文件系统里起个名字,让所有进程都能通过文件名找到它。
这个「有名字的管道」,就是命名管道,也叫 FIFO(First In First Out,先进先出)。
1.3 命名管道的直观形象
你可以把匿名管道想象成「家里的内线电话」,只有一家人(亲缘进程)能用,外人接不上;
而命名管道就是「公共电话号码」,任何人(任何进程)只要拨通这个号码(打开这个文件),就能和另一端通话。
2. 命名管道是什么:有名字的内核缓冲区
2.1 定义
命名管道是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但数据并不存储在磁盘上,而是存放在内核缓冲区中。进程通过操作这个特殊文件,实现跨进程的数据传输。
两个关键词:
- 特殊文件 :类型是
p(pipe),不是普通文件,也不是目录、套接字 - 内核缓冲区:数据只在内存里,不落地,和匿名管道本质一样
2.2 一个最直观的验证
我们可以先在命令行感受一下:
mkfifo mypipe
ls -l mypipe
你会看到类似这样的输出:
prw-r--r-- 1 root root 0 10月 22 10:00 mypipe
注意第一个字母 p,这就代表它是管道文件(pipe)。大小是 0,也印证了它不存数据,只是一个入口标识。
2.3 通信模型
进程A 进程B
open("mypipe", 写) open("mypipe", 读)
│ │
└──────────► 内核缓冲区 ◄────────┘
(通过文件名关联)
两个进程通过同一个文件名,找到内核中同一块管道缓冲区,一个写一个读,完成通信。这就是命名管道的全部秘密。
3. 命名管道的创建:命令行 + 系统调用
命名管道的创建有两种方式:命令行创建,和代码里用函数创建。
3.1 命令行创建:mkfifo
最简单的方式,直接用 mkfifo 命令:
mkfifo 文件名
比如:
mkfifo test_fifo
就会在当前目录下创建一个名为 test_fifo 的命名管道文件。
3.2 代码创建:mkfifo 函数
头文件和原型:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
参数说明:
pathname:要创建的命名管道的路径名mode:文件权限,和 open 的 mode 一样,比如0644,最终权限会受 umask 影响
返回值:
- 成功返回 0
- 失败返回 -1,并设置 errno
3.3 最简单的创建代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
// 创建一个名为 p2 的命名管道,权限 0644
int ret = mkfifo("p2", 0644);
if (ret == -1) {
perror("mkfifo");
exit(1);
}
printf("命名管道创建成功\n");
return 0;
}
3.4 一个常见疑问:文件存在了怎么办?
如果路径对应的文件已经存在,mkfifo 会失败,errno 设为 EEXIST。这和 open 的 O_CREAT | O_EXCL 语义类似。
所以工程上通常的写法是:如果文件已存在且是管道文件,就直接用;如果创建失败且不是 EEXIST,才报错退出。
4. 命名管道的打开规则:阻塞与非阻塞的四种场景
命名管道最核心、最容易考、也最容易踩坑的,就是打开规则。这也是它和匿名管道最大的不同点之一 ------ 匿名管道创建的时候读写端就都有了,而命名管道需要两边分别 open。
打开规则分两个维度:打开目的(读 / 写) 和 是否非阻塞(O_NONBLOCK),组合起来一共四种情况。
4.1 前提:为什么打开会阻塞?
管道是用来通信的,只有读端没有写端,或者只有写端没有读端,都没有意义。所以内核默认会让 open 阻塞,直到另一端也打开,通信链路建立完成。
这是内核提供的同步机制,保证双方都就绪之后再开始通信。
4.2 四种场景详细说明
场景 1:阻塞模式下,以读方式打开(O_RDONLY)
- 行为:阻塞等待,直到有其他进程以写方式打开这个 FIFO
- 类比:就像你接起电话等着,对方不拨号过来,你就一直等
场景 2:阻塞模式下,以写方式打开(O_WRONLY)
- 行为:阻塞等待,直到有其他进程以读方式打开这个 FIFO
- 类比:你拨了号,对方不接,你就一直等接通
这是默认行为,也是最常用的模式。两个进程分别打开,都阻塞,等双方都 open 了,一起往下走,天然同步。
场景 3:非阻塞模式下,以读方式打开(O_RDONLY | O_NONBLOCK)
- 行为:立刻返回成功,不等待写端
- 注意:此时如果没有写端,后续 read 会直接返回 0,而不是阻塞
场景 4:非阻塞模式下,以写方式打开(O_WRONLY | O_NONBLOCK)
- 行为:立刻返回失败 ,errno 设置为
ENXIO - 原因:如果没有读端,写端打开也没用,内核直接报错返回
4.3 规则速查表
表格
| 打开方式 | 阻塞模式(默认) | 非阻塞模式(O_NONBLOCK) |
|---|---|---|
| 只读打开(O_RDONLY) | 阻塞,直到有写端打开 | 立即返回成功 |
| 只写打开(O_WRONLY) | 阻塞,直到有读端打开 | 立即返回失败,errno=ENXIO |
4.4 为什么非阻塞写打开会失败?
这是一个经典面试题。简单说:管道是半双工的单向通道,如果连读端都没有,写端打开之后往哪儿写呢?内核认为这是一个无意义的操作,所以直接返回错误。
而非阻塞读打开之所以允许成功,是因为读端可以先等着,等写端来写数据,语义上是说得通的。
5. 实战一:用命名管道实现跨进程文件拷贝
光说不练假把式。我们来写第一个完整案例:用命名管道实现两个独立进程之间的文件拷贝。
5.1 整体流程
写端进程(send) 读端进程(recv)
打开源文件 abc 打开目标文件 abc.bak
打开命名管道 tp(写) 打开命名管道 tp(读)
循环读文件 → 写管道 循环读管道 → 写文件
关闭文件、关闭管道 关闭文件、关闭管道
删除管道文件
两个进程完全独立,分别编译、分别运行,靠命名管道 tp 传递数据。
5.2 写端代码:读文件,写管道
文件名:send.c
c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
// 错误处理宏:打印错误并退出
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
// 1. 创建命名管道 tp,如果已存在则继续使用
// 这里我们选择直接创建,失败且不是已存在则报错
int ret = mkfifo("tp", 0644);
if (ret == -1 && errno != EEXIST) {
ERR_EXIT("mkfifo");
}
// 2. 打开源文件(只读)
int infd = open("abc", O_RDONLY);
if (infd == -1)
ERR_EXIT("open source file");
// 3. 打开命名管道(只写)
// 阻塞模式:会等到读端也打开才返回
int outfd = open("tp", O_WRONLY);
if (outfd == -1)
ERR_EXIT("open fifo write");
// 4. 循环拷贝:读源文件 → 写管道
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) {
write(outfd, buf, n);
}
// 5. 关闭文件描述符
close(infd);
close(outfd);
printf("文件发送完成\n");
return 0;
}
5.3 读端代码:读管道,写目标文件
文件名:recv.c
c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
// 1. 打开目标文件(只写 | 创建 | 截断)
// 如果文件存在就清空,不存在就创建
int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (outfd == -1)
ERR_EXIT("open dest file");
// 2. 打开命名管道(只读)
// 阻塞模式:会等到写端也打开才返回
int infd = open("tp", O_RDONLY);
if (infd == -1)
ERR_EXIT("open fifo read");
// 3. 循环拷贝:读管道 → 写目标文件
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0) {
write(outfd, buf, n);
}
// 4. 关闭文件描述符
close(infd);
close(outfd);
// 5. 删除管道文件(收尾工作)
unlink("tp");
printf("文件接收完成\n");
return 0;
}
5.4 编译与运行
gcc -o send send.c
gcc -o recv recv.c
# 先准备一个测试文件 abc
echo "hello fifo test" > abc
# 开两个终端
# 终端1:运行读端
./recv
# 终端2:运行写端
./send
现象:
- 先运行哪一个,哪一个就会阻塞在 open 处,等另一个也启动后,双方同时继续,文件拷贝完成。
- 拷贝结束后,当前目录下会生成
abc.bak,内容和abc完全一致。 - 管道文件
tp会被 recv 进程删除。
5.5 关键点解读
- 两个进程完全独立 ,没有任何亲缘关系,靠文件名
tp找到同一条管道 - open 的阻塞特性天然实现了同步,不用我们自己写等待逻辑
- 数据流式传输,和匿名管道一样,没有消息边界,read 能读多少读多少
- 管道文件只是入口,数据全程在内核缓冲区,不写磁盘
6. 实战二:命名管道实现 Server-Client 通信
文件拷贝是单向一次性的,我们再写一个更贴近实际的场景:持续通信的服务端与客户端。客户端发消息,服务端接收并打印。
6.1 架构图
客户端进程 服务端进程
(写管道) (读管道)
键盘输入 → 写入 mypipe → 读取并打印消息
这是一个单向通信模型,服务端一直等消息,客户端发一条服务端收一条。
6.2 服务端代码:创建管道,循环读取
文件名:serverPipe.c
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main()
{
// 1. 设置 umask,保证我们设置的权限生效
umask(0);
// 2. 创建命名管道 mypipe
if (mkfifo("mypipe", 0644) < 0) {
// 如果文件已存在,不报错,继续使用
if (errno != EEXIST) {
ERR_EXIT("mkfifo");
}
}
printf("服务端启动,等待客户端连接...\n");
// 3. 以只读方式打开管道(阻塞等待写端)
int rfd = open("mypipe", O_RDONLY);
if (rfd < 0) {
ERR_EXIT("open");
}
printf("客户端已连接,开始接收消息\n");
// 4. 循环读取客户端消息
char buf[1024];
while (1) {
buf[0] = 0; // 清空缓冲区
printf("等待消息中...\n");
// 从管道读取数据
ssize_t s = read(rfd, buf, sizeof(buf) - 1);
if (s > 0) {
// 读到数据,去掉末尾的换行符再打印
buf[s - 1] = 0;
printf("客户端说# %s\n", buf);
}
else if (s == 0) {
// read 返回 0:所有写端都关闭了,客户端退出
printf("客户端已退出,服务端关闭\n");
break;
}
else {
// 读取出错
ERR_EXIT("read");
}
}
// 5. 关闭文件描述符
close(rfd);
return 0;
}
6.3 客户端代码:打开管道,循环发送
文件名:clientPipe.c
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main()
{
printf("客户端启动,正在连接服务端...\n");
// 1. 以只写方式打开命名管道(阻塞等待读端)
int wfd = open("mypipe", O_WRONLY);
if (wfd < 0) {
ERR_EXIT("open");
}
printf("连接成功,请输入消息(Ctrl+C 退出)\n");
// 2. 循环读取键盘输入,写入管道
char buf[1024];
while (1) {
buf[0] = 0;
printf("请输入# ");
fflush(stdout); // 手动刷新缓冲区,保证提示立刻打印
// 从标准输入读取一行
ssize_t s = read(0, buf, sizeof(buf) - 1);
if (s > 0) {
buf[s] = 0;
// 写入管道发送给服务端
write(wfd, buf, strlen(buf));
}
else if (s <= 0) {
ERR_EXIT("read stdin");
}
}
// 3. 关闭写端
close(wfd);
return 0;
}
6.4 Makefile
.PHONY:all
all:clientPipe serverPipe
clientPipe:clientPipe.c
gcc -o $@ $^
serverPipe:serverPipe.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f clientPipe serverPipe mypipe
6.5 运行效果
# 终端1:启动服务端
./serverPipe
服务端启动,等待客户端连接...
# 终端2:启动客户端
./clientPipe
客户端启动,正在连接服务端...
连接成功,请输入消息(Ctrl+C 退出)
请输入# hello world
# 服务端会显示:
客户端已连接,开始接收消息
等待消息中...
客户端说# hello world
当客户端 Ctrl+C 退出时,写端关闭,服务端 read 返回 0,检测到客户端退出,自身也退出。
6.6 退出逻辑的设计思想
这里又用到了管道的经典特性:所有写端关闭,read 返回 0。
客户端退出 → 写端关闭 → 服务端 read 返回 0 → 服务端知道对方走了,自己也优雅退出。
整个过程不需要发特殊的退出指令,也不需要信号,靠管道本身的语义就完成了退出通知。这是管道通信非常优雅的一点。
7. 内核视角:命名管道的本质与「先描述再组织」
代码写了两个,我们再回到内核层面,把命名管道的本质讲透,再次呼应「先描述,再组织」。
7.1 描述层:内核结构和匿名管道完全一样
当你 open 一个命名管道文件后,内核做的事情是:
- 通过路径找到对应的 inode
- 发现这是一个管道文件,就给这个 inode 关联上
struct pipe_inode_info结构 - 分配环形缓冲区、初始化等待队列
而 struct pipe_inode_info 里面有什么?环形缓冲区指针、读写位置、读者写者计数、等待队列......和匿名管道用的是同一个结构体,一模一样。
所以你会发现,一旦 open 成功之后,read、write、close 的行为,和匿名管道没有任何区别:
- 管道空了 read 阻塞
- 管道满了 write 阻塞
- 写端全关 read 返回 0
- 读端全关 write 触发 SIGPIPE
- PIPE_BUF 以内原子写入
全都是一样的规则,因为底层就是同一套实现。
7.2 组织层:入口方式变了
匿名管道的组织方式:
- 没有文件名,入口就是文件描述符
- 靠 fork 继承 fd 传递入口
- 组织在进程的文件描述符表里
命名管道的组织方式:
-
有文件名,入口是文件系统路径
-
靠文件名索引到同一个 inode
-
组织在文件系统的目录树里
匿名管道的入口组织 命名管道的入口组织 父进程 fd 表 文件系统目录树 │ │ fork / 子进程 fd 表 mypipe (inode) / \ 进程A fd 进程B fd
7.3 一句话总结
命名管道和匿名管道,内核里是同一个东西,差别只在于「你怎么找到它」。
一个靠 fd 继承找,一个靠文件名找。找到之后,用法、规则、本质,完全一致。
这也完美体现了「先描述再组织」的思想:描述结构(管道缓冲区)是统一的,组织方式(入口索引)可以灵活变化。
8. 匿名管道 vs 命名管道:一张表彻底分清
表格
| 对比维度 | 匿名管道(pipe) | 命名管道(FIFO) |
|---|---|---|
| 适用范围 | 只能亲缘进程(父子、兄弟) | 任意进程,只要能访问同一个文件路径 |
| 创建方式 | pipe() 函数,直接创建并返回两个 fd |
mkfifo 创建文件,再用 open 打开 |
| 标识方式 | 无名字,靠文件描述符标识 | 有文件名,在文件系统可见,类型为 p |
| 打开行为 | 创建即有读写两端,不存在打开阻塞 | 需要分别 open,默认阻塞等待另一端 |
| 内核结构 | struct pipe_inode_info 环形缓冲区 |
完全相同 |
| 数据形式 | 字节流,无消息边界 | 完全相同 |
| 通信方向 | 半双工,单向;双向需两条管道 | 完全相同 |
| 原子性 | ≤PIPE_BUF 保证原子写入 | 完全相同 |
| 同步机制 | 读写阻塞、SIGPIPE 等 | 完全相同,额外增加 open 阻塞 |
| 生命周期 | 所有引用关闭后释放,随进程 | 管道文件随文件系统;缓冲区随最后一个引用关闭 |
| 典型场景 | 父子进程通信、进程池、shell 管道 | 独立进程 C/S 通信、跨程序数据传递 |
共同点总结
- 本质都是内核缓冲区,遵循先进先出
- 都遵循「一切皆文件」,用 read/write 操作
- 都是半双工字节流通信
- 读写规则、原子性、信号行为完全一致
核心差异总结
一句话:有没有名字,决定了能不能被无关进程找到。 其他的都是这个差异衍生出来的。
9. 常见坑点与最佳实践
9.1 坑点一:单端打开一直卡住
很多新手刚接触会疑惑:「为什么我 open 一个 FIFO 程序就不动了?」
这不是 bug,这是默认的阻塞行为。必须读写两端都有进程打开,open 才会返回。
解决: 要么启动对端进程,要么使用非阻塞模式(但要处理好返回值)。
9.2 坑点二:以为管道文件存数据
命名管道文件大小永远是 0,它只是一个索引节点,数据在内核内存里。就算你重启程序,只要管道文件还在,之前的数据也没了 ------ 因为进程全关了缓冲区就释放了。
9.3 坑点三:用完不删管道文件
mkfifo 创建的文件会一直留在文件系统里,就算所有进程都退出了也不会自动消失。用完记得 unlink 删除,不然下次创建会报 EEXIST。
9.4 坑点四:多进程同时写的原子性问题
和匿名管道一样,单次写入 ≤ PIPE_BUF(Linux 默认 4096 字节)时,内核保证原子性,不会穿插;超过就不保证了。如果多个客户端同时往一个服务端写管道,要注意消息长度。
9.5 坑点五:忘了是半双工
一条命名管道只能单向传数据。想双向通信,必须开两条管道,一条 client→server,一条 server→client。
9.6 最佳实践
- 服务端创建管道,客户端直接用:创建的职责交给通信的发起方 / 服务方,避免重复创建冲突
- 退出靠关闭写端:不要发特殊消息,利用 read 返回 0 的天然语义,优雅可靠
- 小数据量、简单场景优先用 FIFO:比 socket 简单,比共享内存安全,上手成本极低
- 用完清理管道文件:养成好习惯,避免残留文件干扰
10. 总结与下篇预告
10.1 全文总结
今天我们沿着匿名管道的脉络,把命名管道(FIFO)从原理到实战完整走了一遍:
- 为什么要有命名管道:解决匿名管道只能亲缘进程通信的局限,给管道加个文件名,让任意进程都能找到
- 命名管道的本质:内核缓冲区 + 文件系统名字,底层结构和匿名管道完全一样,变的只是入口组织方式
- 四大打开规则:阻塞模式下读写打开都等对端;非阻塞读成功、写失败
- 两个实战案例:文件拷贝 + Server-Client 通信,覆盖一次性传输和持续通信两种场景
- 内核设计思想:依然遵循「先描述,再组织」,描述结构统一,组织方式灵活
- 五大坑点与最佳实践:帮你避开绝大多数新手会踩的雷
命名管道是 IPC 里非常实用的一种,它继承了管道简单易用、内核兜底同步的优点,又突破了亲缘进程的限制。在很多轻量级跨进程通信场景里,它比 socket 更简单,比共享内存更安全。
10.2 下篇预告:System V 共享内存
管道虽然好用,但有一个绕不开的短板:数据必须经过内核中转,两次拷贝,性能有限。如果要传输大量数据、追求极致低延迟,管道就不够用了。
下一篇我们会讲 IPC 里速度最快的方式 ------System V 共享内存:
- 共享内存为什么是最快的 IPC?零拷贝到底是什么意思?
ftok、shmget、shmat、shmdt、shmctl全套函数详解- 共享内存的数据结构与内核组织方式
- 共享内存的致命问题:没有同步互斥怎么办?
- 管道 + 共享内存的组合实战
感兴趣的同学可以点个关注,我们下篇见。
