Linux 基础体系 · 第 47/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 终端与作业控制:TTY、Session、前后台、nohup 和挂断

在 Linux 中,终端上的一个命令并不是“直接由窗口启动,然后一直运行在那里”。它通常经历了这样一条关系链:

终端模拟器
    │
    │  PTY master
    ▼
TTY slave ── 控制终端 ── Session ── Process Group ── Process
                                      │
                                      └── Shell 管理的 Job

要理解 Ctrl-CCtrl-Zfgbgnohup,以及关闭 SSH 或终端窗口后进程为什么退出,必须同时理解以下对象:

  • TTY:内核提供的终端设备接口;
  • Session:内核中的会话,包含一个或多个进程组;
  • Process Group:作业控制的基本单位,通常对应一条 Shell pipeline;
  • Job:Shell 对进程组的管理抽象;
  • Foreground/Background:进程组相对于控制终端的前台、后台状态;
  • Hangup:控制终端断开或会话结束时触发的挂断路径;
  • nohup:修改进程对 SIGHUP 的处理及部分标准流连接的工具。

这些概念彼此相关,但并不等价。例如,TTY 不是 Shell,Session 也不是 tmux 的 Session;后台进程也不一定能在终端断开后继续运行。


一、TTY:不是“终端窗口”,而是内核看到的终端设备

1. TTY 的含义

TTY 最初来自 TeleTYpewriter。现代 Linux 中,TTY 泛指一种终端设备接口,负责在用户程序和终端输入输出之间提供一组内核语义,包括:

  • 字符输入和输出;
  • 回显;
  • 行规程(line discipline);
  • 特殊控制字符;
  • 控制终端与前台进程组的关联;
  • 终端挂断;
  • 某些终端属性和信号行为。

执行:

tty

可能得到:

/dev/pts/3

这里的 /dev/pts/3 是一个伪终端(PTY)的 slave 端。它不是显示器,也不是终端模拟器窗口本身。

2. 终端模拟器、PTY master 和 TTY slave

在图形桌面中打开 GNOME Terminal、Konsole 或其他终端模拟器时,通常会创建一个伪终端对:

Shell/程序 ── /dev/pts/3(PTY slave)
                         │
                         │ 内核 PTY
                         │
终端模拟器 ── PTY master

终端模拟器负责:

  1. 接收键盘输入;
  2. 将输入写入 PTY master;
  3. 从 PTY master 读取程序输出;
  4. 把输出绘制到窗口;
  5. 在窗口关闭时关闭 PTY master。

Shell 和其子进程通常只看到 /dev/pts/3,它们不需要知道外面是否是图形终端、SSH 客户端,还是其他程序。

因此,下面两种情况都可能让程序拥有一个 TTY:

图形终端模拟器 ── PTY ── Shell
SSH 客户端     ── SSH ── PTY ── Shell

而通过以下方式运行时,程序可能没有 TTY:

ssh host 'long-command'

如果 SSH 没有请求伪终端,远端命令的标准输入、输出可能直接连接到 SSH 通道,而不是 /dev/pts/N

可以用下面的命令观察当前进程的终端状态:

ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd

典型输出可能类似:

    PID    PPID    PGID     SID   TPGID TT       STAT CMD
   4201    3980    4201    4201    4201 pts/3    Ss   bash
   4310    4201    4310    4201    4310 pts/3    S+   sleep 100

各字段含义:

  • PID:进程 ID;
  • PPID:父进程 ID;
  • PGID:进程组 ID;
  • SID:Session ID;
  • TPGID:该 TTY 当前的前台进程组 ID;
  • TTY:控制终端;
  • STAT:进程状态;
  • +:通常表示该进程属于该终端的前台进程组;
  • s:通常表示该进程是 Session Leader。

这里的 SIDTPGID 比单看 TTY 更能说明作业控制关系。


二、Session、Process Group 和 Job 的层次关系

1. Process Group:信号和前后台切换的基本单位

