上一篇讲的是进程本身:怎么用 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 名下、仍然属于这个会话”这个事实。所以:
1python3 -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,不如自己指定,顺便把上一篇讲的重定向用上:
1nohup ./deploy.sh > /var/log/deploy.log 2>&1 &这里的三个符号各司其职:> 把 stdout 写进日志,2>&1 让 stderr 也走同一个去处,& 让 shell 不等。少了最后的 &,nohup 只是“跑在前台但不怕挂断”,一般没意义。
disown:已经跑起来了才想起来
更常见的情况是:命令已经用 & 跑起来了,输出也重定向好了,就是忘了加 nohup,又不想杀掉重来。这时用 shell 自带的 disown:
1jobs # 先看 job 编号,比如 [1] Running ./deploy.sh
2disown -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:
1# /etc/systemd/system/hello.service
2[Unit]
3Description=Demo service
4
5[Service]
6ExecStart=/usr/local/bin/hello
7Restart=always
8StandardOutput=journal
9
10[Install]
11WantedBy=multi-user.target1sudo systemctl daemon-reload
2sudo systemctl enable --now hello.service
3journalctl -u hello -fRestart=always 补上了“崩了没人管”,输出进 journal 解决了日志,enable 解决了开机自启。它还顺手补上了上一篇留下的那个坑:systemctl stop hello 的时候,systemd 默认是 KillMode=control-group,会把整个 cgroup 里剩下的进程一并收掉——主进程和它拉起来的 worker 一起走,不会再出现“壳杀了、端口还占着”的情况。手册里明确说,改成 KillMode=process(只杀主进程)是不推荐的 3。
有一个边界值得提前知道,因为它很容易让人白折腾:如果你用的是 用户级服务(systemd --user),它默认会随着你这个用户最后一个会话结束而被杀掉;要让它在登出后继续活着,得打开 lingering:
1loginctl enable-linger <用户名>这样用户管理器在开机时就起来,并在登出之后继续留着,才谈得上“没登录也跑长任务” 4。
怎么选
- 只是不想站在那儿等它:
&。 - 一次性长任务,干完就走,我不用再和它交互:
nohup 命令 > 日志 2>&1 &。 - 已经跑起来了、不想重来:
disown -h %1。 - 我要在里面持续操作,断了线回来还得接着干:
tmux。 - 这是一个以后每次都该在的东西:写成 systemd unit,
enable --now。