想象你刚搬进一套毛坯房,请了一位经验老到的水电工——大家都叫他“Shell 师傅”。他打开工具箱:洗衣机、热水器、净水机,每台都是独立的好家电。可单独摆放它们,你得一趟趟往中间搬水,又累又慢。真正让这套房子“活”起来的,是那几根不起眼的塑料水管:一台机器的出水口,拧上接头,直接灌进下一台机器的进水口,东西在房子内部自己就流动起来了。 Linux 命令行正是这样一套“房子”。每一条命令都是一台家电,各自会“喝水”(标准输入)、“出水”(标准输出)、“报修”(标准错误)。管道符 | 就是把一台家电的出水口接到下一台进水口的接头;重定向 >、< 是改变水流方向的阀门,让水流入水箱(文件)而不是流向水池(屏幕);tee 是三通管,流经时取一份水样存档;xargs 是分拣员,把成箱的东西拆成小包递给下一站;进程替换则是即插即用的活水接头,随用随取。 这篇文章,就是 Shell 师傅带你把这套水管图纸从头到尾看明白。看完你会发现:以前一条命令一条命令地敲,是在“搬水”;现在用管道把它们组合起来,是在“装修”。

岗位关联

对 AI 从业者来说,日志筛选、数据预处理、模型训练脚本几乎每天都在用管道和重定向——这是机器学习工程中最基础也最常被低估的效率杠杆。

目录
  1. 可视化演示
  2. 管道原理
  3. 三大标准流
  4. 输出重定向
  5. 输入重定向
  6. tee分流
  7. xargs搭档
  8. 进程替换
  9. 组合实战
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示广度优先搜索(BFS)和深度优先搜索(DFS)两种图遍历算法的执行过程,动态呈现节点的访问顺序与前沿队列变化。

本节配有交互动效(进入视口后自动加载,约 3KB)。

广度优先搜索 BFS 紫色节点为已访问,按层级逐层向外扩展 橙色节点为前沿队列,等待被访问 BFS: Queue(FIFO) → 逐层遍历

管道原理

Shell 师傅拎着两件家电走进厨房:一台洗菜机、一台切菜机。他先把机器分别接好水电,然后拿出一根透明塑料管,一头接在洗菜机的出水口,另一头接在切菜机的进水口。启动了开关,洗好的菜叶顺着水流直接滑进切菜机里,哗啦啦就被切成细丝。你愣了一下:“原来家电之间是可以这样接起来的?”师傅嘿嘿一笑:“在 Linux 的命令行世界里,这套接头叫管道。”

每条命令程序启动时,操作系统都给它预备了三个“水管接口”:输入、输出、错误。正常情况下,输出接到屏幕上,输入等键盘。而管道符 | 做的事很朴素:把左边命令的输出口,接到右边命令的输入口。就像 grep ERROR app.log | sort | uniq -c | sort -rn 这样,前一道工序的成品直接变成后一道工序的原料。

这里牵涉到一个底层概念——进程间通信。两个程序要交换数据,最笨的办法是中间放一个文件:第一个写完,第二个再读。管道则省去了这个中转站:内核在内存里开辟一小块缓冲区,左边进程往里面写,右边进程从里面读,数据随到随读、边产边消,整个过程不需要任何磁盘操作。这种临时管道没有名字,Shell 负责创建和回收,所以叫匿名管道。

水管接哪儿,水就流哪儿;管道接哪儿,数据就流哪儿。Shell 师傅点点头:“记住这句话,后面所有的阀门原理,都是这句话的变体。”

(fn∘⋯∘f2∘f1)(x)=fn(⋯f2(f1(x))⋯ )\displaystyle (f_n \circ \cdots \circ f_2 \circ f_1)(x) = f_n(\cdots f_2(f_1(x)) \cdots)
这条公式说的是:一条 A | B | C 的管道链,本质上等价于把三个命令的处理函数从右往左嵌套起来——先 f1 再 f2 再 f3,层层套娃。
  1. 拿到原始数据整个管道链的起点是输入 x,在命令行里它可能是文件内容或前一条命令的输出。
    xx
  2. 第一道工序f1 表示最左侧命令,它对原始数据做第一次加工,产出中间结果 y1。
    y1=f1(x)y_1 = f_1(x)
  3. 中间逐级传递第 k+1 个命令拿到的输入,恰好是第 k 个命令的输出,全程不落盘。
    yk+1=fk+1(yk)(k=1,2,…,n−1)y_{k+1} = f_{k+1}(y_k) \quad (k = 1, 2, \dots, n-1)
  4. 整体等价嵌套一整条管道链等价于这些函数从右往左嵌套成一个复合函数。
    (fn∘⋯∘f1)(x)=fn(⋯f1(x)⋯ )(f_n \circ \cdots \circ f_1)(x) = f_n(\cdots f_1(x) \cdots)

