管道不只是竖线:从 | 到命名管道
说到 Linux 管道,绝大多数人第一反应就是命令行里那根竖线 |:
cat access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head这种管道叫匿名管道(anonymous pipe),它的特点是「用完即弃」——只存在于这条命令执行期间,由 shell 创建一对文件描述符给左右两个进程,命令结束管道也随之销毁,你在文件系统里看不到它。
但管道还有一种更强大、更少人掌握的形态:命名管道(named pipe),也就是 FIFO。它会在文件系统里留下一个实实在在的「文件」,任何进程都可以像读写普通文件一样打开它,从而实现「两个互不相关的进程之间通信」——甚至是两次不同的命令行、两个不同的服务之间。搞懂命名管道,你再去看一些看起来很神奇的操作(比如在线程间做同步、把日志实时喂给分析程序),思路会清晰很多。
匿名管道和命名管道的本质区别
两者在内核层面用的是同一套机制(都是内核里的一段环形缓冲区),区别只在「怎么找到它」:
- 匿名管道:靠继承。父进程(shell)创建管道后 fork 出子进程,子进程通过继承得到的文件描述符访问管道。它没有名字,所以除了有血缘关系(或通过 fd 传递)的进程,别的进程根本找不到它。
- 命名管道:有名字。它在文件系统里有一个路径(比如
/tmp/myfifo),任何进程只要知道这个路径,open()它就能读写,彼此之间不需要有任何父子关系。
还有一个关键细节:命名管道在磁盘上不占实际数据空间。你用 ls -l 看它,开头第一个字符是 p(普通文件是 -,目录是 d),大小显示为 0,因为数据只在内核缓冲区里流动,落不到盘上。
创建和使用 FIFO
创建
mkfifo /tmp/myfifo
ls -l /tmp/myfifo
# prw-r--r-- 1 root root 0 ... /tmp/myfifo ← 注意开头的 p读写
命名管道的行为和管道一样:写的一端会阻塞,直到有进程从另一端读;读的一端也会阻塞,直到有数据可读。这个「阻塞」特性既是它的威力,也是新手最常踩的坑。开一个终端当消费者:
cat /tmp/myfifo此时这个 cat 会卡住不动——因为它打开 FIFO 准备读,但没有写端。另开一个终端当生产者:
echo "hello fifo" > /tmp/myfifo回车的一瞬间,第一个终端的 cat 就打印出了 hello fifo,然后继续等下一次写入。这就是命名管道最基本的用法。
命名管道的经典用途:多进程汇流到一个消费者
匿名管道只能一对一串联,而命名管道可以多写一读。假设你有好几个脚本或服务需要把「事件」上报给同一个处理程序,用 FIFO 就很自然:
mkfifo /tmp/events
# 消费者:一个持续读取的循环
while read line; do
echo "[$(date +%T)] $line" >> /tmp/events.log
done < /tmp/events然后任何进程都可以往 /tmp/events 写:
echo "backup done" > /tmp/events
echo "deploy finished" > /tmp/events所有写入会被那个 while 循环顺序消费掉。注意「多写一读」有个前提:FIFO 不保证多个写进程之间写入的原子顺序,只有当每次写入的数据量小于 PIPE_BUF(Linux 上是 4096 字节)时,单次写入才是原子的、不会被别的写进程截断。超过这个大小,两个进程同时写就可能交错,需要在上层加锁或改用消息队列。
进程替换:<( ) 和 >( ) 的魔法
有了管道的基础,再来看一个让很多人第一次见到直呼「还有这种操作」的语法——进程替换(process substitution)。它的样子是 <(命令) 和 >(命令)。<(命令) 会执行括号里的命令,并把它变成了一个「看起来像文件」的东西——实际上是一个 /dev/fd/N 路径。你可以用它把一个「会输出内容的命令」当成一个「文件」传给只需要读文件的程序:
# 比较两个命令的输出,就像比较两个文件
diff <(ssh host1 'cat /etc/nginx/nginx.conf') <(ssh host2 'cat /etc/nginx/nginx.conf')没有进程替换,你得先把两边的输出分别存成临时文件再 diff;有了它,一行搞定,还不用手动清理临时文件。>(命令) 则是反过来:把一个「文件位置」换成「一个会读取你写入内容的命令」,最经典的用法是边解压边改内容,或者给输出自动加时间戳:
./long_task.sh > >(while read l; do echo "$(date +%T) $l"; done > /tmp/out.log)进程替换的底层其实就是命名管道的思想:shell 在 /dev/fd/ 下创建了一个指向管道的文件描述符,程序按「文件路径」打开它,实际读写的是管道。而且要注意,进程替换是 bash(以及 zsh、ksh)特有的语法,POSIX 标准 sh / dash 不支持。所以写脚本时如果你的 shebang 是 #!/bin/sh,在 Debian/Ubuntu 上实际跑的是 dash,用 <() 会直接报 Syntax error——要么改成 #!/bin/bash,要么避免使用。
FIFO 实战中的一个高级玩法:tee 分流 + 实时处理
把匿名管道、命名管道和进程替换组合起来,能做一些很漂亮的「一进多出」数据流。比如你希望某条命令的输出既要存一份完整日志,又要实时做统计,还要显示在屏幕上:
./producer.sh | tee /tmp/full.log | awk '{sum+=$2} END{print sum}'tee 就是那个「三通」:标准输入复制成多份,一份写文件、一份继续往下传。但这里有个缓冲区陷阱要提醒:管道默认是带缓冲的,当下游的 awk 还在等数据、上游却只在缓冲区没满时才 flush,你会看到「屏幕上延迟一大截才出结果」。要实时,需要让上游用行缓冲,比如很多工具支持 stdbuf -oL:
stdbuf -oL ./producer.sh | tee /tmp/full.log | awk '{sum+=$2} END{print sum}'这是写实时日志分析脚本时最常见的「为什么它不实时」的元凶。
命名管道的坑与收尾纪律
- 打开就阻塞:
cat fifo会一直卡着,直到有写端。open一个 FIFO 用于读,默认会阻塞直到有进程打开它写;用于写会阻塞直到有进程打开它读。想避免阻塞,读端可以用open(O_RDONLY | O_NONBLOCK),或者干脆用一个持续存在的读者。 - 写端全部关闭后,读端会读到 EOF:如果最后一个写进程退出,读端会拿到 EOF,
while read循环就结束了。所以上面那个「消费者循环」如果希望长期存活,需要保证写端至少保持一个打开,或者用while true; do ... done < fifo这类「重开」写法。 - SIGPIPE:如果读端已经退出,写端再写会收到
SIGPIPE信号,默认行为是直接杀死写进程。用>写 FIFO 的脚本如果莫名退出,往这个方向查。 - 清理:FIFO 是文件系统里的实体文件,用完要
rm。否则下次mkfifo同名会报File exists。放进脚本时建议用trap 'rm -f "$fifo"' EXIT保证退出时清理。 - 权限:FIFO 跟普通文件一样有权限位,跨用户通信时要确保读端/写端用户都有对应权限,否则会
Permission denied。
一个完整的小例子:用 FIFO 做进程间「命令通道」
#!/bin/bash
fifo=/tmp/cmd.fifo
rm -f "$fifo"
mkfifo "$fifo"
trap 'rm -f "$fifo"; exit' INT TERM EXIT
while true; do
if read -r cmd < "$fifo"; then
echo "执行:$cmd"
eval "$cmd"
fi
done后台跑起来之后,任何进程 echo "df -h" > /tmp/cmd.fifo 就能让这个守护循环去执行对应命令——这就是一个极简版的「命令总线」。很多自研运维小工具内部都在用这个思路,本质上就是把命名管道当成一个消息通道。
拿 FIFO 当锁用:一个轻量的互斥方案
命名管道还有一个巧妙的用法——做跨进程的互斥锁。因为 open 一个 FIFO 时,如果已经有写端打开着,行为是可预期的;配合 O_EXCL 之类的技巧,或者干脆用「谁先创建谁持有」的思路,能在没有额外依赖的情况下实现简单的串行化:
#!/bin/bash
lock=/tmp/mylock.fifo
if ! mkfifo "$lock" 2>/dev/null; then
echo "另一个实例正在运行,退出"
exit 1
fi
trap 'rm -f "$lock"' EXIT
# 下面是受保护的临界区代码
./do_something.sh这个做法的原理和 flock 类似,都是利用「文件系统的原子性」——mkfifo 成功与否本身就构成了一个原子判断。当然,生产环境里更推荐直接用 flock 命令,它更成熟、支持超时和共享锁,但理解 FIFO 这层原理,能让你在遇到「为什么我的脚本会跑两份」时立刻想到这个方向。cron 任务重叠执行导致的资源争抢,用这套思路往往就能快速止血。
调试小技巧
排查管道问题时,有几个命令格外有用。lsof /tmp/myfifo 能看到当前有哪些进程打开了这个 FIFO(分别以读或写方式),当你不确定「读端是不是真的活着」时,这条命令能立刻给出答案。ls -l 看开头的 p 确认它确实是 FIFO。cat /proc/<pid>/fd/<n> 或 readlink /proc/<pid>/fd/* 能看出某个进程的文件描述符到底指向哪个管道或 /dev/fd/N,在分析进程替换生成的那些 fd 时特别直观。把这些工具和前面的原理串起来,管道相关的疑难杂症基本都能一层层剥开。
总结一下:匿名管道适合命令链式串联,命名管道(FIFO)适合无亲缘关系的进程之间通信、多写一读汇流;进程替换 <() / >() 是基于管道思想的一个语法糖,能把命令输出当文件用。三者都建立在「内核缓冲区 + 文件描述符」这个统一模型之上,理解了这一点,你在写 shell 脚本和数据管道时会顺手很多。