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

难度 ★★★★☆ · 前置:进程的地址空间:那 100 多 TB 地址是给谁的 | 一句话结论:管道不是文件,是内核里一块 64KB 的环形缓冲区;cat 和 wc 之所以能传 2GB,是因为它们一写一读交替进行,缓冲区永远是满的、也永远装不下全部。

一根管子,装不下它搬的东西

清点备份文件的时候,我顺手敲了这么一条,本来只是想数一下这个文件多大,好判断备份盘还剩多少空间。

1
cat /var/lib/app/bigfile.bin | wc -c
1
2147483648

2GB,一个不算大但也绝对不算小的备份文件。

当时正在看监控面板,顺手瞄了一眼内存,这台机器是 4GB 的,我就想,2GB 的数据从 cat 流到 wc,中间那个管道里得装多少,于是又跑了一次,这次盯着进程。

1
2
cat /var/lib/app/bigfile.bin | wc -c &
ps -eo pid,rss,comm | grep -E 'cat|wc'
1
2
8041    1520 cat
8042 1796 wc

各 1.5MB 左右,两个进程加起来 3MB,中间搬了 2GB。

我盯着这个数字看了挺久,第一反应是 ps 的 RSS 不准,还专门去对了一遍 /proc/PID/status 里的 VmRSS,数字一样,那这 2GB 到底存在哪,这个问题在那天下午卡了我大概一个小时,我甚至怀疑过是不是 wc 把数据边读边丢掉了,因为只有这样才能解释内存为什么不涨,可 wc -c 的输出又明明是准确的字节数,它一个字都没丢。

答案是它从没在任何地方完整存在过,数据被切成一小块一小块,cat 写一块,wc 读一块,读完这块的空间马上被下一块复用,整个传输过程中管道里始终只有 64KB,这 64KB 也不是数据的副本,是一块被反复覆写的环形内存,你有多少数据经过它都不影响它的体积,就像传送带不会因为你运了十年货而变长,我后来拿这件事类比过很多次,内存里那些看起来在流动的东西,大多数时候只是一小块地方被反复使用,真正涨起来的从来不是通道,是有人把该丢的东西留着了。

pipe 其实是两块内存和一个编号

管道的定义很短,一对文件描述符,写一端读一端,中间是内核里的一块环形缓冲区,在 cat 和 wc 之间看它们各自的 fd 目录就能看到那个编号。

1
2
P1=$(pgrep -n -f 'sleep 100')   # 换成你要看的进程号
ls -l /proc/$P1/fd
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
2
#include <limits.h>
long n = fpathconf(fd, _PC_PIPE_BUF); /* 通常 4096 */

这个 4096 是 POSIX 的规定,所有系统都得保证,所以多进程往同一个管道写的时候,消息小于 4KB 才能安全,这是很多日志收集器的写入约定背后的原因,它们宁可把一条日志截断或者拆成几条发,也不愿意让两条日志的半截身子搅在一起,因为一旦搅在一起,下游解析出来的时间戳就是错的,而你会在两天之后才发现所有的错误统计都偏了。(我第一次自己写日志收集的时候压根不知道这条规矩,消息大小随缘,压测时一切正常,上了量之后日志里开始出现半截时间戳,查了整整一周才定位到,那之后我看到任何超过 4KB 往管道里写的代码都会问一句确认过原子性吗。)

写超过 64KB 会怎样,阻塞。write 一直等到缓冲区有空位,写一部分、再等、再写,这就是 cat 和 wc 交替的原因。

生产者和消费者自动同步,快的一方会被慢的一方拖住,这就是管道天然背压的原因,也是它比先写文件再处理更省内存的原因,你在 cat 后面接一个 grep,grep 卡住了 cat 就跟着卡住,整条链路的速度由最慢的那一环决定,这个性质在排障的时候很有用,因为一旦管道里的某一段变慢,你从进程状态上能直接看出来是谁在等谁,拿 ps -eo pid,stat,wchan 扫一眼,停在 pipe_write 的进程就是被下游憋住的那个,而停在 pipe_read 的则是上游还没喂东西,两个方向的等待长得一模一样,却指向完全相反的问题。

环形缓冲区的四行代码

管道内部结构简化下来就这么点东西。

1
2
3
4
5
struct pipe_inode_info {
unsigned int head; /* 写位置 */
unsigned int tail; /* 读位置 */
struct pipe_buffer *bufs; /* 页数组 */
};

每次写入填一页(4KB),head 往后移,读出后 tail 往后移,到头了绕回 0,所以它就是个 ring buffer,64KB = 16 页。

读不到东西和读到空,是两件事

管道能被读端读到 EOF(read 返回 0),条件只有一个,所有写端都关了,这个条件比想象中严格,因为它数的是 fd 的数量,不是进程的数量。

1
2
3
int fd[2];
pipe(fd);
/* fork 之后,父子都有 fd[0] 和 fd[1] */

父进程千万要把不用的那端关掉。