三大标准流

师傅领你到卫生间,指着墙上的热水器:“你看,每台设备其实只有三根管。这根进冷水,这根出热水,这根是溢水报警管。”他敲了敲那根细细的管子:“报警管很重要——热水器出毛病了,它叽叽叫,你才知道该修了。要是把这根管子和热水管接到一起,洗澡时报警声混在流水声里,你根本听不见!”

这三根管子在 Linux 里有正式名字和编号。编号 0 是标准输入(stdin),默认接键盘,程序从它那儿读数据;编号 1 是标准输出(stdout),默认接屏幕,程序的正常结果从它那儿冒出来;编号 2 是标准错误(stderr),也默认接屏幕,但专门用来输送报错信息。这三者合称“三大标准流”。

为什么要费劲把正常结果和报错拆成两根管?因为处理的方式完全不同。你写命令时,往往只关心正常输出,报错信息要么想单独存成一个文件,要么干脆倒进下水道。如果混在一起,想做“只看成功的”或“只看失败的”就难了。Linux 还提供了一个绝妙的下水道口:/dev/null,一个特殊的设备文件,写进去的数据会被直接丢弃、永不返回,容量无限,黑洞本洞。

“报警管和热水管分开走,是为了让你在需要的时候,单独处理它们。”师傅拧了拧热水器的阀门:“接下来我们就学,怎么让每一根管改道。”

{0, 1, 2}  ⟷  {stdin, stdout, stderr}\displaystyle \{0,\ 1,\ 2\} \;\longleftrightarrow\; \{\text{stdin},\ \text{stdout},\ \text{stderr}\}
编号 0、1、2 是三大标准流的固定门牌号。程序不关心管的另一端连接着什么设备,只按照编号读写,这正是 Unix“一切皆文件”思想的体现。
  1. 0 号进水口标准输入,默认接键盘,是程序读取数据的默认来源。
    0: stdin(进水口)0:\ \text{stdin(进水口)}
  2. 1 号出水口标准输出,默认接屏幕,是正常结果的默认去向。
    1: stdout(出水口)1:\ \text{stdout(出水口)}
  3. 2 号报警口标准错误,默认也接屏幕,但承载的是诊断和报错信息。
    2: stderr(报警口)2:\ \text{stderr(报警口)}
  4. 按编号寻路重定向的本质,就是把某个编号的出口或入口,改接到新的设备或文件上。
    fd⟶目标:设备、文件或管道\text{fd} \longrightarrow \text{目标:设备、文件或管道}

输出重定向

师傅掏出一个三通阀门,利索地拧在热水器的出水口上:“你看,本来热水哗哗流进浴室水池,现在我拧这个阀门,它就去灌水桶了。”你恍然大悟:“所以 > 就是改道阀门!”师傅点头:“> 是改道去水桶,>> 是把水继续灌进已经装了一半的水桶——旧的不丢,新的续上。”

这就是输出重定向的核心:把本应流向屏幕的 1 号标准输出,改道流进文件。echo "hello" > a.txt 会把 a.txt 整个覆盖成一行 hello;echo "world" >> a.txt 则是在后面追加一行,不像 > 那样先清空再写。特别要小心:> 是全量覆盖,文件原有的内容会被抹掉,操作前务必确认。

重定向还能管 2 号报警口。run.sh 2> err.log 只把报错信息写进文件,正常输出仍然显示在屏幕;run.sh > all.log 2>&1 则是先在 1 号管口接上文件,再把 2 号管口也指向 1 号管口当前的位置,于是正常输出和报错混合写入同一个文件。很多人以为 2>&1 只是个语义记号,其实它是个“照相机”:拍下 1 号管此刻接在哪儿,然后让 2 号管也接去那儿。所以位置很关键!

师傅拧好阀门,直起腰补了一句:“如果有些报错你根本不想看,就把 2 号管接进下水道:2>/dev/null。眼不见,心不烦。”

cmd > file:fd  1  ⟼  file\displaystyle \mathrm{cmd}\ \mathbf{>}\ \mathrm{file}:\qquad \text{fd}\;1 \;\longmapsto\; \text{file}
这条规则表示“1 号出水口的终点改为某文件”。大于号右边是全量覆盖,双大于号是追加;2>&1 则是让 2 号报警口跟随 1 号出水口的当前去向。
  1. 默认去向不加重定向时,1 号标准输出通向屏幕(终端)。
    fd  1⟶屏幕\text{fd}\;1 \longrightarrow \text{屏幕}
  2. 改道写文件> 把 1 号管的终点改成文件,打开文件时先清空旧内容。
    fd  1⟶file(覆盖)\text{fd}\;1 \longrightarrow \text{file(覆盖)}
  3. 追加模式>> 同样改道到文件,但不清空,从文件末尾接着写。
    fd  1⟶file(追加)\text{fd}\;1 \longrightarrow \text{file(追加)}
  4. 报警口跟随2>&1 让 2 号管口指向 1 号管口此刻的目标,通常用于合并输出。
    fd  2  ⇒  fd  1 的当前目标\text{fd}\;2 \;\Rightarrow\; \text{fd}\;1 \text{ 的当前目标}