Linux 进程具有进程组属性。一个进程组由一个进程组 ID 标识,通常使用该组组长的 PID 作为 PGID,但进程组组长退出后,组仍然可以存在。

一条 Shell pipeline 通常作为一个进程组运行:

grep error app.log | sort | less

Shell 通常会为这条 pipeline 中的进程建立同一个进程组:

PGID = 5000
 ├── grep error app.log
 ├── sort
 └── less

这样做很重要,因为用户按下 Ctrl-C 时,终端驱动程序不是只向 grep 发送信号,而是向当前前台进程组发送 SIGINT。整条 pipeline 才能一起收到中断。

可以通过以下命令观察:

sleep 100 | cat &
jobs -l
ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd --forest

jobs -l 是 Shell 的视角,ps 是内核进程视角。一个 Job 通常对应一个进程组,但 Job 是 Shell 的抽象,不能把二者完全当作同一个对象。

2. Session:进程组的集合

Session 是 Linux 进程管理中的更高层级。一个 Session 包含:

  • 一个或多个进程组;
  • 一个 Session Leader;
  • 最多一个控制终端。

通常,交互式登录 Shell 是某个 Session 的 Leader,并拥有控制终端:

Session SID=4201
 ├── Process Group PGID=4201:交互式 bash
 ├── Process Group PGID=4310:sleep 100
 └── Process Group PGID=4400:grep | sort

Session 和父子进程关系不是同一回事:

  • 父进程关系由 PPID 表示;
  • 进程组关系由 PGID 表示;
  • 会话关系由 SID 表示。

一个进程可以被父进程启动后,加入另一个进程组;一个进程组中的成员也可以有不同的父进程。

3. 创建 Session:setsid

系统调用 setsid() 的目标是让调用进程:

  1. 成为新的 Session Leader;
  2. 成为新进程组的组长;
  3. 脱离原来的控制终端;
  4. 不再属于原 Session。

但存在一个重要限制:如果调用者已经是进程组组长,setsid() 会失败。原因是新的 Session Leader 必须同时成为新进程组组长,而同一个进程不能在原进程组组长身份未解除时完成这个转换。

因此,传统 daemon 化流程常见如下:

父进程 fork
    │
    └── 子进程退出
            │
            └── 孙进程调用 setsid()

孙进程通常不再是原进程组组长,于是可以成功调用 setsid()。命令行上的:

setsid command

通常用于让命令启动在新的 Session 中。具体行为可能受 setsid 实现和参数影响;在 util-linux 提供的 setsid 中,如果调用者已经是进程组组长,工具通常会先 fork,以便子进程能够成功建立新 Session。

setsid 只解决 Session 和控制终端关系,不自动解决:

  • 标准输入输出仍指向终端;
  • 工作目录;
  • 文件描述符泄漏;
  • 日志;
  • 进程重启;
  • 权限降级;
  • 服务监督。

所以 setsid command 不等价于完整的生产级 daemon。


三、Shell 的 Job 与前后台

1. Job 是 Shell 的管理对象

交互式 Shell 开启作业控制后,会为每次启动的命令维护 Job 表。例如:

sleep 100

Shell 启动 sleep 后,通常会:

  1. fork 出子进程;
  2. 将它放入一个新的进程组;
  3. 将控制终端的前台进程组切换到该进程组;
  4. 等待该进程组完成或停止;
  5. 收回控制终端;
  6. 更新 Job 状态。

对用户而言,这个 Job 的编号可能是 [1];对内核而言,它是一个 PGID。

2. 前台 Job

执行:

sleep 100

Shell 会等待命令结束。此时通常有:

控制终端 TPGID = sleep 所在的 PGID

因此,终端驱动程序接收到的输入属于这个前台进程组。

按下:

Ctrl-C

终端驱动程序通常向前台进程组发送 SIGINT

TTY ── SIGINT ──> sleep 所在的前台进程组

SIGINT 的默认动作是终止进程,因此 sleep 通常退出,Shell 恢复为前台进程组。

