# SSH 一断，后台进程为什么跟着死：&、nohup、disown、tmux 各管哪一段，最后为什么要交给 systemd

SIGHUP 从哪来、bash 退出时怎么转发给各 job，nohup 改掉了哪三个文件描述符，disown 与 tmux 各补上什么，以及 systemd 为什么根本不在你这条因果链上

> 进程管理 · 命令行操作 · systemd · 约 7 分钟 · 10 月 07 日

## 本篇要点

1. ssh 断开时，内核看到控制终端消失，会给它上面的前台进程组发 SIGHUP，默认动作是终止进程；bash 如果是因为收到 SIGHUP 而退出，在退出前还会把 SIGHUP 转发给自己名下所有 job，包括后台和暂停的任务。正常敲 `exit` 时默认不发这个信号，两者结果不同。
2. `&` 只让 shell 不再等这条命令，不改变它仍挂在当前 shell 名下的事实，所以挡不住断线带来的 SIGHUP。
3. `nohup` 在执行目标程序前把 SIGHUP 设为忽略，该处置随 exec 被继承；它还会把指向终端的 stdin 换成不可读的替代、把 stdout 追加到 nohup.out、把 stderr 并入 stdout。
4. 更可控的写法是 `nohup 命令 > 日志 2>&1 &`：三个符号分别管 stdout 的去处、stderr 的去处和 shell 是否等待。
5. `disown %1` 把 job 从 job 表里删掉，shell 退出时不再给它发 SIGHUP，但也不能再 `fg`；`disown -h %1` 保留在 job 表里，只标记不接收 SIGHUP。它挡不住输出仍写向正在消失的终端，所以启动时就该重定向好。
6. `nohup`/`disown` 让进程活下来但断了交互；`tmux` 是让 tmux server 脱离终端运行、你 attach 上去的一个虚拟终端，断线只失去客户端，交互和输出都还在。
7. `nohup` 一类办法都不解决崩溃重启、开机自启、日志归集和优雅停止，这是它的定位，不是缺陷。
8. systemd service 不受 ssh 断线影响，是因为它由 PID 1 直接拉起、不属于你的会话、没有控制终端，本来就收不到那一发 SIGHUP。
9. `systemctl stop` 默认按 KillMode=control-group 把整个 cgroup 里的剩余进程一起结束，因此能解决“杀了主进程、worker 还占着端口”的问题。
10. `systemd --user` 的用户级服务默认随最后一个会话结束被杀掉，要登出后继续运行需要 `loginctl enable-linger <用户>`。

---

上一篇讲的是进程本身：怎么用 `ps` 找到它、看懂 PID 和父子关系、用 `kill` 结束它。这一篇要处理一个更靠前的意外：你根本没想杀它，只是关掉了一个终端窗口。

刚部署好的东西，ssh 里跑得好好的，把窗口一关，过了一会儿再连上，端口没了、进程也没了。或者你敲了 `Ctrl+Z` 把任务暂停在那儿，断了线，回来发现它连同 shell 一起不见了。为什么？以及 `&`、`nohup`、`disown`、`tmux` 这几个听着各有各的说法，到底谁解决了什么？

## 断线那一刻，内核做了什么

先建立一个前面文章里没提过的东西：**会话和它的控制终端**。你通过 ssh 登录，服务器上会开一个伪终端（pty，pseudo-terminal），它一头连着 sshd，另一头连着你的 shell。你敲的字符、命令打印的结果，都从这头走到那头。这个终端是这次会话的**控制终端**（controlling terminal）。

ssh 连接断开，等于这个 pty 靠 sshd 的那一端被关掉了。内核看到控制终端消失，就给它上面的**前台进程组**发一个信号：**SIGHUP**——HUP 是 hang up，本来是电话时代“对方挂断了”的意思。这个信号的默认动作是终止进程 [1]。

bash 收到 SIGHUP 就准备退出，而在退出之前，它还会做一件事：**把 SIGHUP 转发给自己名下的所有 job**，不管是在后台跑的，还是被 `Ctrl+Z` 停住的。不想让某个 job 收到，就得把它从 job 表里拿掉，或者标记成不收 SIGHUP，也就是后面要讲的 `disown` [1]。（这里说的是断线这条路。你在提示符下正常敲 `exit` 退出时，默认并不会给后台 job 发这个信号，所以“我 exit 过也没事”和“断线就没了”是两回事。）

这就是关键：**`&` 完全不管这件事**。`&` 只做一件事——不让 shell 等这条命令，立刻把提示符还给你。但它没有改变“这条命令仍然挂在这个 shell 名下、仍然属于这个会话”这个事实。所以：