输入重定向

师傅搬来一只大水桶摆在厨房:“刚才都是给输出改道。反过来的道理你也应该猜到了——既然能让水从 > 流进水桶,当然也能让水从 < 流出来。”他把一根管子插进水桶,另一头接在洗菜机进水口:“现在洗菜机不从自来水龙头取水了,直接从水桶抽。”

这就是输入重定向:把 0 号标准输入的默认来源从键盘改成一个文件。sort < words.txt 让 sort 命令从 words.txt 读取待排序内容,效果和 sort words.txt 差不多,但语义不同——前者是“喂标准输入”,后者是“打开文件参数”。对于不接收文件参数的命令,< 几乎是唯一喂数据的方式:比如 wc -l < data.txt。

输入重定向还有个特殊形态:here-document,用 << 加一个边界符,直接在命令行里“注入”一段多行文本。写脚本时,常用它给交互式程序批量喂入指令:

边界符(这里用的是 EOF)必须顶格、单独占一行才能结束输入。如果它前面多了空格,shell 只会把它当成普通数据继续等待——这是新手脚本卡死的头号原因。单行版 <<< "text" 则适合快速喂一小段字符串给需要标准输入的小工具。

“记住,进水管、出水管都不是死的。”师傅拍拍水桶:“想从哪儿取水,想让水去哪儿,全由你这个业主说了算。”

cmd < file:fd  0  ⟼  file\displaystyle \mathrm{cmd}\ \mathbf{<}\ \mathrm{file}:\qquad \text{fd}\;0 \;\longmapsto\; \text{file}
“0 号进水口的来源改为某文件。”与输出重定向完全对称:> 管 1 号出口,< 管 0 号入口;<< 把命令行里预置的文本块当作入水水源。
  1. 默认来源不加重定向时,0 号标准输入连接键盘。
    fd  0⟶键盘\text{fd}\;0 \longrightarrow \text{键盘}
  2. 改道读文件< 把 0 号管接到文件,命令像读键盘一样读文件内容。
    fd  0⟶file\text{fd}\;0 \longrightarrow \text{file}
  3. here-document 注水<< EOF 把接下来直到独立一行的 EOF 为止的文本,整个作为标准输入注入。
    fd  0⟵文本块(到 EOF 结束)\text{fd}\;0 \longleftarrow \text{文本块(到 } \mathrm{EOF} \text{ 结束)}
  4. 单行字符串<<< "text" 把右侧字符串当作标准输入,适合喂给 grep、wc 等小工具。
    fd  0⟵"text"\text{fd}\;0 \longleftarrow \text{"text"}
bash5 行
sqlite3 test.db << EOFCREATE TABLE user(id INT, name TEXT);INSERT INTO user VALUES (1, 'tom');SELECT * FROM user;EOF

tee分流

厨房改造告一段落,师傅拿出一个 T 型三通接头,把它装在净水机的出水口上。水管从三通出来分了两路:一路继续通向客厅的饮水机,另一路接了个小水杯。“这叫旁路取样。”师傅拧开开关,“水照常流去客厅,但每秒钟都有一小股溜进这个杯子,攒起来就是一份水质样本。”

这就是 tee 命令做的事。它的名字本身就来自 T 型三通管:从标准输入读入数据,然后复制成两份,一份写到标准输出(继续沿管道往下走或直接显示),一份写进它参数指定的文件。cmd | tee result.txt 在屏幕上看到完整输出的同时,也把一模一样的副本存进了文件。

tee 最妙的使用场景是“管道中间取样”:一条长管道链,你想观察中间某一步的结果,又不打断链条。比如 grep ERROR app.log | tee errors-only.txt | sort | uniq -c——errors-only.txt 里存下所有错误行,同时数据继续流向后边的排序去重。加 -a 参数则是追加模式,适合连续运行多次、把日志合并到同一个文件,实现“一边看屏幕实时输出、一边留完整存档”的双写效果。

“三通管不改变水流本身,只是让我们多一双眼睛。”师傅把水杯递给你,“调试管道链的时候,你会在每一段都装一个三通,看数据走到哪儿变了样。”