需要注意,Ctrl-C 不是 Shell 直接执行 kill 命令。字符由 TTY 的行规程识别,并由内核产生信号。

3. Ctrl-Z、停止和恢复

按下:

Ctrl-Z

终端驱动程序通常向前台进程组发送 SIGTSTP。其默认动作是停止进程,而不是终止进程。

例如:

sleep 100

按下 Ctrl-Z 后,可能看到:

^Z
[1]+  Stopped                 sleep 100

此时进程仍然存在,只是处于 stopped 状态。可以验证:

jobs -l
ps -o pid,pgid,sid,tpgid,stat,cmd -p <PID>

STAT 中的 T 通常表示进程被作业控制信号停止。

执行:

bg %1

Shell 会:

  1. 向 Job 发送 SIGCONT
  2. 将该 Job 标记为后台运行;
  3. 不等待它结束;
  4. 保持 Shell 自己作为控制终端的前台进程组。

执行:

fg %1

Shell 会:

  1. 将控制终端的前台进程组切换到 Job 所在的 PGID;
  2. 发送 SIGCONT
  3. 等待 Job 结束或再次停止。

这说明 fg 不只是“把输出显示回来”,它首先改变的是控制终端的前台进程组。

4. & 和后台 Job

执行:

sleep 100 &

Shell 通常不会等待 sleep,而是立即返回提示符:

[1] 5120

此时:

  • Job 在后台进程组中运行;
  • Shell 仍然是控制终端前台进程组;
  • 后台进程是否可以读写终端,取决于具体操作和 TTY 设置。

后台并不意味着“脱离终端”,也不意味着“关闭终端后仍然运行”。


四、终端输入输出与后台限制

1. 后台读取终端:SIGTTIN

如果后台进程尝试从其控制终端读取数据,通常会收到 SIGTTIN 并停止。

可以用 Bash 观察:

cat &

在某些环境中,可能得到类似结果:

[1]+  Stopped                 cat

原因不是 cat 自己主动退出,而是它试图从不属于自己的前台进程组的控制终端读取输入。

可以验证:

jobs -l

然后恢复并终止:

fg %1
Ctrl-C

2. 后台写入终端:通常允许,但不总是允许

后台进程向终端写数据通常仍然可以成功:

for i in $(seq 1 5); do
    echo "background: $i"
    sleep 1
done &

输出可能直接混入 Shell 提示符。

但如果 TTY 的 TOSTOP 标志开启,后台进程写终端可能收到 SIGTTOU 并停止。可以查看终端属性:

stty -a

其中可能包含:

-tostop

前面的 - 表示关闭。开启方式:

stty tostop

恢复默认常见状态:

stty -tostop

修改 stty 会改变当前控制终端的行为,属于会话级状态;在共享终端或远程操作中应谨慎使用。

3. 标准流与控制终端是两个不同问题

即使一个进程的 stdout 仍然指向 TTY,它是否属于前台进程组是另一个问题。

例如:

sleep 100 > output.log &

这个进程:

  • stdout 已经重定向到文件;
  • stdin 默认仍可能继承控制终端;
  • 仍然属于当前 Session;
  • 仍然可能受 Shell 退出和 SIGHUP 影响。

因此,“重定向输出”不等于“脱离终端”。


五、终端特殊字符和信号路径

终端驱动程序通常识别一些特殊控制字符。常见默认映射可以通过:

stty -a

查看,典型结果包含:

intr = ^C
quit = ^\
susp = ^Z
stop = ^S
start = ^Q

常见行为如下:

输入 通常产生的信号 默认效果
Ctrl-C SIGINT 请求中断,默认终止
Ctrl-\ SIGQUIT 默认终止并可能生成 core
Ctrl-Z SIGTSTP 停止前台 Job
Ctrl-S 终端输出流控暂停 暂停显示
Ctrl-Q 终端输出流控恢复 恢复显示

这些是“默认行为”。程序可以捕获、忽略或改变部分信号处理。例如:

trap 'echo interrupted' INT
while :; do sleep 1; done

