Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.5 重定向、管道与文本流

本章回答

>、>>、2>&1、|、tee、<() 的求值顺序是什么?为什么 2>&1 >file 和 >file 2>&1 结果不同?管道两端的退出码怎么取?为什么 set -o pipefail 重要?

读完你会

用 fd 表解释任意重定向组合;写出保留退出码的日志管道;避免 while read 的子 Shell 变量丢失;解释 SIGPIPE 与退出码 141;在管道里正确处理缓冲与颜色。

上一章讲「能不能打开文件」,这一章讲「打开之后接到哪里」。Shell 的重定向与管道不是语法糖,而是对进程文件描述符表的精确操作。求值顺序、fd 复制、子 Shell,三件事决定了你看到的输出到底流向哪里。

很多人写脚本时靠试错来排重定向:加一个 2>&1,不行再换个位置。这样能碰对,但遇到「日志里少了错误行」「退出码永远是 0」「变量在循环后丢了」时,就找不到原因。把 fd 表画出来,这些现象都能一步步推出来。

本章的结论以 bash 5.2(Ubuntu 24.04)为准。POSIX sh 的语法子集更小,&>、<<<、<() 都是 bash 扩展;写可移植脚本时要按 POSIX 写法。

0.5.1 三个 fd 与文件描述符表

每个进程启动时,父进程会交给它三个已打开的 fd:

fd名称默认指向C 常量
0标准输入终端或管道读端STDIN_FILENO
1标准输出终端或管道写端STDOUT_FILENO
2标准错误终端(通常与 1 同一个 tty)STDERR_FILENO

fd 是进程内文件描述符表的下标,表项指向内核的「打开文件描述」。重定向的本质是:让某个下标指向另一个打开文件。>file 就是「打开 file 得到新 fd,把它复制到 1,关闭旧 1」。

进程的 fd 表                         内核打开文件描述
┌─────┬──────────────┐
│  0  │ ─────────────┼──→ 终端 /dev/pts/0(读)
├─────┼──────────────┤
│  1  │ ─────────────┼──→ /tmp/out.txt(写,偏移 0)
├─────┼──────────────┤
│  2  │ ─────────────┼──→ 终端 /dev/pts/0(写)
├─────┼──────────────┤
│  3  │ ─────────────┼──→ /tmp/extra.log(写)  ← exec 3>file 的结果
└─────┴──────────────┘

这张图的读法是:fd 是下标,不是文件本身。两个 fd 可以指向同一个打开文件描述(共享偏移),也可以指向不同文件。2>&1 让 fd 2 指向 fd 1 当前指向的那个打开文件描述;它复制的是「指向关系」,不是「1 这个数字」。

用 exec 可以在当前 Shell 里长期打开一个 fd:

$ exec 3>/tmp/fd3.txt
$ echo hello >&3
$ exec 3>&-
$ cat /tmp/fd3.txt
hello
$ ls -l /proc/self/fd
total 0
lrwx------ 1 wang wang 64 Oct 17 11:20 0 -> /dev/pts/0
lrwx------ 1 wang wang 64 Oct 17 11:20 1 -> /dev/pts/0
lrwx------ 1 wang wang 64 Oct 17 11:20 2 -> /dev/pts/0
lr-x------ 1 wang wang 64 Oct 17 11:20 3 -> /proc/22531/fd
$ bash -c 'echo out; echo err >&2' > /tmp/out.txt 2>&1
$ cat /tmp/out.txt
out
err
$ bash -c 'echo out; echo err >&2' 2>&1 > /tmp/out.txt
err
$ cat /tmp/out.txt
out

最后两组命令的差别只在对调了两个重定向,结果却相反。下一节逐条拆。

0.5.2 重定向语法与求值顺序

常用语法:

写法作用备注
>file截断并写 stdout文件不存在则创建;noclobber 下拒绝覆盖
>>file追加 stdout原子性取决于单次 write 大小
2>file写 stderr只重定向 2
&>filestdout + stderr 一起写bash 扩展;POSIX 用 >file 2>&1
2>&1让 2 指向 1 当前的目标顺序敏感
1>&2让 1 指向 2 当前的目标常用于把普通输出变成错误
<file从文件读 stdin
<<EOFhere-doc见下一节
<<<"$s"here-stringbash 扩展,自动补换行
<>file读写打开少见
n>&-关闭 fd nexec 3>&-
`&``2>&1

求值顺序是从左到右。Shell 逐个执行重定向,每一步都基于当前 fd 表。用 fd 表快照解释两组命令:

# 写法 A:>file 2>&1
bash -c '...' > /tmp/out.txt 2>&1
# 1. 打开 /tmp/out.txt,复制到 fd 1
#    fd1 -> out.txt, fd2 -> tty
# 2. 2>&1:fd 2 复制 fd 1 的目标
#    fd1 -> out.txt, fd2 -> out.txt
# 结果:stdout 与 stderr 都进文件

# 写法 B:2>&1 >file
bash -c '...' 2>&1 > /tmp/out.txt
# 1. 2>&1:fd 2 复制 fd 1 当前目标(tty)
#    fd1 -> tty, fd2 -> tty
# 2. >file:fd 1 改为 out.txt
#    fd1 -> out.txt, fd2 -> tty
# 结果:stderr 仍在终端,stdout 进文件

这就是「重定向顺序敏感」的全部原因。记法:2>&1 必须放在 >file 之后,才能让 stderr 跟着 stdout 进文件。&>file 是 bash 提供的原子写法,等价于 >file 2>&1,不用操心顺序。

>file 默认会截断。想防止误覆盖,用 set -o noclobber,此时 >file 对已存在文件报错,>|file 强制覆盖。脚本里更稳的做法是「写临时文件 + mv 原子替换」,见 0.3。

exec 的重定向会改变当前 Shell 的 fd 表,且一直有效:

# 把整个脚本的 stdout/stderr 都写到日志
exec >>/var/log/myapp.log 2>&1
# 之后所有命令的输出都追加到日志;包括后续 echo

这种写法适合守护脚本,但会让交互式调试变难。用完要恢复时,先备份 fd:exec 3>&1 4>&2,结束时 exec 1>&3 2>&4。

0.5.3 here-doc、here-string 与进程替换

here-doc 把一段文本作为 stdin 交给命令。结尾的定界符必须单独一行,前面不能有空格(<<- 允许制表符缩进):

cat > /tmp/app.conf <<'EOF'
# 引号包围定界符:不做变量展开、不执行命令替换
listen = 127.0.0.1:8080
log_level = info
EOF

# 不包围定界符:$VAR 与 $(cmd) 会被展开
name=api
cat <<EOF
service=${name}
started=$(date -Is)
EOF

引号包围定界符是安全默认。写配置、生成脚本、传密码时,不加引号会让 $、反引号、\ 被 Shell 解释,轻则内容错,重则执行意外命令。

<<- 只去掉行首的 tab,不去空格。在缩进的脚本里写 here-doc,用 tab 缩进,或者把定界符顶格写。

here-string 是 <<<,把字符串加一个换行送到 stdin:

read -r first rest <<< "alice admin dev"
printf '%s\n' "$first"   # alice

它比 echo "$s" | cmd 少一个进程,也不会把 -n 当成 echo 的选项。注意它会自动补一个换行;如果字符串本身以换行结尾,会多一个空行。

进程替换(process substitution)把命令的输出变成一个可读的文件路径:

# 比较两个已排序文件的差异,不生成临时文件
diff <(sort a.txt) <(sort b.txt)

# 把命令输出当作输入文件
while read -r line; do
    printf 'line: %s\n' "$line"
done < <(grep -v '^#' /etc/app.conf)

# 把输出写到多个消费者
tee >(gzip > /tmp/app.log.gz) > /tmp/app.log < /var/log/app.log

<(...) 在 bash 里展开成 /dev/fd/63 之类的路径,命令在子 Shell 中异步执行。它依赖 /dev/fd(Linux 上由 procfs 提供),不是 POSIX 标准;dash、旧版 sh 不支持。脚本的 shebang 是 #!/bin/sh 时不要用。

进程替换的一个常见坑:退出码拿不到。diff <(cmd1) <(cmd2) 的退出码是 diff 的,cmd1/cmd2 的失败不会自动传播。需要检查时,让子命令把状态写到文件,或改用临时文件。

0.5.4 管道:连接 fd,不连接命令

a | b 做三件事:创建管道(内核缓冲区);a 的 stdout 接到写端,b 的 stdin 接到读端;两个进程并发执行。管道缓冲区在 Linux 上默认 64 KiB(fcntl(fd, F_GETPIPE_SZ) 可查),单次 write 不超过 PIPE_BUF(4096 字节)时是原子的。

管道的退出码默认是最后一个命令的:

false | true
echo $?          # 0:只看 true
set -o pipefail
false | true
echo $?          # 1:任一命令失败即失败

PIPESTATUS 数组保存整条管道每个命令的退出码:

false | true | false
echo "${PIPESTATUS[@]}"   # 1 0 1

注意 PIPESTATUS 在下一条命令后就被覆盖。要保留,先赋值:st=("${PIPESTATUS[@]}")。

SIGPIPE 是管道最容易被误解的部分。yes | head -n 1 里,head 读一行就退出,yes 继续写时内核向它发 SIGPIPE,默认终止,退出码 141(128+13)。这不是错误配置,而是管道正常的「下游不读了」信号。

$ bash -c 'yes | head -n 1'
y
$ bash -c 'yes | head -n 1; echo status=$?'
y
status=0
$ bash -c 'yes | head -n 1; echo pipestatus=${PIPESTATUS[*]}'
y
pipestatus=141 0
$ bash -c 'set -o pipefail; yes | head -n 1; echo status=$?'
y
status=141
$ bash -c 'false | true; echo status=$?'
status=0
$ bash -c 'set -o pipefail; false | true; echo status=$?'
status=1

未开 pipefail 时,管道退出码取最后一个命令 head 的状态 0;yes 的 141 只出现在 PIPESTATUS 里。开了 pipefail 后,yes 的 141 才成为整条管道的状态。脚本里如果 set -o pipefail,这种管道会被当成失败;而「下游提前退出」很多时候是预期的,需要显式处理:

# 允许下游提前退出:忽略 141
set +o pipefail
yes | head -n 1 >/dev/null
# 或者只关心某个命令的状态
producer | consumer
st=("${PIPESTATUS[@]}")
(( st[0] == 0 || st[0] == 141 )) || exit "${st[0]}"

pipefail 不是万能。它让「任一命令失败」变成失败,但无法告诉你哪一个失败;需要细节时看 PIPESTATUS。另外,pipefail 与 set -e 组合时,管道失败会让 Shell 直接退出,可能来不及清理;在清理逻辑里临时 set +e。

0.5.5 子 Shell 与变量丢失

管道的每一段都在子 Shell 中执行。子 Shell 是 fork 出来的副本,变量修改不会回传父 Shell:

n=0
printf 'a\nb\n' | while read -r line; do
    n=$((n + 1))
done
echo "$n"       # 0,不是 2

这不是 bash 的 bug,而是管道语义:while 在子 Shell 里跑,n 的修改只发生在副本里。三种修复:

# 1. 进程替换:while 在当前 Shell 执行
n=0
while read -r line; do
    n=$((n + 1))
done < <(printf 'a\nb\n')
echo "$n"       # 2

# 2. lastpipe:让管道最后一段在当前 Shell 执行(非交互、job control 关闭时有效)
shopt -s lastpipe
printf 'a\nb\n' | while read -r line; do n=$((n + 1)); done
echo "$n"       # 2

# 3. 让子 Shell 输出结果,父 Shell 用命令替换接收
n=$(printf 'a\nb\n' | awk 'END {print NR}')
echo "$n"       # 2

lastpipe 只在非交互 Shell 且 set +m(job control 关闭)时生效;交互式 Shell 默认 set -m,开了也不生效。这就是为什么网上的「一行修复」在你机器上时灵时不灵。

分组也会创建子 Shell:

# ( ) 子 Shell:cd 不影响父 Shell
( cd /tmp && ls >/dev/null )
pwd                      # 原目录

# { } 当前 Shell:cd 会影响父 Shell,注意分号与空格
{ cd /tmp && ls >/dev/null; }
pwd                      # /tmp

{ ...; } 要求 { 后有空格、} 前有分号,否则是语法错误。它不 fork,适合「临时改 IFS 或 cwd,之后恢复」的场景。

0.5.6 tee 与日志分流

tee 把 stdin 同时写到文件和 stdout。它解决「既要看输出,又要留日志」:

# 覆盖写
make 2>&1 | tee build.log
# 追加写
./deploy.sh 2>&1 | tee -a deploy.log
# 同时写两个文件
cmd | tee out1.log > out2.log

tee 的退出码是它自己的,不是上游命令的。这是「脚本明明失败了,日志里却显示 0」的常见原因。正确写法:

#!/usr/bin/env bash
set -uo pipefail

log=/var/log/deploy.log
{
    printf '== %s start ==\n' "$(date -Is)"
    ./deploy.sh "$@"
} 2>&1 | tee -a "$log"
st=("${PIPESTATUS[@]}")
printf '== %s exit=%d ==\n' "$(date -Is)" "${st[0]}" | tee -a "$log"
exit "${st[0]}"

这里故意没有 set -e:如果开了 set -e 与 pipefail,管道失败会在 st=... 之前退出,日志里就少了结束行。需要严格失败时,用 if ! ...; then 包住,或在管道后立即 set +e。

tee 还能把日志分给多个消费者:

# 一份进文件,一份交给 logger 进 syslog/journald
./app 2>&1 | tee -a /var/log/app.log >(logger -t app)

这行里 >(logger -t app) 是进程替换,tee 把它当成第二个文件写。顺序与缓冲可能导致 journald 与文件的时序略有差异,排查时序问题时不要依赖它。

0.5.7 缓冲、颜色与 TTY 检测

C 标准库的 stdio 有三种缓冲模式:

模式何时使用行为
全缓冲stdout 不是 tty(管道、文件)攒满缓冲区(通常 4–8 KiB)才 write
行缓冲stdout 是 tty遇到换行就 write
无缓冲stderr每次写入都 write

这解释了两个现象:

  1. 程序输出到管道后「卡住」。日志在 stdio 缓冲区里,没攒满就不写;下游 grep 一直等不到数据。用 stdbuf -oL 强制行缓冲,或让程序自己 fflush。
  2. tail -f 看不到最新日志。同上;另外 Python 需要 -u 或 PYTHONUNBUFFERED=1。
# 强制行缓冲;只对动态链接、使用 stdio 的程序有效
stdbuf -oL -eL ./app 2>&1 | grep ERROR

# script 分配一个 pty,让程序以为自己在终端里,从而启用行缓冲与颜色
script -q -c './app' /tmp/typescript

# expect 的 unbuffer 做同样的事
unbuffer ./app | grep ERROR

颜色是另一层。ls --color=auto、grep --color=auto、git 都会检测 isatty(1):输出到终端才上色,输出到管道就关掉。这是好设计,但把带颜色的输出存文件再 less 时会丢色。要保留:

# --color=always 强制颜色;less -R 解释 ANSI 转义
grep --color=always ERROR app.log | less -R

# 尊重 NO_COLOR 约定:程序看到非空 NO_COLOR 应关闭颜色
NO_COLOR=1 ./app

TERM=dumb 也能让部分程序关闭颜色与光标控制。写脚本时不要假设输出一定是 tty;日志、CI、cron 里都没有 tty。

关于重定向与管道的五个误解
  1. 「2>&1 >file 和 >file 2>&1 一样」——顺序不同,fd 复制目标不同。前者 stderr 留在终端,后者进文件。
  2. 「tee 会保留上游退出码」——tee 返回自己的状态;要用 PIPESTATUS[0] 或 pipefail。
  3. 「管道里的 while read 能改外面的变量」——管道每段在子 Shell,变量修改不回传;用进程替换或 lastpipe。
  4. 「SIGPIPE 是错误,应该修」——下游正常关闭时的 SIGPIPE 是预期行为,退出码 141;不要把它当成程序 bug 一律重试。
  5. 「输出到文件后颜色丢了是程序坏了」——程序按 isatty 关闭颜色;需要时用 --color=always 加 less -R,或让程序支持 FORCE_COLOR。

0.5.8 fd 表的内核结构:从 dup2 到 O_CLOEXEC

Shell 的 >、2>&1、exec 3< 最终都落到三个系统调用:open/openat、dup2/dup3、close。理解 struct files_struct 与 struct file 的区别,才能解释「两个 fd 共享偏移」「关闭一个不影响另一个」「O_CLOEXEC 为什么重要」。

每个进程有一张文件描述符表(files_struct),表项是指向 struct file 的指针。struct file 代表「一次打开」,包含:

字段含义谁共享
f_pathvfsmount + dentry所有 dup 出来的 fd
f_pos当前偏移dup/fork 共享;独立 open 不共享
f_flagsO_APPEND、O_NONBLOCK 等共享
f_mode读/写模式共享
f_op文件操作函数表共享
f_count引用计数每次 dup/fork 加一

dup2(oldfd, newfd) 做三件事:关闭 newfd(如果已打开)、让 newfd 指向 oldfd 的 struct file、f_count 加一。它不复制文件内容,也不重置偏移。fork 后父子进程的 fd 指向同一批 struct file,所以父子共享偏移;这也是「父进程读了一半,子进程接着读」的原因。

int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) { perror("open"); return 1; }
/* 让 fd 变成 3,且 3 不带 CLOEXEC(除非用 dup3 + O_CLOEXEC) */
if (dup2(fd, 3) < 0) { perror("dup2"); close(fd); return 1; }
close(fd);

dup2 的原子性只覆盖「关旧 + 复制」这一步;多线程程序里,如果另一个线程正好在 dup2 期间使用 newfd,行为仍然危险。dup3 多了 O_CLOEXEC 参数,可以原子地设置 close-on-exec;fcntl(F_DUPFD_CLOEXEC) 分配一个不小于指定值的新 fd 并带 CLOEXEC。

O_CLOEXEC 解决的是「fork + exec 之间的 fd 泄漏」:

  • fork 后子进程继承所有 fd;
  • execve 会关闭带 FD_CLOEXEC 的 fd,其余保留;
  • 如果父进程在多线程环境里 open 后 fork,另一个线程可能在这期间 exec,把不该传的 fd 带进新程序;
  • O_CLOEXEC 让「打开」与「设置 close-on-exec」成为一个原子操作。

Shell 的重定向默认不带 CLOEXEC(因为要让子命令继承),但它打开的 fd 3 以上通常在执行外部命令时保留。脚本里用 exec {fd}>file 打开的 fd,如果不想传给子进程,要 fcntl 设置,Shell 没有直接开关;可以用 {fd}>&- 在执行前关闭。

fd 的上限由 RLIMIT_NOFILE 控制:

ulimit -n              # 软限制,常见 1024 或 1048576
ulimit -Hn             # 硬限制
cat /proc/self/limits | grep 'open files'

fd 耗尽时,open/pipe/socket 返回 EMFILE(进程上限)或 ENFILE(系统上限)。Shell 的 exec 3>file 失败会返回非零,但不会自动退出;脚本要检查。/proc/PID/fd 是排查 fd 泄漏的第一现场:

ls -l /proc/$$/fd
# lrwx------ 1 wang wang 64 ... 0 -> /dev/pts/0
# lrwx------ 1 wang wang 64 ... 1 -> /dev/pts/0
# lrwx------ 1 wang wang 64 ... 2 -> /dev/pts/0
# lr-x------ 1 wang wang 64 ... 3 -> /tmp/extra.log

ls -l /proc/PID/fd 里的 -> 目标是内核 d_path() 生成的;已删除文件显示 ... (deleted)。/proc/PID/fdinfo/N 给出 pos、flags、mnt_id,可以验证两个 fd 是否共享偏移:对同一文件 dup 出来的两个 fd,读一个另一个的 pos 会一起前进。

0.5.9 重定向语法全表与求值顺序

Bash 的重定向语法比入门表多得多。下表按「打开方式」分组,n 是 fd 编号,省略时默认 0(输入)或 1(输出)。

语法打开方式说明
>fileO_WRONLY|O_CREAT|O_TRUNC截断;noclobber 下对已存在普通文件失败
`>file`同上,忽略 noclobber
>>fileO_WRONLY|O_CREAT|O_APPEND追加;单次 write 的原子性有限
<fileO_RDONLY输入
<>fileO_RDWR|O_CREAT读写,不截断
n>&mdup2(m, n)让 n 指向 m 当前目标
n<&mdup2(m, n)同上,输入方向
n>&- / n<&-close(n)关闭
&>file>file 2>&1bash 扩展;POSIX 不认
&>>file>>file 2>&1bash 扩展
`&``2>&1
<<wordhere-doc见下一节
<<-wordhere-doc,去掉行首 tab不是空格
<<<wordhere-stringbash 扩展,自动补换行
>(list)进程替换,写端展开成 /dev/fd/N
<(list)进程替换,读端同上
{var}>file分配 fd 到变量bash 4.1+,fd ≥ 10
{var}>&-关闭变量里的 fd同上