tee(x)=( x, x )一路到屏幕,一路进文件\displaystyle \mathrm{tee}(x) = (\,x,\ x\,) \qquad \text{一路到屏幕,一路进文件}
tee 把一份输入数据原样复制成两路输出,不做任何加工。它不是“分流一部分”,而是“完整复制一份”,两路数据一模一样。
  1. 读入一份tee 从标准输入读入完整数据流,记作 x。
    x=stdinx = \text{stdin}
  2. 复制两份数据被复制成两个相等的副本,这是三通管的数学含义。
    y1=x,y2=xy_1 = x, \quad y_2 = x
  3. 第一路展示y1 从标准输出继续前进,显示在屏幕或传给管道下游。
    y1⟶stdouty_1 \longrightarrow \text{stdout}
  4. 第二路存档y2 写入参数指定的文件,-a 时改为追加写入。
    y2⟶filey_2 \longrightarrow \text{file}

xargs搭档

师傅带你到阳台,那儿堆着一箱刚从楼下扛上来的零件。他牵着一根管子送过来一把一把的零件,然后指着旁边一台设备说:“这台机器胃口不一样,它不吃'管子送来的散装零件',只认'在命令行参数里点名要的东西'。”说着,师傅掏出一个分拣员——他一手接住管子里流出来的零件,一手把它们装进小包装袋,一袋一袋精准投递给设备。

Linux 里确实有这种“口味刁钻”的命令:rm、cp、mkdir 等命令不接受标准输入作为数据来源,它们只认命令行参数。当你想把“另一个命令输出的文件列表”批量传给它们时,直接 find . -name "*.log" | rm 是行不通的——rm 根本不读标准输入。这时候 xargs 登场:它把标准输入按行(或按空格)拆成一个个参数,再组装成完整的命令行去执行。

xargs 还能控制“每包装多少件”。find . -name "*.py" -print0 | xargs -0 wc -l 中,-0 表示按空字符分隔而不是按空格或换行,专门对付文件名里带空格、换行的极端情况;配合 -n 50 则规定每批最多 50 个参数,避免一次传给命令太多参数导致"Argument list too long"。这就是为什么 find ... | xargs rm 比 rm $(find ...) 安全得多——后者可能因为文件太多直接超限报错。

“记住,管道送'流',参数点名'件'。”师傅总结道,“xargs 就是那个把流拆成件、件装成包、包递给命令的分拣员。”

N=⌈nm⌉,m=每批参数个数(由 −n 指定)\displaystyle N = \left\lceil \frac{n}{m} \right\rceil,\qquad m = \text{每批参数个数(由 } -n \text{ 指定)}
当待处理参数总数 n 很大时,xargs 按每批 m 个分组执行,共执行 N 批。向上取整保证最后一批剩几个就处理几个。
  1. 参数总量n 是标准输入传给 xargs 的参数总条数,比如 find 找到的文件数。
    n=参数总条数n = \text{参数总条数}
  2. 批量上限m 是 -n 指定的每批参数个数,决定一次调用塞几个参数。
    m=−n 指定的批量大小m = -n \text{ 指定的批量大小}
  3. 批次总数总批数等于 n 除以 m 并向上取整,m 越大批数越少。
    N=⌈nm⌉N = \left\lceil \dfrac{n}{m} \right\rceil
  4. 尾批处理不能整除时,最后一批只含剩余的参数个数。
    最后一批=n mod m (若不为 0)\text{最后一批} = n \bmod m \ (\text{若不为}\ 0)

进程替换

最后,师傅拿出一件你从没见过的东西:一个即插即用的活水接头。它没有水箱,没有水管,却可以直接拧在水龙头上取水。“这叫活水接头,”师傅解释,“你不需要先拿桶接满水放好,再接设备——接头直接连到一个正在流水的龙头,用到的时候水自然来,不用的时候它自己停。”

Linux 的进程替换(Process Substitution)就是这个活水接头。语法是 <(命令) 或 >(命令)。<(cmd) 会把 cmd 的执行结果包装成一个“虚拟文件路径”,传给那些只认参数、不读标准输入的命令。最常见的例子:

这条命令把 a.txt 和 b.txt 分别排序,然后 diff 直接比较两份“虚拟文件”——整个过程没有生成任何真实文件。Shell 在背后创建的是命名管道(FIFO):sort 进程把结果写进管道,diff 把这根管道当作普通文件路径来读。用完之后,Shell 自动清理,不留垃圾。

进程替换与管道的核心区别在于:管道只能“一对一”单向串联;进程替换可以把一个命令的输出当作参数插入任意位置,甚至一次插两个不同命令的输出供 diff、comm 这类“双文件选手”使用。相比手工执行 sort a.txt > tmp1; sort b.txt > tmp2; diff tmp1 tmp2; rm tmp1 tmp2,一行进程替换就把四步缩成一步。