按下 Ctrl-C 后,Shell 脚本可能打印 interrupted 而不退出。信号的默认动作、当前处理器和屏蔽状态共同决定最终效果。


六、挂断(Hangup)到底是什么

1. 挂断不是单一事件

“挂断”通常描述控制终端断开,或者与终端相关的会话结束。可能的来源包括:

  • 关闭终端模拟器窗口;
  • SSH 客户端断开;
  • 网络连接断开导致 SSH 会话结束;
  • 伪终端的 master 端关闭;
  • 用户退出登录 Shell;
  • Shell 收到 SIGHUP 后结束或通知其 Job。

这些情况的具体信号传播路径并不完全相同,但经常会导致 SIGHUP 出现。

SIGHUP 的名称来自“挂断电话”,其默认动作是终止进程。它并不表示“程序一定收到过用户按键”,而是一个普通 Unix 信号,编号和默认行为由系统定义。

2. 关闭终端时的典型路径

假设用户在终端中运行:

sleep 1000 &

典型关系是:

终端模拟器关闭
        │
        ▼
PTY master 关闭
        │
        ▼
内核检测到终端挂断
        │
        ├── 向相关前台进程组发送 SIGHUP/SIGCONT
        └── Shell 观察到终端断开或自身收到 SIGHUP
                  │
                  └── Shell 可能向其 Job 发送 SIGHUP

实际细节会受 Shell、SSH 服务端、进程所处状态和终端设置影响。这里有三个需要区分的事实:

  1. PTY 关闭是设备连接层事件;
  2. Shell 向 Job 发送 SIGHUP是 Shell 行为;
  3. 程序因 SIGHUP 退出是目标进程的信号处理行为。

不能简单地把三者说成“终端关闭直接杀掉所有子进程”。

3. Shell 是否向 Job 发送 SIGHUP

许多交互式 Shell 在退出时会通知其管理的 Job。Bash 对交互式登录 Shell 还有 huponexit 选项:

shopt -s huponexit

查看状态:

shopt huponexit

但 Shell 类型、是否为登录 Shell、退出路径以及 Shell 选项都会影响行为。不要假设所有 Shell 都完全按 Bash 的规则处理。

此外,当 Shell 自己收到 SIGHUP 时,它还可能向 Job 转发 SIGHUP,并对已停止的 Job 发送 SIGCONT,使它们有机会处理挂断信号,而不是永久停留在 stopped 状态。具体实现仍然属于 Shell 行为,而不是 TTY 的统一保证。

4. SIGHUP 不一定表示终止

程序可以捕获或忽略 SIGHUP

trap 'echo "got SIGHUP"' HUP
while :; do
    sleep 1
done

这时收到 SIGHUP 后,Shell 脚本会执行 trap,而不会自动退出。

另一方面,很多 Unix 守护进程把 SIGHUP 约定为“重新加载配置”,例如某些服务程序会把它解释为 reload。这个语义来自具体程序,不是 Linux 对 SIGHUP 的统一规定:

kill -HUP <pid>

对未知程序随意发送 SIGHUP 可能导致它退出,也可能触发重载或其他自定义动作。


七、前台、后台与挂断的完整算例

下面的例子适用于 Bash 和常见 Linux 发行版。

1. 观察停止、后台运行和前台恢复

运行:

sleep 300

Ctrl-Z,然后执行:

jobs -l

可能得到:

[1]+  6200 Stopped                 sleep 300

此时:

ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -p 6200

可能得到:

    PID    PPID    PGID     SID   TPGID TT       STAT CMD
   6200    5800    6200    5800    5800 pts/3    T    sleep 300

可以看到:

  • sleepPGID 是 6200;
  • 当前 TPGID 是 Shell 的 PGID 5800;
  • STAT 中的 T 表示它已停止;
  • 它仍然属于同一个 Session,SID 仍为 5800。

执行:

bg %1

进程继续运行,但仍属于原 Session。执行:

jobs -l

通常会显示:

[1]+  6200 Running                 sleep 300 &

再执行:

fg %1

Shell 把控制终端交给该 Job,并等待它结束。

2. 后台命令不等于独立服务

执行:

sleep 300 &
pid=$!
echo "pid=$pid"

此时可以看到它仍然有控制终端:

readlink /proc/"$pid"/fd/0
readlink /proc/"$pid"/fd/1
readlink /proc/"$pid"/fd/2

可能输出:

/dev/pts/3
/dev/pts/3
/dev/pts/3

即使命令在后台运行,三个标准文件描述符仍可能继承自终端。关闭终端后:

  • 它可能收到 SIGHUP
  • 如果没有忽略该信号,可能退出;
  • 如果标准流仍指向已关闭的 PTY,后续读写还可能失败。

八、nohup 做了什么

1. nohup 的核心语义

nohup 的核心目的不是“让进程永不退出”,而是让命令忽略 SIGHUP,并在标准输出仍连接终端时进行适当重定向。

常见用法:

nohup long-command > app.log 2>&1 &

这条命令包含三层含义:

nohup              修改 SIGHUP 处理
> app.log           重定向 stdout
2>&1               让 stderr 指向 stdout 当前指向的位置
&                  让 Shell 不等待命令

启动顺序很重要。Shell 先处理重定向,再启动 nohup。因此,nohup 接收到的 stdout 已经是 app.log,不需要再处理 stdout。

2. GNU nohup 的标准流行为

在现代 Linux 中,nohup 通常来自 GNU coreutils。常见行为是:

  • 如果 stdout 是终端,将 stdout 追加到 nohup.out
  • 如果当前目录不可写,尝试 $HOME/nohup.out
  • 如果 stderr 是终端,则将 stderr 重定向到 stdout;
  • SIGHUP 设置忽略处理;
  • 如果 stdin 是终端,GNU 实现通常会将其重定向到一个不可读的文件,以避免后台命令继续等待终端输入。

例如:

nohup sh -c 'for i in 1 2 3; do echo "tick $i"; sleep 1; done' &

可能显示:

nohup: ignoring input and appending output to 'nohup.out'

随后查看:

cat nohup.out

可能得到:

tick 1
tick 2
tick 3

nohup.out 的具体路径、提示信息和标准流细节应以实际 nohup 实现为准。POSIX 规定了 nohup 的基本目的,但 GNU 实现包含更具体的行为。

3. nohup 不会自动后台运行

以下命令:

nohup long-command

仍然是前台 Job。Shell 仍然会等待它,用户仍然可以按 Ctrl-C,并且命令仍可能与当前控制终端存在其他关系。

通常需要:

nohup long-command > app.log 2>&1 &

但即使加了 &,它仍可能:

  • 继承当前工作目录;
  • 继承环境变量;
  • 保留其他打开的文件描述符;
  • 使用当前用户权限;
  • 产生无法轮转的日志;
  • 在程序自身主动退出、收到其他信号或发生资源错误时退出。

nohup 只针对 SIGHUP,不会屏蔽:

SIGTERM
SIGKILL
SIGINT
SIGSEGV

也不会防止 OOM killer、磁盘满、程序 bug 或依赖服务故障。

4. 进程可以覆盖 nohup 的效果

nohup 设置的是进程启动时继承的信号处理方式。程序可以之后重新安装 SIGHUP 处理器,或者显式退出。

因此:

nohup app &

并不构成“忽略挂断”的绝对保证。它只表示启动程序时请求忽略 SIGHUP

可以用一个简单脚本验证:

cat > /tmp/hup-demo.sh <<'EOF'
#!/bin/sh
trap 'echo "received HUP"; exit 0' HUP
while :; do
    echo "running"
    sleep 2
done
EOF

chmod +x /tmp/hup-demo.sh
nohup /tmp/hup-demo.sh > /tmp/hup-demo.log 2>&1 &
echo $!

