
脚本里把一个 sleep 300 甩到后台,过一会儿 kill 掉脚本自己的 PID。终端回一行 Terminated,看着像收工了。再 ps 一下,那个 sleep 还挂在进程表里,父进程那一栏已经不是原来的脚本。
本机是 macOS 自带的 bash 3.2,每条输出都来自这台机器上真跑过的一遍。
== 1. 这台机器的 shell、ps 和几个相关命令 ==
$ bash --version | head -1
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
$ uname -sm
Darwin arm64
$ /Users/yxh/.hermes/node/bin/node --version
v26.8.1
$ command -v setsid
(没有任何输出,本机没有 setsid 这个命令)
$ command -v bash5
(没有任何输出,本机没有 bash5)
$ bash -c "echo BASHPID=${BASHPID:-空}"
BASHPID=空
$ bash -c "shopt -p lastpipe"
bash: line 0: shopt: lastpipe: invalid shell option name
退出码 1
$ ps -o pid,ppid,pgid,command -p $$
PID PPID PGID COMMAND
24434 24433 24433 bash tools/verify-kill-process-group.sh
外层脚本自己的 PID=24434,组号 24433
PGID 那一列是进程组号。样例是一段最常见的结构,父脚本里挂一个后台的 sleep:
$ cat spawn.sh
#!/bin/bash
sleep 300 &
echo "父脚本 PID=$$,子进程 PID=$!"
echo "$!" > /private/tmp/verify-kill-process-group/child.pid
wait
$ bash spawn.sh > spawn.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24434,24449,24451
PID PPID PGID COMMAND
24434 24433 24433 bash tools/verify-kill-process-group.sh
24449 24434 24433 bash spawn.sh
24451 24449 24433 sleep 300
三行依次是外层脚本、父脚本、sleep,组号都是 24433
父脚本和子进程的组号都是外层脚本那一组里的数字,谁也还没离开。
只见父脚本那一行空了
== 3. 只杀父脚本那一个 PID ==
$ kill 24449
tools/verify-kill-process-group.sh: line 86: 24449 Terminated: 15 bash spawn.sh > spawn.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24449
PID PPID PGID COMMAND
(父脚本那一行空了,只剩表头)
$ ps -o pid,ppid,pgid,command -p 24451
PID PPID PGID COMMAND
24451 1 24433 sleep 300
子进程还在,PPID 变成了 1,组号仍旧是 24433
kill 只发给了父脚本那一个进程号。父脚本退出,它的子进程不跟着走,PPID 被系统改成 1,这一栏的变化就是被领养的痕迹。组号没有动,还是原来那个数字,因为进程组不是父进程的孩子,父进程死了它也不消失。
孤儿还占着端口和锁文件
剩下那条命还开着端口,锁文件和临时目录也没撒手,脚本再跑一遍就撞车。CI 上更常见:job 绿了,机器上留着几条 sleep 或者 node,下一轮构建少一块内存和磁盘。孤儿不是错误状态,系统不会为它发任何通知,只能自己找出来。
定位靠组号那一列
父脚本如果开了作业控制,它自己就是一组,组号等于它自己的 PID,这一组里还有什么进程,pgrep 能按组号列出来:
== 4. 开作业控制之后,父脚本才有自己的一组 ==
$ set -m; bash spawn.sh > spawn4.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24463,24465
PID PPID PGID COMMAND
24463 24434 24463 bash spawn.sh
24465 24463 24463 sleep 300
父脚本组号 24463,等于它自己的 PID,跟外层脚本那一组 24433 不是同一个
$ kill 24463
tools/verify-kill-process-group.sh: line 111: 24463 Terminated: 15 bash spawn.sh > spawn4.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24465
PID PPID PGID COMMAND
24465 1 24463 sleep 300
$ pgrep -g 24463
24465
$ kill -- -24463
退出码 0
$ ps -o pid,ppid,pgid,command -p 24465
PID PPID PGID COMMAND
(只剩表头,leader 已经死了,组号还认得出那个孤儿)
上面这一段先杀父脚本的单个 PID,组里留下一个 pgrep -g 认得出的进程,再对着组号发一次信号,退到只剩表头。不写 set -m 的时候没这么整齐:父脚本和外层脚本共用一组,组号那一栏两边是同一个数字。
== 6. 没有作业控制时,父脚本和外层脚本同组 ==
$ bash spawn.sh > spawn3.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24434,24500,24502
PID PPID PGID COMMAND
24434 24433 24433 bash tools/verify-kill-process-group.sh
24500 24434 24433 bash spawn.sh
24502 24500 24433 sleep 300
外层脚本组号 24433,父脚本组号 24433,两个数字一样就说明它们在同一组里
两个数字一样,意味着对着这个组号发信号,外层脚本也在射程里。
负号前面那个 --
kill 后面直接跟一个负数,shell 内建会把它当成信号号来解释:
== 5. 负号必须配 --:kill -PGID 会被当成信号号 ==
$ set -m; bash spawn.sh > spawn2.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24489,24491
PID PPID PGID COMMAND
24489 24434 24489 bash spawn.sh
24491 24489 24489 sleep 300
$ kill -24489
tools/verify-kill-process-group.sh: line 139: kill: 24489: invalid signal specification
退出码 1
$ kill -- -24489
退出码 0
tools/verify-kill-process-group.sh: line 144: 24489 Terminated: 15 bash spawn.sh > spawn2.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24489,24491
PID PPID PGID COMMAND
(这一组两个进程一起没了)
报的是 invalid signal specification,退出码 1。要按组发信号,中间得补一个 -- 把选项和参数隔开,退出码 0,这一组里的 bash 和 sleep 一起没了。
第一次写这个脚本我在本机撞过一次:拿外层 shell 那一组的组号发了信号,连着运行脚本的 shell 一起被 TERM 掉,日志写到一半就断了。组号跟外层共用的场合,这一下收走的是谁并不好预判。
TERM 到父脚本为止
脚本自己 trap 了 TERM,父脚本确实收到了,同组的子进程没收到:
== 7. 父脚本收到 TERM,子进程不一定收到 ==
$ cat trapwait.sh
#!/bin/bash
trap 'echo 父脚本收到 TERM' TERM
sleep 300 &
echo "$!" > /private/tmp/verify-kill-process-group/trap.pid
wait
$ set -m; bash trapwait.sh > trap.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24516,24518
PID PPID PGID COMMAND
24516 24434 24516 bash trapwait.sh
24518 24516 24516 sleep 300
$ kill -TERM 24516
$ cat trap.out
父脚本收到 TERM
$ ps -o pid,ppid,pgid,command -p 24516,24518
PID PPID PGID COMMAND
24518 1 24516 sleep 300
$ kill -- -24516
退出码 0
$ ps -o pid,ppid,pgid,command -p 24518
PID PPID PGID COMMAND
(子进程要单独清:TERM 到父脚本为止,没往下走)
trap 管的是发到自己 PID 上的信号,往下转发是脚本自己的活,得在 trap 里写一条 kill。日志末尾那两条命令是分开的,先杀父,再对着组号补一次。
前台跑的时候信号被压住
同一个信号,落到正卡在子进程上的父脚本身上,表现又不一样:
== 8. 子进程在前台跑的时候,TERM 被压到它结束 ==
$ cat trapfg.sh
#!/bin/bash
trap 'echo 父脚本收到 TERM' TERM
sleep 4
echo 前台子进程自然结束
$ set -m; bash trapfg.sh > fg.out 2>&1 &
$ kill -TERM 24665
$ ps -o pid,ppid,pgid,command -p 24665
PID PPID PGID COMMAND
24665 24434 24665 bash trapfg.sh
(父脚本还挂在 ps 里,TERM 没生效)
$ cat fg.out
父脚本收到 TERM
前台子进程自然结束
$ ps -o pid,ppid,pgid,command -p 24665
PID PPID PGID COMMAND
(父脚本处理完 TERM 又接着往下跑了:TERM 只是被推后,没有终止它)
TERM 发出去一秒后父脚本还在进程表里,等子进程自然跑完,trap 那行才打出来,脚本接着往下把最后一行也跑了。信号被推后,不是被忽略。想让它立刻停,得让前台的孩子先收到信号。
想让子进程活着离开
反过来也有需求:希望子进程躲开这一轮清理。本机没有 setsid 这个命令,command -v setsid 一行输出都没有。
要另起一组,用 python 的 os.setsid 顶一下,起出来的父脚本组号等于自己的 PID:
== 9. 本机没有 setsid,改用 python 的 os.setsid ==
$ /Users/yxh/.hermes/hermes-agent/venv/bin/python3 -c 'import os,sys; os.setsid(); os.execv("/bin/bash",["bash","spawn.sh"])'
$ ps -o pid,ppid,pgid,command -p 24691,24693
PID PPID PGID COMMAND
24691 24434 24691 bash spawn.sh
24693 24691 24691 sleep 300
外层脚本组号 24433,这一组组号 24691,组号等于它自己的 PID 24691
$ kill -- -24691
退出码 0
tools/verify-kill-process-group.sh: line 255: 24691 Terminated: 15 "${PY}" -c 'import os,sys; os.setsid(); os.execv("/bin/bash",["bash","spawn.sh"])' > setsid.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24691,24693
PID PPID PGID COMMAND
(外层脚本没事,这一组两个一起走)
这一组的组号跟外层脚本那一组不是同一个数字,对着它发信号,外层脚本毫发无伤。
nohup 挡不住组号
nohup 容易被高估,它只把 SIGHUP 的处理方式改成忽略,进程组照旧跟着父 shell:
== 10. nohup 只挡 SIGHUP,不换进程组 ==
$ set -m; bash -c "nohup sleep 300 & wait" > nohupsh.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24702,24704
PID PPID PGID COMMAND
24702 24434 24702 bash -c nohup sleep 300 > nohup.out 2>&1 & echo "$!" > nohup.pid; wait
24704 24702 24702 sleep 300
$ kill -- -24702
tools/verify-kill-process-group.sh: line 275: 24702 Terminated: 15 bash -c 'nohup sleep 300 > nohup.out 2>&1 & echo "$!" > nohup.pid; wait' > nohupsh.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24704
PID PPID PGID COMMAND
(只剩表头,nohup 起来的 sleep 跟着这一组走了)
nohup sleep 300 那行的组号跟包着它的 shell 一样,组号一杀它跟着走。以为 nohup 会顺手把进程挪到别处去,实测没有这一回事,它挡的只是挂断信号那一件。
node 的 detached 动的是组
node 的 spawn 默认跟 node 同组,写成 detached: true 之后子进程另起一组:
$ set -m; node nspawn.js > n1.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24713,24718
PID PPID PGID COMMAND
24713 24434 24713 /Users/yxh/.hermes/node/bin/node nspawn.js
24718 24713 24713 sleep 300
$ kill -- -24713
退出码 0
tools/verify-kill-process-group.sh: line 314: 24713 Terminated: 15 "${NODE}" nspawn.js > n1.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24718
PID PPID PGID COMMAND
(只剩表头,默认 spawn 的 sleep 在 node 那一组里)
$ set -m; node ndetach.js > n2.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24728,24733
PID PPID PGID COMMAND
24728 24434 24728 /Users/yxh/.hermes/node/bin/node ndetach.js
24733 24728 24733 sleep 300
$ kill -- -24728
退出码 0
tools/verify-kill-process-group.sh: line 333: 24728 Terminated: 15 "${NODE}" ndetach.js > n2.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24728
PID PPID PGID COMMAND
$ ps -o pid,ppid,pgid,command -p 24733
PID PPID PGID COMMAND
24733 1 24733 sleep 300
(node 没了,detached 的 sleep 还在,PPID 已经变成 1)
第一段里那个 sleep 跟 node 的组号相同,一起被收走;第二段里它自己的组号等于自己的 PID,node 死了它还活着,PPID 变成 1。写 detached: true 的场合要自己想清楚,这个子进程打算由谁负责收尾。
收尾时按组清一遍
落成脚本里的做法是:起父进程之前用 ps -o pgid= 取一次组号,收尾时对着这个组号发 kill --,对不上就说明它已经另起了组。中间层能用 exec 顶掉的尽量顶掉,少一层就少一次转发:
== 12. exec 换掉 shell 之后,组里只剩一个进程 ==
$ set -m; bash -c "exec sleep 300" > exec.out 2>&1 &
$ ps -o pid,ppid,pgid,command -p 24772
PID PPID PGID COMMAND
24772 24434 24772 sleep 300
$ kill -TERM 24772
tools/verify-kill-process-group.sh: line 353: 24772 Terminated: 15 bash -c 'exec sleep 300' > exec.out 2>&1
$ ps -o pid,ppid,pgid,command -p 24772
PID PPID PGID COMMAND
(exec 之后那个 PID 上就是 sleep 本身,TERM 直接送到)
exec 之后那个 PID 上坐着的就是 sleep 本身,TERM 一步送到,中间那层 shell 不存在了。剩下一条是把组号写进日志,过两天在机器上翻出还活着的进程时,照着组号能找到它是谁留下的。