“这个接头最优雅的地方在于:该读文件的地方读文件,该跑命令的地方跑命令,两者无缝切换,还不留垃圾。”师傅把活水接头小心收进工具箱,“真正的高级用法,都是拿它跟前面所有阀门组合出来的。”

<(cmd)    ≈    一个即用即走的临时文件\displaystyle <( \mathrm{cmd} ) \;\; \approx \;\; \text{一个即用即走的临时文件}
进程替换的本质是“把命令输出伪装成文件路径,供参数型命令使用”。背后的实现是命名管道,读方只知道自己打开了一个文件,实际上读的是另一个进程的实时输出。
  1. 进程替换语法<(cmd) 表示把 cmd 的标准输出包装成一个可访问的虚拟文件路径。
    virtual=<(cmd)\mathrm{virtual} = <(\mathrm{cmd})
  2. 背后是命名管道Shell 通过 FIFO(命名管道)实现——进程持续写入,读取方像读文件一样消费。
    FIFO: cmd⟶reader(当作文件)\text{FIFO}: \ \mathrm{cmd} \longrightarrow \text{reader(当作文件)}
  3. 多参数位可用同一命令中可以出现多个进程替换,diff 得以同时比较两个命令的输出。
    diff <(A) <(B)\text{diff} \ <(A) \ <(B)
  4. 免临时文件与手工创建中间文件相比,进程替换用后即焚,不残留文件。
    ∄ 残留临时文件\nexists \ \text{残留临时文件}
bash1 行
diff <(sort a.txt) <(sort b.txt)

组合实战

天色渐晚,师傅带你回到厨房,把整张“水管图纸”摊在桌上。图纸上,自来水进户管分成很多支路:一路进洗菜机,洗菜机出水接切菜机,切菜机出料到炒锅,炒锅边上还挂着三通接头——一路出菜,一路取样;另一路从水桶接出,直接注进饮水机;报警管全部汇进下水道。“单独看每个接头都很简单,”师傅说,“把它们串成一张网,房子才真正好用。”

Linux 的命令行组合也是这样。一个真实的日志排查场景:你的 AI 训练脚本跑了六个小时,你要找出训练日志里 loss 突然升高的原因。一条命令组合拳就够:

这条命令链里:grep 像安检员,从训练日志里筛出所有带 loss 的行;tee 像三通管,一边把完整结果存入 loss-raw.txt 做原始备份,一边让数据继续前进;sort 按第二列的数值排序,让 loss 最大的行沉到末尾;tail 只截取最后 20 行,也就是 loss 最异常的记录;最终结果写入文件,多余的报错被倒进 /dev/null。整个排查过程,一次键入、零中转文件。

把这种能力放进 Shell 脚本里,就形成了运维级的自动化:每天凌晨用管道链处理系统日志,用重定向归档结果,用 here-document 自动生成配置文件,用 xargs 批量清理过期模型检查点。你不再是一个命令一个命令地敲键盘,而是设计一条条“数据流水线”,让数据自己流经每道工序。

站在门口,你回头再看这套房子,心里已经清清楚楚:每一根管、每一个阀、每一个三通,单独看都是不起眼的小零件;但把它们按水流方向接起来,整套房子就活了过来。命令行也是一样——简单命令是零件,组合起来才是工程。

result=uniq ⁣(sort ⁣(grep E(log)))\displaystyle \text{result} = \mathrm{uniq}\!\left(\mathrm{sort}\!\left(\mathrm{grep}_{\,E}(\text{log})\right)\right)
一条复杂的组合命令,从数学上看就是把多个处理函数层层嵌套:先 grep 过滤、再 sort 排序、再 uniq 去重。外侧函数处理内侧函数的结果,与管道从左到右的流向一致。
  1. 第一层过滤grep 从原始日志中筛出包含目标模式 E 的行。
    r1=grepE(log)r_1 = \mathrm{grep}_{E}(\text{log})
  2. 第二层排序sort 将相同或相邻内容聚拢,为后续去重与对比做准备。
    r2=sort(r1)r_2 = \mathrm{sort}(r_1)
  3. 第三层去重uniq 合并连续重复的行,配合 -c 还能统计出现次数。
    r3=uniq(r2)r_3 = \mathrm{uniq}(r_2)
  4. 整体即嵌套整条管道链等价于最外侧函数作用于最内侧结果。
    result=uniq(sort(grepE(log)))\text{result} = \mathrm{uniq}(\mathrm{sort}(\mathrm{grep}_{E}(\text{log})))
bash5 行
grep -E "loss" train.log \  | tee loss-raw.txt \  | sort -k2 -n \  | tail -20 > loss-top20.txt 2>/dev/nullcat loss-top20.txt