```
python3 -m http.server 8000 &
```

在这里能跑、能访问，一断线，shell 收到 SIGHUP 时顺手就把这个 job 一起带走了。`&` 解决的是“我不想站在这里等”，不是“我要它活过我退出”。

## nohup 做的事：让这个进程对 SIGHUP 免疫

`nohup` 的思路很直接：在真正执行你给的程序之前，先把 SIGHUP 的处置设成“忽略”，这个设置会随 `exec` 被新程序继承下去，所以之后收到的 SIGHUP 直接被丢掉，进程继续跑 [2]。注意它只保证了一件事：这个信号打不死它。

但光忽略信号还不够，因为标准输入、标准输出、标准错误这三个 fd 还指着那个马上要消失的终端。所以 `nohup` 顺手替你做了三件事 [2]：

- 如果标准输入是终端，把它换成 `/dev/null`（特意做成不可读，谁误读 stdin 会立刻报错，而不是默默读到东西）；
- 如果标准输出是终端，就改成**追加**写 `nohup.out`，当前目录写不了就用 `$HOME/nohup.out`；
- 如果标准错误是终端，就并到标准输出上。

所以你运行时会看到它打印一句 `ignoring input and appending output to 'nohup.out'`——这不是警告，是在告诉你它把这些 fd 改成什么了。

实际用的时候，与其让日志散进 `nohup.out`，不如自己指定，顺便把上一篇讲的重定向用上：

```
nohup ./deploy.sh > /var/log/deploy.log 2>&1 &
```

这里的三个符号各司其职：`>` 把 stdout 写进日志，`2>&1` 让 stderr 也走同一个去处，`&` 让 shell 不等。少了最后的 `&`，`nohup` 只是“跑在前台但不怕挂断”，一般没意义。

## disown：已经跑起来了才想起来

更常见的情况是：命令已经用 `&` 跑起来了，输出也重定向好了，就是忘了加 `nohup`，又不想杀掉重来。这时用 shell 自带的 `disown`：

```
jobs          # 先看 job 编号，比如 [1]  Running   ./deploy.sh
disown -h %1  # 留在 job 表里，但标记为不接收 SIGHUP
```

它有两种用法，区别值得记住。`disown %1` 是把 job 从 job 表里整个删掉——shell 退出时不会给它发 SIGHUP，但你也不再能用 `fg` 把它调回前台了，等于“交出去，不管了”。`disown -h %1` 是“我还认它，但别打死它”：job 还列在那里，能 `fg` 能 `bg`，只是 shell 转发 SIGHUP 时会跳过它 [1]。

不过 `disown` 挡的只是 bash 转发的那一发信号，它没法让进程的输出有一个安稳的去处。所以更靠谱的顺序是：启动时就把 stdout/stderr 重定向到文件，再用 `disown`；否则进程活着，输出却仍然往一个正在消失的终端里写。

## tmux：它解决的不是同一件事

`nohup` 和 `disown` 让进程活下来了，但代价是你也失去了和它对话的渠道：`nohup` 把 stdin 换成了不可读的文件，输出进了日志，你没法再看见它、也没法再往里敲东西。

`tmux` 的思路完全不同。它先起一个 tmux 服务进程（tmux server），这个服务自己脱离终端在后台跑；你 ssh 上来执行 `tmux attach`，看到的那个“终端窗口”其实是 tmux 画给你的一层虚拟终端。断线只是让 tmux 少了一个客户端，面板里的进程和你在里面敲的历史输出全都原封不动。重连之后 `tmux attach -t 会话名`，画面就回来了——交互还在，这是 `nohup` 做不到的。

所以选择的依据是“你还要不要跟它对话”：需要持续操作、事后还要回来看现场，用 `tmux`；一次性长任务，只要它把活干完，用 `nohup ... &` 配日志。

## 这几种办法都缺同一样东西：一个负责的进程

把 `nohup ./deploy.sh &` 这条命令的缺口列一列：

- 它中途被别的信号杀死、自己崩了、或者被 OOM killer 干掉，**没有人知道，也没有人重新拉起它**；
- 机器重启之后，它不会自己回来；
- 日志按你当时写的重定向散在各个文件里，切分、清理全靠自己；
- 要停它，得先找回 PID 再 `kill`，优雅不优雅看运气——而且上一篇说过，如果它是个会拉 worker 的主进程，杀了壳，worker 可能还在占着端口。

这些不是 `nohup` 的缺陷，是它的定位决定的：它只管“别被 SIGHUP 打死”这一件事。

