先讲一个我实习时候的真实困惑。当时我在一个模拟项目里写部署脚本,脚本里cd /opt/app、export APP_HOME=/opt/app写得清清楚楚,跑完脚本我pwd一看,人还在老目录;更离谱的是,脚本里 export 的变量在终端里 echo 出来居然是空的。我一度以为是脚本里执行顺序出了问题,后来才发现根本不是命令没生效,而是它们生效在一个“平行世界”里——那个世界属于脚本的子进程,而我的交互 Shell 在父进程这一层,两者天生就隔着一堵墙。这篇文章就围绕 Shell 中的父子进程关系展开,把 fork/exec、子 Shell、僵尸进程、作业控制这些散落的知识点串成一张完整的地图。不管你是刚学 Linux 命令行的新手,还是经常写自动化脚本的运维或后端,理解这层关系,都能帮你省下大量调试时间,少走很多弯路。
1. 一个经典翻车现场:脚本里 cd 为什么“没用”
1.1 复现一次“灵异事件”
假设你写了一个脚本:#!/bin/bash,内容只有两行cd /tmp和pwd。我猜绝大多数第一次接触的人都会觉得,执行完这个脚本,当前终端的目录就该变成/tmp。实际呢?
$ pwd /home/user $ bash cdtest.sh /tmp $ pwd /home/user脚本执行过程中确实进入了/tmp,但脚本一结束,你的终端目录纹丝不动。这不是错觉,也不是系统抽风,而是“执行脚本”这件事本身,就意味着开启了一个新的子进程。脚本里的cd作用于这个子进程的工作目录,子进程退出后,它的工作目录也随之销毁,父 Shell 的工作目录毫发无损。
很多第一次遇到这个问题的人会往“脚本写错了”“权限不对”上猜,其实方向从一开始就偏了。关键不在脚本内容,而在启动脚本的方式。只要想通“脚本是在另一个进程里跑的”,这个现象就完全合理了:你在文件里改了个临时变量,文件关掉后变量自然不存在;脚本里的 cd 也是同样的道理,只是很多人不会把“一个进程的工作目录”和“文件里的临时内容”类比到一起而已。
1.2 source 和 bash 的本质区别
那么问题来了,为什么有时候用 source 执行脚本,目录就真的变了?
$ source cdtest.sh /tmp $ pwd /tmpsource(或写成.)的含义是“让当前 Shell 逐行执行这个文件”,它不会 fork 一个新进程,所以cd直接改在当前 Shell 自己身上。而bash cdtest.sh是“启动一个新的 bash 去执行”,天然就是一个子进程。同样是执行同一份脚本,一个改了父进程,一个改了自己的临时副本——这就是父子进程关系最直观的一次现身。
| 执行方式 | 是否创建子进程 | cd/export 对当前 Shell 的影响 | 典型适用场景 |
|---|---|---|---|
bash script.sh | 是 | 不影响 | 隔离运行、定时任务、CI 构建 |
source script.sh | 否 | 影响 | 修改环境变量、切目录、复用函数定义 |
./script.sh | 是(需可执行权限) | 不影响 | 和bash script.sh类似,但依赖 shebang |
一次简单的实测就能验证:交互 Shell 的 PID 是 12345,bash -c 'echo $$'会得到一个完全不同的 PID,而source之后再echo $$,得到的还是 12345。这就是有没有创建子进程的铁证。
1.3 这个“没用的 cd”其实是隔离性的福音
别急着骂这个设计。如果真的没有这层隔离,你天天用的 Shell 早就被各种烂脚本折腾得不成样子了。设想一下:你在终端里跑一个别人写的脚本,它随心所欲地cd、改环境变量、覆盖 alias,如果它直接在你的 Shell 里执行,你的终端状态瞬间就被人改得面目全非。
子进程机制的核心目的就是隔离:一个脚本崩了、卡了、把环境改坏了,最多影响它自己那一层,父 Shell 该干嘛干嘛。你可以把它理解成公司里的“项目部”——项目组内部怎么折腾都行,项目结束,资源释放,总公司不受牵连。后续所有章节,都是围绕这个“隔离 + 独立”的基本盘展开的。理解了这层设计意图,你就不会觉得“cd 传不回父进程”是个缺陷,反而会意识到它是保护你终端环境的护城河。
2. 进程的底层模型:fork/exec 与 PID/PPID 的关系
2.1 父子关系的来源:fork 出来那一刻
在 Unix 世界里,除了开机后最早的那一个进程(PID 1),其他所有进程都不是凭空出现的。每个进程都有一个父进程,父子关系在fork()那一刻就被钉死了。fork的意思是:以当前进程为模板,复制出一个几乎一模一样的新进程。子进程拥有父进程内存、打开的文件描述符、环境变量的完整快照,但它有一个全新的 PID。随后,如果子进程要运行一个完全不同的程序(比如 bash 要执行/bin/ls),它会调用exec系列函数,把当前进程的映像整个替换成 ls 的代码和数据。
所以 Shell 执行外部命令的标准流程是:先 fork 出一个子进程,然后子进程 exec 替换成目标命令。父进程自己原地不动,继续等着接收子进程的退出状态。这个模式稳定运行了几十年,你今天敲下的每一行命令,背后都是这套“先复制、再替换”的流程。
在 Linux 里,用ps -eo pid,ppid,cmd就能看到每个进程的亲生父亲是谁:
$ ps -eo pid,ppid,cmd | head -5 PID PPID CMD 1 0 /sbin/init 12345 9999 -bash 23456 12345 sleep 100经常有同学问:为什么 PID 1 的 PPID 是 0?因为它是“孤儿院院长”,所有被人抛弃的子进程最后都会被过继给它。后面第 7 章我们还会专门聊到。
2.2 为什么 Shell 必须“先复制再替换”,而不是直接替换
你可能会想,既然 bash 最终要执行 ls,直接把自己变成 ls 不就行了?这就是exec单独的效果。但那样的话,ls 执行完,你的 Shell 也就消失了。Shell 的定位是“命令的解释器 + 退出状态的回收站”,它必须先 fork 保住自己这条命,再让子进程去 exec。
这里可以类比餐馆的点单流程:服务员(父进程)不能自己钻进后厨变成一道菜,他得下单让厨师(子进程)去做,菜做完了服务员还在,继续接待下一位客人。如果服务员每次下单都把自己变成菜,餐馆早就没人接客了。Shell 之所以能连续不断地执行你输入的成百上千条命令,正是因为它每次都只“派”一个子进程出去干活,自己始终守在主位上。
2.3 $$ 的陷阱:$BASHPID 才是“此刻我”的 PID
这里有个高频坑位:在脚本里echo $$,你以为得到的是“当前正在执行这段代码的 Shell 的 PID”,但 bash 的$$记录的是最外层主 Shell的 PID。一旦进入子 Shell(第 5 章会细讲),$$依然沿用父 Shell 的值,而真正当前 Shell 的 PID 要用$BASHPID获取。
$ echo $$ $BASHPID 12345 12345 $ ( echo $$ $BASHPID ) 12345 23456我第一次写日志的时候用$$做进程号标记,结果在子 Shell 里启动的后台任务,日志里全记成了父 Shell 的 PID,排查起来一片混乱。后来养成了习惯:日志里区分“会话级别”用$$,记录“到底是谁在干活”用$BASHPID。这两个变量看似一个意思,实际差了整整一个父子层级。
3. 外置命令与内建命令:Shell 是否“生娃”的分界线
3.1 type 命令揭示的真相
既然 fork/exec 是执行外部命令的通用流程,那是不是所有命令都会产生子进程?答案是否定的。Shell 里有一部分命令是“内建命令”(builtin),它们由 Shell 自己实现、直接在当前进程里执行,根本不走 fork。用type看一眼就明白:
$ type cd cd is a shell builtin $ type ls ls is /usr/bin/lscd是内建,所以它能修改当前 Shell 的目录;ls是外置,所以它运行在子进程里。这也解释了为什么你给ls加个别名、改个参数,完全不影响cd的行为——两者根本在不同层级运作。
| 命令 | 类型 | 说明 |
|---|---|---|
cd | 内建 | 必须改当前 Shell 的工作目录 |
echo | 内建(多数 Shell) | 也有/bin/echo,行为存在差异 |
export | 内建 | 改当前 Shell 的环境块 |
ls/grep/cat | 外置 | 每次调用都 fork + exec |
printf | bash 内建 + 外置 | 内建版性能更高、行为更统一 |
type/help | 内建 | 命令自省工具 |
3.2 内建与外置的性能与行为差异
这不是纯理论问题,对性能也有实打实的影响。写循环的时候,反复调用外部命令,每一次都要 fork + exec + 等待,开销不小。我做过一个简单的对比:循环 1000 次调用内建true和/bin/true,外置版明显慢了好几倍。所以脚本里能交给内建和 Bash 自身语法完成的,就别拼命起外部进程。
更隐蔽的问题在行为差异上。比如芭蕾舞剧里常提到的/bin/echo对-n、-e的处理,GNU 版本和 BSD 版本就不一样,而 bash 内建的 echo 行为是固定的。跨平台脚本如果依赖这些细节,最好的方案是用printf '%s\n'替代 echo,少一份玄学。
实战建议:写性能敏感的循环时,优先用内建命令和 Bash 的数组、字符串操作。只有在确实需要外部工具的过滤、排序能力时,才把它们拉进循环。
3.3 想临时禁用内建怎么办
bash 有个enable -n可以关闭某个内建,让它走外置路径。比如enable -n echo之后,echo 就变成/bin/echo了。这个功能日常几乎用不到,但排查诡异差异时值得一试。
我遇到过这样一件事:某个容器环境里echo -e "\n"的行为和预期不一致,折腾了半天,最后用enable -n echo切换路径一对比,才发现是 bash 内建 echo 与容器里的外置 echo 实现不一致导致的。命令报错不可怕,可怕的是行为不一致且没有明显报错,这时候“内建 vs 外置”的排查方向就特别重要。
4. 环境变量和位置参数:哪些会“遗传”给子进程
4.1 export 是“遗传”的开关
现在进入最实用的部分:子进程到底从父进程继承了哪些东西。最核心的是环境变量。Linux 的每个进程都有一张环境表,fork 时子进程会原样复制这张表;exec 时如果没有显式清理,也会保留。但要注意,Shell 里的变量分两种:普通 Shell 变量和导出变量(exported)。只有 export 过的变量才会写进这张环境表,才能被子进程看到。
$ FOO=bar # 普通变量,不在环境表里 $ export BAZ=qux # 导出变量,进环境表 $ bash -c 'echo "FOO=$FOO"; echo "BAZ=$BAZ"' FOO= BAZ=qux这就是文章开头那个现象的完整解释:脚本里 export 的变量,source 之后能留在当前 Shell;bash script.sh执行完就消失——因为那个变量是 export 给了脚本的子进程,而子进程一退出,它的整个环境表就灰飞烟灭。
4.2 子进程改环境,父进程不会“看见”
反方向的坑更常见:子进程里修改了某个环境变量,父进程一点感觉都没有。因为 fork 复制的是“那一刻的快照”,之后的修改发生在各自的内存空间,谁也不知道谁。想在父子之间传递结果,只能靠显式手段:把值输出到 stdout 让父进程捕获,或者写进临时文件、用命令替换读回来,又或者干脆 source 让它在同一进程里执行。
这也是为什么很多脚本设计成“以输出为接口”:比如export APP_HOME=$(get_home)中的$( )子进程只负责把结果打到 stdout,赋值动作交给父 Shell 完成。命令替换的本质,就是“子进程输出 + 父进程接收”的桥。理解了这个桥的运作方式,你对“配置脚本怎么写才能生效”的把握会立刻上一个台阶。
4.3 还有哪些“遗传物质”:工作目录、umask、文件描述符与信号
除了环境变量,子进程还会继承一批“软属性”:当前工作目录、umask、资源限制(ulimit)、未关闭的文件描述符、以及信号处理方式。这里面有两条容易踩的线。
第一,工作目录会继承,但子进程改自己那份,父进程不变,正好呼应第 1 章的 cd 问题。第二,父进程里 catch 过的信号,在子进程 exec 之后会被重置为默认行为(SIG_IGN忽略项除外),所以你在脚本里 trap 的信号不会自动传给外部程序,除非那个程序自己处理。
文件描述符的继承尤其值得注意。管道能跨进程传递数据,靠的就是子进程继承了父进程的读写端。你写ls | grep xxx,ls 的 stdout 和 grep 的 stdin 是同一个管道文件,这不是巧合,而是 fork 时文件描述符复制的结果。现代语言里经常给文件描述符标记 close-on-exec 来避免泄漏,防的就是这整条继承链。进程替换的场景同样依赖这个机制,我们在下一章展开。
5. 子 Shell 的诞生场景:管道、命令替换与括号
5.1 管道两侧的隐秘隔间
很多跑过脚本的人都有过这个经历:在管道里用 while 累加计数,循环结束一看,变量还是 0。
count=0 seq 1 10 | while read -r line; do count=$((count + 1)) done echo "$count" # 0原因在于:管道里的每一条命令默认都运行在独立的子 Shell 中。while 循环虽然语法上是对seq的输出做逐行处理,但它本身作为管道的一个环节,跑在自己的子 Shell 里。它改的 count 是子 Shell 自己的副本,循环结束,这个副本跟着子 Shell 一起没了。
解决办法有几个,最常见的进程替换写法:
count=0 while read -r line; do count=$((count + 1)) done < <(seq 1 10) echo "$count" # 10< <(seq 1 10)的意思是:把seq命令放进子进程,把它的输出接到一个虚拟文件描述符上,再把这个 fd 当作 while 循环的输入重定向。这样 while 循环本身留在当前 Shell 里执行,count 的修改就能生效了。bash 4.2 以后还可以用shopt -s lastpipe让管道最后一个命令在当前 Shell 跑,但非交互脚本里这个选项有时不够稳定,不如进程替换直观。
5.2 命令替换和括号表达式:悄悄创建的子 Shell
命令替换$(...)和反引号,以及括号包起来的( ... ),都会开启一个新的子 Shell。区别在于:命令替换只关心子进程的输出,括号表达式则只关心副作用;双方的共同点是,你在里面做的所有变量修改、cd、export,都出不来。
于是( cd /tmp && make )成了临时切目录的安全写法:括号里 cd 只影响那个子 Shell,不影响当前 Shell。反过来,{ cd /tmp && make; }用的花括号语法在当前 Shell 执行,敲完这行,你的终端目录真的会变。这个区别经常被写进面试题,也经常在实际脚本里害人。
$ pwd /home/user $ ( cd /tmp && pwd ) /tmp $ pwd /home/user $ { cd /tmp && pwd; } /tmp $ pwd /tmp注意一个细节:命令替换里的变量也是带不出来的。x=$(x=1)之后外部 x 还是原样,因为$( )里的赋值发生在子 Shell。想获取子进程的单值结果,只能靠 stdout;想传递多个结果,就用临时文件或者用 eval 接住(不推荐,有注入风险)。
5.3 进程替换:一种“伪文件”子进程
进程替换<(...)长得很像命令替换,但它做的事情不是捕获输出,而是把一个子进程的输出接到了/dev/fd/下的一个虚拟文件描述符上。经典场景是 diff 两个排序结果:
diff <(sort a.txt) <(sort b.txt)这里的<(...)各起一个子进程执行 sort,并把它们的输出作为文件路径传给 diff。既然它也是子进程,同样的铁律依然成立——别指望在进程替换里改外面的变量。我见过有人在里面 export 一个变量想传出来,结果自然是一场空。进程替换最大的价值在于:它把“子进程的输出”伪装成一个可寻址的文件,让那些只接受文件路径的工具也能吃到子进程的产出。
6. 作业控制与信号:父进程如何“遥控”子进程
6.1 &、wait、jobs:父亲的三件套
前面讲的都是“生”和“死”,现在聊“管”。Shell 启动后台任务,用的就是&符号。一个sleep 100 &,Shell 立刻返回提示符,这个 sleep 成了 Shell 的子进程,而且进了 Shell 的作业表。jobs -l能看到作业号和 PID,fg把它带回前台,bg让停住的作业继续跑,wait则是父进程堵在那儿,等后台子进程结束。
sleep 30 & sleep 20 & wait -n # bash 4.3+,先结束一个就算wait的意义不只是“等”,它同时完成了对子进程退出状态的回收。如果父进程既不 wait,也不在收到SIGCHLD信号时去收尸,子进程退出了就会变成僵尸(第 7 章展开)。脚本里对关键子任务,记得wait $pid并检查其退出码,这是很多自动化任务判断成败的基础:
do_work & pid=$! do_other_stuff if wait "$pid"; then echo "work finished ok" else echo "work failed with $?" fi6.2 终端、进程组和 Ctrl+C 的“连坐”规则
这里有个非常反直觉的机制:按 Ctrl+C 时,终端发出的 SIGINT 并不是只发给某一个 PID,而是发给整个前台进程组。进程组是什么?一组互相关联的进程,通常一个管道命令的所有环节会在同一进程组。所以 Ctrl+C 才会把cmd1 | cmd2 | cmd3一起打断,而不是只杀其中一个。后台任务因为在另一个进程组,不在终端的前台,通常不会收到终端发的 SIGINT/SIGTSTP,这也是sleep 100 &之后按 Ctrl+C 打不掉它的原因——你得kill <PID>。
这个概念只要做个实验就懂:挂一个前台的sleep 100,另开终端看ps -o pid,ppid,pgid,sid,cmd,能看到 sleep 和当前 bash 同组;然后再起一个sleep 100 &,它的 PGID 和前台组不一样。理解这个,之后调试“为什么 Ctrl+C 没反应”或“为什么 Ctrl+C 连坐一片”会快很多。
6.3 让子进程“陪葬”还是“放生”:nohup、disown 与 trap
最后一个高频需求:父进程退了,子进程该怎么办?默认行为分两种——如果子进程和父进程在同一个会话里,父进程退出时终端挂断(SIGHUP)会往会话里发一份 HUP,子进程可能跟着死;但如果子进程已经脱离了控制终端,或者被 nohup/disown 明确处理,它就会变成孤儿进程继续活着。
nohup:忽略 SIGHUP,并把输出落到 nohup.out。disown:把后台任务从作业表里摘出去,Shell 退出时不再等它、也不再对它发 HUP。setsid:让进程开一个新会话,彻底脱离原进程组和终端。
脚本里想要可靠的后台守护,组合拳一般是setsid nohup cmd >/tmp/log 2>&1 &,把会话、HUP、输出三者全部处理干净。
反过来,如果脚本要求“所有子任务必须在我退出前结束”,那就用 trap 在 EXIT 信号上收网:
trap 'kill "$child" 2>/dev/null; wait' EXIT child=$(sleep 100 & echo $!)这里顺便破除一个常见误解:父进程被 kill,并不一定会连坐杀死子进程,除非它们在同一进程组且收到了同样的终端信号,或者父进程主动去 kill 子进程。很多人 kill 了父进程,发现子进程还在跑,正是这个原因。
7. 僵尸与孤儿:子进程退出后的两类“善后”问题
7.1 僵尸进程:虽然死了,却还“挂”在进程表里
子进程退出了,父进程还没调用 wait(或 waitpid)取它的退出状态,这时候进程就变成僵尸。僵尸进程的代码和数据已经全部释放,只剩进程表里的一条记录:PID、退出状态码。它不占内存,但占 PID,而且赖着不走——直到父进程把它收走,或者父进程自己也退出,把它过继给 PID 1 后由 PID 1 负责清理。
$ ps -eo pid,ppid,stat,cmd | grep -w Z PID PPID STAT CMD 4567 1234 Z [sleep] <defunct>很多同学第一次看到 Z 状态会慌,以为进程泄漏了。其实单个僵尸不可怕,可怕的是堆积——如果父进程是个长期运行的程序,又不写 wait 回收逻辑,每挂一个子进程就留下一具僵尸,积累到系统进程数上限,新进程就 fork 不出来了。
Shell 脚本因为是解释执行的,一般会在每条外部命令结束后自动 wait 回收,所以单条命令很少留下僵尸;真正容易留僵尸的是 C/Go/Java 这类自己管理子进程的程序,以及脚本里用&大量起后台子任务却不 wait 的场景。
7.2 孤儿进程:父先走,子“过继”给 1 号
与之对称的另一个场景是:父进程先退出了,子进程还没完事。按 Linux 的逻辑,子进程不会因为“没人管”就被杀掉,而是被过继给最近的子 reaper——通常就是 PID 1。这种进程叫孤儿进程,它继续正常跑,只是爹变成了 1 号。
经典的 daemon 化技巧 double-fork(两次 fork),利用的就是这个机制:第一次 fork 出来的中间进程直接退出,让真正的子进程被过继给 1 号,同时脱离原来的会话和进程组,不再受终端信号和父 Shell 生命周期的影响。现在很多项目用setsid一条命令就能达到类似效果,但面试题里 double-fork 依然经久不衰,因为它把代码中“孤儿 + 脱离会话”这两件事讲得最透彻。
7.3 动手清理:别乱杀,先找“爹”
遇到僵尸堆积,最立竿见影的清理办法不是 kill 僵尸——僵尸已经死了,根本杀不掉,只能由父进程 wait 才能让它消失。正确姿势是找到它的父进程:
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/ {print $2}' | sort -u找到 PPID 后,观察那个父进程是不是该处理却没有处理。如果父进程本身是个可以重启的短命服务,直接重启它,僵尸会被 PID 1 回收;如果父进程是长驻程序且代码没写 wait,那只能从业务侧修代码,或者给父进程发信号让它退出后再重启。
顺带一提:容器里 PID 1 也会承担孤儿收养的责任,所以很多容器镜像里 PID 1 是类似 tini 这样的监督进程,而不是裸跑业务程序。这类工具治的就是“PID 1 不回收僵尸”和“信号不转发”这两种病。
8. 排查实战:用 pstree、ps 和 /proc 看清进程家族
8.1 pstree:一眼看完族谱
排查父子进程问题,最先用的应该是 pstree。它把进程的树形关系直接画出来,比一屏 ps 输出直观太多。
$ pstree -ap 12345 bash,12345 ├─sleep,23456,100 └─bash,23457 └─find,23458 / -name '*.log'pstree -p会附带 PID,-a显示命令行。有些发行版没有 pstree,装个 psmisc 就有。它适合看“大厂结构”:谁是谁的上一级,哪个分支开了一大堆子孙,一目了然。
8.2 ps 的 PPID 列:按父亲排序的名单
pstree 看树,ps 看表。ps -ef默认就能看到 PID 和 PPID,但进程一多眼睛就看花了。我习惯这样按 PPID 排序,再结合 grep 把可疑家族捞出来:
ps -eo pid,ppid,pgid,stat,cmd --sort=ppid | grep -E 'PID|12345|23456'还有一个省事的命令:pgrep -P 12345能直接列出一个父进程的所有子 PID,反向亲缘用pstree -p 12345。排查“某个进程是谁拉起来的”时,养成“先取 PPID,再往上找爹,再回头看一次树”的习惯,基本不会迷路。
8.3 /proc:进程的“个人档案”
ps 和 pstree 的信息归根结底来自 /proc。想确认一个进程到底继承了哪些东西,直接翻档案最踏实:
/proc/<PID>/status里的PPid、NSpid等字段;/proc/<PID>/environ能看到它启动时的环境变量,排查环境类问题最常用;/proc/<PID>/fd符号链接列表,看它继承了哪些文件描述符(比如是不是把管道读端拽在手里,导致对端写不进去);/proc/<PID>/cwd指向它的当前工作目录。
我遇到过这样一个问题:脚本启动的服务环境不对,程序里读到的一个环境变量和预期不一致。我直接tr '\0' '\n' < /proc/<PID>/environ,发现有个变量被脚本后置的 export 覆盖了——父进程后来改了自己的环境表,但子进程早就 fork 出去了,继承的是旧快照。如果不是这个档案,你根本对不上“为什么外面改的变量,里面进程没生效”。
8.4 一个完整的排查小案例
最后拼一个综合案例。某次我在模拟环境里起了一个 deploy.sh 部署脚本,脚本内部调用了另一个 helper,并在后台跑着一个长任务。我想搞清楚“这个后台任务到底是怎么从我的会话里脱离出来的”。按顺序排查:
# 1. 找到部署脚本的 PID pgrep -f deploy.sh # 2. 看它的子进程树 pstree -ap <PID> # 3. 观察后台任务的进程组与会话 ps -o pid,ppid,pgid,sid,cmd -p <后台PID> # 4. 翻后台任务的环境档案 tr '\0' '\n' < /proc/<后台PID>/environ | sort第 3 步如果发现后台任务的 SID 和终端不一样,说明它已经被 setsid 或者 daemon 化逻辑摘出去了,父 Shell 退出时它大概率会继续活着;如果 SID 相同,它可能还随时会被终端 HUP 波及。这套四连招用得多了,基本不用靠猜,进程间的血缘关系几分钟就能查清楚。
最后分享一个我一直在用的开发期小技巧:给脚本里每个关键子进程都打一行echo "[$$/$BASHPID] doing X"的日志,跑起来后用 tail 观察日志里 PID 的变化,你会非常直观地看到什么时候 Shell 分裂成了子进程,什么时候又在同一进程里继续执行。这比事后开调试器直观得多。动手写脚本之前,先在脑子里过一遍“谁会 fork、谁会开子 Shell、退出时由谁回收”,很多玄学 Bug 还没发生就已经被拦住了。今天就拿你自己的一个脚本,按第 8 章的流程做一次“查族谱”练习吧。