yes 管道 head 发生了什么
提出问题
让我们先看一个简单的命令:
yes | head
输出结果:
y y y y y y y y y y
这里 yes 命令会不断输出 y (默认情况下),而 head 命令默认只取前 10 行。问题来了:为什么这个管道命令不会一直运行下去?
毕竟 yes 是个"不听话"的程序,它会无限地输出 y ,理论上应该一直写下去才对。但实际上,命令执行完 10 行后就正常退出了。这是为什么?
Shell 做了什么
要理解这个问题,首先需要看看 Shell 在遇到管道符号 | 时做了什么。
当 Shell 执行 yes | head 时,它会经历以下步骤:
- 调用
pipe()系统调用创建一个管道,得到两个文件描述符:fd[0](读端)和fd[1](写端) - 调用
fork()两次,创建两个子进程 - 在
yes进程中:- 关闭读端
fd[0] - 使用
dup2(fd[1], 1)将标准输出(stdout)重定向到管道写端 - 关闭原来的
fd[1] - 执行
yes程序
- 关闭读端
- 在
head进程中:- 关闭写端
fd[1] - 使用
dup2(fd[0], 0)将标准输入(stdin)重定向到管道读端 - 关闭原来的
fd[0] - 执行
head程序
- 关闭写端
可以用一个 ASCII 图示来表示这种连接关系:
Shell (bash)
|
+-- pipe() --> [fd[0] 读端, fd[1] 写端]
|
+-- fork() --> yes 进程
| 关闭 fd[0]
| dup2(fd[1], 1) <-- stdout --> 管道写端
|
+-- fork() --> head 进程
关闭 fd[1]
dup2(fd[0], 0) <-- 管道读端 --> stdin
最终效果:
yes --stdout--> [ 内核管道缓冲区 ] --stdin--> head
让我们用 strace 来观察一下实际的系统调用序列:
strace -f -e trace=pipe2,clone,dup2,close,execve bash -c 'yes | head' 2>&1
输出(经过简化,只保留关键行):
pipe2([3, 4], 0) = 0
clone(..., child_tidptr=0x7ff31745ae50) = 62132
close(4) = 0
clone(..., child_tidptr=0x7ff31745ae50) = 62133
close(3) = 0
62132 close(3) = 0
62132 dup2(4, 1) = 1
62132 close(4) = 0
62133 dup2(3, 0) = 0
62133 close(3) = 0
62132 execve("/usr/bin/yes", ["yes"], ...) = 0
62133 execve("/usr/bin/head", ["head"], ...) = 0
从 strace 输出可以看到:
- Shell 创建了管道(
pipe2([3, 4], 0)),得到读端 fd=3,写端 fd=4 - Shell 两次
clone()创建了子进程 62132 和 62133 - Shell 自己关闭了管道的两端(
close(4)和close(3)),因为它自己不参与数据的读写 - 进程 62132(yes)关闭了读端 fd=3,把写端 fd=4 通过
dup2重定向到 stdout - 进程 62133(head)关闭了写端 fd=4(已经不需要了),把读端 fd=3 通过
dup2重定向到 stdin - 两个进程分别
execve加载了yes和head程序
现在 yes 的 stdout 连接到管道写端,=head= 的 stdin 连接到管道读端。但这还没有解释为什么 yes 会停止输出。让我们继续往下看。
管道是什么
在深入理解进程协作之前,我们需要先了解管道的本质。
管道并不是什么神奇的东西,它本质上就是内核维护的一个 固定大小的缓冲区 。在 Linux 中,这个缓冲区默认大小是 64KB(可以通过 fcntl(fd, F_GETPIPE_SZ) 查询)。
管道有几个重要的特性:
- 先进先出(FIFO) :先写入的数据会被先读出,就像排队买票一样
- 数据被读走后即从缓冲区中移除 ,腾出空间给新数据
- 单向流动 :数据只能从写端流向读端,不能反向
- 匿名管道 :没有名字,只能用于有亲缘关系的进程之间(如父子进程)
可以把管道想象成一根水管:
yes 进程 内核管道 head 进程
| |
| 写入 y ----> [ y | y | y | y | ... | y ] ----> 读取 y
| |
^ |
| v
写端入口 读端出口
(只能进) (只能出)
这个水管(缓冲区)有固定的容量。当水满了(缓冲区满了),就不能再往里倒水(写入数据);当水空了(缓冲区空了),就取不出水(读取数据)。
运行中的协作
现在我们终于可以回答最初的问题了:为什么 yes 会停止输出?
关键在于 blocking I/O 的协调机制。(关于 blocking I/O 的详细原理,我已经在另一篇文章中详细讨论过,这里只概述结论)。
当进程通过管道协作时,内核会自动协调它们的速率:
- 当缓冲区满了 :=yes= 的
write()系统调用会阻塞,=yes= 进程进入睡眠状态,等待管道腾出空间 - 当缓冲区空了 :=head= 的
read()系统调用会阻塞,=head= 进程进入睡眠状态,等待新的数据到来
这种机制就像一个自动调节阀门,让快慢不同的进程能够和谐地协同工作。
不过,在我们这个具体的场景中,情况有些特殊。=yes= 的输出速度 远快于 head 的消耗速度(=head= 只需要 10 行就满足了)。所以缓冲区几乎不会满——=head= 很快就读完了它需要的 10 行数据。
那么 head 读完后发生了什么呢?这才是真正让 yes 停下来的原因。
head 完成后发生了什么
这个终止链条实际上是一个精妙的连锁反应,可以分解为以下四个步骤:
第一步:head 读够了 10 行
head 命令默认只读取前 10 行数据。一旦读够了,它的任务就完成了。
yes --stdout--> [ 管道缓冲区 ] --stdin--> head (完成)
第二步:head 进程退出,内核关闭管道读端
当 head 进程退出时,内核会自动关闭该进程打开的所有文件描述符,包括管道的读端(stdin)。
yes --stdout--> [ 管道缓冲区 ] --X-- (读端已关闭)
第三步:yes 继续写入,内核发现没有读端
此时 yes 进程还在不知情地继续调用 write() 往管道里写数据。但内核发现管道的读端已经没有进程在持有了(所有引用都关闭了)。
内核向 yes 进程发送 SIGPIPE 信号(信号编号 13),并且让 write() 系统调用返回 EPIPE 错误。
yes --write()--> ??? <-- 内核发送 SIGPIPE (信号13)
第四步:SIGPIPE 默认行为:终止 yes 进程
SIGPIPE 信号的默认处理方式是 终止进程 。=yes= 没有特意设置信号处理函数,所以它收到了 SIGPIPE 后就直接退出了。
yes X (被 SIGPIPE 杀死)
让我们用 strace 来观察这个过程:
strace -f -e trace=write,close,exit_group bash -c 'yes | head' 2>&1 | tail -20
y
y
y
y
y
y
y
y
y
[pid 62218] --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=62218, si_uid=1000} ---
[pid 62219] <... write resumed> = 20
[pid 62219] close(1) = 0
[pid 62219] close(2) = 0
[pid 62219] exit_group(0) = ?
[pid 62218] +++ killed by SIGPIPE +++
[pid 62219] +++ exited with 0 +++
close(3) = -1 EBADF (错误的文件描述符)
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=62218, si_uid=1000, si_status=SIGPIPE} ---
exit_group(0) = ?
+++ exited with 0 +++
可以看到:进程 62218(yes)收到了 SIGPIPE 信号后被杀死,而进程 62219(head)正常退出。
实验验证
前面我们通过简化的 strace 输出理解了整个过程。现在让我们做一个完整的、可复现的实验,亲眼见证这个精妙的机制是如何运作的。
执行以下命令:
strace -f -e trace=write,read,pipe,pipe2,clone,close,dup2,execve,exit_group -o /tmp/yes_head.log bash -c 'yes | head'
这个命令会跟踪所有相关的系统调用,并将结果保存到 /tmp/yes_head.log 文件中。让我们看看完整的输出:
61937 execve("/usr/bin/bash", ["bash", "-c", "yes | head"], ...) = 0
61937 pipe2([3, 4], 0) = 0
61937 clone(...) = 61938
61937 close(4) = 0
61937 clone(...) = 61939
61937 close(3) = 0
61938 close(3) = 0
61938 dup2(4, 1) = 1
61938 close(4) = 0
61939 dup2(3, 0) = 0
61939 close(3) = 0
61938 execve("/usr/bin/yes", ["yes"], ...) = 0
61939 execve("/usr/bin/head", ["head"], ...) = 0
61938 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192) = 8192
61939 read(0, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192) = 8192
61938 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192) = 8192
61938 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192) = 8192
61938 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192 <unfinished ...>
61939 close(0) = 0
61938 <... write resumed> = 8192
61939 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\n", 20 <unfinished ...>)
61938 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\ny\n"..., 8192 <unfinished ...>)
61939 <... write resumed> = 20
61938 <... write resumed> = -1 EPIPE (断开的管道)
61938 --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=61938, si_uid=1000} ---
61939 close(1) = 0
61939 close(2) = 0
61939 exit_group(0) = ?
61938 +++ killed by SIGPIPE +++
61939 +++ exited with 0 +++
61937 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=61938, si_status=SIGPIPE} ---
61937 exit_group(0) = ?
61937 +++ exited with 0 +++
让我们逐步解读这个输出(61937 是 bash,61938 是 yes,61939 是 head):
阶段一:Shell 建立管道和进程
61937 pipe2([3, 4], 0) = 0 <-- 创建管道:fd[0]=3(读), fd[1]=4(写) 61937 clone(...) = 61938 <-- fork 出 yes 进程 61937 close(4) = 0 <-- bash 关闭写端(自己不写) 61937 clone(...) = 61939 <-- fork 出 head 进程 61937 close(3) = 0 <-- bash 关闭读端(自己不读)
Shell 创建了管道和两个子进程,然后自己关闭了管道的两端(因为 bash 不需要读写这个管道)。
阶段二:子进程重定向文件描述符
61938 close(3) = 0 <-- yes 关闭读端 61938 dup2(4, 1) = 1 <-- yes: fd[1] --> stdout 61938 close(4) = 0 <-- yes 关闭原来的 fd[1] 61939 dup2(3, 0) = 0 <-- head: fd[0] --> stdin 61939 close(3) = 0 <-- head 关闭原来的 fd[0]
yes 进程(61938)关闭了读端,将写端设为 stdout;=head= 进程(61939)关闭了写端,将读端设为 stdin。
阶段三:进程执行
61938 execve("/usr/bin/yes", ["yes"], ...) = 0
61939 execve("/usr/bin/head", ["head"], ...) = 0
两个进程分别加载并执行对应的程序。
阶段四:数据传输
61938 write(1, "y\ny\n...", 8192) = 8192 <-- yes 写入 8192 字节 61939 read(0, "y\ny\n...", 8192) = 8192 <-- head 读取 8192 字节 61938 write(1, "y\ny\n...", 8192) = 8192 <-- yes 继续写入 61938 write(1, "y\ny\n...", 8192) = 8192 <-- yes 继续写入 61938 write(1, "y\ny\n...", 8192) = 8192 <-- yes 继续写入
yes 持续写入数据(每次 8192 字节),=head= 读取了一次 8192 字节后就不再读取了——因为它只需要 10 行数据(20 字节)就够了。
阶段五:head 完成任务,关闭读端
61938 write(1, "y\ny\n...", 8192 <unfinished ...> 61939 close(0) = 0 <-- head 关闭 stdin(管道读端) 61938 <... write resumed> = 8192
注意这里的时序:=yes= 正在写入第 5 次数据(尚未完成),=head= 就已经关闭了 stdin(管道读端)。然后 yes 的写入操作才完成。这说明 head 和 yes 是真正并行运行的。
阶段六:yes 收到 SIGPIPE 终止
61939 write(1, "y\ny\ny\ny\ny\ny\ny\ny\ny\ny\n", 20 <unfinished ...>)
61938 write(1, "y\ny\n...", 8192 <unfinished ...>)
61939 <... write resumed> = 20
61938 <... write resumed> = -1 EPIPE (断开的管道)
61938 --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=61938, si_uid=1000} ---
61938 +++ killed by SIGPIPE +++
head 把 10 行结果输出到终端( write(1, ..., 20) ),同时 yes 尝试再次写入管道,但此时管道读端已经关闭,=write()= 返回 EPIPE 错误,=yes= 随即收到 SIGPIPE 信号被杀死。
阶段七:head 输出结果并退出
61939 close(1) = 0 <-- head 关闭 stdout 61939 close(2) = 0 <-- head 关闭 stderr 61939 exit_group(0) = ? <-- head 正常退出 61939 +++ exited with 0 +++
head 将读取的数据输出到终端后,关闭所有文件描述符,正常退出。
阶段八:Shell 收到子进程退出信号
61937 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=61938, si_status=SIGPIPE} ---
61937 exit_group(0) = ?
61937 +++ exited with 0 +++
Shell 收到 SIGCHLD 信号,知道子进程已经退出,于是自己也退出了。
总结
通过这篇文章,我们深入剖析了 yes | head 这个看似简单的命令背后所隐藏的精妙机制。让我们用一句话总结全文:
管道不只是数据传输通道,还是进程间的"生死契约"——读端关闭,写端自动终止。
整个过程涉及三个层面的协作:
- Shell 层 :解析管道符号,创建管道,通过
fork()和dup2()建立进程间的连接 - 内核层 :管理管道缓冲区,通过 blocking I/O 协调读写速率,确保数据顺畅流动
- 信号层 :当读端关闭时,通过
SIGPIPE信号通知写端进程终止
最精妙的是:这一切对 yes 和 head 来说都是 透明的 。它们不需要知道彼此的存在,不需要写任何协调代码,只需要各做各的事——=yes= 只管写入,=head= 只管读取。操作系统把剩下的事情都安排好了。
这正是 Unix 设计哲学的体现: 简单组件,巧妙组合,强大功能 。每个程序只做一件事,并通过标准接口(管道)连接,就能构建出复杂而可靠的数据处理流程。