我最早接触Linux进程间通信,就是从管道开始的。ls | grep txt这种写法看起来平平无奇,但你把两个程序的输出输入接起来的瞬间,真会觉得命令行是有生命力的。不过匿名管道用多了,迟早会碰到几个挠头的问题:跨终端传数据没法弄、后台程序之间没法互相递消息、想拿日志给另一个进程处理更是无从下手。命名管道(Named Pipe,也叫FIFO)就是专门解决这类问题的——它在文件系统里占一个名字,任何进程按路径就能打开,数据在进程之间走的是先进先出的内核缓冲区。这篇文章我打算把命名管道的原理、命令行玩法、简单日志搭建,以及实操中真正会遇到的坑一次讲清楚,适合刚接触进程间通信的开发者,也适合想给脚本加一个解耦层的老手。
1. 为什么管道需要"命名":匿名管道的使用边界
1.1 匿名管道是怎么工作起来的
先看最常见的写法:ls | grep txt。很多人以为这是"两个命令之间的约好了的默契",其实背后是shell替你做了三件事:调用pipe()这个系统调用在内核里创建一个管道对象,拿到一个读端文件描述符和一个写端文件描述符;然后fork()出两个子进程;再把ls的标准输出接到写端,把grep的标准输入接到读端,最后把自己手里的两个fd关掉。数据从写端进入内核缓冲区,读端按先进先出的顺序取走,全部读完就结束。
这个机制非常高效,但也埋了一个关键的前提:通信双方必须是同一个shell直接或间接fork()出来的子孙进程。换句话说,它们是"有血缘关系"的。那如果是两个互不相干的进程呢?比如你手动在终端起了一个top,另一个终端想给它发个消息——用这种匿名管道就完全没戏,因为你连管道对象都访问不到。
1.2 匿名管道的三个明显局限
我把日常使用中的痛点归纳成三点:
- 血缘限制:管道对象没有路径名,外部进程无法按名字引用它。只有创建它的shell的后代进程才能继承到对应的fd,其他进程连"这个管道存在"都感知不到。
- 生命周期太短:命令行执行完,管道对象立刻销毁。数据不落盘,也没有任何延迟消费的机制,错过就是错过了。
- 单向且单通道:匿名管道天然只有一个方向,数据从写端流向读端,进程之间想要一问一答就得再建一根管道。这在复杂一点的脚本里很容易写得混乱。
这三个局限放在"命令拼接"场景下完全不是问题,但一旦你想让独立运行的几个程序之间互相传递数据、或者搭一个日志收集通道,匿名管道就明显不够用了。
1.3 命名管道(FIFO)的定位:给内核管道一个"文件名"
命名管道在文件系统里是一个真实存在、有路径名的条目。它叫FIFO,全称是 First In, First Out,意思是数据严格按照写入的先后顺序被读出。你可以用mkfifo /tmp/myfifo创建它,ls -l查看时它的文件类型标志是p,而不是普通文件的-。
这里要特别澄清一个常见的误解:FIFO这个名字听起来像文件,但数据根本不存放在文件里。文件系统里的这个条目只是一个"会合点"(rendezvous point),真正的数据住在内核的管道缓冲区里。两个进程只要open同一个FIFO路径,内核就能把它们连接到同一个管道对象上,一个写一个读。文件的路径名,本质上是给管道提供了一个外部可寻址的门牌号。
有了这个门牌号,匿名管道的三个局限就全部解决了:无血缘关系的进程可以通过同一个路径相连;FIFO文件只要你不删,它就一直在;多个生产者可以同时往里写,多个消费者也可以按顺序轮流读。理解了这一点,后面的所有操作都顺理成章。
2. 创建FIFO之后,系统里到底发生了什么
2.1 两种创建方式与文件系统中的表现
命令行创建只需要一条命令:
mkfifo /tmp/myfifo如果你想在创建时就固定权限位,可以用-m参数:
mkfifo -m 660 /tmp/myfifo创建后你会看到ls -l输出第一列是prw-r--r--,开头那个p就是FIFO的标志。用file命令查看,它会告诉你这是 "named pipe" 或 "fifo"。
在C语言里,对应的系统调用是mkfifo():
#include <sys/types.h> #include <sys/stat.h> #include <stdio.h> int main(void) { if (mkfifo("/tmp/myfifo", 0666) == -1) { perror("mkfifo"); return 1; } return 0; }注意这里有个细节:mkfifo()只负责创建文件系统条目,它不主动打开管道、不分配数据缓冲区。缓冲区是第一次有进程以读或写方式打开它时,内核才真正把管道对象挂载到这个inode下面的。所以你可以提前创建好一批FIFO备用,完全不会占用资源。
2.2 open() 的阻塞特性:理解FIFO的关键钥匙
很多人在FIFO上第一次卡住,就是因为没搞懂open()的行为。普通文件无论何时open都会立即成功,但FIFO不一样——它存在一个非常有意思的握手机制。我把不同情况整理成了一张表:
| 打开方式 | 阻塞模式下的行为 | 加了 O_NONBLOCK 之后 |
|---|---|---|
| O_RDONLY(只读) | 一直阻塞,直到有某个进程以写方式打开同一个FIFO | 立即成功,即使当前没有写端 |
| O_WRONLY(只写) | 一直阻塞,直到有某个进程以读方式打开同一个FIFO | 如果当前没有读端,open失败,errno为ENXIO |
这个设计的意图是:管道这东西有生产者和消费者才算一个完整的通道,只开一头没有意义。内核用阻塞来强制你"等齐了再开工"。所以你会发现,在一个终端执行cat /tmp/myfifo之后,命令会一直悬着不动——这是正常的,它正在等一个写端出现。
2.3 缓冲区大小与写入原子性:日志会不会被写乱
FIFO的数据在内核缓冲区中排队,Linux下默认的管道容量通常是 64KB(具体可以用fpathconf(fd, _PC_PIPE_BUF)查询)。但这里更关键的是一个叫PIPE_BUF的概念:Linux规定,单次写入的数据量如果不超过PIPE_BUF(通常是4096字节),这次写入在管道层面是原子性的——要么整段写进去,要么完全不写,不会被其他同时写入的进程的数据插队或劈开。
这个特性对多生产者写日志的场景至关重要。后面搭建日志系统的时候,我会反复回到"写入是否超过PIPE_BUF"这个点上。简单说就是:如果每个进程每次只写一行短日志(几百字节以内),那并发写入不会互相污染;一旦某个进程一次性写了几十KB的大块数据,原子性就没了,其他进程的数据可能从中间插进来。
3. 命令行快速上手:三招跑通FIFO
3.1 两个独立终端之间的实时通信
理论讲完了,先来一个最直观的体验。打开两个终端,第一个终端里执行:
mkfifo /tmp/chatfifo cat /tmp/chatfifo第二个终端里执行:
echo "hello from terminal B" > /tmp/chatfifo你会看到第一个终端立刻打印出hello from terminal B。这个演示虽然简单,但已经把前面提到的阻塞机制走了一遍:cat打开读端后阻塞等待,echo打开写端后解除阻塞,数据流进缓冲区又被cat取走打印出来。
这里有个值得反推的细节:如果两个命令的执行顺序反过来——先在第二个终端执行echo > /tmp/chatfifo,你会发现 echo 也会一直卡住不返回,因为它打开写端时发现没有读端。所以我常说,FIFO的时序依赖比普通文件强得多,谁先open、谁后open,直接决定运行是否顺畅。
3.2 一条命令把数据流同时落盘:tee接FIFO
实际项目中,我们经常希望某个程序的输出既能直接打印,又能被另一个进程收集起来写进日志。这时可以用tee加FIFO组合:
mkfifo /tmp/access.fifo cat /tmp/access.fifo | tee -a /tmp/access.log另开一个终端模拟数据源:
echo "GET /index.html" > /tmp/access.fifocat从FIFO读出数据后,借助tee -a把同样一份内容追加到日志文件里。这比直接在业务脚本里拼字符串写文件要灵活得多——业务方只负责往管道里丢数据,落盘、格式化、归档全部由消费端决定。
但这里有一个坑:上面的cat遇到写端关闭后,读到EOF就会退出,整条管道链就断了。如果你希望这条链路长期存活,需要把cat包进循环,这个我在第4节写日志消费端时会专门解决。
3.3 脚本里避免卡死的实用技巧:exec 3<>
初学阶段最容易翻车的就是脚本往FIFO写数据时被阻塞。我经常看到有人写了类似echo x > /tmp/a.fifo放在脚本里,结果脚本在没有任何消费者的情况下直接挂住。最稳妥的写法是先用一条命令同时以读写方式打开FIFO:
#!/bin/bash FIFO=/tmp/daemon.fifo [ ! -p "$FIFO" ] && mkfifo "$FIFO" exec 3<>"$FIFO" echo "message" >&3关键在于exec 3<>"$FIFO":它让当前shell持有一个读写的文件描述符。读写都持有,相当于自己既是读端又是写端,open阶段必然能成功,后续再写入就不会因为"没有读端"而被阻塞了。这样脚本就能在无人消费FIFO的环境下正常运行。需要注意,O_RDWR打开FIFO是Linux特有的行为,在严格遵循POSIX的其他系统上不一定成立,跨平台脚本要谨慎使用。
4. 搭一个简单日志系统:从"写文件"到"写管道"
4.1 为什么日志层要用FIFO做解耦
传统做法是每个脚本里直接echo "$(date) error message" >> /var/log/app.log。短时间跑几个脚本没问题,但脚本一多,问题就来了:日志路径散落各处、格式全靠手动、想统一加个过滤器就得把所有脚本翻一遍。用FIFO把"日志生产"和"日志处理"拆开,是成本最低的解耦方式。
大致结构是这样的:
业务脚本A ──┐ 业务脚本B ──┼──> /tmp/applog.fifo ──> 日志消费进程 ──> /var/log/myapp.log 业务脚本C ──┘生产端不需要关心日志最终存到哪里、按什么格式存;消费端负责统一加时间戳、过滤空白行、按天归档。生产端写管道比直接写磁盘文件更快,几乎不影响业务主流程,这是FIFO日志方案隐藏的另一个好处。
4.2 生产者侧:三种接法
第一种,把已有程序的stdout直接接入FIFO:
/opt/bin/myapp 2>&1 | tee /tmp/applog.fifotee保证程序输出既进FIFO,又能保留在原终端方便人工观察。
第二种,业务代码里主动写入一行日志:
printf '%s\n' "order created, id=10086" > /tmp/applog.fifo第三种,用系统自带的logger命令配合FIFO标记来源和级别:
logger -t order-service -p user.info "order created"logger默认是往系统日志里送,但配合FIFO使用时可以把来源、级别这些元数据一并带过去,消费端解析起来更方便。无论哪种接法,都要记住一句话:确保消费端先启动。生产端一旦写入时发现没有读端,就会阻塞或收到SIGPIPE直接退出。
4.3 消费者侧:一个长期运行的日志读取进程
下面这个脚本是我在实际项目里用了很久的模板,足够简单,也足够稳:
#!/bin/bash FIFO=/tmp/applog.fifo LOG=/var/log/myapp.log while true; do if IFS= read -r line < "$FIFO"; then printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$line" >> "$LOG" fi done解释几个关键点。read -r line < "$FIFO"是每次循环重新打开FIFO读取一行——如果当前没有生产端打开写端,read会一直阻塞等待,这正好是想要的"挂了也不退出"的效果。某个生产者写完关闭后读端会收到EOF,read返回失败,外层while true立刻再开一轮循环,重新等待下一个写端。所有写入方都关闭的情况下,这个脚本就安静地悬在阻塞状态,但进程不会退出。
生产端写入的原始消息会被消费端统一加上当前时间戳,这保证日志格式在源头统一。你还可以扩展这行printf:加上主机名、日志级别、进程名等字段,一切取决于你的需求。
4.4 一个零竞态的"按天轮转"方案
日志文件最怕越涨越大。传统的按月轮转方案会用mv改名旧文件再让程序重新打开新文件,但消费端为了性能通常一直持有旧文件描述符,改名后日志仍然写进旧fd,新文件反倒一直空着,这个坑足够写一篇文章。
我的建议是:如果只是简单场景,直接用按天分文件替代轮转,完全避开"重开文件"的问题:
DLOG="$LOG.$(date '+%Y%m%d')" printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$line" >> "$DLOG"每天一个文件,日志消费端永远只追加、不截断、不重命名。配合一个定时清理脚本,删除30天前的旧日志即可。这种方式没有mv和重开fd的竞态窗口,非常适合FIFO这个小规模场景。
另外分享一个实践技巧:给消费端加一个定时的心跳日志,比如每5分钟写一行"heartbeat alive"。这样你可以快速区分"管道里确实没消息"和"消费端早就死掉了"这两种状态,排查生产环境时特别管用。
5. 实操中踩过的坑与排查思路
5.1 脚本"卡死"在往FIFO写数据上
这是最高频的问题。现象是脚本执行到echo xxx > /tmp/fifo后就不再往下走了,看起来像死循环。本质原因就是open写端时没有读端在等待,阻塞了。
排查链路我一般是这样走的:
- 另开一个终端,
lsof /tmp/fifo看有几个进程打开了这个FIFO,分别是什么模式。 - 如果没有任何读端,说明消费端没起来,或者消费端进程早就退出了。
- 如果读端存在但脚本还是卡住,检查是不是消费端自己也被别的阻塞牵住了,没有实际执行读操作。
解法的优先级:先确保消费端先于生产端启动;其次在生产端使用我在3.3节写的exec 3<>技巧绕开阻塞;最后才是考虑O_NONBLOCK这种非阻塞打开方式。
5.2 消费端读了一行就退出,再也收不到数据
另一种常见情况是消费者脚本里写了read line < "$FIFO",但执行一次之后循环就结束了。原因是:读端打开FIFO后,把所有写端关闭这个动作当作EOF信号。某个生产者写完一行、关闭fd,唯一在读的消费者收到EOF,read返回非零,导致你只处理了一行就退出循环。
解决思路就是在消费端加一个"永远重启打开"的循环,就像我4.3节里while true包装read的方法。要理解FIFO的EOF是"所有写端关闭"的信号,而不是"数据空了"的信号,数据空了你会一直阻塞在read上等待下一位写者,这跟EOF是两码事。
5.3 多个生产者并发写入,日志行被劈断
如果A进程正在写一行30KB的日志,B进程同时写一行200字节的日志,由于30KB超过了PIPE_BUF的原子写限制,内核缓冲区里的数据就可能交错,消费端拿到的行就是"你中有我"的乱码。排查时看到日志行不完整、首尾拼接异常,基本就是这个原因。
对策有三个层面:一是要求所有生产方的单次日志消息控制在PIPE_BUF以内,通常就是要求"一行日志别超4KB",这在实际业务中完全够用;二是把可能产生大块数据的输出先拆行再写管道,比如用fold -w 200或 awk 按行切割;三是在消费端做行缓冲识别,但这是治标不治本。我一般默认执行第一层方案,在写入侧统一约束消息长度,既简单又可靠。
5.4 SIGPIPE、权限位与残留清理
生产者打开写端成功后,如果对端的消费者中途挂了,写端再写数据就会触发SIGPIPE信号,进程默认行为是直接终止。所以生产端脚本最好捕获一下:
trap '' PIPE或者在写入前2>&1把错误信息打出来,避免死得不明不白。权限方面,mkfifo创建时受umask影响,想给其他用户进程写入权限记得用mkfifo -m 666这种显式指定。共享目录下的FIFO注意不要放在可被他人删除的目录,否则有被恶意替换文件的安全隐患。
最后是清理习惯。FIFO文件不像普通文件那样删了就完事,如果还有进程阻塞在open上,删除文件并不会让它们立刻退出,它们可能继续傻等。稳妥的做法是:先停掉所有读写进程(fuser -k /tmp/fifo),确认没有进程持有fd之后再rm文件和FD。
6. 往更实用了想:FIFO在当前技术体系里的生态位
6.1 与其他进程通信方式放在一起看
很多初学者会把FIFO和Unix域套接字、消息队列混在一起。我把它们的关键差异列个表,方便对照:
| 通信方式 | 是否需要文件路径 | 数据是否依赖进程存活 | 方向性 | 典型使用场景 |
|---|---|---|---|---|
| 匿名管道 | 不需要 | 是,进程结束即销毁 | 单向 | 命令行过滤器链(ls | grep) |
| 命名管道FIFO | 需要 | 文件在,数据不落盘 | 单向 | 本机脚本解耦、简单日志汇聚 |
| Unix域套接字 | 需要socket文件 | 是 | 双向 | 本机服务间的请求/响应 |
| TCP套接字 | 不需要 | 否,可跨主机 | 双向 | 跨机器通信 |
| 消息队列 | 需要对象标识 | 通常可持久化 | 单向/双向 | 跨进程解耦、多消费者扩展 |
从这个表能看出来,FIFO的生态位非常特殊:它比匿名管道灵活,比套接字简单,又不引入消息队列那套重量级依赖。如果你的需求是"几个本地脚本或小服务互相递消息、统一收日志",FIFO是性价比最高的选择。一旦需要双向通信、请求响应模型,就应该升级到Unix域套接字;需要跨主机、需要消息可靠投递,那才轮到TCP或消息队列出场。
6.2 从FIFO日志走向更完整的日志体系
我见过很多小团队从"每个脚本各自写日志"演进到"FIFO收集日志",再逐步演进到"统一日志接收端 + 轮转归档"。FIFO恰恰是整个演进过程中最好的第一步:不改变上游任何逻辑,只要把原先>> logfile改成> /tmp/xxx.fifo,就能立刻拥有统一的格式化和归档能力。
再往后,你可以把4.3节的消费端换成更完善的后端:比如对接系统日志协议,让FIFO里的消息转交给系统的日志守护进程;或者把消费进程的落盘逻辑换成上传到集中式日志平台,对业务方来说几乎透明。也就是说,FIFO不是只能当玩具,它是真实的"最后一公里"解耦手段,很多生产环境的采集代理第一版就是靠它跑通的。
最后再分享一点个人体会。命名管道这个东西,入门门槛低到"一条mkfifo就能玩",但它背后牵扯的阻塞语义、原子写、EOF边界这些概念,足够让人折腾好几天。我见过不少人在FIFO上翻了车之后转去上重量级消息队列,其实很多时候只是没搞明白"先启动消费端"和"单条消息别超过4K"这两条铁律而已。先用好这个小工具,等到规模真的撑不住了再升级——这才是务实的演进路线。