>file 的 mode 是 0666 & ~umask;目录不可写、磁盘满、inode 耗尽分别返回 EACCES、ENOSPC、ENOSPC。>>file 用 O_APPEND,每次 write 前内核把偏移移到末尾;多个进程同时追加时,单次 write 小于 PIPE_BUF 的原子性只对管道成立,普通文件的 O_APPEND 在本地文件系统上通常原子,但 NFS 不保证。

重定向在命令执行前完成,所以命令失败也会留下副作用。ls /nonexistent > out.txt 会先创建/截断 out.txt,再执行 ls,最后 out.txt 是空的;脚本不能靠「命令失败所以文件没动」做假设。Shotts 用一个真实事故说明另一面:ls > less 会把当前目录的列表写进名为 less 的可执行文件,管理员从此失去 less。重定向目标如果是已存在的命令、配置或设备节点,> 会静默覆盖,执行前先 ls -l 看一眼目标。

求值顺序是从左到右,且重定向在命令执行前完成。用 fd 表快照解释三组常见写法:

# A: >out 2>&1 —— stderr 跟 stdout 进文件
cmd >out 2>&1
# 1) open(out) -> fd 3; dup2(3,1); close(3)   fd1=out, fd2=tty
# 2) dup2(1,2)                                fd1=out, fd2=out