因为脚本显式安装了 SIGHUP handler,它可能覆盖 nohup 设置的忽略处理。这个例子说明:nohup 的作用依赖于目标程序是否保留该信号处置方式。


九、disownsetsidnohup 的区别

1. disown 是 Shell 内部操作

Bash 中:

long-command &
disown

或者:

disown %1

disown 从 Shell 的 Job 表中移除 Job,或标记它不再被 Shell 管理。它不是一个内核系统调用,也不会自动:

  • 创建新的 Session;
  • 关闭控制终端;
  • 重定向标准输入输出;
  • 修改进程的 SIGHUP 处理;
  • 杀死或重启进程。

如果在命令启动后再执行:

disown %1

目标进程已经继承了原本的信号处理设置。Shell 不再向它发送 SIGHUP,并不代表其他挂断路径一定不存在。

更稳妥的组合通常是:

long-command </dev/null >app.log 2>&1 &
disown

这里分别处理:

  • stdin:不再依赖终端;
  • stdout/stderr:写文件;
  • Shell Job 管理:使用 disown 移除。

但这仍不是服务监督。

2. setsid 主要改变 Session 和控制终端

setsid long-command </dev/null >app.log 2>&1 &

它通常使命令成为新 Session 的 Leader,并脱离原控制终端。

nohup 相比:

工具 主要作用
nohup 忽略 SIGHUP,并按实现处理终端标准流
disown 修改 Shell 对 Job 的管理
setsid 创建新的 Session,脱离原控制终端
& 让 Shell 不等待当前命令

它们解决的是不同层次的问题:

&         Shell 等待关系
disown    Shell Job 表
nohup     SIGHUP 处理和部分标准流
setsid    Session 与控制终端

不要因为命令同时使用了这些工具,就认为它已经具备服务管理能力。


十、关闭终端、退出 Shell 和 SSH 断开

1. 关闭窗口和执行 exit 不是完全相同

执行:

exit

是 Shell 主动退出。关闭终端窗口通常还会导致终端模拟器关闭 PTY master。两者可能产生不同的事件顺序:

exit:
Shell 退出
  └── Shell 可能向 Job 发送 SIGHUP

关闭窗口:
PTY master 关闭
  └── 内核执行终端挂断处理
       └── Shell/前台进程组可能收到 SIGHUP
            └── Shell 可能继续通知 Job

所以实验时不要仅用“是否执行过 exit”来推断结果。应检查目标进程的:

ps -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd -p <pid>

以及日志和退出状态。

2. SSH 断开

交互式 SSH 常见结构是:

本地终端
   │
SSH 客户端
   │ TCP
sshd
   │
远端 PTY
   │
远端 Shell
   │
远端 Job

网络断开时,SSH 客户端、sshd、远端 PTY 和 Shell 之间会发生关闭或错误传播。远端 Job 是否退出,取决于:

  • 是否分配了 PTY;
  • SSH 服务端的清理行为;
  • Shell 是否收到并转发 SIGHUP
  • Job 是否忽略 SIGHUP
  • 标准流是否仍依赖 SSH 通道;
  • 程序是否有自己的连接保持和错误处理逻辑。

因此,下面的命令不应被视为可靠的远程长任务方案:

ssh host 'long-command'

而下面的方式通常能减少“因挂断而退出”的概率:

ssh host 'nohup long-command </dev/null >long-command.log 2>&1 &'

但它仍然缺少可靠的状态查看、日志聚合、重启策略和权限控制。交互式远程工作通常更适合 tmuxscreen;长期服务则应使用 systemd、容器编排系统或其他监督器。


十一、tmux Session 与内核 Session 不是同一个概念

tmux 也称“终端复用器”,并提供名为 Session 的对象,但它的 Session 是 tmux 服务端维护的用户空间结构:

tmux server
 ├── tmux session
 │    ├── window
 │    └── pane ── PTY ── Shell
 └── tmux session

运行:

tmux new -s work

通常会启动一个 tmux server,并为窗口中的 Shell 创建 PTY。客户端退出时:

Ctrl-b d