AI 应用场景

AI 训练日志的异常筛选

阶段:模型调试与训练监控

解决的问题:训练日志动辄几百 MB,肉眼翻找 loss 异常和 NaN 报错效率极低;用编辑器反复搜索不仅慢,还容易漏掉上下文。管道链可以一条命令完成“过滤—排序—截取—存档”全套动作,把排查时间从半小时压缩到几秒。

具体案例:训练 BERT 模型时 loss 突然飙高,运行 grep -E "loss|NaN" train.log | tee loss-check.txt | sort -k4 -n | tail -30,既能实时看到最异常的 30 条记录,又把全部 loss 行备份到 loss-check.txt,方便后续用脚本画 loss 曲线定位出问题的 epoch。

数据预处理流水线

阶段:数据处理与特征工程

解决的问题:清洗千万级原始数据时,手工分步操作既慢又容易出错;重定向和管道能把“抽取—清洗—排序—去重—抽样”串成一条流水线,保证每一步的结果都自动流到下一步,中间产物按需保留。

具体案例:对用户行为日志做采样:cat raw.log | awk -F',' '{print $1,$3}' | sort -u | shuf -n 100000 > sample.csv,再配合 tee 在排序后截一份全量去重结果,供后续统计维度使用。整个过程无需编写临时 Python 脚本,命令即流水线。

实验管理的自动化配置

阶段:部署与环境管理

解决的问题:每次训练实验都要生成配置文件、比对不同超参数实验的输出差异。手工复制粘贴容易出错,临时中间文件堆积成山。here-document 注入配置、进程替换直接对比,让整个流程干净利落。

具体案例:用 cat > config.yaml << EOF 自动写入模型配置;用 diff <(sort exp-A.txt) <(sort exp-B.txt) 直接对比两次实验的指标差异,不用手工建临时文件。配合 find . -name "*.pt" -print0 | xargs -0 rm 批量清理过期检查点。

Python 演示

python53 行
import subprocessfrom pathlib import Path LOG_FILE = "app.log"  def build_sample_log(path):    lines = [        "INFO  user login user=tom",        "ERROR db timeout service=pay",        "INFO  user login user=jerry",        "WARN  retry attempt service=pay",        "ERROR db timeout service=pay",        "INFO  user login user=tom",    ]    path.write_text("\n".join(lines) + "\n", encoding="utf-8")  def run_pipeline(cmd_list):    procs = []    prev = None    for i, cmd in enumerate(cmd_list):        stdin_handle = prev.stdout if prev else None        p = subprocess.Popen(            cmd,            stdin=stdin_handle,            stdout=subprocess.PIPE,            stderr=subprocess.DEVNULL,            text=True,        )        procs.append(p)        prev = p    output, _ = procs[-1].communicate()    for p in procs:        p.wait()    return output  def main():    log = Path(LOG_FILE)    build_sample_log(log)    pipeline = [        ["grep", "ERROR"],        ["sort"],        ["uniq", "-c"],        ["sort", "-rn"],    ]    result = run_pipeline(pipeline)    print(result)  if __name__ == "__main__":    main()
目的:用 Python 的进程对象手动搭起一条 grep | sort | uniq -c | sort -rn 管道链,统计日志中各类 ERROR 出现的次数并降序输出。
  1. stdin_handle = prev.stdout if prev else None把上一个进程的输出口接到下一个进程的输入口,这正是 Shell 管道符 | 的底层原理。
  2. stdout=subprocess.PIPE让 Python 接管该进程的标准输出,数据先流入管道而不是直接打印。
  3. stderr=subprocess.DEVNULL把 2 号错误流丢进“下水道”,相当于 Shell 里的 2>/dev/null。
  4. output, _ = procs[-1].communicate()阻塞等待最后一条命令结束,并从它的输出口读回最终结果。
  5. ["grep", "ERROR"]管道链第一站:只保留错误日志行。
  6. ["uniq", "-c"]` 与 `["sort", "-rn"]对排序后的同类错误合并计数,再按数量降序排列。
  7. if __name__ == "__main__":标准可执行入口,保证脚本既能被导入也能直接运行。

常见误区

误区我以为 `|` 和 `>` 都是“把输出送过去”的箭头,既然 `echo hi > file` 能把内容写进文件,那 `echo hi | file` 应该也差不多吧?反正都是把数据从一个地方搬到另一个地方。

正解两者服务的对象完全不同。管道 | 连接的是两个进程:左边命令的输出变成右边命令的输入,数据在内存里流动,不产生文件。重定向 > 或 < 连接的是一个命令和一个文件/设备:水从命令流向文件,或从文件流向命令。水管接水管 ≠ 水管接水箱,一个通向命令,一个通向文件,不能混用。