# B: 2>&1 >out —— stderr 留在终端
cmd 2>&1 >out
# 1) dup2(1,2)                                fd1=tty, fd2=tty
# 2) open(out) -> fd 3; dup2(3,1); close(3)   fd1=out, fd2=tty

# C: &>out —— bash 的原子写法
cmd &>out
# 等价于 A,但不需要记顺序

&& 与 || 也按退出码短路,但它们的优先级相同、从左到右结合,混用时必须加括号或改写:

cmd1 && cmd2 || cmd3      # cmd1 成功跑 cmd2;cmd2 失败会继续跑 cmd3
cmd1 || cmd2 && cmd3      # cmd1 失败时,cmd2 成功后还会跑 cmd3

第二种写法在 cmd1 失败时可能同时执行 cmd2 与 cmd3,因为 || 只绑定相邻的两条命令。需要「二选一」时写 if cmd1; then cmd2; else cmd3; fi,不要依赖 &&/|| 的直觉。

exec 形式的重定向会改变当前 Shell 的 fd 表,并在后续命令中持续有效:

exec 3>&1 4>&2          # 备份 stdout/stderr
exec >>"$log" 2>&1      # 之后全部追加到日志
printf 'hello\n'        # 进日志
exec 1>&3 2>&4          # 恢复
exec 3>&- 4>&-          # 关闭备份

顺序不能反:如果先 exec 1>&3 再 exec 3>&-,恢复成功;如果先关 3,1>&3 会报 Bad file descriptor。恢复段本身也可能失败,脚本里要检查,或把日志重定向放在子 Shell ( ... ) 里,避免污染父 Shell。

noclobber 的边界:

set -C
> existing.txt          # 失败:cannot overwrite existing file
>| existing.txt         # 强制覆盖
>> existing.txt         # 追加,允许
<> existing.txt         # 读写,允许

set -C 只防「> 截断已存在的普通文件」,不防 >>、<>、tee、dd。脚本里更稳的写法是「临时文件 + mv 原子替换」,而不是依赖 noclobber。

简单命令的赋值与重定向顺序也值得注意:

FOO=bar > /tmp/x        # 创建 /tmp/x(空),FOO 在当前 Shell 生效
FOO=bar cmd > /tmp/x    # FOO 只对 cmd 生效

只有赋值和重定向、没有命令时,重定向在当前 Shell 执行,赋值也留在当前 Shell;有命令时,赋值进入命令的环境,重定向只影响该命令。这个区别在「用重定向创建文件」时很有用,也容易误以为 FOO=bar >file 会把 FOO 导出。

复合命令也能加重定向:

while IFS= read -r line; do printf '%s\n' "$line"; done < input.txt > output.txt
{ printf 'a\n'; printf 'b\n'; } > out.txt
if true; then printf 'yes\n'; fi > out.txt
f() { printf 'body\n'; } > /tmp/f.out   # 重定向在函数执行时生效

{ ...; } 与 ( ... ) 的重定向作用域不同:前者在当前 Shell,后者在子 Shell。函数定义上的重定向在调用时生效,不是定义时。

0.5.10 here-doc 的引号、临时文件与 /dev/fd 细节

