
前两篇我们把匿名管道玩出了花:从内核里的 "披着文件外皮的内存",到进程池的 fd 泄漏排查。但匿名管道有一个与生俱来的天花板 ------ 只能用于亲缘进程 。原理我们说透了:它没有名字、没有路径,第三方进程根本 "找不到" 它,唯一入口是 fork 时的 fd 表拷贝。那两个素不相识的进程(比如一个日志服务和一个监控工具)想通信怎么办?这篇的主角登场:命名管道(FIFO) ------ 给管道起个名字,让任何进程都能凭这个名字打开同一份内核缓冲区。它还是第五篇共享内存的 "最佳搭子":共享内存裸奔没有同步机制,FIFO 恰好可以给它当 "信号灯" 用。
文章目录
-
- [一、匿名管道的天花板与 FIFO 的破局](#一、匿名管道的天花板与 FIFO 的破局)
-
- [1.1 回顾:为什么匿名管道走不出亲缘圈](#1.1 回顾:为什么匿名管道走不出亲缘圈)
- [1.2 破局思路:给它一个 "名字"](#1.2 破局思路:给它一个 "名字")
- [1.3 与匿名管道的本质关系](#1.3 与匿名管道的本质关系)
- [二、创建 FIFO:命令行与系统调用](#二、创建 FIFO:命令行与系统调用)
-
- [2.1 命令行创建](#2.1 命令行创建)
- [2.2 系统调用创建](#2.2 系统调用创建)
- [2.3 一个必须面对的问题:谁来删?](#2.3 一个必须面对的问题:谁来删?)
- [三、打开 FIFO:open 的阻塞配对规则](#三、打开 FIFO:open 的阻塞配对规则)
-
- [3.1 打开规则表](#3.1 打开规则表)
- [3.2 创建 + 打开一步到位?](#3.2 创建 + 打开一步到位?)
- [四、动手实验:文件拷贝版 FIFO](#四、动手实验:文件拷贝版 FIFO)
- [五、实战:Server & Client 聊天程序](#五、实战:Server & Client 聊天程序)
-
- [5.1 C 版本:最简 server-client](#5.1 C 版本:最简 server-client)
- [5.2 C++ 封装版本:NamedFifo 类](#5.2 C++ 封装版本:NamedFifo 类)
- [六、FIFO 的杀手锏应用:给共享内存当同步器](#六、FIFO 的杀手锏应用:给共享内存当同步器)
-
- [6.1 问题的提出](#6.1 问题的提出)
- [6.2 Wait / Signal 原语](#6.2 Wait / Signal 原语)
- [七、五大特性对照:FIFO vs 匿名管道](#七、五大特性对照:FIFO vs 匿名管道)
- 八、常见坑清单
- 九、总结与下一篇预告
-
- [9.1 本篇核心结论](#9.1 本篇核心结论)
- [9.2 下一篇预告](#9.2 下一篇预告)
一、匿名管道的天花板与 FIFO 的破局
1.1 回顾:为什么匿名管道走不出亲缘圈
- 匿名管道由
pipe()创建,得到的是进程私有的两个 fd; - 它没有路径名,文件系统里不存在它的任何痕迹;
- 想拿到它的 fd?只能靠 fork 拷贝 fd 表(或拷贝给兄弟进程)。
一句话:匿名管道的 "共享" 靠血缘继承,而继承只在 fork 那一刻发生。
1.2 破局思路:给它一个 "名字"
我们想在不相关的进程之间交换数据,可以让它们通过文件系统中一个可见的路径名 建立联系。这个路径名指向一种特殊类型的文件 ------ FIFO 文件 ,常被称为命名管道(named pipe)。
注意这里的措辞:FIFO 是 "一种特殊类型的文件 ",但上一句同时强调它是管道。它到底是什么?看下面这张对照图:

关键认知:FIFO 的文件名只是 "门牌号",数据依然存在内核内存缓冲区里,不落盘。 ls -l 看到的 FIFO "大小为 0" 正是这个原因 ------ 名字存在文件系统里,数据活在内存中。
1.3 与匿名管道的本质关系
先抛结论(这也是本篇最重要的一句话):
FIFO 与 pipe 之间唯一的区别是创建与打开的方式不同,一旦这些工作完成之后,它们具有相同的语义。
也就是说:同样的四种读写规则、同样的 64KB 容量与 PIPE_BUF 原子性、同样的五大特性(除了 "亲缘限制" 被解除)、同样可以设置 O_NONBLOCK。前两篇学的知识对 FIFO 全部适用 ,我们只需要重点研究它多出来的两件事:怎么创建 、怎么打开。
二、创建 FIFO:命令行与系统调用
2.1 命令行创建
bash
$ mkfifo mypipe
$ ls -l mypipe
prw-r--r-- 1 user user 0 Oct 2 10:00 mypipe
^
类型位 'p':pipe,这就是 FIFO 在文件系统中的身份证
第一个字符是 p(pipe),这是 FIFO 区别于普通文件 -、目录 d、链接 l 的类型标志。
2.2 系统调用创建
c
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
// 成功返回 0,失败返回 -1(常见错误:EEXIST 已存在)
c
int main()
{
mkfifo("p2", 0644);
return 0;
}
mode 参数与 open 的含义一致(受 umask 影响的实际权限 = mode & ~umask)。实践中常用 umask(0) 先把掩码清零,保证拿到的就是指定的 0666/0644。
2.3 一个必须面对的问题:谁来删?
FIFO 名字落在文件系统里,进程退出后它不会自动消失(对比匿名管道 "生命周期随进程")------ 这是它为了 "可见性" 付出的代价。所以服务端通常要在退出前清理:
c
unlink("mypipe"); // 从文件系统中删除该名字
工程惯例:谁创建,谁 unlink (一般是 server 端),或者干脆在 shell 里用 rm mypipe 清理。忘了 unlink 的下场是下次运行 mkfifo 报 EEXIST ------ 这是 FIFO 的第一大常见坑。
三、打开 FIFO:open 的阻塞配对规则
如果说 mkfifo 只是 "挂牌",open 才是 "进门"。而 FIFO 的 open 有一套特殊的规则 ------ 读打开与写打开必须配对成功,否则阻塞。
3.1 打开规则表
设当前进程要 open 一个 FIFO:
| 打开目的 | O_NONBLOCK 关闭(默认) | O_NONBLOCK 打开 |
|---|---|---|
| 为读而打开 | 阻塞,直到有进程为写而打开该 FIFO | 立刻返回成功 |
| 为写而打开 | 阻塞,直到有进程为读而打开该 FIFO | 立刻返回失败 ,errno = ENXIO |
用图理解这个 "配对握手":

这个规则其实非常符合直觉:管道是两端协作的资源,一端 open 而另一端不在,通信无从谈起 ------ 内核干脆让先到的一方等在 open 上 。还记得第三篇进程池里 Worker "阻塞在 read 上待命" 吗?FIFO 把 "待命" 提前到了 open 这一步,这带来了一个好处:建立连接的时序由内核保证,后启动的一方永远来得及加入。
(O_NONBLOCK enable 时写打开直接失败 ENXIO,语义是 "没人读还硬写,拒绝";而读打开仍成功,语义是 "先占好位置等写者来"。这个不对称在写服务端时有用。)
3.2 创建 + 打开一步到位?
严格来说 mkfifo 只创建不打开,之后必须 open。而实际代码中经常看到对同一进程来说 "创建与打开" 合并的写法 ------ server 端负责 mkfifo + open 读端,client 端只 open 写端。FIFO 与 pipe 的区别就在这里:pipe 是 "创建即打开"(一个系统调用拿到两个 fd),FIFO 是 "创建"(mkfifo)和 "打开"(open)分成两步、由不同进程分别执行 ------ 正是这两步的分离,让任意进程都能参与。
四、动手实验:文件拷贝版 FIFO
用 FIFO 在两个独立进程间拷贝文件,最能体现 "陌生进程通信":进程 1 读磁盘文件 abc 写入 FIFO,进程 2 读 FIFO 写入 abc.bak ------ 两个进程没有 fork 关系,仅凭 FIFO 名字协作。
写进程(读源文件 → 写 FIFO):
c
// write_fifo.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#define ERR_EXIT(m) \
do { perror(m); exit(EXIT_FAILURE); } while(0)
int main()
{
mkfifo("tp", 0644);
int infd = open("abc", O_RDONLY); // 打开源文件
if (infd == -1) ERR_EXIT("open");
int outfd = open("tp", O_WRONLY); // 打开 FIFO 写端(阻塞等读者)
if (outfd == -1) ERR_EXIT("open");
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0)
write(outfd, buf, n); // 磁盘 → 管道
close(infd);
close(outfd);
return 0;
}
读进程(读 FIFO → 写目标文件):
c
// read_fifo.c
// 头部同上
int main()
{
int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (outfd == -1) ERR_EXIT("open");
int infd = open("tp", O_RDONLY); // 打开 FIFO 读端
if (infd == -1) ERR_EXIT("open");
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0)
write(outfd, buf, n); // 管道 → 磁盘
close(infd);
close(outfd);
unlink("tp"); // 谁最后用完,谁负责删名字
return 0;
}
运行时先启动读进程再启动写进程(或反过来都行 ------ open 的阻塞配对会自动把时序摆正),执行完 cmp abc abc.bak 验证一字不差。
两个观察点:
- 中间有一块数据从未落盘 :abc → 内核缓冲区 → abc.bak,FIFO 只过了个手。可以对比
ls -l tp执行前后 ------ FIFO 的 "大小" 始终是 0; - 写进程结束时 FIFO 读端收到 EOF :写进程 close 写端后,读进程的
read返回 0,while循环自然结束 ------ 匿名管道的规则原样生效。
五、实战:Server & Client 聊天程序
5.1 C 版本:最简 server-client
server 创建 FIFO 并从读端收消息;client 从写端发消息。这是 FIFO 的经典用法:
c
// serverPipe.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define ERR_EXIT(m) do{ perror(m); exit(EXIT_FAILURE); }while(0)
int main()
{
umask(0);
if (mkfifo("mypipe", 0644) < 0) // server 负责 "挂牌"
ERR_EXIT("mkfifo");
int rfd = open("mypipe", O_RDONLY); // open 阻塞,直到 client 打开写端
if (rfd < 0)
ERR_EXIT("open");
char buf[1024];
while (1)
{
buf[0] = 0;
printf("Please wait...\n");
ssize_t s = read(rfd, buf, sizeof(buf)-1);
if (s > 0)
{
buf[s-1] = 0; // client 写入的是 strlen(buf),含末尾 '\n',去掉它
printf("client say# %s\n", buf);
}
else if (s == 0) // 所有写端关闭 → client 退出
{
printf("client quit, exit now!\n");
exit(EXIT_SUCCESS);
}
else
ERR_EXIT("read");
}
close(rfd);
return 0;
}
c
// clientPipe.c
#include <stdio.h>
#include <string.h>
// 头部同上
int main()
{
int wfd = open("mypipe", O_WRONLY); // 只 open,不创建 ------ server 已挂牌
if (wfd < 0)
ERR_EXIT("open");
char buf[1024];
while (1)
{
buf[0] = 0;
printf("Please Enter# ");
fflush(stdout);
ssize_t s = read(0, buf, sizeof(buf)-1); // 从键盘读
if (s > 0)
{
buf[s] = 0;
write(wfd, buf, strlen(buf)); // 写进 FIFO
}
else
ERR_EXIT("read");
}
close(wfd);
return 0;
}
bash
$ gcc serverPipe.c -o serverPipe && gcc clientPipe.c -o clientPipe
$ ./serverPipe & # 后台先起 server
$ ./clientPipe
Please Enter# hello
# server 侧输出:client say# hello
注意 server 循环里的 s == 0 分支:client 退出(写端关闭)后 server 读到 EOF、优雅退出。fork/pipe 的全部读写规则,在这里原封不动地重演 ------ 印证了 1.3 的那句话:"创建打开方式不同,语义相同"。
5.2 C++ 封装版本:NamedFifo 类
延续本系列的 C++ 封装风格,把 FIFO 的生命周期管理做成类(构造时创建、析构时可选清理),读写对 open 规则做薄封装:
cpp
#pragma once
#include <iostream>
#include <string>
#include <cstdio>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
class NamedFifo
{
public:
NamedFifo(const std::string &path, bool creator)
: _fifoname(path), _fd(-1), _creator(creator)
{
if (_creator)
{
umask(0);
int n = mkfifo(_fifoname.c_str(), 0666);
if (n == 0)
std::cout << "mkfifo success" << std::endl;
else if (errno == EEXIST) // 已存在不算错:允许重复启动
std::cout << "fifo exists, reuse" << std::endl;
else
ERR_EXIT("mkfifo");
}
}
bool OpenForRead() { return OpenFile(O_RDONLY); } // 阻塞等写者
bool OpenForWrite() { return OpenFile(O_WRONLY); } // 阻塞等读者
void Write(const std::string &in)
{
::write(_fd, in.c_str(), in.size());
}
int Read(std::string *out)
{
char buffer[1024];
ssize_t n = ::read(_fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
*out = buffer;
}
return n; // >0 正常 / ==0 EOF / <0 错误
}
void Close() { if (_fd >= 0) { ::close(_fd); _fd = -1; } }
~NamedFifo()
{
Close();
if (_creator) // 创建者负责摘牌
::unlink(_fifoname.c_str());
}
private:
bool OpenFile(int flags)
{
_fd = ::open(_fifoname.c_str(), flags);
return _fd >= 0;
}
private:
std::string _fifoname;
int _fd;
bool _creator; // 是否为创建方(决定谁 unlink)
};
封装的价值在析构函数:RAII 把 "谁创建谁删除" 变成自动行为 ,server 端 NamedFifo fifo(PATH, true) 走到作用域结尾自动摘牌,再也不怕忘 unlink。
六、FIFO 的杀手锏应用:给共享内存当同步器
6.1 问题的提出
先剧透第五篇的一个结论:System V 共享内存是所有 IPC 里速度最快的,但内核对它完全不提供同步与互斥保护 ------ 读写双方想什么时候写就什么时候写,想什么时候读就什么时候读,数据可能读到 "写了一半" 的状态。
而 FIFO 恰好相反:它速度一般,但自带阻塞语义 (空读阻塞、配对 open 阻塞)。让两者合作 ------ 共享内存搬数据,FIFO 传信号 ------ 就是一个经典设计:
text
Client (写方) Server (读方)
│ │
│ ① 把数据写进共享内存(裸写,飞快) │
│ ─────────────────────────────────► │
│ │
│ ② 往 FIFO 写 4 字节 "信号" │
│ ──────────────┐ ┌──────────────► │
│ ▼ ▼ │
│ FIFO(内核缓冲区) │
│ │
│ │ ③ 从 FIFO 读走信号 → 知道数据就绪
│ │ ④ 从共享内存读数据(此刻数据是完整的)
信号的大小刻意选了 uint32_t(4 字节 < PIPE_BUF):信号内容无所谓,信号 "到来" 这个事件本身才是信息 ------ 4 字节既保证了原子性,又把 FIFO 的数据面压缩到最小,纯粹利用它的阻塞等待能力。
6.2 Wait / Signal 原语
把这套思路封装成两个函数(完整代码在第五篇展开,这里先看骨架):
cpp
void Wait(int fd) // 读方:等信号(FIFO 为空时 read 阻塞,天然实现等待)
{
uint32_t temp = 0;
ssize_t s = read(fd, &temp, sizeof(uint32_t)); // 阻塞直到写方发信号
assert(s == sizeof(uint32_t));
(void)s;
}
void Signal(int fd) // 写方:发信号(4 字节原子写入)
{
uint32_t temp = 1;
ssize_t s = write(fd, &temp, sizeof(uint32_t));
assert(s == sizeof(uint32_t));
(void)s;
}
Server 循环里 Wait(fd) 阻塞着等 Client 写完共享内存后发信号,拿到信号才去读共享内存 ------ 用 FIFO 的阻塞补上了共享内存的同步缺口。这两个函数名是不是很眼熟?P、V 操作!它们的正式身份将在第七篇(信号量)登场,FIFO 版的 Wait/Signal 可以看作信号量思想的 "手工预演"。
这个设计还留了一个思考题给读者:Wait/Signal 用一根 FIFO 只能实现 "读方等写方" 的单向同步,如果写方还想等读方 "读完了再写下一批",该怎么加第二根 FIFO?(方向相反即可 ------ 这个 "双向握手" 问题,正是信号量同步机制的雏形。)
七、五大特性对照:FIFO vs 匿名管道
| 特性 | 匿名管道 | FIFO |
|---|---|---|
| 亲缘限制 | 只能亲缘进程 | 任意进程(凭路径名打开) |
| 半双工 | 单向,双向要两条 | 相同,双向要两条 |
| 流式服务 | 字节流,无边界 | 相同 |
| 生命周期 | 随进程(内核对象随 fd 引用计数归零销毁) | 名字随文件系统(需 unlink);缓冲区数据仍随打开它的进程 |
| 同步互斥 | 内核自带 | 相同 |
| 容量/原子性 | 64KB / PIPE_BUF 原子 | 相同 |
| 创建打开 | pipe() 一步到位 | mkfifo() 创建 + open() 打开(可跨进程分两步) |
| 文件系统可见 | 不可见 | 可见(ls 能看到 p 类型文件) |
可以看到,除了 "亲缘限制" 这一条被彻底解除、生命周期多了一个 "名字持久" 的维度,其余语义完全一致。学习成本几乎为零,收益是完全不同的使用场景 ------ 这就是 Unix 设计哲学中 "复用语义、扩展入口" 的典型例子。
八、常见坑清单
- 忘 unlink ------ 进程退出 FIFO 名字还在,下次 mkfifo 报 EEXIST。解法:RAII 封装(5.2 节析构函数)或启动时容忍 EEXIST 复用;
- server 先 open 写端却没人读 ------ 默认模式下 open 直接阻塞(好情况),O_NONBLOCK 下直接 ENXIO 失败(坏情况且容易被当成 "FIFO 坏了");
- 把 FIFO 当普通文件存数据 ------ 往 FIFO 里 "写文件" 的内容不会变大,
ls -l大小恒为 0;重定向> mypipe的输出全部流向了内核缓冲区,没人读就会阻塞在 write 上; - 误以为 FIFO 数据落盘 ------ mkfifo 创建的文件不占数据块,断电丢失的是名字还是数据?名字还在(元数据),数据早随进程消失了。要落盘请用普通文件;
- 双向通信只开一根 FIFO ------ 单根 FIFO 半双工,两进程同时读写会把自己发出的数据读回来。双向 = 两根 FIFO(方向相反),这与匿名管道要两条管道是同一个道理。
九、总结与下一篇预告
9.1 本篇核心结论
- FIFO 的本质:文件系统里的一个路径名(门牌号)+ 内核内存缓冲区(真正的管道) ,ls 中类型位为
p; - 与匿名管道唯一的区别是创建(mkfifo)与打开(open)的方式 ------ 打开后语义完全相同,四种读写规则、原子性、特性全部沿用;
- open 有阻塞配对规则:读/写打开必须双方到齐才返回(O_NONBLOCK 下读打开立即成功、写打开失败 ENXIO);
- 生命周期多一个维度:名字随文件系统持久,需要显式 unlink,工程上用 RAII 或 "创建方负责删除" 约定;
- 最有价值的组合拳:共享内存搬数据 + FIFO 传信号,Wait/Signal 两个原语补上共享内存的同步短板 ------ 这也是信号量思想的预演。
9.2 下一篇预告
FIFO 解决了 "陌生进程见面" 的问题,但它依然是字节流、依然要经过两次内核拷贝(写方用户态 → 内核 → 读方用户态)。有没有更快的路?下一篇《进程通信系列(五):System V 共享内存 ------ IPC 中的 "速度之王"》,我们直接在物理内存上划一块区域,把它同时映射进多个进程的地址空间 ------ 数据零拷贝,读写直达。快是有代价的:内核完全不管同步,一切保护要自己做,我们会用本篇留下的 Wait/Signal 组合把它治住。