为何常见:两个符号看起来都像“指向某个方向”,初学者容易按形状归类,忽略了它们连接的对象类型天差地别。

误区我觉得 `2>&1` 就是把错误合并到输出里,位置放哪儿都行,反正最后输出都进文件。所以 `2>&1` 写在前面还是后面有什么区别?我从来都是随手写。

正解Shell 处理重定向是从左到右逐项生效的。cmd > file 2>&1:先让 1 号管指向文件,再把 2 号管指向 1 号管此刻的位置(文件),所以错误和输出都进文件。cmd 2>&1 > file:先让 2 号管指向 1 号管此刻的位置(屏幕),再把 1 号管改到文件——此时 2 号管仍然指向屏幕,错误照样打印在屏幕上。顺序颠倒,结果天差地别。

为何常见:人脑倾向把重定向看作一条整体规则,而忽略了 Shell 是“按顺序执行的动作列表”,这也暴露了对“2>&1 是复制当前指向”这一语义的理解缺失。

误区我看了很多教程都这么写,先 `cat` 把文件内容吐进管道,再交给 grep 处理,感觉这样步骤清晰、逻辑完整,应该是最标准的写法。所以我写脚本时一律先 cat。

正解这是著名的“无用 cat”(Useless Cat)反模式。grep 本身就能直接读取文件参数,grep xxx file 不需要任何中间搬运。多余的 cat 白白多启动一个进程、多占一条管道,还让命令变长、变慢。管道虽好,但不是什么地方都需要;直接传文件名是更简洁高效的原生用法。

为何常见:许多教程为了“顺便演示管道”,习惯性地叠加 cat,初学者照着模仿后形成路径依赖,误以为“步骤多才专业”。

误区我一直以为管道像工厂流水线一样,左边命令全部算完之后,把结果存进一个临时文件,右边命令再开始工作。所以 `head -1 big.log | wc -l` 这种命令,big.log 没读完它不会动。

正解管道是“边产边消”的流式协作。左边命令每产出一点数据,内核就把它放进管道缓冲区,右边命令可以立刻读取并处理,两边同时运行。缓冲区满时左进程写不进去会阻塞等待右侧消费;缓冲区空时右进程读不到数据会阻塞等待左侧生产。head -1 big.log 读完一行就退出,管道断裂,整个链立即结束——这正是它处理超大文件比“先全部读完”快得多的原因。

为何常见:日常经验里的“第一步、第二步”是串行思维,而管道是一种并发协作模型,没有亲眼看到两侧进程同时存在,很难凭直觉建立正确认知。

误区写 `cat << EOF` 时,我在文档中间写了一行带缩进的 EOF,或者在 EOF 前面加了空格,想着“字段一样不就行了吗”,结果脚本像卡住了一样一动不动,一直等输入。

正解结束标记必须顶格(行首没有任何空白字符)、单独占一行、且与起始标记完全一致。缩进的 EOF 会被当作普通文本继续读入,命令会一直等待你输入直到收到真正的结束标记。在函数或 if 块里写 heredoc 时,很多人因为代码缩进而踩坑;简单解法是把内容抽成独立脚本,或用 <<- EOF 允许前导制表符。

为何常见:编辑器和复制粘贴会保留缩进,而缩进在 heredoc 的结束符判定上是致命的;加上平时几乎没人讲解这个“隐形规则”,只有踩坑后才记住。

面试题

Q1. 在 ls | wc -l 中,竖线 | 的作用是什么?

单选题 · easy

  • A.把 ls 的输出保存到文件中
  • B.把 ls 的输出作为 wc 的输入← 正确答案
  • C.让两条命令同时独立运行
  • D.把 wc 的输出传给 ls 处理

答案:B。管道符把左边命令的标准输出接到右边命令的标准输入,形成数据流水线。A 是重定向而非管道;C 的两命令确实同时运行,但不是各自独立,而是生产-消费关系;D 方向反了,管道永远从左往右流。

Q2. 先执行 echo hello > a.txt,再执行 echo world > a.txt,a.txt 最终内容是什么?

单选题 · easy

  • A.包含 hello 和 world 两行
  • B.只包含最后写入的 world← 正确答案
  • C.文件被清空没有任何内容
  • D.只包含最先写入的 hello

答案:B。单 > 是全量覆盖模式,第二次执行会先清空文件再写入新内容,所以只剩 world。若想保留两行应使用追加模式 >>,D 和 A 分别是覆盖语义和追加语义的混淆,C 则错误理解了写入过程。

Q3. 下列哪条命令能把标准错误也一起写进日志文件?

