2829 字
约 9 分钟
14
S15-B08 · Linux 进程入门:从 fork 到信号与退出码

有一个进程 kill -9 也杀不掉,ps 里状态是一个字母 D。这不是"权限不够",也不是"系统卡了",而是这个进程在内核里等一个不可中断的 IO——它根本没机会处理你发的信号。要解释清楚这件事,得先知道进程是怎么来的、信号是怎么送的、状态是谁写的。

以下命令在常见 Linux 发行版上通用,具体字段随内核版本与 procps 版本略有差异,按你的环境核对。


1. 进程由什么构成

进程不只是"一段在跑的程序",它至少有四样东西:内核里的任务结构(PID、PPID、状态、优先级)、独立的虚拟地址空间、打开的文件描述符表、以及一份信号处理配置。

ps -eo pid,ppid,stat,ni,rss,etime,cmd --sort=-rss | head
cat /proc/1234/status | head -20
字段 含义 排查用途
PID 进程号 所有操作的入口
PPID 父进程号 为 1 说明已变成孤儿进程
STAT 状态字(含修饰符) 见第 5 节的五种状态
RSS 常驻物理内存 找内存大户
NI nice 值 负值表示优先级被提升
CMD 命令行([] 表示内核线程) 确认是谁

2. fork / exec / clone:复制后替换

fork() 创建子进程,父子几乎完全相同(同一份内存的写时复制副本、同样的文件描述符)。exec() 不创建进程,它把当前进程的内存映像整个替换成另一个程序。所以"起一个进程"实际是两步:fork 复制,再 exec 替换。

调用 做了什么 常见直觉误区
fork 复制当前进程 以为 fork 后父进程会暂停
exec 用新程序替换当前映像 以为 exec 会新建进程
clone 更底层,可挑选用哪些资源共享 shell 的 &、线程库都走它
vfork 共享地址空间,父进程阻塞 历史产物,现代基本不用
$ echo $$              # 当前 shell 的 PID
$ sleep 60 &
[1] 20481
$ ps -o pid,ppid,stat,cmd -p 20481
  PID  PPID STAT CMD
20481  19876 S    sleep 60
$ exec ls              # 这一行会把当前 shell 换成 ls,之后 shell 就没了

clone 的参数字段决定了 fork 与"创建线程"的区别,这也是线程在 Linux 里"也是进程"一说的来源:它们共享地址空间,各自有自己的 PID(gettid)。


3. 僵尸与孤儿

子进程退出后并不立刻消失,它要留在进程表里等父进程调用 wait() 取走退出状态。这段窗口里它是僵尸(Z)。状态取走后表项才释放。

$ bash -c 'sleep 5 & sleep 200' &      # 制造一个子进程先退出的局面
$ ps -eo pid,ppid,stat,cmd | grep -w Z
类型 成因 状态 处理
僵尸 子进程已退出,父进程没 wait Z 让父进程回收;父进程死了会被 init 收养
孤儿 父进程先退出 被 PID 1 收养 一般无需处理,但要留意 PPID=1 的含义
失控子进程 父进程不管也不等 正常状态 + PPID=1 找出来源,考虑托管给进程管理工具

Z 状态不占 CPU、不占内存,但占进程表项。少量无害,大量僵尸会耗尽 PID 或进程表容量——而这通常说明你的程序里创建子进程的地方没有回收逻辑。


4. 进程组、会话与控制终端

shell 每起一条命令就建一个进程组,会话里有一个前台组。终端一关,内核向前台组发 SIGHUP,进程就跟着死了——这就是"关掉 SSH 窗口程序就没了"的原因。

nohup ./worker.sh > worker.log 2>&1 &   # 忽略 SIGHUP,输出重定向到文件
setsid ./worker.sh > worker.log 2>&1 &  # 新建会话,彻底脱离控制终端
disown -h %1                            # 已在后台的任务:不再收 SIGHUP
手段 解决什么 没解决什么
& 放到后台运行 仍属当前会话,关终端会被 SIGHUP
nohup 忽略 SIGHUP 仍可能随会话结束收到其他信号
setsid 新建会话,无控制终端 与终端交互的输入不再可用
进程管理器(systemd 等) 托管、重启、日志 需要写 unit 配置

5. 五种状态与信号的三类

状态 含义 典型场景
R 可运行(运行或就绪) CPU 忙
S 可中断睡眠 等 IO、等锁,最常见
D 不可中断睡眠 等磁盘/网络文件系统,信号杀不掉
T 已停止 SIGSTOP 或调试器断住
Z 僵尸 已退出等父进程回收

状态还能带修饰符:S 后面的 l 表示多线程,+ 表示前台进程组,N 表示低优先级。

信号按行为分三类:

类别 例子 行为
终止类 SIGTERM、SIGKILL、SIGINT 结束进程
暂停/继续 SIGSTOP、SIGTSTP、SIGCONT 停住与恢复(Ctrl+Z 是 SIGTSTP)
用户/其他 SIGHUP、SIGUSR1、SIGWINCH、SIGCHLD 含义由程序约定

SIGTERM(15)可被捕获、可被忽略,是"请你退出";SIGKILL(9)由内核直接终止,不可捕获、不可忽略,但对 D 状态无效——因为进程压根没在用户态运行,没有机会被"处理"。SIGHUP(1)默认终止,但很多守护程序把它重定义为"重载配置"。SIGINT(2)是 Ctrl+C。


6. 退出码

