在Linux终端里写Shell脚本绕不开的一个概念就是“父子进程”。说实话这个概念我刚开始接触时也晕了很久——为什么一个脚本里明明赋值了变量管道跑完就没了为什么脚本里cd半天退出脚本后目录还是原来的为什么nohup也用了SSH一断任务还是没了这些问题背后几乎都能归结到同一个核心机制Shell通过创建子进程来执行任务而子进程与父进程之间天然存在一道“隔离墙”。这篇就来把Shell父子进程彻底掰开揉碎用3W1H的框架聊清楚它是什么、为什么存在、在哪些场景现身、到底怎么控制和排查。适合刚入门Shell脚本、被各种“变量传不回去”折磨的人也适合想系统理解进程模型、想写出更稳脚本的人。1. 什么是Shell父子进程一次fork引发的“身世之谜”1.1 敲一条命令时Shell到底干了什么在你打开终端输入一条命令比如ls -l然后按下回车的那一刻当前交互式Shell通常就是bash并不是自己亲自去读取目录列表而是先把自己“复制”一份再由这份复制品去执行ls这个程序。这个“复制”动作是内核提供的fork()系统调用完成的。fork()会基于当前进程创建一个几乎一模一样的新进程内存、环境变量、打开的文件描述符都会继承下来。创建出的新进程就是“子进程”原来的Shell就是“父进程”。光复制还不行子进程复制出来后要加载/bin/ls、/usr/bin/mv这些外部程序的可执行文件这个换程序的动作由exec系系统调用完成。所以子进程的完整生命周期是当前Shell进程 └── fork() 复制出一个几乎一样的子进程 └── exec() 把子进程的内存映像替换成目标程序比如 ls └── 子进程干活、退出 └── 父进程 wait() 接收子进程退出码继续下一条命令整个流程对用户来说就是一瞬间的事但它确确实实发生了。你在终端里敲的绝大多数外部命令都是由这个fork exec机制“生”出来的子进程干完的。1.2 fork与exec分身术与换装术这两个系统调用经常一起出现但职责完全不同。fork()像是“分身术”调用的进程把自己几乎完整复制一份复制完那一刻两个进程长得一模一样都还跑着同样的代码、同样的Shell逻辑。区别仅仅是返回值——父进程拿到的是子进程的PID子进程拿到的是0。exec则像“换装术”它不创建新进程而是在当前进程内部把正在运行的程序换成一个新程序。当前进程的PID不会变内存中的代码、数据被彻底替换开始执行/bin/ls之类的程序逻辑。Shell执行外部命令时二者配合先fork()出子进程让子进程去exec()成目标程序。这里的巧妙之处在于Shell自己是不会直接去执行ls的代码的因为一旦直接exec当前Shell就被替换掉了终端就没法继续和你交互了。所以必须通过子进程来“替身执行”。这里要区分一个概念Shell命令分“内建命令”和“外部命令”。内建命令cd、export、shift、echo、pwd部分是内建等它们直接由当前Shell进程执行不额外fork子进程。外部命令mv、grep、cut、curl、ls等它们存在于/usr/bin、/bin等路径下必须先fork出子进程再exec。为什么说这个区别重要因为它直接决定了“这个操作会不会改变当前Shell环境”。比如cd是内建命令它改变的就是当前Shell的当前工作目录mv是外部命令某个子进程去执行重命名文件的操作改名动作发生在文件系统里不涉及环境变量传递问题。所以用Shell重命名文件mv old.txt new.txt虽然看起来是“一条命令”但它在进程眼里是“一个子进程去执行外部工具完成了文件系统操作”。1.3 常见误区子进程和“子Shell”不是一回事我见过很多初学者把子进程和子Shell混为一谈其实它们有重叠但是不同维度的概念。子进程是操作系统层面的概念任何进程都可以通过fork()创建子进程。Shell执行一个外部命令时会产生一个子进程。**子Shellsubshell**是Shell层面的概念它是当前Shell复制出的一个新Shell实例。括号语法( command )会明确创建一个子Shell环境管道符导致的子进程、命令替换$(...)也会创建子Shell环境。举个直观的例子pwd (cd /tmp pwd) pwd在( ... )当中执行cd /tmp这个cd发生在子Shell里退出括号环境后父Shell的工作目录还是原来的。这就是Shell世界里典型的“父子环境分离”案例。再如管道echo hello | read var echo $var # 输出为空管道右侧的read var其实运行在子Shell里它读到了hello但赋值行为只发生在子Shell的变量空间里父Shell根本看不见。这也是热词里“shell中常见坑”排名前几的问题。1.4 怎么“看见”父子关系理论讲再多都不如亲眼看看进程树来得直观。我平时排查问题第一件事就是打开pstree把进程家族关系摊开看。写一个最简单的脚本#!/bin/bash echo 我的PID: $$ echo 我的父PID: $PPID sleep 60在前台运行这个脚本然后另开一个终端执行pstree -p | grep sleep我在实验室里跑出来的结果类似这样|-sshd(1000)---bash(2189)---sleep(2210)bash(2189)是脚本进程sleep(2210)是脚本执行sleep命令时fork出的子进程。而bash(2189)的父进程是sshd会话链上的某个进程。再看几个有用的查看命令echo $$打印当前Shell进程的PID。不过在子Shell里它仍然可能显示父Shell的PIDbash的$$在某些情况下不会变想看子Shell真身要用echo $BASHPID。echo $PPID打印父进程PID。ps -ef --forest以树状形式列出所有进程能直观看到谁是谁的子进程。pstree -p我最常用的进程树工具比ps的树状输出更清晰。掌握了这些查看手段后后面几节聊到的所有坑你都可以自己动手把进程树画出来验证这比死记结论要牢靠得多。2. 为什么要搞父子进程隔离、并发与“追责”2.1 隔离子进程闯祸不连累父进程Shell选择把外部命令放在子进程里执行首要原因是“隔离”。你想一想如果你执行一个会崩溃的程序比如某个第三方工具因为段错误直接退出但它是在子进程里发生的父进程Shell顶多是收到了一个非零退出码终端不至于因此崩溃。这是Shell能保持稳定的关键设计。更关键的是环境隔离。子进程从父进程继承环境变量、当前目录等但它对环境做的任何修改都不会反向影响父进程。子进程改自己的局部变量、切自己的目录、改自己的环境变量父进程这边毫发无损。这种隔离机制也带来了一个实际问题如果你在脚本里想让变量更新“传出去”就会很痛苦。比如你写了一个脚本run_job.sh在里面执行某些操作想给当前Shell留下一个变量直接bash run_job.sh是不行的因为脚本跑在子进程里里面的变量在子进程退出后全部消亡。你得用source让它在当前Shell环境执行或者让脚本把变量写到文件里再读出来。用生活化的话说子进程就像外卖员他进不了你屋也不能把你家家具搬走只能在门外按门铃、递给你结果。父子进程之间默认的交流通道非常有限——参数、环境变量、退出码、文件读写。2.2 并发后台任务与子进程的“职业”父子进程模型还带来一个重要能力并发执行。你在命令末尾加Shell会把这条命令放到后台执行它同样是一个子进程但父进程不等待它结束而是立刻返回继续执行下一条。你可以用$!拿到刚启动的后台子进程PID。这个机制对批量任务特别有用。比如我经常在脚本里做批量下载或批量处理#!/bin/bash for url in https://example.com/a.iso https://example.com/b.iso; do curl -O $url done wait echo 所有后台下载任务结束这个脚本启动了两个curl子进程在后台并行跑父脚本调用wait等待所有后台任务结束。执行完再统一收口。注意wait在这里非常重要。如果不wait父脚本会直接跑到最后退出时后台子进程还在运行很容易被挂断或变成孤儿进程。用wait能精细控制进程的汇合点。另外启动的后台子进程和前台子进程有一个关键差别前台命令执行时父Shell会用wait()系统调用等它后台命令则不会等待父Shell继续干活直到某次显式wait或Shell退出前才统一处理。2.3 管道、命令替换与循环你没察觉到的子进程很多Shell“玄学”问题追溯到底都是“某个语法结构偷偷创建了子进程”。管道|会创建子进程。比如echo hello | read line echo $line # 空因为read在子进程里赋值丢失管道符左侧、右侧的命令通常都会各起一个子进程。这不代表所有情况都如此但绝大多数情况下可以这么理解。所以管道内改变变量管道结束后变量值不会出现在父Shell里。命令替换$( )也会创建子Shell。比如result$(pwd)pwd是在一个子Shell里执行的它的输出会被捕获并赋值给父Shell的result。命令替换的“返回值”是以标准输出为载体的这恰恰是父子进程之间少有的“传出信息”手段之一。循环 管道的组合是许多脚本bug的重灾区count0 for i in 1 2 3; do count$((count i)) done | cat echo count$count # 这里 count 还是0因为for循环整体在管道左侧子进程里执行在管道左侧执行的for循环整块代码都运行在子Shell里循环体内给count的赋值全部发生在那一个子Shell里管道结束后父Shell的count依然是最初的0。想解决要么让循环脱离管道要么用进程替换( )把管道的角色转移到当前Shell里。2.4 退出码与“追责”父子进程如何互相汇报子进程干完活需要把结果“交账”给父进程这个账本是退出码exit code范围0~255。0表示成功非0表示各种异常。父进程通过$?获取上一条前台命令的退出码ls /tmp /dev/null echo ls的退出码是 $? # 通常是0脚本排查时退出码简直是“追责”的关键线索。比如你写了一个多层脚本A脚本调用B脚本B脚本调用C命令C失败导致后续异常。如果每一步都做了退出码检查就能迅速定位是哪个子进程掉的链子。我还习惯在关键步骤后立刻存下退出码important_command status$? if [ $status -ne 0 ]; then echo important_command 失败了退出码 $status exit 1 fi注意$?很容易被“冲掉”——只要再执行一条任意命令$?就会更新。所以有需要就立刻把它存到一个变量里。另外set -e这个选项能让脚本在任意一条命令返回非零退出码时立即退出省去大量手写判断。但它也有坑管道命令的退出码默认取最后一段命令的返回值如果管道中前段命令失败而后段成功set -e并不会中断脚本。可以配合set -o pipefail使用让管道中任意一段失败都算整体失败。这是我在实际脚本里必加的两行set -eo pipefail3. How父子进程实操控制“六板斧”3.1 wait等子进程干完再继续wait是Shell内建命令作用是让父Shell等待某个或某批后台子进程结束。用法wait等所有后台子进程结束。wait $PID等指定PID的后台子进程结束。wait -n只要任意一个后台子进程结束就返回。它还能拿到被等待子进程的退出码这对并发任务的错误处理特别关键。我在写多并行任务脚本时通常这样收集每个子进程的状态#!/bin/bash pids() for name in task_a task_b; do ./run_one.sh $name pids($!) done for pid in ${pids[*]}; do wait $pid echo 子进程 $pid 的退出码是 $? done这里有个细节$!每次调用都会更新为最近一次后台启动的进程PID所以在每次启动后台任务后立刻把它存入数组别等到循环结束再取否则拿到的只会是最后一个。3.2 exit父进程的“关门力度”exit用来结束当前Shell进程并交出退出码。不带参数时它把上一条命令的退出码当作自己的退出码exit 5则明确返回5。在父子进程语境里exit特别容易踩一个坑当你source一个脚本时如果脚本里写了exit它退出的可不是子Shell而是你当前所在的、正在source它的那个Shell。我自己就有过这样的经历——写了个工具脚本用source引入后想测试一下谁知脚本里测试用的exit 0直接把我的终端Shell也带走了。规避方法是给工具类脚本增加“可被source检测”。比如脚本开头判断自己是不是被直接执行if [ $(basename $0) 脚本名.sh ]; then main $ fi或者直接用return代替exit。Shell函数里和脚本里都可以用return它在source场景下更安全因为它只结束当前函数或脚本文件不会杀死调用它的父Shell。3.3 export给子进程“递纸条”普通变量与子进程之间默认是“互不相通”的。父Shell里定义了一个变量子进程里默认看不到。要让子进程看到必须用export把这个变量导出为“环境变量”。这个过程可以想象成父进程给子进程递了一张纸条纸条上的内容只要是“环境变量”子进程在启动时就会把它复制进自己的环境。但注意关键点子进程拿到的是父进程环境的快照。它在那张纸条上涂改父进程完全不知道。export MY_NAMEzhang bash -c echo 子进程看到: $MY_NAME; MY_NAMEli; echo 子进程改成: $MY_NAME echo 父进程仍是: $MY_NAME # 输出 zhang不是 li这个例子我在教学时经常用因为它就一句话点破了“export与作用域”的本质export控制的是“能不能往下传”控制不了“子进程改了能不能传回来”。想反向传值得靠文件、退出码、或进程间通信手段。另一个细节export一次导出多个变量时可以写export VAR1value1 VAR2value2export也可以用export -n取消导出这在某些需要临时隐藏变量的场合比较好用。3.4 exec不想分家直接“换血”exec与fork是相反的思路它不创建子进程而是直接在当前进程里替换要执行的程序。执行后当前Shell和原Shell的逻辑就没了PID不变但干的事完全变了。#!/bin/bash echo 脚本开始 exec python3 server.py echo 这行永远不会执行脚本执行到exec python3 server.py后当前bash进程会被替换成python3进程。刚才的bash已经不存在了后面的echo当然不会执行。这也是exec在脚本里常见的使用场景脚本在前面做一堆环境准备最后用exec启动主程序让主程序“接管”这个进程省掉一层多余的进程。还可以用exec做文件描述符重定向。比如把当前Shell的标准输出永久重定向到文件exec /tmp/all.log 21执行完后当前Shell里的所有输出都进日志文件了。这种用法配合脚本跑运行日志特别好使但初学者经常理解不了“怎么突然屏幕就没输出了”的原因。3.5 source在“家”里执行不是开新家source简写.和bash script.sh最大的区别是source不创建子Shell它在当前Shell进程里逐行执行脚本内容。也就是说脚本里定义的变量、执行的cd、修改的export都会保留在当前Shell中。cat config.sh EOF CONFIG_FILE/tmp/app.conf export LOG_LEVELdebug cd /tmp EOF source config.sh echo $CONFIG_FILE # 能输出 /tmp/app.conf pwd # 会在 /tmp 下对比一下bash config.sh echo $CONFIG_FILE # 空 pwd # 还是原来的目录这就是为什么环境准备脚本、工具函数库都习惯用source加载而不是bash执行。我在写公共函数库时会保证库脚本内部不用可能导致副作用的手段这样被source时才不会污染当前Shell环境。另外提醒一点source脚本里如果用了exit会直接退出当前Shell用return则只会退出source操作本身。所以在source场景下我已习惯让库脚本用return传递错误状态。3.6$!、$?与$BASHPID追踪进程的“身份证号”这几个内建变量是处理父子进程问题时的核心工具$!最近一个后台子进程的PID。$?上一条前台命令的退出码。$$当前Shell的PID。但在子Shell中bash的$$不一定是子Shell的PID它可能仍是父Shell的PID。$BASHPID当前bash实例的真实PID在子Shell中它就是子Shell的PID。$$和$BASHPID这个区别容易让人栽跟头。看这个例子echo 父Shell中: $$ $BASHPID ( echo 子Shell中: $$ $BASHPID )我在bash里跑出来的结果类似父Shell中: 2231 2231 子Shell中: 2231 3212在子Shell中$$还顽固地保留着父Shell PID$BASHPID则如实反映子Shell真实PID。所以排查子Shell环境时想确认“当前到底是谁”要用$BASHPID别只看$$。$PPID同理打印当前进程的父进程PID。在脚本里多用echo 本进程 $$, 父进程 $PPID基本就能把进程脉络理清楚。4. 那些年踩过的父子进程坑Shell常见问题实录与排查4.1 经典坑一管道里改变量怎么“消失”了现象很典型脚本里统计行数、累加值运行完一查变量归零。total0 cat access.log | while read line; do total$((total 1)) done echo 总行数: $total # 输出0原因就是管道右侧的while read跑在子Shell里total的累加都发生在子进程的变量空间。退出管道时这些修改随风飘散。解决办法几个进程替换 ( )让while循环跑在当前Shelltotal0 while read line; do total$((total 1)) done (cat access.log) echo 总行数: $total用临时文件保存结果cat access.log | while read line; do echo $((total 1)) /tmp/count.tmp done total$(cat /tmp/count.tmp)bash 4.0以上可以用mapfile读数组成数组循环走数组下标。我个人的习惯是优先用进程替换因为它在不改逻辑的前提下让变量留在当前Shell更直观。4.2 经典坑二脚本里cd没用路径没变脚本里cd /some/path退出脚本后当前终端的目录原封不动。原因和上面一样脚本作为子进程运行子进程里的cd只改变子进程自己的工作目录它退出了父Shell的路径当然不动。想要“改变当前Shell目录”的效果只有两种方式在交互式Shell里直接执行cd内建命令。用source执行脚本让cd发生在当前Shell进程内。这里多说一句为什么有些安装脚本跑完以后提示你“重启终端或source一下配置”。比如nvm的安装脚本它会往你的~/.bashrc里写入配置而不是直接去改当前Shell的变量。因为安装脚本是子进程它改不了父进程的环境只能通过写文件的方式让你下次启动Shell时自动加载。4.3 经典坑三shift命令操作参数为什么外面没变化“shift”是Shell脚本里处理位置参数的内建命令作用是把位置参数左移一位$2变$1$3变$2。坑在于shift只能影响当前Shell上下文里的位置参数。如果你在一个脚本里shift它只影响这个脚本子进程自己的$父Shell的位置参数当然纹丝不动。很多时候我们写一个函数想在函数里逐位消费参数按道理是在函数内部shift这也是正确的做法不要指望函数里shift能改变调用者的参数。常见的误区是把shift当成全局都能用的“指针移动”其实它的作用域严格限定在当前函数或当前脚本。定位问题的时候想想你操作的位置参数到底是哪个进程的参数如果你在子脚本里那是子脚本的。要操作父Shell的参数那就回到父Shell里去shift。4.4 经典坑四后台任务一断开就没了nohup没效果后台任务以启动后属于父Shell的子进程。当父Shell退出时通常会向它仍未结束的子进程发送SIGHUP信号通知它们“父亲走了”。很多后台任务没做信号处理于是收到SIGHUP就终止了。nohup的用途是让子进程忽略SIGHUP信号nohup ./my_server.sh server.log 21 setsid则把子进程放到新的“会话”session中让它彻底脱离当前终端的会话控制效果比nohup更彻底setsid ./my_server.sh server.log 21 如果你在写一个长期运行的服务脚本建议优先用setsid或配合nohup。还要注意重定向后台任务直接启动时如果在终端关闭前没有把输出重定向到文件容易因为终端或stdout释放导致各种异常。把这些细节都处理完一个后台子进程才能真正安全脱离父进程存活。4.5 经典坑五adb shell场景下的进程与脚本执行热词里有一堆adb shell相关命令比如adb shell pm uninstall --user 0 ...还有往/data/app/.../lib/arm64/...路径下操作的情况。这些场景同样受父子进程模型支配但多了两层额外复杂性。当你执行adb shell时本机会启动一个adb客户端进程通过服务和设备上的adbd通信。设备端adbd会启动一个远程shell进程这个远程shell就是你命令执行的环境。在这个远程shell里再执行pm、rm、ls等命令时又会产生新的子进程。所以我经常看到这样的问题在adb shell里执行一个脚本脚本里写了#!/bin/bash结果设备上报bash: not found。因为Android设备上默认的shell往往是mksh一个新版sh不是完整bash。你在“子进程”里加载bash但设备根本没装bash自然失败。此外Android系统的$PATH可能不包含/system/xbin等目录可执行命令的查找路径也会影响子进程能否找到工具。还有引号转义问题当你写adb shell sh /sdcard/test.sh arg1 arg2时引号传递要额外小心。外层Shell解析一遍adb又解析一遍设备端shell再解析一遍。多层的“父子解析链条”很容易让参数带上奇怪的引号或转义字符。我的经验是能用单引号包脚本内容就尽量用单引号避免外层Shell插值。4.6 经典坑六孤儿进程与僵尸进程讲父子进程必然绕不开这两种“异常状态进程”。孤儿进程父进程先退出子进程还在运行这时子进程会成为孤儿被系统中的initPID 1进程收养。在容器场景下可能是tini这类进程充当收养者。孤儿进程本身不是bug但如果你期望子进程退出后有人帮它收尸、处理退出码孤儿化就会导致无人wait。僵尸进程子进程已经退出但父进程没有用wait()读取它的退出状态。此时子进程会保留一个“尸体”在进程表中显示为Z状态。累积多了会消耗进程表空间。排查方法ps -ef | grep defunct ps aux | awk $8 ~ /Z/ {print}在Shell脚本中产生僵尸的常见方式是父脚本不停生成后台子进程却从不wait。解决思路就是要么明确wait回收要么确保父进程退出时由init接管。这里trap也能配合信号做处理但最直接的还是“谁的孩子谁负责”原则。4.7 亲子进程排错“三步走”遇到父子进程相关问题时我习惯按下面三步排查通常几分钟内就能定位:第一步画进程树pstree -p 目标PID把所有相关进程的父子关系摊开看一眼。第二步确认执行上下文。在问题发生的位置打印echo PID: $$, BASHPID: $BASHPID, PPID: $PPID如果这一步输出的PID和你想的不是同一个说明代码正在一个你没预料到的子Shell/子进程环境里运行。第三步打开调试跟踪。用set -x查看每条命令实际执行参数或者用strace -f -e traceprocess跟踪fork/exec调用链看到底是哪一步创建了子进程、哪一步丢了环境变量。这套组合几乎能解决90%的“变量怎么就没了”“命令怎么找到不到了”之类的问题。在我自己实际使用中最深的一条体会是遇到进程相关的问题先看进程树再动手改代码。多花10秒钟确认“当前到底运行在谁的环境里”远比反复试错高效。另一个小提示是在写任何一段Shell脚本前先在脑子里过一遍三连问“这一步是在当前Shell还是子Shell里执行我想让什么传过去传完还要不要带回来”想清楚这三件事大多数坑都能提前避开。