here-doc 的完整规则:

  1. <<word 把后续行作为 stdin,直到一行只包含 word(前面不能有空格;<<- 允许 tab)。
  2. 定界符加引号(<<'EOF'、<<"EOF"、<<\EOF)时,正文不做参数展开、命令替换、算术展开;不加引号时三者都做。
  3. 正文里的 \ 在未加引号时用于转义 $、`、\、换行;加引号时原样保留。
  4. 多个 here-doc 按出现顺序读取;<<A <<B 的正文先给 A,再给 B。
  5. here-doc 在命令执行前被读入;它不占用命令行参数,也不受 set -u 影响。
# 安全:配置原样写入
cat > /etc/myapp.conf <<'EOF'
listen = 127.0.0.1:8080
password = $SECRET          # 不会被展开
EOF

# 需要展开:变量与命令替换都会执行
name=api
cat <<EOF
service=${name}
started=$(date -Is)
EOF

写配置、生成脚本、传密码时,默认加引号。不加引号时,正文里的 $() 会执行任意命令;如果正文来自用户输入,就是命令注入。

<<- 只去掉行首的 tab,不去空格。缩进的脚本里用 tab 缩进 here-doc,或把定界符顶格:

if true; then
	cat <<-'EOF'
		indented text
	EOF
fi

<<< 是 here-string:把字符串加一个换行送到 stdin,不经过分词与通配符展开(但参数展开仍发生)。

read -r first rest <<< "alice admin dev"
printf '%s|%s\n' "$first" "$rest"     # alice|admin dev

它比 echo "$s" | cmd 少一个进程,也不会把 -n 当成 echo 的选项。边界:字符串本身以换行结尾时会多一个空行;<<< 是 bash 扩展,dash 不支持。

进程替换把命令的输出/输入变成一个文件路径:

diff <(sort a.txt) <(sort b.txt)
while IFS= read -r line; do printf '<%s>\n' "$line"; done < <(grep -v '^#' conf)
tee >(gzip > /tmp/app.log.gz) > /tmp/app.log < /var/log/app.log

<(cmd) 在 bash 里展开成 /dev/fd/63,命令在子 Shell 中异步执行;>(cmd) 同理,写端交给 cmd。它依赖 /dev/fd(Linux 由 procfs 提供),不是 POSIX;dash、旧 sh 不支持。容器里如果没挂 /proc,进程替换会失败。

进程替换的退出码拿不到:diff <(cmd1) <(cmd2) 的 $? 是 diff 的,cmd1/cmd2 的失败不会传播;$! 也不是它们的 PID。需要检查时,让子命令把状态写到文件,或改用临时文件/命名管道。

here-doc 与进程替换的实现细节:bash 对 here-doc 可能使用临时文件或管道,具体取决于版本与大小;不要在 here-doc 里假设 stdin 可 seek。进程替换的 /dev/fd/N 是「重新打开」而不是「复制 fd」,所以它不共享原命令的偏移;对管道而言偏移无意义,对普通文件则可能有独立偏移。

coproc 是 bash 的另一种双向管道:

coproc WORKER { while IFS= read -r line; do printf 'got:%s\n' "$line"; done; }
printf 'a\nb\n' >&"${WORKER[1]}"
exec {WORKER[1]}>&-
while IFS= read -r out; do printf '%s\n' "$out"; done <&"${WORKER[0]}"
wait "$WORKER_PID"

coproc 的 fd 在数组里,读写要手动关闭写端,否则读循环永远等不到 EOF。它适合「父 Shell 与一个长期子进程对话」,不适合高并发;并发用后台任务 + wait -n(见 0.8)。

0.5.11 管道的内核语义:缓冲区、原子性与 SIGPIPE

a | b 在 Shell 里是:创建管道、fork 两个子进程、分别 dup2 到 stdin/stdout、exec。内核里管道是 struct pipe_inode_info 加一组 pipe_buffer,每个 buffer 指向一个页。默认容量 16 页(64 KiB),可通过 fcntl(F_SETPIPE_SZ) 调整,上限是 /proc/sys/fs/pipe-max-size(默认 1 MiB,root 可调)。

管道只连接 stdout。a 写到 stderr 的错误消息不会进入 b,而是留在终端;要让 stderr 也进管道,必须在 a 这一侧写 2>&1(或 |&)。find / -name x | grep foo 里 find 的 Permission denied 不会出现在 grep 的输入中,这就是「管道里看不到错误」的原因。

# 查看/调整管道容量(C 或 Python 都可以;Shell 没有直接命令)
cat /proc/sys/fs/pipe-max-size
python3 - <<'PY'
import fcntl, os
r, w = os.pipe()
print('default', fcntl.fcntl(w, fcntl.F_GETPIPE_SZ))
fcntl.fcntl(w, fcntl.F_SETPIPE_SZ, 1 << 20)
print('after', fcntl.fcntl(w, fcntl.F_GETPIPE_SZ))
PY

原子性由 PIPE_BUF(Linux 4096)决定:

  • 单次 write ≤ PIPE_BUF:不会与其他写者交错,要么全写,要么阻塞;
  • 单次 write > PIPE_BUF:可能被拆分,多个写者的数据会交错;
  • 管道满时,写者阻塞(除非 O_NONBLOCK,此时返回 EAGAIN);
  • 读端关闭后,写者收到 SIGPIPE;若忽略/阻塞,write 返回 EPIPE。

SIGPIPE 的默认动作是终止进程,Shell 报退出码 141(128+13)。yes | head -n 1 里 head 读一行就退出,yes 继续写,内核向它发 SIGPIPE;这是正常的下游关闭,不是错误。

bash -c 'yes | head -n 1; echo status=$?'
# y
# status=141

要区分「下游正常关闭」与「上游真失败」,看 PIPESTATUS:

set -o pipefail
yes | head -n 1 >/dev/null
st=("${PIPESTATUS[@]}")
printf 'yes=%s head=%s\n' "${st[0]}" "${st[1]}"
# yes=141 head=0

pipefail 让整条管道返回最右侧非零状态;它不能告诉你是哪一段,PIPESTATUS 才能。PIPESTATUS 在下一条命令后就被覆盖,必须立刻赋值。set -e + pipefail 下,yes | head 会让脚本在管道处退出;如果这是预期,显式处理 141:

if ! yes | head -n 1 >/dev/null; then
    st=("${PIPESTATUS[@]}")
    (( st[0] == 141 )) || exit "${st[0]}"
fi

注意 ! 会反转整个管道的状态,PIPESTATUS 仍是各段原始状态;不要在 ! 之后再依赖 $? 判断具体段。

管道还有两个容易忽略的点:

  • 管道不是文件。ls -l /proc/$$/fd/1 在管道里显示 pipe:[12345],不能 seek,fstat 的 st_size 是 0。
  • 管道缓冲区不是无限。生产者比消费者快时,写满 64 KiB 就阻塞;这提供了天然的背压,但也可能让「先读全部再处理」的程序死锁(双方都在等对方)。

tee 系统调用(tee(2))与 tee(1) 命令不是一回事:前者在两个管道之间复制数据,不经过用户态;splice(2) 把管道数据零拷贝地接到文件/另一个管道。Shell 的 tee 命令是用户态程序,读写 + 写两份,性能和原子性都不同。

0.5.12 子 Shell、lastpipe 与变量作用域

管道的每一段默认在子 Shell 执行,fork 出来的副本有独立的变量表、cwd、umask、trap。父 Shell 看不到子 Shell 的修改:

n=0
printf 'a\nb\n' | while read -r line; do n=$((n + 1)); done
printf 'n=%d\n' "$n"      # n=0

三种修复的边界:

方法写法生效条件代价
进程替换while ...; done < <(cmd)bash/ksh,依赖 /dev/fd仍是子进程产生数据,但循环在当前 Shell
lastpipeshopt -s lastpipe非交互 + job control 关闭(set +m)只让最后一段在当前 Shell
命令替换n=$(cmd | awk ...)POSIX结果走 stdout,末尾换行被剥
临时文件cmd > tmp; while ...; done < tmp可移植磁盘 I/O,需清理

lastpipe 的生效条件经常被忽略:交互式 Shell 默认 set -m(job control 开),lastpipe 不生效;脚本默认非交互且 job control 关,才生效。shopt -s lastpipe 后可以用 shopt -q lastpipe 检查,但无法直接检查 job control 对它的影响,用 set +m 显式关闭。

# 非交互脚本里
set +m
shopt -s lastpipe
printf 'a\nb\n' | while read -r line; do n=$((n + 1)); done
printf 'n=%d\n' "${n:-0}"

子 Shell 还会继承并隔离其他状态:

( cd /tmp; umask 077; trap 'echo child' EXIT; exit 0 )
pwd; umask          # 父 Shell 不受影响

$BASHPID 是当前 Shell/子 Shell 的 PID,$$ 是父 Shell 的 PID(在子 Shell 里不变)。判断是否在子 Shell:

printf '$$=%s BASHPID=%s\n' "$$" "$BASHPID"
( printf '$$=%s BASHPID=%s\n' "$$" "$BASHPID" )

{ ...; } 不 fork,( ... ) fork;{ cd /tmp; } 会改变当前 Shell 的 cwd,( cd /tmp ) 不会。{ 后必须有空格,} 前必须有分号或换行,否则是语法错误。

判断「当前在第几层子 Shell」用 BASH_SUBSHELL:

printf 'top=%s
' "$BASH_SUBSHELL"        # 0
( printf 'sub=%s
' "$BASH_SUBSHELL" )    # 1
( ( printf 'sub2=%s
' "$BASH_SUBSHELL" ) )  # 2
printf 'pids: $$=%s BASHPID=%s
' "$$" "$BASHPID"

ps --forest 能直观看到父子链,ps -o pid,ppid,cmd 看 PPID。bash -c '...' 启动一个非交互子 Shell;bash -l 启动登录 Shell;bash -i 强制交互。这些选项会影响读哪些启动文件,进而影响 PATH、别名与 umask(见 0.7)。

0.5.13 tee 与日志分流

tee 把 stdin 复制到每个文件参数和 stdout。它的选项不多,但默认行为有坑:

选项作用
-a / --append追加而不是截断
-i / --ignore-interrupts忽略 SIGINT
-p对非管道输出诊断错误(GNU)
--output-error[=MODE]warn、warn-nopipe、exit、exit-nopipe
--help/--version标准

tee 的退出码是它自己的,不是上游命令的。./deploy.sh | tee deploy.log 的 $? 是 tee 的 0,即使 deploy.sh 失败。正确写法是先取 PIPESTATUS[0]:

#!/usr/bin/env bash
set -uo pipefail
log=${LOG_FILE:-/var/log/deploy.log}
{
    printf '== %s start ==\n' "$(date -Is)"
    ./deploy.sh "$@"
} 2>&1 | tee -a "$log"
st=("${PIPESTATUS[@]}")
printf '== %s exit=%d ==\n' "$(date -Is)" "${st[0]}" | tee -a "$log"
exit "${st[0]}"

这里故意不 set -e:开了 set -e + pipefail,管道失败会在 st=... 之前退出,日志缺少结束行。需要严格失败时,用 if ! ...; then 包住并显式记录,或在管道后立即 set +e。

tee 还能分流给多个消费者:

# 一份进文件,一份交给 logger 进 syslog/journald
./app 2>&1 | tee -a /var/log/app.log >(logger -t app)

# 一份原文,一份压缩
./app 2>&1 | tee >(gzip > /var/log/app.log.gz) > /var/log/app.log

>(logger) 是进程替换,tee 把它当成第二个文件写。进程替换的退出码拿不到,logger 失败不会让 tee 失败;需要保证日志送达时,用 logger 的同步模式或先写文件再异步投递。两条输出的时序也可能不同,排查顺序问题时不要依赖。

tee 写普通文件失败(磁盘满、权限)默认会报错并继续/退出,具体取决于 --output-error;写管道失败(下游关闭)默认可能忽略。需要精确控制时显式指定 --output-error=exit,并检查 tee 的退出码。

0.5.14 /dev/null、/dev/stdin 与特殊 fd 路径

/dev/null、/dev/zero、/dev/full 是字符设备,/dev/stdin、/dev/stdout、/dev/stderr 在 Linux 上是指向 /proc/self/fd/N 的符号链接。它们的语义与普通文件不同,重定向到它们时要理解「打开」发生了什么。

路径读写典型用途
/dev/null立即 EOF丢弃,成功> /dev/null 2>&1
/dev/zero无限零字节丢弃dd if=/dev/zero
/dev/full无限零字节ENOSPC测试写失败
/dev/stdin当前 fd 0取决于 fd 0cmd < /dev/stdin
/dev/stdout取决于 fd 1当前 fd 1cmd > /dev/stdout
/dev/stderr取决于 fd 2当前 fd 2cmd 2> /dev/stderr
/dev/fd/N复制 fd N复制 fd N进程替换、exec
/proc/self/fd/N同上同上Linux 特有

2>/dev/null 与 2>&- 不同:前者打开 /dev/null 并让 fd 2 指向它,后续写成功但被丢弃;后者关闭 fd 2,后续写返回 EBADF,程序可能报错或崩溃。需要「静音」时用 /dev/null,需要「让程序知道没有 stderr」时才关闭。

2>/dev/stdout 与 2>&1 的差别:

  • 2>&1 复制 fd 1 的 struct file,共享偏移、flags,且不依赖 /proc;
  • 2>/dev/stdout 重新打开 /proc/self/fd/1 指向的目标,得到新的 struct file(偏移可能独立),如果 fd 1 已关闭或目标不可打开(例如已删除的临时文件),会失败。

在容器、chroot、/proc 未挂载的环境里,/dev/stdout 可能不存在或指向错误;脚本里优先 2>&1。

/dev/stdin 也有类似问题:cmd < /dev/stdin 重新打开当前 stdin 的目标,而不是复制 fd 0。如果 stdin 是一个已经读到一半的文件,重新打开会从偏移 0 开始(新 struct file),而不是接着读。需要「接着读」时用 <&0 或 exec 3<&0。

exec 3<file 在当前 Shell 打开一个只读 fd,之后可以用 read -u 3 或 cat <&3:

exec 3< /etc/hosts
while IFS= read -r line <&3; do printf '%s\n' "$line"; done
exec 3<&-

read -u 3 与 <&3 都会从 fd 3 读,且共享偏移;cat <&3 会读走剩余全部内容。exec {fd}<file 自动分配 ≥10 的 fd,避免和 0/1/2/3 冲突:

exec {in}< /etc/hosts
printf 'fd=%d\n' "$in"
cat <&"$in"
exec {in}<&-

exec 3<>file 以读写方式打开,读和写共享同一个文件偏移。读一行后写回,会从「读到的位置」开始覆盖,而不是追加;shell-book 的例子把 This is a test line 写进第二行中间,留下 ine. 这样的残片。需要「读后追加」时先 lseek 到末尾(Shell 做不到,改用 C/Python),或干脆用 >> 另开一个 fd。

关闭 fd 后重新打开同一路径,> 会重新截断:

exec 3>f; printf 'one
' >&3; exec 3>&-
exec 3>f; printf 'two
' >&3; exec 3>&-
cat f          # 只有 two

lsof -a -p $$ -d0,1,2 能列出当前 Shell 的标准 fd;加上 3,6,7 能看到脚本自定义的 fd。-a 是「与」运算,少了它会把 -p 与 -d 当成「或」,输出全系统的 fd。

/dev/fd/N 在 Linux 上通常等价于 /proc/self/fd/N,但打开它会复制 fd 的 flags(除 O_CLOEXEC),并可能改变 FD_CLOEXEC。不要把 /dev/fd/3 当成「fd 3 的别名」做权限判断,它是一次新的 open。

0.5.15 缓冲、TTY 与 stdbuf

C 标准库的 stdio 有三种缓冲:

模式何时默认行为
全缓冲 _IOFBFstdout 不是 tty(管道、文件)攒满缓冲区(glibc 通常 4096/8192)才 write
行缓冲 _IOLBFstdout 是 tty遇到换行就 write
无缓冲 _IONBFstderr每次写入都 write

这解释了两个现象:

  1. 程序输出到管道后「卡住」:日志在 stdio 缓冲区里,没攒满就不写,下游 grep 一直等。
  2. tail -f 看不到最新日志:同上;Python 需要 -u 或 PYTHONUNBUFFERED=1。
# 强制行缓冲;只对动态链接、使用 stdio 的程序有效
stdbuf -oL -eL ./app 2>&1 | grep ERROR

# 强制无缓冲
stdbuf -o0 -e0 ./app

# 用 pty 让程序以为自己在终端里
script -q -c './app' /tmp/typescript
unbuffer ./app | grep ERROR       # expect 包提供

stdbuf 的原理是 LD_PRELOAD=libstdbuf.so,在程序启动时替换 stdio 的缓冲设置。它对静态链接、setuid、直接使用 write(2) 的程序无效;LD_PRELOAD 在安全执行模式下被忽略。程序自己调用 setvbuf 时,stdbuf 也可能被覆盖。

在程序内部设置缓冲:

setvbuf(stdout, NULL, _IOLBF, 0);   /* 行缓冲 */
setvbuf(stdout, NULL, _IONBF, 0);   /* 无缓冲 */
fflush(stdout);                     /* 立即写出 */

grep 有 --line-buffered,awk 可以 fflush(),Python 用 -u,perl 用 $|=1。这些是「让程序自己配合」,比 stdbuf 可靠。

TTY 检测影响颜色与交互:

test -t 1 && echo "stdout is a tty"
test -t 2 && echo "stderr is a tty"

ls --color=auto、grep --color=auto、git 都用 isatty(1) 决定是否上色;输出到管道/文件时自动关闭。要在保存的日志里保留颜色:

grep --color=always ERROR app.log | less -R

NO_COLOR 约定:非空时程序应关闭颜色(见 no-color.org);CLICOLOR=0、TERM=dumb 也有类似效果。写脚本时不要假设有 tty:cron、CI、systemd 都没有,交互式提示会挂起或读到 EOF。

缓冲与 TTY 的五个误解
  1. 「stdbuf 对所有程序有效」——只对动态链接、使用 stdio 的程序;静态/setuid/直接 write 的无效。
  2. 「stderr 总是无缓冲」——C stdio 的 stderr 默认无缓冲,但程序可以 setvbuf 改;Shell 的 echo >&2 走的是 write。
  3. 「输出到文件后颜色丢了是程序坏了」——程序按 isatty 关闭颜色;用 --color=always 或 FORCE_COLOR。
  4. 「tail -f 能立刻看到所有日志」——上游可能全缓冲;用 stdbuf -oL 或程序自己的 flush。
  5. 「script 只是录屏」——它分配 pty,改变程序的缓冲与颜色行为;录出来的文件还带控制字符。

0.5.16 实验:dup2、管道与 SIGPIPE 的证据

实验一:看重定向如何改写 fd 表

strace -f -e trace=openat,dup,dup2,dup3,close,pipe,pipe2 \
    -o /tmp/redir.trace \
    bash -c 'echo out; echo err >&2' > /tmp/out.txt 2>&1
grep -E 'out\.txt|dup|close\(3\)' /tmp/redir.trace | tail -20
cat /tmp/out.txt

预期能看到类似(fd 号与是否用 dup3 因 bash 版本而异):

openat(AT_FDCWD, "/tmp/out.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 3
dup2(3, 1)                              = 1
close(3)                                = 0
dup2(1, 2)                              = 2

读法:openat 返回 3,dup2(3,1) 让 fd 1 指向文件,close(3) 释放临时 fd,dup2(1,2) 让 stderr 复制 stdout 的目标。把顺序换成 2>&1 > /tmp/out.txt,会看到 dup2(1,2) 在前、openat 在后,stderr 仍指向终端。-f 跟随子进程,-yy 能把 fd 显示成路径。

实验二:观察 SIGPIPE

strace -f -e trace=write,close,pipe,pipe2 -o /tmp/pipe.trace \
    bash -c 'yes | head -n 1'
grep -E 'SIGPIPE|write\(1' /tmp/pipe.trace | tail -10

预期:

write(1, "y\n", 2)                      = 2
--- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, si_pid=..., si_uid=...} ---
+++ killed by SIGPIPE +++

head 读一行后关闭读端,yes 下一次 write 触发 SIGPIPE。如果给 yes 加 trap '' PIPE,信号被忽略,write 返回 EPIPE:

bash -c 'trap "" PIPE; yes | head -n 1; echo rc=$?'

实验三:/proc 里的 fd 与偏移

exec 3< /etc/hosts
ls -l /proc/$$/fd/3
cat /proc/$$/fdinfo/3
lsof -a -p $$ -d0,1,2,3 2>/dev/null || true
exec 3<&-

fdinfo 里的 pos 是当前偏移,flags 是八进制 O_* 标志。用 exec 3< 打开后读几行,再看 pos 会前进;exec 3<&- 关闭后,/proc/$$/fd/3 消失。lsof -a 的 -a 是 AND,少了它会把 -p 与 -d 当成 OR。

0.5.17 三个可运行脚本

脚本一:logwrap.sh——保留上游退出码的日志包装

#!/usr/bin/env bash
# logwrap.sh — 把命令的 stdout/stderr 同时写到终端与日志,并保留退出码
# 用法: logwrap.sh COMMAND [ARG...]
set -uo pipefail

if (( $# == 0 )); then
    printf 'usage: %s COMMAND [ARG...]\n' "${0##*/}" >&2
    exit 2
fi
log=${LOG_FILE:-./run.log}

{
    printf '== %s start cmd=%q ==\n' "$(date -Is)" "$*"
    "$@"
} 2>&1 | tee -a -- "$log"

st=("${PIPESTATUS[@]}")
printf '== %s end rc=%d ==\n' "$(date -Is)" "${st[0]}" | tee -a -- "$log"
exit "${st[0]}"

关键点:set -uo pipefail 故意不加 -e,否则管道失败会在记录结束行之前退出;PIPESTATUS[0] 是花括号组的退出状态,也就是 "$@" 的状态;tee -a 写日志失败不会改变上游状态,需要单独检查时可以看 st[1]。"$*" 只用于日志展示,真正执行用 "$@",参数里的空格不会被拆开。

脚本二:fd-guard.sh——备份/恢复 fd 与信号清理

#!/usr/bin/env bash
# fd-guard.sh — 把命令输出重定向到日志,退出/中断时恢复原 stdout/stderr
# 用法: fd-guard.sh LOG COMMAND [ARG...]
set -euo pipefail

log=${1:?usage: fd-guard.sh LOG COMMAND [ARG...]}
shift
(( $# > 0 )) || { printf 'missing command\n' >&2; exit 2; }

exec 3>&1 4>&2
cleanup() {
    local rc=$?
    trap - EXIT INT TERM
    exec 1>&3 2>&4
    exec 3>&- 4>&-
    printf 'restored rc=%d\n' "$rc" >&2
    exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

exec >>"$log" 2>&1
printf '== %s start\n' "$(date -Is)"
"$@"
printf '== %s done\n' "$(date -Is)"

exec 3>&1 4>&2 先把原 stdout/stderr 存到 3/4,再把 1/2 接到日志;cleanup 首行保存 $?、先 trap - 防止递归,再恢复并关闭 3/4。信号处理器只负责 exit 130/143,清理统一在 EXIT 里做。exec >> 用追加,避免每次运行覆盖历史日志。

脚本三:fanout.sh——命名管道与并发消费者

#!/usr/bin/env bash
# fanout.sh — 用两个 FIFO 把一份输入同时交给 grep 与 wc
# 用法: fanout.sh SRC OUTDIR
set -euo pipefail

src=${1:?usage: fanout.sh SRC OUTDIR}
out=${2:?usage: fanout.sh SRC OUTDIR}
[[ -r $src ]] || { printf 'not readable: %s\n' "$src" >&2; exit 2; }
mkdir -p -- "$out"

tmp=$(mktemp -d "${TMPDIR:-/tmp}/fanout.XXXXXX")
cleanup() { rm -rf -- "$tmp"; }
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

mkfifo -- "$tmp/errors" "$tmp/all"
grep -E 'ERROR|FATAL' <"$tmp/errors" >"$out/errors.log" &
p_err=$!
wc -l <"$tmp/all" >"$out/count" &
p_cnt=$!

if ! tee -- "$tmp/errors" "$tmp/all" <"$src" >/dev/null; then
    printf 'tee failed (consumer gone?)\n' >&2
    kill "$p_err" "$p_cnt" 2>/dev/null || true
    wait "$p_err" "$p_cnt" 2>/dev/null || true
    exit 1
fi
wait "$p_err" "$p_cnt"

printf 'errors=%s lines=%s\n' \
    "$(wc -l <"$out/errors.log")" "$(cat -- "$out/count")"

三个进程并发:两个消费者先打开 FIFO 读端并阻塞,tee 打开写端后开始复制。tee 写两个 FIFO,任一消费者提前退出都会让它收到 SIGPIPE/EPIPE,脚本按失败处理并杀掉另一个消费者,避免留下孤儿。cleanup 只删临时目录,不删输出;输出路径用 -- 防止以 - 开头。这个脚本在源文件很大、消费者处理速度不同时会体现背压:慢的一方会让 tee 的写阻塞,快的一方先读完也不会丢数据。

0.5.18 陷阱清单

重定向与管道的十二个坑
  1. 2>&1 >file 与 >file 2>&1 不同——前者 stderr 留在终端,后者进文件;顺序决定 dup2 的目标。
  2. >file 在命令执行前就截断——命令失败也会留下空文件;不要用「失败所以没动」做假设。
  3. cmd > cmd2 会覆盖可执行文件——ls > less 是真实事故;重定向前先确认目标不是命令/设备。
  4. > list 2> list 不是合并——两次独立 open 会各自截断、各自偏移,输出交错甚至互相覆盖;用 > list 2>&1 或 &>。
  5. 管道只传 stdout——stderr 不会进入下游;要合并必须在上游写 2>&1,|& 是简写。
  6. 管道每段在子 Shell——while read 里的变量修改不回传;用 < <(...)、lastpipe(需 set +m)或命令替换。
  7. tee 返回自己的状态——cmd | tee log 的 $? 不是 cmd 的;取 PIPESTATUS[0] 或开 pipefail。
  8. PIPESTATUS 会被下一条命令覆盖——必须紧接着赋值:st=("${PIPESTATUS[@]}")。
  9. SIGPIPE/141 不一定是 bug——yes | head 的正常结束就是 141;pipefail 下要显式接受或改设计。
  10. stdbuf 不能救所有程序——只对动态链接、用 stdio 的程序有效;静态、setuid、直接 write 的无效。
  11. /dev/stdout 不等于 2>&1——前者重新 open 路径,后者复制 fd;/proc 未挂载、fd 已关闭时会失败。
  12. exec 不是启动新 Shell——它替换当前 Shell 的命令或只改 fd 表;exec 3>file 的效果一直留到关闭或脚本退出。

0.5.19 本章出口检查

检查项达标线没达标去哪
用 fd 表解释 2>&1能画两次重定向后的 fd 指向0.5.2
写出把 stdout/stderr 都写入文件的命令顺序正确,或会用 &>0.5.2
用 here-doc 生成配置知道引号定界符的作用0.5.3
取得管道中某个命令的退出码会用 PIPESTATUS 与 pipefail0.5.4
避免子 Shell 变量丢失会用 < <(...) 或 lastpipe0.5.5
保留日志与退出码tee 后取 PIPESTATUS[0]0.5.6
处理缓冲与颜色会用 stdbuf、script、--color=always0.5.7、0.5.15
解释 fd 表与 dup2能画出重定向前后的指向0.5.8
写对重定向顺序能解释 2>&1 >file 与 >file 2>&10.5.9
用 here-doc 安全生成配置知道引号定界符与 <<- 的边界0.5.10
解释管道容量与 PIPE_BUF知道 64 KiB 与 4096 字节的原子性0.5.11
处理子 Shell 变量丢失会用 < <(...)、BASH_SUBSHELL0.5.12
用 tee 保留退出码会取 PIPESTATUS[0]0.5.13
区分 /dev/stdout 与 2>&1知道一次是重开、一次是复制 fd0.5.14
即时自测 · 0.5

Q1 cmd >out 2>&1 与 cmd 2>&1 >out 的 stderr 分别去哪?画出 fd 表变化。

Q2 set -o pipefail 后,grep pattern huge.log | head -n 1 返回 141。这是 bug 吗?脚本里应该怎么处理?

Q3 下面这段为什么最后输出 0?改成两种能输出 2 的写法。

n=0
printf 'a\nb\n' | while read -r line; do n=$((n + 1)); done
echo "$n"

Q4 ./app | tee app.log 之后 $? 是 0,但 ./app 实际上失败了。解释原因并给出修复。

答案

A1 >out 2>&1:先把 fd 1 指向 out,再让 fd 2 复制 fd 1,stderr 进 out。2>&1 >out:先把 fd 2 复制到 fd 1 当前目标(终端),再把 fd 1 改成 out,stderr 留在终端。关键是 2>&1 复制的是「当时」fd 1 的目标。

A2 不是 bug。head 读够一行就退出,grep 继续写时收到 SIGPIPE,退出码 141。脚本里如果允许下游提前退出,临时 set +o pipefail,或检查 PIPESTATUS 并接受 141;如果不允许,说明管道设计有问题,应让上游支持提前终止。

A3 管道最后一段 while 在子 Shell 执行,n 的修改只发生在副本。修复:① while ... done < <(printf ...);② shopt -s lastpipe(非交互、job control 关闭时);③ 让子 Shell 输出计数,父 Shell 用 $(...) 接收。

A4 $? 是 tee 的退出码,不是 ./app 的。修复:用 PIPESTATUS[0] 取上游状态,或 set -o pipefail 让管道整体返回非零;但要注意 set -e 下会提前退出,需在清理逻辑里处理。

母本对应

母本章节本书落点程度
S Shotts第 6 章 重定向0.5.1–0.5.3完整(fd、重定向顺序、here-doc)
B shell-book第 5 章 理解 shell、第 15 章 呈现数据0.5.4–0.5.6完整(管道、子 Shell、tee)
G 鸟哥第 10 章 数据流重导向与管线命令0.5.1–0.5.6完整(fd、管道、tee)
K Kerrisk第 2.5 节 文件 I/O 模型、第 44 章 管道与 FIFO0.5.4背景(管道缓冲区、原子写)
C CSAPP第 10.8–10.9 节 共享文件、I/O 重定向0.5.1–0.5.2背景(fd 表与 dup2)
S Shotts第 6 章 重定向0.5.8–0.5.14完整(fd、顺序、/dev/null、tee)
B shell-book第 15 章 呈现数据0.5.8、0.5.13、0.5.14完整(自定义 fd、exec 备份、lsof)
B shell-book第 5 章 理解 shell0.5.12精选(子 Shell、BASH_SUBSHELL)
G 鸟哥第 10.5–10.6 节 数据流重导向与管线0.5.9、0.5.11完整(2>&1、&&/`

下一站:0.6 正则表达式与 grep/sed/awk开始处理管道里的文本;先学会筛选,再谈统计与改写。