进程:一个正在执行的骗局
教科书说「进程是运行中的程序」。这句话没错,但它什么也没告诉你。更有用的定义是:进程是内核为了骗你而必须记住的那一堆东西。这一章把那堆东西摊开看,顺便解决两个你多半遇到过的困惑。
从骗局的角度看,进程是什么
内核要让每个程序都以为自己独占机器。要维持这个假象,它必须为每个程序记住一些东西——不然换回来的时候就露馅了。
这堆东西就是进程。在 Linux 里它叫 struct task_struct,是内核里最庞大的结构体之一(几百个字段)。但你只需要记住它的四类内容:
| 类别 | 装了什么 | 为了骗你相信什么 | 本书哪章 |
|---|---|---|---|
| CPU 现场 | 所有寄存器、程序计数器、栈指针、标志位 | 「我的程序从头跑到尾没断过」 | 第 9 章 |
| 地址空间 | 指向页表的指针(mm_struct)、各个内存段的范围 | 「内存都是我的,我的 main 在 0x400000」 | 卷 III |
| 打开的资源 | fd 表、当前目录、根目录、umask | 「我打开了一个文件,它是我的」 | 卷 IV |
| 身份与关系 | pid、ppid、uid/gid、进程组、信号处理器、优先级 | 「我是我,我有我的权限」 | 第 21 章 |
换句话说:PCB 里的每一个字段,都是骗局的一个必要道具。去掉任何一类,假象就会当场崩塌——去掉寄存器就接不回去,去掉页表指针就换错内存,去掉 fd 表就读错文件。
进程 = 一个地址空间 + 至少一个执行流 + 一组打开的资源 + 一个身份。
为什么这个定义更好用?因为它让你能拆开看——而第 7 章会告诉你,Linux 真的允许你把这四样单独拿出来共享或不共享。「进程」和「线程」并不是两个物种,它们只是这几个开关的不同组合。
ps 里那个字母
你一定敲过 ps aux,也一定见过 STAT 那一列的 S、R、D、Z。这些不是随便标的——它们是内核里那个状态机的当前状态,而这个状态机只有几个格子。
下面这台机器让你亲手驱动一个进程走完它的一生。请务必走两条路径:一条 R → D → R,一条 R → Z → X。走完你就理解了本章要解决的两个困惑。
困惑一:R 其实是两种状态
这是最容易被忽略、后果又最大的一件事。
R 在内核里叫 TASK_RUNNING,但它同时代表两种截然不同的处境:
- 正在 CPU 上执行——真的在跑。
- 随时可以执行,但还没轮到——在运行队列里排着。
ps 不区分这两者。一台 4 核机器上有 100 个 R 状态的进程,其中最多 4 个真的在跑,另外 96 个在排队。
而 Linux 的 load average 统计的是两者之和(再加上 D 状态的,见下)。所以:
load average 是「想用 CPU 的人数 + 卡在不可中断 IO 上的人数」,不是「CPU 使用率」。这两件事经常一起高,但它们完全可以分开:
- load 高 + CPU 满:真的算不过来了。加 CPU 或者优化算法。
- load 高 + CPU 空闲:★ 那 15 个人大多是
D状态——卡在磁盘或网络存储上。加 CPU 一点用没有,要去查 IO。用ps -eo stat,comm | grep ^D一抓一个准。 - load 低 + CPU 满:少数几个线程在死磕。多半是单线程瓶颈,加机器也没用。
顺带说:Linux 的 load 把 D 状态算进去,是它独有的。传统 Unix(以及 macOS)的 load 只算可运行的。所以「Linux 的 load 数字看起来总是偏高」不是错觉,是口径不同。
困惑二:kill -9 都杀不掉的进程
D 状态(TASK_UNINTERRUPTIBLE)是另一个高频困惑的来源。
正常情况下,一个睡着的进程处于 S 状态——它在等网络包、等锁、等 sleep() 到点。这种睡眠可以被信号打断:你 Ctrl-C,它立刻醒过来处理信号。
但有些等待不能半路取消。典型是磁盘 IO:内核已经把请求提交给了硬盘控制器,DMA 正在往某块内存里写数据。这时候如果进程被杀掉、那块内存被回收——DMA 会继续往一块已经不属于它的内存里写,破坏掉别人的数据。
所以内核把这类等待标成 D:连信号都不递,包括 SIGKILL。
很多人以为 SIGKILL 是「无法被忽略的信号」,所以应该无坚不摧。前半句对,后半句错。
信号的投递需要进程配合。内核不会粗暴地把一个进程从内存里抹掉——它只是设一个标志位,然后等这个进程下次从内核态返回用户态时,检查标志位并执行处死。
而 D 状态的进程正卡在内核里,它根本没有「返回用户态」这个时刻。标志位设了,但没人去看。
所以 kill -9 对 D 状态进程确实无效——不是权限不够,是时机没到。等 IO 完成、它一从内核出来,就会立刻死掉。
实践含义很明确:看到一堆 D 状态进程,别去研究怎么杀它们,去查存储。常见元凶是 NFS 挂载点断了、磁盘坏道重试、云盘限流打满。等 IO 恢复,它们自己就走了。
Z:已经死了,但尸体没人收
僵尸进程是另一个经典。它的存在有个很实际的理由:
子进程 exit(3) 之后,那个 3 得留给谁看?父进程可能想知道。但父进程此刻未必正好在等。所以内核不能立刻把进程彻底清掉——它得留一个记录退出码的壳。
这个壳就是僵尸。它的特点是:
- 不占内存——地址空间早就释放了。
- 不占 CPU——它不会被调度。
- 占一个 PID——而 PID 是有限的(默认
/proc/sys/kernel/pid_max是 32768 或 4194304)。
父进程调 wait() 读走退出码,壳才释放。这一步叫「收尸」(reaping)。
所以僵尸堆积只有一种原因:父进程不 wait(),而且还活着。如果父进程死了,孤儿会被 PID 1 收养,而 PID 1 的职责之一就是不停地 wait()——所以孤儿反而不是问题。
你的容器 ENTRYPOINT 是一个 Java 或 Node 进程,它成了容器里的 PID 1(第 22 章会讲为什么)。
但你的应用没准备好当 PID 1——它不知道自己要负责收养孤儿、循环 wait()。于是容器里每有一个进程的父进程先死,它的孤儿就挂到你的应用底下,死了之后变成僵尸,永远没人收。
跑几天,PID 用完,容器里再也起不了新进程,报 fork: Resource temporarily unavailable。
解法:用 docker run --init,或者在镜像里放个 tini/dumb-init 当 PID 1。它们什么都不干,就是转发信号 + 不停收尸。这是第 22 章「容器就是被限制视野的普通进程」这句话的一个直接后果——你得到了 PID 1 的位置,就得承担 PID 1 的义务。
进程的一生,从内核的视角
把上面的串起来,一个进程完整的生命周期是:
fork()
↓
[ 就绪 R ] ←───────────────┐
↓ 调度器挑中 │
[ 运行 R ] ─── 时间片用完 ──┘
│
┌──────────┼──────────┬──────────┐
↓ ↓ ↓ ↓
read() 等 读磁盘 Ctrl-Z exit()
↓ ↓ ↓ ↓
[ 睡 S ] [ 睡 D ] [ 停 T ] [ 僵尸 Z ]
│ │ │ ↓
└──────────┴──────────┘ 父进程 wait()
被唤醒,回到就绪 ↓
[ 消失 X ]
注意这张图里,所有的转移都发生在「进了内核」的时刻——要么是你主动系统调用,要么是被中断打断。第 4 章那三种过线方式,同时也是进程状态迁移的唯一触发点。
很多人以为「阻塞」意味着浪费 CPU——想象一个线程在那儿空转等待。完全不是。
阻塞的真身是:进程把自己从运行队列里摘出去,然后主动让出 CPU。在它睡着的整段时间里,它执行的指令数是 0,占用的 CPU 是 0。
阻塞真正的成本是两次上下文切换(睡过去一次、被唤醒一次),约 6 μs,外加它占着的那些内存和一个线程栈(默认 8 MB 虚拟、实际按需)。
所以「一个线程一个连接」的模型,问题从来不是「阻塞浪费 CPU」,而是「一万个线程 = 一万个栈 + 调度器要在一万个任务里挑」。这个区别很重要,它决定了你该往哪儿优化。第 19 章会接着算这笔账。
「Android 的进程为什么会被后台杀掉?」
Android 在 Linux 的进程模型上加了一层优先级分类(前台 / 可见 / 服务 / 后台 / 空),并把它映射成一个叫 oom_adj 的分数写进内核(/proc/PID/oom_score_adj)。
内存紧张时,lmkd(低内存杀手守护进程)按这个分数从高到低杀。所以「被杀」不是 Android 的特殊魔法——它就是第 15 章那个 OOM killer,只不过分数是由 Activity 生命周期决定的。
这也解释了为什么 onSaveInstanceState 那么重要:你的进程可能在任何时刻被 SIGKILL,而 SIGKILL 无法被捕获(第 21 章)——你不会收到任何「我要死了」的通知,没有 onDestroy,什么都没有。状态必须提前存好。
这一章的一句话
进程是内核为了维持骗局而必须记住的那堆东西——每个字段都是一个道具。而 ps 里那个字母比它看起来重要得多:R 混着「在跑」和「排队」,解释了 load 高但 CPU 空闲;D 不收信号,解释了 kill -9 为什么杀不掉。
下一章:这些进程是从哪儿来的?答案是一个会返回两次的函数。