1
2
3
4
5
6
7
8
pipe(fd);
if (fork() == 0) {
close(fd[0]);
write(fd[1], "hi", 2);
exit(0); /* 子进程退出,它的 fd[1] 关了 */
}
close(fd[1]); /* 父进程这本该关 —— 忘了! */
read(fd[0], buf, 100); /* 永远阻塞 */

父进程手里还留着 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
2
/* 把 in_fd 的数据搬到 out_fd,不经过用户内存 */
splice(in_fd, NULL, out_fd, NULL, len, SPLICE_F_MOVE);

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
2
3
4
5
6
7
8
9
10
11
12
13
python3 -c "
import os, fcntl
r, w = os.pipe()
os.set_blocking(w, False)
total = 0
try:
while True:
total += os.write(w, b'x' * 65536)
except BlockingIOError:
pass
print('管道容量:', total, '字节')
print('F_GETPIPE_SZ:', fcntl.fcntl(w, 1032)) # F_GETPIPE_SZ
"

Ubuntu 22.04 上跑出来是 65536 和 65536,一致,这个实验的用处是,当你怀疑写入卡住是因为管道满了的时候,先确认容量到底是多少,容器里可能被 fs.pipe-max-size 或 rlimit 改过。

找出管道在哪

管道在哪,随便起一对不干活的进程就能看到,它们之间只要有一条竖线。

1
2
3
sleep 100 | sleep 100 &
P1=$(pgrep -n -f 'sleep 100')
ls -l /proc/$P1/fd
1
2
3
4
total 0
lrwx------ 1 ark ark 64 Nov 13 20:55 0 -> /dev/pts/0
l-wx------ 1 ark ark 64 Nov 13 20:55 1 -> pipe:[126481]
lrwx------ 1 ark ark 64 Nov 13 20:55 2 -> /dev/pts/0

1 -> pipe:[126481],它的 stdout 是管道。

(l-wx 里的 w 表示只写。这个模式位是 fd 的打开模式,不是文件权限。)

用 C 亲手建一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(void) {
int fd[2];
pipe(fd);

if (fork() == 0) {
close(fd[0]);
const char *msg = "hello from child\n";
write(fd[1], msg, strlen(msg));
close(fd[1]);
_exit(0);
}

close(fd[1]); /* 关键:父进程不用写端 */
char buf[128];
ssize_t n = read(fd[0], buf, sizeof buf);
printf("parent got %zd bytes: %.*s", n, (int)n, buf);
close(fd[0]);
wait(NULL);
return 0;
}

把 close(fd[1]) 那行注释掉,再跑一次,程序会挂在那里不动,用 Ctrl-Z 之后 ls -l /proc/$(pgrep -n a.out)/fd 看,两端都还在。

测一测容量。

1
2
3
4
5
6
7
8
9
10
11
12
python3 -c "
import os
r, w = os.pipe()
os.set_blocking(w, False)
total = 0
try:
while True:
total += os.write(w, b'x' * 4096)
except BlockingIOError:
pass
print('管道容量约', total, 'bytes =', total // 1024, 'KB')
"

大多情况下会看到 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
2
y
PIPESTATUS: 141 0

141 = 128 + 13,yes 被 SIGPIPE 杀了。

试一下 PIPE_BUF 的原子性边界,两次写的长度刚好卡在 4096 两边。

1
2
3
4
5
6
7
8
python3 -c "
import os
r, w = os.pipe()
os.write(w, b'A' * 4096) # 刚好 PIPE_BUF,原子
print(len(os.read(r, 4096)))
os.write(w, b'B' * 8192) # 超过 PIPE_BUF
print(len(os.read(r, 8192)))
"

两次都成功,但第二次的 8192 字节不保证在一次 write 里完成的,当前管道有空位时内核会一次写完,缓冲区吃紧时会拆,这就是为什么原子性这件事只能在 4KB 以内谈,超过之后内核有权利按它觉得合适的方式切,而切在哪里你说了不算,于是我后来写日志聚合的时候干脆定了个规矩,任何一条消息超过 4096 字节就分片并带上序号,让读的一端自己拼,虽然麻烦,但至少不会出现两条日志互相插进对方身体里这种事,那种坏掉的数据在排查现场看起来特别像业务代码的 bug,能骗你整整两天。

验证 lseek 失败。

1
2
3
4
5
6
7
8
python3 -c "
import os
r, w = os.pipe()
try:
os.lseek(w, 0, 0)
except OSError as e:
print(type(e).__name__, e.errno, e.strerror)
"
1
OSError 29 Illegal seek

ESPIPE = 29。

看一眼 FIFO。

1
2
mkfifo /tmp/test.fifo
ls -l /tmp/test.fifo
1
prw-r--r-- 1 ark ark 0 Nov 13 20:56 /tmp/test.fifo

p 开头,大小 0。

两个终端配合。

1
2
3
4
5
# 终端 A
cat /tmp/test.fifo

# 终端 B
echo "hello" > /tmp/test.fifo

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 能干这个,坑比管道多得多。