第二十二章:僵尸之殇
筑基中期涉及内核源码:
一
林小源在内核中发现了一个僵尸。
不是比喻。那个进程已经退出了——它的代码段被释放了,它的文件描述符被关闭了,它的地址空间被回收了。但它的 还在系统中,状态是 。
它的 PID 是 4567。它的 是 "worker"。它的 是 0。
它在等什么?
等父进程来收尸。
林小源查了一下它的父进程——PID 1234,一个叫 "manager" 的进程。但那个 manager 进程已经睡眠很久了,似乎忘记了自己还有一个子进程没有回收。
"这就是僵尸。"林小源低声说,"死了,但没人知道。"
/*
* 僵尸进程的生命周期:
* 1. 子进程调用 exit() → 变成僵尸
* 2. 内核向父进程发送 SIGCHLD
* 3. 父进程调用 wait() 回收子进程
* 4. 如果父进程不调用 wait(),僵尸永远存在
* 5. 如果父进程先退出,子进程被 init 收养
*/
struct task {
int pid;
int ppid;
char comm[16];
int state; /* 0=RUNNING, 16=ZOMBIE */
int exit_code;
int waited; /* 是否被 wait 过 */
};
struct task processes[10];
int nr_procs = 0;
struct task *find_task(int pid) {
for (int i = 0; i < nr_procs; i++) {
if (processes[i].pid == pid)
return &processes[i];
}
return 0;
}
void do_exit(struct task *tsk, int code) {
tsk->state = 16; /* EXIT_ZOMBIE */
tsk->exit_code = code;
printf("[do_exit] PID %d (%s) → ZOMBIE (exit_code=%d)\n",
tsk->pid, tsk->comm, code);
/* 通知父进程 */
struct task *parent = find_task(tsk->ppid);
if (parent) {
printf("[do_exit] 通知父进程 PID %d (SIGCHLD)\n", parent->pid);
}
}
int waitpid(struct task *parent, int child_pid) {
struct task *child = find_task(child_pid);
if (!child || child->state != 16) {
printf("[waitpid] PID %d: 没有僵尸子进程\n", parent->pid);
return -1;
}
printf("[waitpid] PID %d 回收子进程 PID %d (exit_code=%d)\n",
parent->pid, child->pid, child->exit_code);
child->state = -1; /* 标记为已回收 */
child->waited = 1;
return child->pid;
}
printf("=== 僵尸进程演示 ===\n\n");
/* 创建进程 */
processes[0] = (struct task){1, 0, "init", 0, 0, 0};
processes[1] = (struct task){100, 1, "shell", 0, 0, 0};
processes[2] = (struct task){200, 100, "worker", 0, 0, 0};
processes[3] = (struct task){300, 100, "helper", 0, 0, 0};
nr_procs = 4;
printf("进程树:\n");
printf(" init (PID 1)\n");
printf(" └── shell (PID 100)\n");
printf(" ├── worker (PID 200)\n");
printf(" └── helper (PID 300)\n\n");
/* worker 退出 → 变成僵尸 */
do_exit(&processes[2], 0);
/* shell 没有 wait → worker 一直是僵尸 */
printf("\nshell 没有调用 waitpid...\n");
printf("PID 200 (worker) 的状态: %s\n\n",
processes[2].state == 16 ? "ZOMBIE(僵尸)" : "已回收");
/* shell 也退出了 */
do_exit(&processes[1], 0);
/* init 收养孤儿并回收僵尸 */
printf("\ninit 收养 PID 300 (helper)\n");
processes[3].ppid = 1;
printf("init 回收僵尸:\n");
waitpid(&processes[0], 200);
waitpid(&processes[0], 100);
printf("\n最终状态:\n");
for (int i = 0; i < nr_procs; i++) {
printf(" PID %d (%s): %s\n",
processes[i].pid, processes[i].comm,
processes[i].state == -1 ? "已回收" :
processes[i].state == 16 ? "ZOMBIE" : "运行中");
}#include <stdio.h>
#include <string.h>
/*
* 僵尸进程的生命周期:
* 1. 子进程调用 exit() → 变成僵尸
* 2. 内核向父进程发送 SIGCHLD
* 3. 父进程调用 wait() 回收子进程
* 4. 如果父进程不调用 wait(),僵尸永远存在
* 5. 如果父进程先退出,子进程被 init 收养
*/
struct task {
int pid;
int ppid;
char comm[16];
int state; /* 0=RUNNING, 16=ZOMBIE */
int exit_code;
int waited; /* 是否被 wait 过 */
};
struct task processes[10];
int nr_procs = 0;
struct task *find_task(int pid) {
for (int i = 0; i < nr_procs; i++) {
if (processes[i].pid == pid)
return &processes[i];
}
return 0;
}
void do_exit(struct task *tsk, int code) {
tsk->state = 16; /* EXIT_ZOMBIE */
tsk->exit_code = code;
printf("[do_exit] PID %d (%s) → ZOMBIE (exit_code=%d)\n",
tsk->pid, tsk->comm, code);
/* 通知父进程 */
struct task *parent = find_task(tsk->ppid);
if (parent) {
printf("[do_exit] 通知父进程 PID %d (SIGCHLD)\n", parent->pid);
}
}
int waitpid(struct task *parent, int child_pid) {
struct task *child = find_task(child_pid);
if (!child || child->state != 16) {
printf("[waitpid] PID %d: 没有僵尸子进程\n", parent->pid);
return -1;
}
printf("[waitpid] PID %d 回收子进程 PID %d (exit_code=%d)\n",
parent->pid, child->pid, child->exit_code);
child->state = -1; /* 标记为已回收 */
child->waited = 1;
return child->pid;
}
int main() {
printf("=== 僵尸进程演示 ===\n\n");
/* 创建进程 */
processes[0] = (struct task){1, 0, "init", 0, 0, 0};
processes[1] = (struct task){100, 1, "shell", 0, 0, 0};
processes[2] = (struct task){200, 100, "worker", 0, 0, 0};
processes[3] = (struct task){300, 100, "helper", 0, 0, 0};
nr_procs = 4;
printf("进程树:\n");
printf(" init (PID 1)\n");
printf(" └── shell (PID 100)\n");
printf(" ├── worker (PID 200)\n");
printf(" └── helper (PID 300)\n\n");
/* worker 退出 → 变成僵尸 */
do_exit(&processes[2], 0);
/* shell 没有 wait → worker 一直是僵尸 */
printf("\nshell 没有调用 waitpid...\n");
printf("PID 200 (worker) 的状态: %s\n\n",
processes[2].state == 16 ? "ZOMBIE(僵尸)" : "已回收");
/* shell 也退出了 */
do_exit(&processes[1], 0);
/* init 收养孤儿并回收僵尸 */
printf("\ninit 收养 PID 300 (helper)\n");
processes[3].ppid = 1;
printf("init 回收僵尸:\n");
waitpid(&processes[0], 200);
waitpid(&processes[0], 100);
printf("\n最终状态:\n");
for (int i = 0; i < nr_procs; i++) {
printf(" PID %d (%s): %s\n",
processes[i].pid, processes[i].comm,
processes[i].state == -1 ? "已回收" :
processes[i].state == 16 ? "ZOMBIE" : "运行中");
}
return 0;
}林小源望着这段代码,心情沉重。
如果父进程负责任地调用 ,僵尸很快就被回收。如果父进程忘记了——或者更糟,父进程自己也退出了——僵尸就只能等待 init 来收尸。
"你看到了,"归途引者不知何时出现在他身边,"那个 worker 进程,exit_code 是 0——它正常退出了,没有任何错误。但它的父进程 shell 没有调用 waitpid,所以它一直挂着, 不释放,PID 不回收。"
"后来 shell 也退出了,"林小源补充道,"然后 init 收养了 helper,回收了 worker 和 shell 的僵尸。"
"对。init 是最后的兜底。"归途引者的语气很平静,"但你想想——如果 init 也出了问题呢?如果 init 忙不过来呢?如果僵尸堆积得太多呢?"
林小源没有回答。他知道答案:系统会耗尽 PID,无法创建新进程。
二
林小源在观察僵尸的过程中,对 init 童子有了更深的理解。
init 童子的工作之一就是不停地调用 。在用户态,init 进程(通常是 systemd 或 SysVinit)会注册 的信号处理器,每当收到 时就调用 waitpid(-1, &status, WNOHANG) 来回收所有已退出的子进程。
这是一份枯燥的工作。没有人会感谢 init 童子回收了僵尸——就像没有人会感谢清洁工打扫了街道。但如果没有 init 童子,僵尸就会堆积起来,占用 PID 和内存。
归途引者说:"你知道 init 为什么总是很'冷淡'吗?因为他每天要处理几十个 SIGCHLD 信号,回收几十个僵尸。他已经麻木了——不是因为他不在乎,而是因为他在乎太多了,多到已经没有精力表达在乎。"
林小源沉默了一会儿,说:"也许他的傲慢只是一层外壳。在那层外壳之下,是一个日夜不停地回收僵尸、管理服务、处理信号的'苦力'。"
"你终于开始理解他了。"归途引者微微点头。
也许我之前对他太苛刻了。
三
林小源在研究僵尸的过程中,发现了一个有趣的现象:有些进程故意不回收子进程。
某些守护进程在 fork 子进程后,不调用 ,让子进程变成僵尸。这是一种"双 fork"技巧:父进程 fork 子进程,子进程再 fork 孙进程,然后子进程立即退出。孙进程变成孤儿,被 init 收养。这样父进程就不需要等待孙进程——因为孙进程的父进程(子进程)已经退出了,init 会负责回收。
归途引者解释道:"双 fork 的目的是让孙进程脱离原进程组。守护进程不想被任何终端控制——它需要完全独立。所以它 fork 两次,让中间的子进程退出,孙进程变成 init 的孩子。init 会负责回收孙进程的僵尸,守护进程的父进程不需要做任何事。"
"所以僵尸不是纯粹的'坏事'?"林小源问。
"当然不是。"归途引者说,"僵尸是一种设计选择。有些情况下,让进程变成僵尸是有意为之的。只有当僵尸堆积——父进程不调用 ,僵尸越来越多——那才是问题。"
林小源在内核的世界中看到了一种"灰色地带"——僵尸不是纯粹的"坏事",它是一种设计选择。关键在于父进程是否负责任地回收。
道藏笔记
内核启示
僵尸进程是已退出但未被回收的进程。
僵尸的 仍然保留在系统中,占用 PID 和内存(但不占用 CPU 时间)。僵尸的存在是为了让父进程获取子进程的退出状态。
僵尸的回收机制:
- 父进程调用 / 回收子进程
- 内核释放子进程的 和 PID
- 如果父进程先退出,子进程被 init(PID 1)收养
- init 负责回收所有孤儿进程的僵尸状态
"双 fork"技巧:父进程 fork 子进程,子进程 fork 孙进程,子进程立即退出。孙进程变成孤儿被 init 收养,父进程不需要等待孙进程。
僵尸不是 bug,是 feature。它是进程生命周期的一部分。
僵尸之试
本章里已经退出但尚未被父进程回收的任务,会在 task_struct 中留下哪一种状态?