nohup 之后进程还是死了:GUI 子进程的存活陷阱

2 阅读3分钟

有个脚本要跑一个会开窗口的桌面程序。我按常规做法启动:

nohup ./cli.cmd publish-article ... >/dev/null 2>&1 &
disown
echo "launched"

命令立刻返回,PID 打印出来了。过一分钟去看输出文件——文件压根没生成

同一条命令,换个方式跑就好了。这篇记录中间的差别在哪,以及我确证了什么、没确证什么。

先排除一个常见误判

nohupdisown 各自只解决一个很窄的问题:

  • nohup 让进程忽略 SIGHUP(终端挂断信号)。它的名字就写着自己能干什么,不多干。
  • disown 把作业从当前 shell 的作业表里移除,这样 shell 退出时不会给它发信号。

两者都阻止 SIGTERM。而清理动作发的通常是 SIGTERM。所以「加了 nohup 和 disown 就该活着」这个预期本身是错的——它们防的是挂断,不是终止。

我观察到的现象

同一条命令,两种跑法,结果稳定可复现:

跑法父进程是否等待结果
nohup ... & + disown,命令立即返回输出文件从未生成
放进带轮询循环的后台任务,父进程等到结束正常跑完,输出完整

差别只有一条:父进程是不是活着等到了子进程结束

补充几个观察到的细节:

  • 被杀时没有任何错误输出,表现为「命令成功返回了,但什么都没发生」。
  • 目标程序是会开浏览器窗口的 GUI 程序。纯命令行的子进程在同环境下没有这个问题。
  • 单篇任务稳定耗时约 40 秒,父进程只要撑过这 40 秒就没事。

我没有确证的部分

坦白说,具体是谁发的 SIGTERM、按什么粒度清理,我没有确证。几种可能性都解释得通:

  • 进程组(process group)被整体回收
  • 作业对象(Windows Job Object)在父进程退出时关闭
  • 无控制台会话的 GUI 进程被会话管理器清理

能确证的只有行为和解法,机制层面以上都是推测。 如果你的环境和我不一样,先复现再说,别直接照搬结论。

有效的三件事

一、让父进程活到子进程结束

这是最可靠的一条。不要用「启动后就返回」的写法,改成启动后同步等待:

nohup ./cli.cmd publish-article ... >/dev/null 2>&1 &
disown
for i in $(seq 1 40); do
  sleep 5
  [ -f "$OUT" ] && grep -q "执行结束" "$OUT" && break
done

父进程在这里耗着,子进程就活得下来。代价只是父进程多占一会儿。

二、输出必须落到文件,不能靠管道

管道会随着进程一起消失,文件不会。把 stdout/stderr 全部重定向到文件,然后轮询文件内容判断进度,而不是等进程退出。

这一步同时解决了另一个问题:即使中途被杀,已经写进文件的部分输出还在,能从中断的位置看出跑到哪一步了。

三、用哨兵文件区分「没启动」和「启动后被杀」

这两种失败的排查方向完全不同,但表现很像(都是没有产物)。

OUT=/tmp/job.log
: > "$OUT"                      # 任务开始前清空
echo "STARTED $(date +%s)" >> "$OUT"
nohup ./cli.cmd ... >> "$OUT" 2>&1 &
# ...轮询...

判据:

  • OUT 完全为空 → 子进程启动即死,或者根本没起来。查命令路径、环境变量。
  • OUTSTARTED 但没有完成标记 → 起来了,中途被杀。查父进程生命周期。
  • OUT 有完成标记 → 正常。

我的第一次失败属于第一种,正因为有文件可查,才没把它误判成「程序内部报错」。

顺带:退出码 0 不等于任务完成

这套流程里还有一个独立的坑。有一次命令退出码是 0,看输出里的状态字段也是 true,但产物文件里根本没有完成标记。

原因是:子进程把状态写进日志后,主进程才做收尾;退出码反映的是主进程的收尾结果,不代表业务真的走完了。

所以判据要用产物里的完成标记,不要用退出码,也不要只信状态字段。三个都对上才算数。

一句话

nohup 是免死金牌的印象,来自「终端关掉进程别死」这个原始场景。在会被主动清理的运行环境里,真正决定子进程生死的是父进程还在不在。让父进程等着,比折腾信号处理简单得多。