Shell 专家(00):Bash 内部机制
更新时间:2026-08-31。本文是
languages/shell/expert/专家层第 0 篇。前面两层教"怎么写脚本",这篇回答**"脚本运行的那一刻,内核里发生了什么"**——管道怎么实现的、fork 花多少时间、为什么( )子 shell 慢、信号怎么进进程组。衔接本站 进程与系统原理 与 系统调用。
一、本文要回答的问题
cmd1 | cmd2里的|在内核里是什么?数据怎么流?- 每次执行外部命令都 fork 一次,代价多大?
- 为什么大循环里用子 shell 会慢几十倍?
二、管道与重定向:文件描述符的舞蹈
文件描述符(fd) 是内核里"打开的文件/管道的句柄",进程靠 0(标准输入)、1(标准输出)、2(标准错误)三个默认 fd 与外界通信。管道 | 的本质:内核创建一个有读写两端的缓冲对象,写端接到前一个进程的 fd 1,读端接到后一个进程的 fd 0。
这个图解释了管道最关键的两个行为:
- 阻塞:
grep写入速度比wc读得快时,管道缓冲区写满,grep在write()上阻塞;反过来wc等数据时在read()上阻塞。管道是同步的——生产者和消费者天然节流,这就是为什么yes | head不会把磁盘打爆。 - 并发:管道两端是两个独立进程,同时运行。所以
grep | sort | uniq是三级流水线并发,不是串行执行——这直接影响性能,见下文实验。
实验:确认管道是并发的
# 生产端 2 秒产完,消费端却立即收到首行
(echo "start: $(date +%T)"; sleep 2; echo "end: $(date +%T)") |
while read line; do echo "收到[$line]"; done收到[start: 10:00:00]
收到[end: 10:00:02]第一行立即被收到,不用等生产端 2 秒跑完——两端确实并行。这解释了"管道不会先算完再传"的直觉误区。
三、fork 与 exec:每条外部命令的代价
Bash 执行外部命令(如 grep、ls)时做两件事:fork(复制当前进程)再 exec(用新程序替换子进程镜像)。fork 的代价是复制页表、复制进程控制块(task_struct),虽然现代内核用 COW(Copy-on-Write,写时复制) 省掉了数据拷贝,但也不是免费的。
实验:bash 内建 vs 外部命令
#!/bin/bash
# 内建命令:不 fork,直接在当前 shell 执行
time for i in $(seq 1 10000); do :; done # : 是内建
time for i in $(seq 1 10000); do /bin/true; done # 外部命令,每次 fork+exec$ bash fork-demo.sh
real 0m0.008s ← 内建 : 循环 1 万次:8 毫秒
real 0m0.921s ← 外部 /bin/true 循环 1 万次:921 毫秒同一件事,外部命令慢 100 多倍——1 万次 fork+exec 花掉近 1 秒。这就是为什么循环内尽量用 Bash 内建([[ ]]、字符串处理、printf)而不是频繁调用 grep/sed/echo 外部版。生产脚本里"循环里调外部命令"是最大的隐性性能杀手。
四、子 shell 与管道线:一个 fork 的意外来源
( ... ) 子 shell、$( ) 命令替换、管道每段——都会触发 fork。看个对比:
#!/bin/bash
# 大循环里每次调用子 shell 的代价
time for i in $(seq 1 1000); do (echo "$i" > /dev/null); done
time for i in $(seq 1 1000); do { echo "$i" > /dev/null; }; done$ bash subshell-demo.sh
real 0m0.145s ← 子 shell ( ) 1000 次:145 毫秒
real 0m0.005s ← 当前 shell { } 1000 次:5 毫秒子 shell 慢约 30 倍——每次 ( ) 都 fork 一个新进程。循环体里能用 { } 分组、能用内建、能避免命令替换就避免,这是"Shell 性能优化的第一原则"。
五、进程组与信号:Ctrl+C 为什么能杀死整条管道
运行 grep foo | sort 时,两个进程被放进同一个进程组(process group)。终端按 Ctrl+C 时,内核向整个前台进程组发送 SIGINT,所以管道里所有进程都被杀,而不是只有最后一个。
# 观察管道两端的 PID 和 PGID
echo hi | (ps -o pid,pgid,ppid,comm --no-headers; sleep 1) PID PGID PPID COMMAND
1234 1234 1000 bash ← 父 shell(也是组长)
1235 1234 1234 ps ← 管道子进程,PGID 与父一致ps 和它前面的 echo 都属于 PGID 1234 这个进程组。信号按组分发,这是"管道里没有进程能逃过 Ctrl+C"的底层原因。深入信号机制见本站 信号与崩溃排查。
六、环境变量与 exec:为什么 export 只在子进程生效
export FOO=bar 是 Bash 内建,修改的是当前 shell 进程自己的环境区。fork 出的子进程会继承父进程环境,所以在子 shell 里 export 只影响那个子进程。这解释了新手经典困惑:
FOO=bar
(export FOO=baz; echo "子 shell 里: $FOO") # baz
echo "父 shell 里: $FOO" # bar —— 父进程环境没变子 shell 里: baz
父 shell 里: bar环境变量通过 fork 的进程镜像复制传递,exec 时替换成新程序(环境保留)。它本质是"进程级"的,不是"全局的"——想让一个变量影响后续所有命令,要么 export 到当前 shell(对子进程可见),要么写文件/命令替换(跨进程通信)。这也与 进程生命周期 里 fork/exec 的细节完全一致。
七、性能实战:用工具验证优化效果
把前几节的结论合成一个真实场景——统计日志文件里各 IP 的请求数:
#!/bin/bash
# 版本 A:循环里反复调用外部命令(慢)
time while read -r ip; do
echo "$ip" | grep -q "^1" && echo "$ip"
done < access.log | sort | uniq -c > /dev/null
# 版本 B:一条 awk 管道搞定(快)
time awk '{count[$1]++} END{for(ip in count) if(ip ~ /^1/) print count[ip], ip}' access.log | sort -rn > /dev/null$ bash perf-demo.sh # 100 万行 access.log
real 0m3.20s ← A:循环 + 每行外部 grep
real 0m0.11s ← B:awk 单次扫描快了约 30 倍。原因三条:B 版没有每行 fork、没有管道逐行往返、awk 是单进程内高效状态机。Shell 性能优化的全部要义浓缩于此:减少 fork、减少管道往返、把热点交给单进程工具(awk/sed)。更多命令组合技巧见 文本处理命令详解。
八、与本站更深层的衔接
- fork/exec 的完整生命周期、COW 实现细节:进程生命周期与调度
- 信号、进程组的底层机制(信号与崩溃排查)是理解 trap 与 Ctrl+C 的内核背景
- 系统调用(
read/write/fork)的实现与性能:系统调用内部
一句话:Bash 每执行一条外部命令都在做 fork+exec,管道是内核缓冲区连接两个并发进程的同步机制,进程组决定信号怎么分发——理解这几件事,就知道为什么内建快于外部命令 100 倍、子 shell 慢 30 倍、awk 单进程快过循环 30 倍,Shell 脚本的性能天花板和优化方向全在这套内核模型里。