只是 detach,tmux server 和其中的 Shell 通常继续运行。之后可以:

tmux attach -t work

重新连接。

这里的两个“Session”必须区分:

  • Linux kernel Session:由 SID 表示,属于进程管理和控制终端机制;
  • tmux Session:由 tmux server 管理,属于终端复用器的窗口和 pane 生命周期。

tmux 并不是简单地调用一次 setsid 就完成全部工作。它通过持有 PTY、管理客户端连接和维护窗口状态,使 Shell 看到的终端在客户端 detach 后仍然存在。

tmux 也不是无限可靠的后台服务:

  • tmux server 进程崩溃,窗口中的程序可能受影响;
  • 机器重启后,除非另有恢复机制,内存状态会丢失;
  • 程序仍可能因自身错误、资源限制或外部信号退出;
  • tmux 中运行数据库迁移、发布任务时,仍需考虑幂等性和状态记录。

十二、如何诊断一个命令是否仍依赖终端

1. 查看进程层次和作业关系

ps -eo user,pid,ppid,pgid,sid,tpgid,tty,stat,etime,args --forest

重点观察:

  • TTY 是否为 ?
  • SID 是否仍属于交互式 Shell;
  • TPGID 是否与进程所在 PGID 相同;
  • STAT 是否包含 TZ+
  • PPID 是否变成 1 或 systemd 的子收割进程。

TTY=? 只表示进程没有关联的控制终端,不表示它一定由 systemd 管理,也不表示它一定不会退出。

2. 查看标准文件描述符

pid=<目标 PID>

readlink /proc/$pid/fd/0
readlink /proc/$pid/fd/1
readlink /proc/$pid/fd/2

可能的结果:

/dev/pts/3
/dev/pts/3
/dev/pts/3

表示仍然依赖当前终端。

如果输出是:

/dev/null
/home/user/app.log
/home/user/app.log

则标准流已经脱离终端,但这仍不能证明进程脱离了 Session 或忽略了 SIGHUP

3. 查看信号处理状态

Linux 的 /proc/<pid>/status 提供信号相关位图:

grep -E 'SigIgn|SigCgt|SigBlk' /proc/$pid/status

这些字段是十六进制位掩码,不适合仅凭肉眼判断某个信号。也可以使用:

strace -f -e trace=signal -p "$pid"

前置条件:

  • 需要足够权限;
  • ptrace 可能受到 Yama 或容器安全策略限制;
  • 附加 strace 会改变观察对象的运行环境,应避免对高风险生产进程直接操作。

4. 检查进程是否仍被 Shell 管理

在原 Shell 中:

jobs -l

在另一个终端中执行:

ps -o pid,ppid,pgid,sid,tpgid,tty,cmd -p <pid>

如果原 Shell 仍然能通过 jobs 列出它,那么它仍然是该 Shell 的 Job。使用 disown 后,jobs 可能不再显示,但内核中的进程关系不会因此自动变化。


十三、常见误解和对应反例

误解一:& 就是脱离终端

反例:

long-command &

它只是让当前 Shell 不等待。进程仍可能:

  • 属于当前 Session;
  • 拥有 /dev/pts/N
  • 从终端读取而被 SIGTTIN 停止;
  • 在 Shell 退出时收到 SIGHUP
  • 将输出写到终端。

误解二:重定向 stdout 就能防止挂断

反例:

long-command >app.log &

stdout 已经不再是终端,但 stdin 和进程的 Session 关系可能没有变化。Shell 也可能仍然管理该 Job。

更完整的最小形式通常是:

long-command </dev/null >app.log 2>&1 &

还需要根据 Shell 行为决定是否使用:

disown

或者使用:

nohup long-command </dev/null >app.log 2>&1 &

误解三:nohup 可以防止所有终止

反例:

nohup long-command &
kill -TERM <pid>

SIGTERMSIGHUP 是不同信号,nohup 不会屏蔽 SIGTERM。同样,SIGKILL 不能被忽略或捕获。

