管道:2GB 文件过一次管道,内存为什么不涨

管道:2GB 文件过一次管道,内存为什么不涨
隔壁老康难度 ★★★★☆ · 前置:进程的地址空间:那 100 多 TB 地址是给谁的 | 一句话结论:管道不是文件,是内核里一块 64KB 的环形缓冲区;
cat和wc之所以能传 2GB,是因为它们一写一读交替进行,缓冲区永远是满的、也永远装不下全部。
一根管子,装不下它搬的东西
清点备份文件的时候,我顺手敲了这么一条,本来只是想数一下这个文件多大,好判断备份盘还剩多少空间。
1 | cat /var/lib/app/bigfile.bin | wc -c |
1 | 2147483648 |
2GB,一个不算大但也绝对不算小的备份文件。
当时正在看监控面板,顺手瞄了一眼内存,这台机器是 4GB 的,我就想,2GB 的数据从 cat 流到 wc,中间那个管道里得装多少,于是又跑了一次,这次盯着进程。
1 | cat /var/lib/app/bigfile.bin | wc -c & |
1 | 8041 1520 cat |
各 1.5MB 左右,两个进程加起来 3MB,中间搬了 2GB。
我盯着这个数字看了挺久,第一反应是 ps 的 RSS 不准,还专门去对了一遍 /proc/PID/status 里的 VmRSS,数字一样,那这 2GB 到底存在哪,这个问题在那天下午卡了我大概一个小时,我甚至怀疑过是不是 wc 把数据边读边丢掉了,因为只有这样才能解释内存为什么不涨,可 wc -c 的输出又明明是准确的字节数,它一个字都没丢。
答案是它从没在任何地方完整存在过,数据被切成一小块一小块,cat 写一块,wc 读一块,读完这块的空间马上被下一块复用,整个传输过程中管道里始终只有 64KB,这 64KB 也不是数据的副本,是一块被反复覆写的环形内存,你有多少数据经过它都不影响它的体积,就像传送带不会因为你运了十年货而变长,我后来拿这件事类比过很多次,内存里那些看起来在流动的东西,大多数时候只是一小块地方被反复使用,真正涨起来的从来不是通道,是有人把该丢的东西留着了。
pipe 其实是两块内存和一个编号
管道的定义很短,一对文件描述符,写一端读一端,中间是内核里的一块环形缓冲区,在 cat 和 wc 之间看它们各自的 fd 目录就能看到那个编号。
1 | P1=$(pgrep -n -f 'sleep 100') # 换成你要看的进程号 |
1 | 3 -> pipe:[12453] |
pipe:[inode 号],不是 socket:[...],也不是某个文件路径,说明背后是 pipefs 上的一个匿名 inode,这个文件系统你看不到,也没有挂载点,它存在的唯一目的就是给管道一个编号,你在 df 里翻不到它,在 mount 里也翻不到它,它从来没有出现在任何一次磁盘 I/O 的统计里,因为它压根不碰磁盘,只是内存里一张注册表,让两个进程能拿着同一个数字找到同一块缓冲区。
两种管道,差别比想象中大,用一段表格摆在一起最直观。
| 匿名管道 | 命名管道(FIFO) | |
|---|---|---|
| 怎么创建 | pipe(int fd[2]) 系统调用 |
mkfifo /tmp/myfifo |
| 有没有名字 | 没有,只有 fd | 文件系统里有个路径 |
| 谁在用 | | 就是它 |
无关进程之间 |
ls -l 显示 |
看不到 | prw-r--r--,p 开头 |
| 生命周期 | 两端都关了就没 | 文件在就在 |
| 跨机器 | 不行 | 不行 |
| 这个符号背后就是匿名管道,shell 在 fork 之前先 pipe(),然后 fork 两次,各自 dup2 一端到 fd 0 或 1,没有别的魔法,我入职前一直以为管道是 shell 自己实现的某种语法糖,是 bash 源码里的一段字符串处理逻辑,直到看到 pipe:[12453] 这种实打实的 inode 编号才改掉这个印象,那之后我才明白 shell 干的活其实很朴素,它只是在 fork 前后摆弄几个整数,剩下的全在内核里,这也是为什么 zsh、fish、dash 的管道行为一模一样,它们谁也没有自己实现管道,只是都在调同一个系统调用。
64KB 这个数字是从哪来的
1 | cat /proc/sys/fs/pipe-max-size |
1 | 1048576 |
1MB,但这是上限,不是默认值。
从 Linux 2.6.11 起,管道缓冲区默认是 64KB(16 页,每页 4KB),再早是 4KB,那时候 cat 大文件确实慢,你现在嫌弃的 64KB 在 2.6.11 那会儿是一次数十倍的扩容,一个默认值背后往往是一段被忘掉的抱怨史。
单次 write 超过 PIPE_BUF(4096 字节)就不保证原子,可能被拆开,和另一个写者的数据交织在一起。
这一篇的 C 代码都用 gcc -O2 -o x x.c && ./x 编译,头文件要 #include <unistd.h>、#include <string.h>、#include <sys/wait.h>,缺哪个编译器会直接报出来,照着补就行。
1 |
|
这个 4096 是 POSIX 的规定,所有系统都得保证,所以多进程往同一个管道写的时候,消息小于 4KB 才能安全,这是很多日志收集器的写入约定背后的原因,它们宁可把一条日志截断或者拆成几条发,也不愿意让两条日志的半截身子搅在一起,因为一旦搅在一起,下游解析出来的时间戳就是错的,而你会在两天之后才发现所有的错误统计都偏了。(我第一次自己写日志收集的时候压根不知道这条规矩,消息大小随缘,压测时一切正常,上了量之后日志里开始出现半截时间戳,查了整整一周才定位到,那之后我看到任何超过 4KB 往管道里写的代码都会问一句确认过原子性吗。)
写超过 64KB 会怎样,阻塞。write 一直等到缓冲区有空位,写一部分、再等、再写,这就是 cat 和 wc 交替的原因。
graph TD
A["cat 读 8KB 到用户缓冲区"] --> B["cat write 8KB 进管道"]
B --> C{"管道满了吗?"}
C -->|没满| A
C -->|满了| D["cat 阻塞,睡在 pipe_write 上"]
D --> E["wc 读走 8KB"]
E --> F["唤醒 cat"]
F --> A
生产者和消费者自动同步,快的一方会被慢的一方拖住,这就是管道天然背压的原因,也是它比先写文件再处理更省内存的原因,你在 cat 后面接一个 grep,grep 卡住了 cat 就跟着卡住,整条链路的速度由最慢的那一环决定,这个性质在排障的时候很有用,因为一旦管道里的某一段变慢,你从进程状态上能直接看出来是谁在等谁,拿 ps -eo pid,stat,wchan 扫一眼,停在 pipe_write 的进程就是被下游憋住的那个,而停在 pipe_read 的则是上游还没喂东西,两个方向的等待长得一模一样,却指向完全相反的问题。
环形缓冲区的四行代码
管道内部结构简化下来就这么点东西。
1 | struct pipe_inode_info { |
每次写入填一页(4KB),head 往后移,读出后 tail 往后移,到头了绕回 0,所以它就是个 ring buffer,64KB = 16 页。
读不到东西和读到空,是两件事
管道能被读端读到 EOF(read 返回 0),条件只有一个,所有写端都关了,这个条件比想象中严格,因为它数的是 fd 的数量,不是进程的数量。
1 | int fd[2]; |
父进程千万要把不用的那端关掉。
1 | pipe(fd); |
父进程手里还留着 fd[1] 的副本,所以还有写端开着,read 永远等不到 EOF,这是管道最经典的 bug,症状是程序卡在 read 上不动,用 ls -l /proc/PID/fd 一看发现两端都在同一个进程手里。
我第一次遇到它是在写一个采样脚本,卡了一下午,中间怀疑过是子进程没起来、怀疑过是数据量太大、还重启过两次机器,最后是 strace 一直停在同一行 read(3, 才反应过来,那种排查过程毫无美感,就是一次次换假设,每换一次都要重跑一遍,跑完还得等五分钟才确认这次是不是又卡住了,后来我学乖了,凡是写 fork 加管道的代码,先把 close 那几行写下来再去写业务逻辑,哪怕业务还没想清楚,因为这几行的顺序比业务重要得多。(这个教训我写在了自己的笔记第一条,位置比任何内核知识都靠前,因为它不需要懂原理就能避免,而懂了原理的人反而更容易觉得这种事不会发生在自己身上。)
cat 是被杀掉的,不是跑完的
1 | cat /var/log/huge.log | head -3 |
head 读三行就退出了,cat 还在往里写,写到一个没人读的管道会怎样,内核给 cat 发 SIGPIPE(信号 13),默认动作是终止进程。
所以 cat 是被 SIGPIPE 杀掉的,不是写完退出的,这个区别在日志里看得出来,cat 没有报错,它就是没了。
1 | yes | head -1 |
yes 也是这么死的,不过管道本身的 $? 是最后一条命令 head 的状态,也就是 0,yes 的退出状态得从 ${PIPESTATUS[0]} 里读,那才是 141(128 + 13)。
这也是为什么服务端程序第一件事要处理 SIGPIPE,客户端断开之后往 socket 写,如果没忽略 SIGPIPE,整个服务就被一个断开的连接杀掉了。
1 | signal(SIGPIPE, SIG_IGN); /* 然后 write 返回 -1,errno = EPIPE */ |
写 Python 的时候这个坑更隐蔽,因为 Python 默认把 SIGPIPE 换成了 BrokenPipeError,异常不一定在 write 那一行冒出来,可能在解释器退出的 flush 阶段才打一行 Exception ignored in: <_io.TextIOWrapper>,你看到那行的时候早就离出事的地方十万八千里了,而且它通常出现在标准错误的最后几行,日志系统抓不到,只能靠人肉翻,我有个服务就是因为这个,日志里一行错误都没有,进程却每天少活几次,最后是靠比对 systemd 的重启计数才定位到的,那次之后我在所有 Python 服务的启动脚本里都加了一行 signal.signal(signal.SIGPIPE, signal.SIG_DFL),让默认行为回到内核那边,宁愿直接死也不要死得不明不白。
它比文件少了什么
| 行为 | 普通文件 | 管道 |
|---|---|---|
lseek |
可以 | 返回 ESPIPE |
st_size |
真实大小 | 0(fstat 里没有有意义的大小) |
mmap |
可以 | ENODEV |
| 打开方式 | open(path) |
只能 pipe() 或 mkfifo 后 open |
| 数据残留 | 有 | 读完就没了 |
管道不能 lseek,它没有位置的概念,只能顺序读写,所以 tail -f something | ... 里的管道也是纯流,你想往回看一行都做不到。
splice:能不搬就不搬
普通管道有一个隐藏开销,数据要在内核缓冲区和用户缓冲区之间来回 copy,cat bigfile | wc -c 里 2GB 数据被 copy 了 4 次,磁盘到页缓存是 DMA 不能省,剩下的三次是页缓存到 cat 的用户缓冲区、cat 用户缓冲区到管道、管道到 wc 的用户缓冲区。
Linux 2.6.17 加了 splice(),在两个文件描述符之间直接搬数据,完全不经过用户空间。
1 | /* 把 in_fd 的数据搬到 out_fd,不经过用户内存 */ |
cat 如果实现了 splice,理论上只要 1 次内核内搬动,cp、dd 的加速、还有 nginx 的 sendfile(内核从 2.6.23 起就是用 splice 实现 sendfile)都走这条路。(我第一次知道 sendfile 不是自己造了一套搬运逻辑,而是拿 splice 改了个名字,是有种被骗的感觉的。)
判断一个工具支不支持,用 strace -e trace=splice,read,write 看它走的是 splice 还是 read/write,我第一次这么干的时候很意外,系统里的 cat 还真走 splice,而我自己写的小程序全是 read/write,那会儿我甚至不知道这两个词在性能上是有区别的,只知道代码能跑、输出对得上,后来才明白同一次数据搬运,走 splice 的时候内核只是改了改页的归属,走 read/write 要真的把字节从一块内存抄到另一块内存,抄的时候 CPU 在干活,缓存在被挤,而这两者的差距在大文件上会变成几十秒,管道还有一些不那么常用的口子,知道它们存在比记住用法重要。
| 口子 | 干什么 | 起于 |
|---|---|---|
F_GETPIPE_SZ / F_SETPIPE_SZ |
读/改一个管道的缓冲区大小(fcntl) |
2.6.35 |
vmsplice |
把用户态的一串页「直接挂进」管道,不 copy | 2.6.17 |
tee |
在两个管道之间复制数据,不消费源管道 | 2.6.17 |
splice 到 socket |
文件直接进网卡,nginx 的 sendfile 底层 |
2.6.17 |
ss -x -m 或者 lsof -p PID | grep pipe 能看到管道,但看不到它的容量,容量只能靠 F_GETPIPE_SZ 问。
64KB 这个默认值也不是不能验证,写满一个没人读的管道,写到第几字节阻塞,就是它的容量。
1 | python3 -c " |
Ubuntu 22.04 上跑出来是 65536 和 65536,一致,这个实验的用处是,当你怀疑写入卡住是因为管道满了的时候,先确认容量到底是多少,容器里可能被 fs.pipe-max-size 或 rlimit 改过。
找出管道在哪
管道在哪,随便起一对不干活的进程就能看到,它们之间只要有一条竖线。
1 | sleep 100 | sleep 100 & |
1 | total 0 |
1 -> pipe:[126481],它的 stdout 是管道。
(l-wx 里的 w 表示只写。这个模式位是 fd 的打开模式,不是文件权限。)
用 C 亲手建一个。
1 |
|
把 close(fd[1]) 那行注释掉,再跑一次,程序会挂在那里不动,用 Ctrl-Z 之后 ls -l /proc/$(pgrep -n a.out)/fd 看,两端都还在。
测一测容量。
1 | python3 -c " |
大多情况下会看到 65536 bytes = 64KB(如果先写了几个小包,可能不到 65536 因为页粒度),这就是那个 2GB 数据只占 64KB 的证据,我拿这个脚本在三种环境里跑过,物理机上稳定 65536,某个容器里只有 8192,这个我到现在也没完全弄明白,fs.pipe-max-size 只管 F_SETPIPE_SZ 能设到多高、不改默认值,默认一直是 16 个页,所以更可能是那边限制了 pipe-user-pages-soft,或者内核版本根本不是一回事。同样的代码在那边吞吐直接掉了七成,而你如果只看应用日志,只会看到处理变慢,永远猜不到是管道容量被砍了八分之七,这就是为什么要先量一遍再开始调优,量出来的数字比任何文档都可信。
看 SIGPIPE,这一步顺手就能看出为什么 yes 是被杀的。
1 | yes | head -1; echo "PIPESTATUS: ${PIPESTATUS[@]}" |
1 | y |
141 = 128 + 13,yes 被 SIGPIPE 杀了。
试一下 PIPE_BUF 的原子性边界,两次写的长度刚好卡在 4096 两边。
1 | python3 -c " |
两次都成功,但第二次的 8192 字节不保证在一次 write 里完成的,当前管道有空位时内核会一次写完,缓冲区吃紧时会拆,这就是为什么原子性这件事只能在 4KB 以内谈,超过之后内核有权利按它觉得合适的方式切,而切在哪里你说了不算,于是我后来写日志聚合的时候干脆定了个规矩,任何一条消息超过 4096 字节就分片并带上序号,让读的一端自己拼,虽然麻烦,但至少不会出现两条日志互相插进对方身体里这种事,那种坏掉的数据在排查现场看起来特别像业务代码的 bug,能骗你整整两天。
验证 lseek 失败。
1 | python3 -c " |
1 | OSError 29 Illegal seek |
ESPIPE = 29。
看一眼 FIFO。
1 | mkfifo /tmp/test.fifo |
1 | prw-r--r-- 1 ark ark 0 Nov 13 20:56 /tmp/test.fifo |
p 开头,大小 0。
两个终端配合。
1 | # 终端 A |
cat 会一直阻塞,直到有人写,反过来也一样,echo 会阻塞直到有人读,这就是 FIFO 的同步语义。
清理。
1 | rm /tmp/test.fifo |
管道不在磁盘上
管道是文件、数据存在磁盘上,不是的,管道在 pipefs 里,纯内存,df 看不到它,重启就没了,顺带一提 mkfifo 出来的那个文件本身确实在磁盘上,可它只是个名字,你在里面写不进任何东西,ls -l 显示 0 字节不是因为数据被清空了,是它压根没有数据这个字段。
管道容量是无限的,64KB,写满就阻塞,这是背压的来源,你在一个不读数据的管道上写,进程会一直挂着,CPU 不占,看起来像是卡死。
| 是 shell 语法糖、跟管道系统调用无关,恰恰相反,| 的实现就是 pipe() + fork() + dup2(),没有别的魔法,你在 bash 源码里能找到的只有这几个调用的排列组合。
写管道失败会返回错误码,写到没人读的管道会先收 SIGPIPE,默认直接死,要拿到 EPIPE 必须先忽略 SIGPIPE,顺序不能反,先 write 再 signal 的话进程已经不在了。
read 返回 0 表示有数据但是空,read 返回 0 只有一个意思,所有写端都关了,空管道会让 read 阻塞,不是返回 0,这两个状态在代码里长得一样,在语义上隔了十万八千里,写循环的时候搞混就会出现那种 CPU 100% 又什么都不干的服务。
管道能跨机器,不能,跨机器用 socket。
数据在管道里是可靠不丢的,管道本身不丢,有缓冲区兜着,但进程崩溃时缓冲区里的数据跟着没了。
FIFO 和普通文件差不多,FIFO 没有内容,stat 大小是 0,读走过的数据就没了,不能 lseek 回去重读。
cat a | wc -c 比 wc -c < a 慢不了多少,管道多两次 copy,大文件上差 10~30%,<( ) 和 < 重定向能省掉这些拷贝。
一块缓冲区,不是文件
管道是内核里一块 64KB 的环形缓冲区,不是文件,不落盘,写满阻塞、读走唤醒,天然的生产者-消费者同步,这就是它内存不涨的原因。
PIPE_BUF = 4096 是原子写的上限,超过就可能被拆开交织。read 返回 0 只表示所有写端都关了,忘关写端是管道最经典的死锁。写没人读的管道会触发 SIGPIPE,服务端必须忽略它。
管道不能 lseek,st_size 恒为 0,想要零拷贝用 splice,它是 sendfile、cp 加速的底层。
还有一种更极端的做法,不要缓冲区,让两个进程直接看同一块内存,那是最高效的 IPC,也是最容易出错的,因为同步得靠你自己。真要做这件事,得先把一个进程的地址空间映射进另一个进程里去,mmap 能干这个,坑比管道多得多。