单选题 · easy

  • A.run.sh > log 2>&1← 正确答案
  • B.run.sh 2> log > log
  • C.run.sh 2>&1 > log
  • D.run.sh > 2>&1 log

答案:A。A 先让 1 号输出指向文件,再让 2 号错误指向 1 号当前目标,两者都进文件。B 两次打开 log 互相干扰写法错误;C 因顺序颠倒,2 号仍然指向屏幕;D 语法不合规,> 后面必须是目标文件。

Q4. 关于 cmd > file 2>&1 与 cmd 2>&1 > file 的区别,正确的是?

单选题 · mid

  • A.前者错误进文件,后者错误进屏幕← 正确答案
  • B.前者错误进屏幕,后者错误进文件
  • C.两者输出完全相同,没有任何区别
  • D.两条命令在执行时都会直接报错

答案:A。Shell 从左到右处理重定向。前者先把 1 号管接到文件,再把 2 号管接到 1 号管的位置,所以错误也进文件;后者先把 2 号管接到屏幕,再把 1 号管改到文件,导致错误仍留在屏幕。这就是“顺序即语义”。

Q5. tee 命令最典型的用途是什么?

单选题 · mid

  • A.只把输入内容追加到指定文件
  • B.复制一份输出到文件并继续显示← 正确答案
  • C.把文件内容作为命令的标准输入
  • D.删除标准输出中的重复数据行

答案:B。tee 是三通管,从标准输入读入后同时写到两个目标:标准输出和参数指定的文件。A 只描述了追加且丢失了主路;C 是输入重定向的职责;D 是 uniq 的职责。记住“T 型三通,一份变两份”。

Q6. 执行 cat << EOF 时,下列哪种情况才真正结束输入?

单选题 · mid

  • A.输入内容里出现 EOF 五个字符并回车
  • B.行首出现 EOF 且单独占一行时← 正确答案
  • C.源文件被读取到末尾时
  • D.必须先按 Ctrl+D 再输入 EOF

答案:B。here-document 的结束标志必须顶格、独占一行,不能有前导空格或尾部符号。A 中 EOF 出现在行中间会被当成普通数据;C 混淆了文件读取与 heredoc 的键盘输入;D 是画蛇添足,正确结束直接输入顶格 EOF 即可。

Q7. 关于 xargs 的作用,下列描述正确的是?

单选题 · mid

  • A.xargs 总是比 for 循环执行得更快
  • B.xargs 可以把标准输入转换成命令参数← 正确答案
  • C.xargs 只能处理纯数字类型的参数
  • D.xargs 只能放置在管道的最左端

答案:B。xargs 的核心职责是把标准输入按分隔符拆成参数,再组建成命令行传给其他程序,解决 rm 这类不读 stdin 的命令的批量传参问题。A 性能结论太绝对;C 参数可以是任意文本;D 它恰恰要放在管道中间接上游输出。

Q8. diff <(sort a.txt) <(sort b.txt) 这条命令实际做了什么?

单选题 · extreme

  • A.把两个命令的输出当作临时文件比较← 正确答案
  • B.把两个文件排序后覆盖写回原文件
  • C.把左边排序结果逐行追加到右边文件
  • D.用管道把两边输出合并成一个文件

答案:A。进程替换 <(cmd) 把命令输出包装成虚拟文件路径,diff 把两者当作参数读取比较。整个过程不产生真实临时文件。B 的覆盖写回不存在;C 是重定向语义的误读;D 管道合并输出与 diff 的比较语义不符。

Q9. 下列哪些命令组合可以统计 app.log 中 ERROR 出现的次数?

多选题 · mid

  • A.grep ERROR app.log | wc -l← 正确答案
  • B.awk '/ERROR/{n++} END{print n}' app.log← 正确答案
  • C.wc -c ERROR app.log
  • D.grep -c ERROR app.log← 正确答案

答案:ABD。A 用管道把 grep 筛选出的行数交给 wc -l 统计;B 用 awk 逐行匹配并计数;D 用 grep 自带 -c 参数直接输出计数;C 的 wc -c 统计的是字节数,且参数用法不匹配,无法统计行数。

Q10. 关于三大标准流与重定向,下列说法正确的有?

多选题 · hard

  • A.0 号标准输入默认来源是键盘← 正确答案
  • B.1 号标准输出默认显示到屏幕← 正确答案
  • C.2 号标准错误默认写入日志文件
  • D./dev/null 是无限写入的黑洞设备← 正确答案

答案:ABD。默认情况下标准输入来自键盘、标准输出显示到屏幕,A 和 B 正确。标准错误默认也流向屏幕而非日志文件,C 错误,它需要重定向才会进文件。/dev/null 是一个特殊的字符设备,写入的数据被系统直接丢弃,就像下水道,D 正确。