## systemd：把进程交给一个不随你登录退出的管理者

正经服务最后都交给 systemd，原因不在于它有什么更厉害的“抗断线”技巧，而在于**它根本不在你这条因果链上**。systemd 是开机就跑起来、一直活到关机的 PID 1；你这次 ssh 登录是它管的一个 session，你的 shell 是那个 session 的孩子，`nohup` 起来的进程仍然是这个 shell 的后代。而一个 service 是 systemd 直接拉起的进程：不属于你的会话，也没有控制终端。所以 ssh 断不断，跟它无关——不是它扛住了那一发 SIGHUP，而是没有任何人会为它准备那一发信号。

写一个最小可用的 unit：

```ini
# /etc/systemd/system/hello.service
[Unit]
Description=Demo service

[Service]
ExecStart=/usr/local/bin/hello
Restart=always
StandardOutput=journal

[Install]
WantedBy=multi-user.target
```

```
sudo systemctl daemon-reload
sudo systemctl enable --now hello.service
journalctl -u hello -f
```

`Restart=always` 补上了“崩了没人管”，输出进 journal 解决了日志，`enable` 解决了开机自启。它还顺手补上了上一篇留下的那个坑：`systemctl stop hello` 的时候，systemd 默认是 `KillMode=control-group`，会把整个 cgroup 里剩下的进程一并收掉——主进程和它拉起来的 worker 一起走，不会再出现“壳杀了、端口还占着”的情况。手册里明确说，改成 `KillMode=process`（只杀主进程）是不推荐的 [3]。

有一个边界值得提前知道，因为它很容易让人白折腾：如果你用的是**用户级服务**（`systemd --user`），它默认会随着你这个用户最后一个会话结束而被杀掉；要让它在登出后继续活着，得打开 lingering：

```
loginctl enable-linger <用户名>
```

这样用户管理器在开机时就起来，并在登出之后继续留着，才谈得上“没登录也跑长任务” [4]。

## 怎么选

- 只是不想站在那儿等它：`&`。
- 一次性长任务，干完就走，我不用再和它交互：`nohup 命令 > 日志 2>&1 &`。
- 已经跑起来了、不想重来：`disown -h %1`。
- 我要在里面持续操作，断了线回来还得接着干：`tmux`。
- 这是一个以后每次都该在的东西：写成 systemd unit，`enable --now`。

## 术语表

- 控制终端（controlling terminal）：你这次登录会话绑定的那个终端设备，输入输出都经过它；它消失会触发 SIGHUP。
- SIGHUP：终端挂断时内核发出的信号，默认动作是终止进程；要区分“内核发给前台进程组”和“shell 退出时转发给各 job”这两个来源。
- job：shell 自己管理的一条命令或管道，能用 `jobs` 列出、用 `fg`/`bg` 切换、用 `disown` 交出。
- `nohup`：执行前把 SIGHUP 设为忽略，并处理指向终端的三条标准文件描述符，使命令不受挂断影响。
- `disown`：把已经在跑的 job 从 shell 的 job 表里移除，或标记为不接收 SIGHUP。
- tmux server：脱离终端在后台运行的进程，你 attach 上去看到的是一层虚拟终端，断线只断开客户端。
- systemd unit：描述一个服务怎么启动、怎么重启、输出去哪的配置文件，由 PID 1 负责执行和监管。
- KillMode：unit 停止时如何结束其进程集合，默认 control-group 会收掉整个 cgroup 里的剩余进程。
- lingering：让某个用户的 systemd 用户管理器在开机时启动、登出后继续保留的设置。

## 来源

1. [bash 手册 — 收到 SIGHUP 后把信号转发给所有 job、disown 与 disown -h、huponexit 的说明](https://manpages.debian.org/unstable/bash/bash.1.en.html)
2. [nohup(1) 手册 — 忽略挂断信号，以及 stdin/stdout/stderr 的处理规则](https://man7.org/linux/man-pages/man1/nohup.1.html)
3. [systemd.kill(5) — KillMode= 各取值的含义，默认 control-group 及不推荐 process 的原因](https://www.man7.org/linux/man-pages/man5/systemd.kill.5.html)
4. [loginctl 手册 — enable-linger 让用户管理器在登出后继续保留](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html)
5. [GNU coreutils 手册 — nohup 对标准输入输出改写的详细说明](https://www.gnu.org/software/coreutils/manual/html_node/nohup-invocation.html)

---

原文：https://pangzhengboyin.com/articles/why-background-processes-die-on-ssh-logout-cf6e25e1

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
