把文件通过管道喂给less,按下j键它依然能滚动。但如果 less 是从标准输入读取文件,那你的按键又是从哪里读到的?

这个问题在把管道输入接到交互式程序时同样会出现。当交互式程序启动时,文件描述符 0 已经是一条被读尽的管道。lessfzf这类工具证明了拿回键盘是可行的:fzf 从管道读取完整的候选列表,同时仍然允许你输入过滤。但作者并不清楚它们是怎么做到的,于是做了一次实验。

实验设计

这个程序是一个二进制文件通过管道自我连接四次:

./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-test

每个阶段等待轮到自己,从终端读取一行,然后把累积的行传给下游。最后一个阶段打印全部四行,并附上读取每行内容的进程 PID。

在输入任何内容之前,在另一个窗口运行ps:四个进程已经全部存在。Shell 并不会等第一阶段结束后再启动第二阶段。它一次性启动整条管道,在你按下任何一个键之前,最后一个阶段就已经存活并在等待了。

这留下三个问题。所有进程都是同一个二进制的实例,每个进程如何知道自己是第一个、最后一个还是中间某个位置?第一阶段之后,标准输入携带的是管道数据,进程如何回到键盘?既然四个进程从一开始就同时运行,是什么阻止它们同时尝试读取你的输入?

进程如何知道自己的位置?

人们容易把管道想象成开头有一个标准输入、结尾有一个标准输出。但标准输入和标准输出属于进程,不属于管道。每个进程都有自己的 fd 0 和 fd 1,Shell 只是把它们连接到不同的东西上。

对于第一个进程,fd 0 仍然指向终端。对于后面的每个进程,它指向前一阶段的管道。在另一端,最后一个进程的 fd 1 指向终端,而每个更早的进程都写入管道。

Rust 通过IsTerminal暴露了相关的检查:

let lines: Vec = if stdin.is_terminal() {// 第一阶段:从标准输入读取,即终端。} else {// 后续阶段:排空管道,然后从终端读取。if stdout.is_terminal() {// 最后阶段:为用户打印。} else {// 更早阶段:为下一个进程序列化。}

这个二进制文件不需要阶段编号。它可以从自己的标准输入和标准输出连接到了什么来推断自己的位置。

什么告诉下一阶段开始?

作者最初以为各阶段需要一个单独的协调通道。其实不需要。下游阶段通过排空自己的标准输入来开始:

let mut buf = String::new();stdin.read_to_string(&mut buf)?;

乍一看,这像是普通的数据加载。但read_to_string不会仅仅因为管道为空就返回。空管道意味着还没有东西可读;EOF 才意味着永远不会有东西到达。

只要前一阶段还活着,管道就不会到达 EOF。因此,下游阶段的 read_to_string 会一直阻塞,直到上游阶段退出。这就是协调机制:上游进程退出时,它的管道写入端关闭,下游进程的读取才结束。整个过程不需要任何额外的通信通道,管道本身的 EOF 语义就是天然的同步信号。

这个实验揭示了一个被大多数开发者忽略的细节:在 Unix 管道中,进程的存活状态本身就是一种消息。上游退出即通知下游,下游阻塞即等待上游。less 和 fzf 之所以能在管道输入后仍然响应键盘,正是因为它们检测到标准输入不是终端后,会重新打开/dev/tty来获取键盘输入,而管道中的数据则通过 EOF 机制按顺序传递。

理解这一点,对调试复杂的管道命令、编写交互式命令行工具都有实际帮助。下次再看到 less 从管道读取文件还能响应按键,你就知道它背后发生了什么。