有一个进程 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. 自测
- 为什么
kill -9也可能杀不掉一个进程? fork与exec各自的职责是什么,为什么说"起进程是两步"?Z状态为什么不耗 CPU 却要处理?nohup解决的是哪个信号带来的问题?- 脚本看到退出码 137,能推断出什么?
ulimit、nice、cgroup分别控制什么、作用范围有何不同?