关掉终端,进程为什么就没了
晚上十点,你 ssh 到服务器跑一个数据迁移脚本。四十分钟后,笔记本合盖,网络断开。第二天重新连上,pgrep 什么也没找到,日志停在连接断开的那一秒。
很多人第一反应是“程序被杀了”。更准确地说,是它从一开始就没有真正离开过终端。
终端掌握的不只是输入输出
从 shell 启动的进程,通常带着三样东西:
- 一个控制终端,比如
/dev/pts/3 - 一个会话 ID(SID)和进程组 ID(PGID)
- 指向该终端的标准输入、标准输出、标准错误
可以先看一眼自己 shell 的会话结构:
1ps -o pid,ppid,sid,pgid,tty,stat,cmd -p $$SID 那一列通常就是 shell 的 PID,TTY 是 pts/N。所有在这个 shell 里启动的作业,默认都留在这个会话里。
终端断开时,内核会向控制终端上的会话首进程和前台进程组发送 SIGHUP。这个信号的默认动作是终止进程。交互式 shell 在退出前,通常还会把 SIGHUP 转发给自己管理的作业。名字里的 HUP,原本就是 modem 挂断的意思。
所以“关掉终端,进程就没了”并不是玄学,而是一条很老的 Unix 约定。
复现一次
写一个每秒写日志的脚本:
1cat > slow.sh <<'EOF'
2#!/usr/bin/env bash
3while true; do
4 echo "$(date +%T) pid=$$ ppid=$PPID tty=$(ps -o tty= -p $$)"
5 sleep 1
6done
7EOF
8chmod +x slow.sh直接放到后台运行:
1./slow.sh > slow.log 2>&1 &然后断开 ssh。重新连上后检查:
1pgrep -af slow.sh
2tail slow.log进程已经不在了,日志也停在断开的时间点。这里的 & 只是把作业放进后台,并没有让它脱离当前会话,更没有让它忽略 SIGHUP。
换成 nohup:
再断开,进程就能活下来。
nohup 究竟做了什么
nohup 做的事情比很多人想象中少:
它没有创建新会话,没有改变工作目录,也没有自动重定向标准输入和标准错误。被忽略的信号处置会在 exec 后继续保留,而被捕获的信号处理函数则会恢复默认,这也是 nohup 能生效的关键。
可以用 ps 验证:
你会看到它的 TTY 仍然是 pts/N,SID 仍然是当前 shell 的会话。它只是“不怕 SIGHUP”,并没有真正离开终端。
disown 则是另一件事:把作业从 shell 的作业表里移除,避免 shell 退出时向它转发 SIGHUP。disown -h 只标记“不要给它发 HUP”,作业仍然留在表里。
真正脱离终端的方式
用 setsid 新建会话
1setsid -f ./slow.sh > slow.log 2>&1 </dev/nullsetsid 会创建新会话,进程成为会话首进程,不再拥有控制终端。-f 表示总是 fork,这样 shell 的作业表里不会留下一个仍然关联的作业。
验证一下:
1pgrep -af slow.sh
2ps -o pid,ppid,sid,tty,stat,cmd -p <PID>此时 TTY 应该显示为 ?,SID 等于它自己的 PID。它不再怕终端挂断,但并没有忽略 SIGHUP;如果有人手动 kill -HUP,它照样会退出。
用 tmux 托管
1tmux new-session -d -s migrate './slow.sh > slow.log 2>&1'
2tmux ls
3tmux attach -t migratetmux server 会自己脱离终端运行,ssh 断开后任务还在,之后还能 attach 回去看输出。对需要交互、需要中途查看日志的长任务,这通常是最省事的方式。
交给 systemd
临时任务可以这样启动:
1loginctl enable-linger "$USER" # 只需执行一次,必要时加 sudo
2
3systemd-run --user --unit=migrate --collect \
4 --working-directory="$PWD" \
5 /bin/bash -lc 'exec ./slow.sh'
6
7journalctl --user -u migrate -f--collect 表示任务结束后自动清理临时 unit。enable-linger 让用户级 systemd 在用户未登录时继续运行,否则注销后用户管理器停止,unit 也会跟着停。
长期运行的任务,最好老老实实写一个 service:
1# ~/.config/systemd/user/migrate.service
2[Unit]
3Description=Daily migration
4
5[Service]
6Type=simple
7WorkingDirectory=%h/projects/migrate
8ExecStart=/usr/bin/bash -lc 'exec ./slow.sh'
9Restart=on-failure
10
11[Install]
12WantedBy=default.target1systemctl --user daemon-reload
2systemctl --user enable --now migrate.service| 方式 | 脱离控制终端 | 忽略 SIGHUP | 输出 | 适合 |
|---|---|---|---|---|
cmd & | 否 | 否 | 终端 | 几秒钟的后台任务 |
nohup cmd & | 否 | 是 | 重定向或 nohup.out | 临时任务 |
setsid -f cmd | 是 | 否 | 需自行重定向 | 一次性脚本 |
tmux | 是 | 由 tmux 处理 | 可 attach 回看 | 交互式长任务 |
systemd-run --user | 是 | 是 | journal | 需要审计或开机自启 |
为什么加了 nohup 还是会死
1. logind 把整个用户会话清理了
systemd-logind 有一个 KillUserProcesses 配置。开启后,当用户的最后一个会话结束时,logind 会杀掉该用户的所有进程。nohup、setsid、tmux 都挡不住,因为它们仍然属于这个用户。
检查配置:
1grep -R '^KillUserProcesses' /etc/systemd/logind.conf /etc/systemd/logind.conf.d/ 2>/dev/null
2loginctl show-user "$USER" -p Linger如果 KillUserProcesses=yes,要么开启 linger,要么把任务做成系统级 service。注意 linger 会让用户级服务在未登录时也保持运行,方便,但也意味着更大的常驻面。
2. 标准输入输出还挂在终端上
后台进程如果读取控制终端,会收到 SIGTTIN 并被暂停,ps 里的状态是 T。终端断开后,向它写数据可能返回 EIO,程序如果没处理这个错误,就可能自己退出。
所以重定向要写全:
1nohup ./slow.sh >slow.log 2>&1 </dev/null &只重定向 stdout,不处理 stdin 和 stderr,仍然会留下隐患。
3. 进程还在某个 service 的 cgroup 里
如果任务是从某个 systemd service 里启动的,停止该 service 时,默认的 KillMode=control-group 会杀掉 cgroup 内的所有进程,包括你手动放到后台的子进程。
1cat /proc/<PID>/cgroup
2systemctl status <unit>这类情况不是终端的问题,而是 cgroup 的生命周期问题。
一张排查表
| 现象 | 先看什么 | 常见原因 |
|---|---|---|
| 进程完全消失 | pgrep -af 名字、journalctl -b | SIGHUP、logind 清理、service 停止 |
进程还在但状态是 T | ps -o stat,tty,cmd -p PID | 后台读终端触发 SIGTTIN |
TTY 仍是 pts/N | ps -o sid,tty -p PID | 没有真正脱离会话 |
| 日志在断开时截断 | tail 日志、检查 fd | stdout 写终端失败 |
| 手动发 HUP 才死 | grep SigIgn /proc/PID/status | SIGHUP 是否被忽略 |
/proc/<PID>/status 里的 SigIgn 是一个十六进制掩码,最低位对应信号 1,也就是 SIGHUP。如果最低位是 1,说明它忽略了 SIGHUP:
1grep -E 'SigIgn|SigCgt' /proc/<PID>/status最后
& 是“放到后台”,nohup 是“别怕挂断信号”,setsid 是“换一个没有终端的会话”,systemd 则是“把生命周期交给专门的管理者”。它们解决的不是同一个问题。
临时跑一次长任务,用 tmux 或 setsid -f 加完整重定向就够了;需要长期运行、自动重启、开机自启,就不要再拿 nohup 凑合,写一个 service 更可靠。毕竟真正让进程活下来的,不是某一个命令,而是你清楚它到底挂在谁的生命周期上。