误解四:父进程退出,子进程一定退出

Linux 不会因为父进程退出就自动杀死所有子进程。子进程可能被重新托管给 PID 1 或其他 subreaper。

但“父进程退出不必然杀子进程”不等于“子进程可以稳定运行”:

  • 它可能仍收到 SIGHUP
  • 它的 stdin/stdout 可能连接到已关闭的终端;
  • 它可能依赖父进程提供的管道;
  • 它可能被 Shell 或监督器显式终止。

误解五:看到进程成为 PID 1 的子进程,就说明它已 daemon 化

反例:

PPID = 1

只能说明原父进程已经退出,进程被重新托管。它可能仍然保留:

  • 当前工作目录;
  • 原始文件描述符;
  • 环境变量;
  • 某些终端关联;
  • 未完成的交互式逻辑。

是否 daemon 化,应分别检查 Session、TTY、文件描述符、信号处理和监督关系。

误解六:SIGHUP 总是表示“重新加载配置”

这只是某些服务的约定。例如某个程序可能把 SIGHUP 用于 reload,另一个程序则按默认动作退出。对未知程序发送信号前,应查阅该程序文档并先在非生产环境验证。


十四、生产环境中的选择

1. 一次性、可容忍失败的远程命令

可以使用:

nohup command </dev/null >command.log 2>&1 &

适合:

  • 临时数据处理;
  • 用户明确接受进程失败;
  • 有独立日志;
  • 不要求自动重启;
  • 不需要实时交互输入。

执行后至少记录 PID 并验证:

pid=$!
echo "$pid" > command.pid
ps -p "$pid" -o pid,ppid,pgid,sid,tpgid,tty,stat,cmd
tail -f command.log

2. 需要中途重新连接的交互式任务

使用:

tmux new -s deploy

在 tmux 中运行任务,断开客户端:

Ctrl-b d

重新连接:

tmux attach -t deploy

它适合保留交互界面和终端状态,但任务本身仍缺少服务监督。

3. 需要重启、日志、权限和状态管理的长期服务

不应把以下命令当作生产服务管理器:

nohup app &
setsid app &
app &

更适合使用 systemd 等监督器,例如一个最小的 systemd 服务单元:

[Unit]
Description=Example application
After=network-online.target

[Service]
ExecStart=/opt/example/bin/app
Restart=on-failure
User=example
WorkingDirectory=/opt/example
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

然后由管理员按实际发行版和权限执行:

sudo systemctl daemon-reload
sudo systemctl enable --now example.service
systemctl status example.service
journalctl -u example.service

这套机制解决的是:

  • 进程生命周期;
  • 自动重启;
  • 权限和用户;
  • 工作目录;
  • 日志;
  • 启动顺序;
  • 停止和状态查询。

它与 TTY 作业控制属于不同层次:systemd 服务通常不依赖交互式控制终端,也不应等待用户输入。


十五、一个可操作的决策模型

面对“关闭终端后还要不要继续运行”的问题,可以按依赖关系分析:

命令是否需要交互输入?
        │
        ├── 需要:使用 tmux/screen,或设计专用交互通道
        │
        └── 不需要
             │
             ├── 是否需要可靠重启、日志和状态管理?
             │       ├── 需要:systemd 或其他监督器
             │       └── 不需要
             │
             └── 是否只是一次性远程任务?
                     ├── 是:nohup + stdin/stdout/stderr 重定向
                     └── 否:重新评估任务生命周期和监督边界

判断命令是否能承受终端断开,至少要回答四个问题:

  1. 它是否仍从 TTY 读取输入?
  2. stdout 和 stderr 是否仍写入 TTY?
  3. 它是否会收到或处理 SIGHUP
  4. 它是否需要 Shell、SSH 或父进程继续提供服务?

只有同时处理这些问题,才能说明命令“独立运行”。nohup 主要处理第 3 个问题,并部分处理标准流;tmux 主要保留终端环境;systemd 则从进程监督层解决长期运行问题。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。