暗无天日

=============>DarkSun的个人博客

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 时,它会经历以下步骤:

  1. 调用 pipe() 系统调用创建一个管道,得到两个文件描述符: fd[0] (读端)和 fd[1] (写端)
  2. 调用 fork() 两次,创建两个子进程
  3. yes 进程中:
    • 关闭读端 fd[0]
    • 使用 dup2(fd[1], 1) 将标准输出(stdout)重定向到管道写端
    • 关闭原来的 fd[1]
    • 执行 yes 程序
  4. 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 输出可以看到:

  1. Shell 创建了管道( pipe2([3, 4], 0) ),得到读端 fd=3,写端 fd=4
  2. Shell 两次 clone() 创建了子进程 62132 和 62133
  3. Shell 自己关闭了管道的两端( close(4)close(3) ),因为它自己不参与数据的读写
  4. 进程 62132(yes)关闭了读端 fd=3,把写端 fd=4 通过 dup2 重定向到 stdout
  5. 进程 62133(head)关闭了写端 fd=4(已经不需要了),把读端 fd=3 通过 dup2 重定向到 stdin
  6. 两个进程分别 execve 加载了 yeshead 程序

现在 yes 的 stdout 连接到管道写端,=head= 的 stdin 连接到管道读端。但这还没有解释为什么 yes 会停止输出。让我们继续往下看。

管道是什么

在深入理解进程协作之前,我们需要先了解管道的本质。

管道并不是什么神奇的东西,它本质上就是内核维护的一个 固定大小的缓冲区 。在 Linux 中,这个缓冲区默认大小是 64KB(可以通过 fcntl(fd, F_GETPIPE_SZ) 查询)。

管道有几个重要的特性:

  1. 先进先出(FIFO) :先写入的数据会被先读出,就像排队买票一样
  2. 数据被读走后即从缓冲区中移除 ,腾出空间给新数据
  3. 单向流动 :数据只能从写端流向读端,不能反向
  4. 匿名管道 :没有名字,只能用于有亲缘关系的进程之间(如父子进程)

可以把管道想象成一根水管:

yes 进程                     内核管道                     head 进程
  |                                                          |
  |  写入 y ---->  [ y | y | y | y | ... | y ]  ---->  读取 y
  |                                                          |
                ^                 |
                |                 v
             写端入口          读端出口
           (只能进)           (只能出)

这个水管(缓冲区)有固定的容量。当水满了(缓冲区满了),就不能再往里倒水(写入数据);当水空了(缓冲区空了),就取不出水(读取数据)。

运行中的协作

现在我们终于可以回答最初的问题了:为什么 yes 会停止输出?

关键在于 blocking I/O 的协调机制。(关于 blocking I/O 的详细原理,我已经在另一篇文章中详细讨论过,这里只概述结论)。

当进程通过管道协作时,内核会自动协调它们的速率:

  1. 当缓冲区满了 :=yes= 的 write() 系统调用会阻塞,=yes= 进程进入睡眠状态,等待管道腾出空间
  2. 当缓冲区空了 :=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 的写入操作才完成。这说明 headyes 是真正并行运行的。

阶段六: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 这个看似简单的命令背后所隐藏的精妙机制。让我们用一句话总结全文:

管道不只是数据传输通道,还是进程间的"生死契约"——读端关闭,写端自动终止。

整个过程涉及三个层面的协作:

  1. Shell 层 :解析管道符号,创建管道,通过 fork()dup2() 建立进程间的连接
  2. 内核层 :管理管道缓冲区,通过 blocking I/O 协调读写速率,确保数据顺畅流动
  3. 信号层 :当读端关闭时,通过 SIGPIPE 信号通知写端进程终止

最精妙的是:这一切对 yeshead 来说都是 透明的 。它们不需要知道彼此的存在,不需要写任何协调代码,只需要各做各的事——=yes= 只管写入,=head= 只管读取。操作系统把剩下的事情都安排好了。

这正是 Unix 设计哲学的体现: 简单组件,巧妙组合,强大功能 。每个程序只做一件事,并通过标准接口(管道)连接,就能构建出复杂而可靠的数据处理流程。

linux和它的小伙伴