退出码 含义
0 成功
1–125 程序自定义失败码
126 命令存在但不可执行
127 命令未找到
128+n 被信号 n 杀死(如 137 = 128+9)
$ ./worker.sh; echo $?
$ kill -TERM 20481; wait $!; echo $?     # 被 SIGTERM 杀 → 143 (128+15)
$ kill -KILL 20481; wait $!; echo $?     # 被 SIGKILL 杀 → 137 (128+9)

128+n 这个约定非常实用:脚本里看到退出码 137,就知道进程是被 SIGKILL 干掉的(也常见于被 OOM 机制干掉),而不是自己返回了 137。

6.1 捕获信号的两种写法

# shell 里:收到 TERM 时先清理再退出,退出码自己决定
trap 'echo cleanup; exit 0' TERM INT

# Python 里:把信号转成标志位,交给主循环判断
import signal, sys
stop = False
def on_term(signum, frame):
    global stop; stop = True
signal.signal(signal.SIGTERM, on_term)

两段代码都只在用户态有意义:SIGKILL 与 SIGSTOP 不可捕获,D 状态下的进程也轮不到执行处理函数。所以"优雅退出"的前提是对方先发 SIGTERM 并给足时间,超时才用 SIGKILL。


7. 文件描述符与 /proc

ls -l /proc/1234/fd                   # 该进程打开的 fd
cat /proc/1234/limits                 # 进程级资源限制
cat /proc/1234/status | grep -E 'State|VmRSS|Threads'
ls -l /proc/1234/cwd /proc/1234/exe   # 工作目录与实际可执行文件
cat /proc/1234/environ | tr '\0' '\n' | head
路径 内容 用途
/proc/<pid>/fd 打开的 fd 及目标 查谁占着文件、Too many open files
/proc/<pid>/exe 实际可执行文件 确认跑的是哪个二进制
/proc/<pid>/limits 该进程的 ulimit 确认限制是否被继承
/proc/<pid>/stack 内核栈(需权限) D 状态卡在哪
/proc/<pid>/wchan 内核等待点 与上一行配合定位

ulimit 是进程级限制(nofile 最常见),nice 是调度优先级(-20 最高、19 最低),cgroup 是资源组(CPU、内存配额),三者层次不同:

手段 控制什么 生效范围
ulimit -n 单进程可用 fd 数 进程及其子进程
nice / renice CPU 调度权重 单个进程
cgroup CPU、内存、IO 配额 一组进程(容器的基础)

8. 最小实验:造一个僵尸

# 1. 起一个后台进程并观察
sleep 300 &
PID=$!
ps -o pid,ppid,stat,cmd -p $PID

# 2. 发 SIGTERM,看退出码
kill -TERM $PID; wait $PID; echo "exit=$?"      # 143

# 3. 造僵尸:子进程退出而父进程不回收
bash -c 'sleep 1 & exec sleep 300' &            # 父进程 sleep,子进程先退
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/'

# 4. 观察 D 状态(找个在做块 IO 的进程,或制造高 IO 再 ps)
ps -eo pid,stat,wchan:24,cmd | awk '$2 ~ /^D/'

顺序很重要:先看状态,再发信号,最后看退出码。只发信号不看退出码,就永远分不清"进程自己退了"还是"被杀了"。


9. 排错

现象 根因 处理
kill -9 杀不掉 D 状态、内核阻塞在不可中断 IO 查 /proc/<pid>/stack 与 wchan,等 IO 恢复或处理底层设备
进程杀完立刻换了个 PID 被进程管理器自动拉起 用管理器停服务,而不是杀进程
kill 没反应但也没报错 进程屏蔽或自定义处理了该信号 换 SIGTERM 与 SIGKILL 对照验证
大量 Z 进程 父进程不回收子进程 修父进程逻辑,或重启父进程
PPID=1 却仍在跑 父进程已退出,被 init 收养 正常现象,注意别误判为"孤儿泄漏"
脚本里 $? 总是 0 判的是管道最后一段或 echo 的退出码 用 set -o pipefail 或分别取退出码
Too many open files fd 泄漏或 ulimit 太小 看 /proc/<pid>/fd 是否单调增长,再调限制
关终端后任务消失 属同一会话,收到 SIGHUP nohup / setsid / 进程管理器

10. 边界

场景 更合适的做法 理由
需要进程崩溃后自动重启 systemd / 容器重启策略 & 与 nohup 只管一时
需要按 CPU/内存限额隔离 cgroup / 容器 nice 只管优先级,不限用量
需要看内核态在干什么 strace、perf、/proc/<pid>/stack ps 只给用户态快照
排查线程级问题 线程视图与 /proc/<pid>/task 进程视图会把线程聚合掉
信号语义不确定 查该程序的文档 同号信号在不同程序里含义不同

一句话:ps 告诉你"现在是什么状态",/proc 告诉你"为什么是这个状态"。


11. 自测

  1. 为什么 kill -9 也可能杀不掉一个进程?
  2. fork 与 exec 各自的职责是什么,为什么说"起进程是两步"?
  3. Z 状态为什么不耗 CPU 却要处理?
  4. nohup 解决的是哪个信号带来的问题?
  5. 脚本看到退出码 137,能推断出什么?
  6. ulimit、nice、cgroup 分别控制什么、作用范围有何不同?
S15-B08 · Linux 进程入门:从 fork 到信号与退出码
https://www.clxhxhhr.top/posts/6113/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。