进程为什么会在 SSH 断开后死掉:搞懂进程组、会话与控制终端
几乎每个站长都遇到过这个场景:在 SSH 里跑一个耗时很长的备份或编译脚本,中途网络抖动了一下,PuTTY 或者终端窗口一断,回来一看,任务没了,日志停在半路。你明明没有按 Ctrl+C,进程却「自己死了」。要真正理解这件事,就必须搞清 Linux 里三个常被忽略的概念:进程组(process group)、会话(session)和控制终端(controlling terminal)。它们构成了作业控制和信号传递的基础,也是 nohup、setsid、disown 这些命令为什么能生效的根本原因。
进程组:一组可以被一起发信号的进程
Linux 里每个进程都属于一个「进程组」。进程组的意义在于,你可以把一整个组当作一个单位来发送信号。最典型的例子是一个 shell 管道:cat big.log | grep error | wc -l 会启动三个进程,它们被放进同一个进程组,当你按 Ctrl+C 时,终端驱动只发一个信号给前台进程组,然后由内核分发给组里所有进程,三个进程一起收到 SIGINT 一起退出。这就是「作业」的底层实现。
每个进程组有一个组长(process group leader),通常是最先创建该组的那个进程,其 PID 就等于进程组 ID(PGID)。你可以在 shell 里看到这些信息:
# 加 -j 参数,输出中会多出 PGID、SID(会话 ID)、TTY(控制终端)几列
ps -o pid,ppid,pgid,sid,tty,cmd -j
# 例子输出(节选):
# PID PPID PGID SID TT CMD
# 1234 1200 1234 1200 pts/0 -bash
# 1235 1234 1235 1200 pts/0 ping example.com
上面 -bash 的 PID 和 PGID 都是 1234,说明它是自己这个进程组的组长;ping 的 PGID 是 1235,和它自己的 PID 一致,它也是自己所在组的组长(因为它是 shell 单独启动的一个前台作业)。注意它们的 SID 都是 1200,表示它们属于同一个会话。
会话:一次登录的生命周期
会话比进程组更外层。当你登录进一个终端,系统会为你创建一个新会话,会有一个「会话首进程」(session leader),通常是你的登录 shell。一个会话里可以包含多个进程组,但同一时刻只有一个进程组是「前台进程组」,其余的都在后台。
会话的关键作用是「管住一整套作业」。当你关闭终端、断开 SSH 连接时,终端的 Hangup(挂断)信号会发往该会话的前台进程组。默认情况下,收到 SIGHUP 的进程会退出,这也就是 SSH 一断、前台跑的脚本就死掉的原因——它被挂断信号带走了。
理解这一点后,那些「保命命令」的原理就豁然开朗了:它们的本质都是把进程从当前会话、当前终端的挂断影响范围里「摘出去」。
# nohup:忽略 SIGHUP 信号,同时在当前目录留下 nohup.out
nohup ./backup.sh &
# disown:把已启动的后台作业从 shell 的作业表里移除,shell 退出时不会再给它发 SIGHUP
./backup.sh &
disown -h %1 # -h 表示仍然对挂断信号免疫,但保留在作业表
# setsid:新建一个会话运行进程,让它彻底脱离当前终端会话
setsid ./backup.sh </dev/null >backup.log 2>&1 <&-
这三者的差别值得记住:nohup 只处理挂断信号,进程仍然属于原会话;disown 是 shell 内建命令,操作的是 shell 的作业表;而 setsid 最彻底,它直接给进程开一个新会话,新会话没有控制终端,从根本上切断了和旧终端的联系。这也解释了为什么 setsid 最常用于编写守护脚本——它一步到位地完成了「脱离终端」这件事。
孤儿进程、僵尸进程,别再搞混
说到进程关系,有两个名字听起来很像但完全不同东西的概念,很多站长会把它们弄混。
孤儿进程(orphan process):父进程先于子进程退出,子进程就变成了孤儿。此时它会被 init(PID 1)或 systemd 收养,PPID 变成 1。孤儿进程是正常现象,不会占用额外资源,最终会正常结束。你 nohup 起来的任务在终端关闭后,就是被 PID 1 接管的孤儿进程——这恰恰是我们想要的效果。
僵尸进程(zombie process):子进程已经退出,但父进程还没有调用 wait() 回收它的退出状态,于是这个进程在进程表里留下一条只剩 PID 和退出码的空壳。僵尸进程不占内存也不占 CPU,但占 PID。偶尔一两个无害,长期大量堆积会耗尽 PID 导致无法创建新进程。僵尸的解法不是 kill 它本身(它已经死了,kill 无效),而是要处理它的父进程,让父进程去回收,或者重启父进程。
# 找僵尸进程:状态列(STAT)为 Z
ps aux | awk '$8 ~ /^Z/ {print}'
# 查看某进程的完整状态字符串(前几个字段是 PID 和状态)
cat /proc/12345/status | grep -E 'State|PPid|Pid'
# 找出僵尸的父进程,从父进程入手
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'
用 kill 精确地发信号:负号就是发往整个进程组
理解了进程组,kill 命令的一个高级用法就顺理成章了。给 kill 传一个负数,表示把信号发给「PGID 为该绝对值的整个进程组」:
# 杀死单个进程
kill 12345 # 默认发 SIGTERM(15),礼貌地请它退出
kill -9 12345 # SIGKILL,强杀,进程无法捕获,可能丢失数据
# 杀死整个进程组(PGID 为 12345 的所有进程)
kill -- -12345
# 命令行按名字批量停掉,pgrep 默认返回 PID,加 -f 匹配整条命令行
pkill -f 'backup.sh'
# 想连子进程一起处理时,先确认进程组,再按组发信号
ps -o pid,pgid,cmd -j | grep backup.sh
这里有个细节:kill -- -12345 里的 -- 是为了让命令行解析器不要把 -12345 当成一个选项参数。杀掉整个进程组在清理「一个脚本拉起一堆子进程」的场景里特别有用,因为它一次就能把事情做干净,不用逐个去 kill 子进程。
实战:写一个真正能扛住断线的守护脚本
把上面的知识串起来,我们来写一个「SSH 断了也不死、被 kill 时能优雅清理、不产生僵尸」的脚本。核心套路是:用 setsid 脱离会话,重定向所有标准描述符,用 trap 捕获信号做清理。
#!/bin/bash
# daemon.sh —— 后台守护脚本模板
# 关键第一步:如果还没脱离终端,用 setsid 重新拉起自己,然后父进程退出
if [ "$1" != "__detached" ]; then
setsid "$0" __detached </dev/null >>/var/log/daemon.log 2>&1 &
echo "started, pid=$!"
exit 0
fi
# 到这里已经是脱离终端的新会话,开始正经干活
LOG=/var/log/daemon.log
echo "$(date '+%F %T') daemon start, pid=$$" >> "$LOG"
cleanup() {
echo "$(date '+%F %T') received signal, cleaning up..." >> "$LOG"
rm -f /tmp/daemon.lock
exit 0
}
# 捕获 TERM 和 INT,收到时先清理再退出,避免留下垃圾文件
trap cleanup TERM INT
while true; do
# 实际任务主体
echo "$(date '+%F %T') doing work..." >> "$LOG"
sleep 60
done
几点说明:setsid "$0" __detached 通过给自己传一个特殊参数来判断「是不是已经被重新拉起过」,避免无限循环。</dev/null 切断标准输入,让进程不会因为尝试读终端而挂起;>>/var/log/daemon.log 2>&1 把标准输出和错误都引到日志;结尾的 & 让它在后台运行。trap cleanup TERM INT 确保被 kill -15 时能先删掉锁文件再退出,而不是硬邦邦地被终止。
如果想要更强的「常驻」保证(崩溃自动拉起、开机自启),最正规的做法是交给 systemd,用 Type=simple 或 Type=forking 的 unit 来托管,这样连 setsid 和 trap 都可以省掉,systemd 会替你把会话隔离、信号处理和重启都管好。
一张表分清 nohup、disown、setsid、systemd
| 方式 | 做了什么 | 适用场景 |
|---|---|---|
| nohup | 忽略 SIGHUP,输出默认写入 nohup.out | 临时跑一个不想被断线杀掉的任务 |
| disown | 从 shell 作业表移除,shell 退出时不再通知它 | 已经用 & 启动后的补救 |
| setsid | 新开会话,彻底脱离控制终端 | 脚本内部自我脱离,最彻底 |
| systemd | 由 init 托管,管理生命周期、重启、日志 | 长期常驻服务,生产环境首选 |
选型建议:临时任务用 nohup 就够了;脚本里需要自我脱离用 setsid;而任何要长期跑的服务,别用 nohup 硬扛,交给 systemd 或 supervisor 才是正解。很多「服务跑着跑着没了」的事故,根源就是有人用 nohup ... & 起了一个本该由 systemd 管的服务,服务器一重启就全丢了。
排查现场:进程关系的几个常用命令
ps -eo pid,ppid,pgid,sid,tty,stat,cmd:一张表看全进程的父子、组、会话、终端归属。pstree -p:树状展示进程父子关系,看谁拉起了谁一目了然。ps -o pid,pgid,sid,cmd -j:聚焦查看目标和它的 PGID/SID。cat /proc/PID/status | grep -E 'State|PPid|Pid':单个进程的权威状态,含僵尸判断。pwdx PID:查进程当前工作目录,常用来找它读的是哪个配置。
从「进程为什么会被断线杀死」这个具体问题出发,我们把进程组、会话、控制终端、信号传递这一整条链路理顺了。你会发现,nohup 和 setsid 不再是需要死记的命令,而是「把进程移出挂断信号影响范围」这一原理的自然推论。个人站长做运维,遇到「任务莫名其妙没了」「服务重启后全丢」「僵尸进程堆了一堆」这类问题时,先想到去看进程组和会话关系,通常就能少走很多弯路。