☰
Linux进程控制全攻略:fork、exec、wait与僵尸进程、守护进程详解
2026/10/10 2:27:43 网站建设 项目流程

写这篇进程控制的详解,我其实犹豫了很久。网上讲fork()和wait()的文章多如牛毛,但大多数都在念 man page,读完只知道“有这么一个函数”,真到了线上环境,进程变成僵尸、父进程退出子进程变孤儿、守护进程怎么都杀不掉的时候,又抓瞎了。今天这篇我打算换个讲法,不单纯罗列 API,而是把 Linux 进程从诞生、运行、退出、回收这条完整的生命周期链串起来讲,重点落在进程控制这块:你要创建进程、给进程换程序、等待进程结束、回收进程资源,每步背后都有系统调用,每处调用都有坑。学完这篇你至少能看懂下面这类问题:为什么子进程退出后ps还能看到它、为什么父进程退出后子进程还在跑、nohup和daemon到底做了什么、线上排查时top里的 D 状态和 Z 状态分别意味着什么。

1. 进程是什么:先建立控制的基本盘

1.1 进程不是程序,而是程序的运行实例

我一直喜欢用“做菜”这个类比。菜谱是程序,它躺在文件系统里,是一堆静态的指令和数据;厨师拿起菜谱开始洗菜、切菜、开火,这个“正在进行的烹饪过程”就是进程。进程有状态:正在切菜(运行)、等水烧开(阻塞)、菜做完了还没端出去(就绪)。同一个菜谱可以同时被十个厨师使用,对应着同一个程序 fork 出的多个进程。

从内核视角看,进程是“资源分配的基本单位”。“资源”具体指什么?至少包括这几样:

  • 独立的地址空间:每个进程认为自己独占内存,通过虚拟内存机制映射到物理内存,互不干扰。
  • 打开的文件描述符:默认从父进程继承,也可以自行打开新文件。
  • 信号处理器:特定信号对应的处理函数或者默认行为。
  • PID(进程 ID):系统内唯一标识进程的数字。

Linux 里用task_struct结构体(进程描述符)管理这一切,一个进程对应一个task_struct。ps、top这类命令做的核心事情,就是遍历内核中的task_struct链表并格式化输出而已。所以说,理解进程控制的第一步,是意识到你操作的不是程序,而是内核里一个活生生的“任务实体”。

1.2 控制进程的几条主线

进程控制“总纲”其实就是四个问题:

问题解决的系统调用作用
怎么创建一个进程fork()/vfork()/clone()复制当前进程,生成子进程
怎么让进程执行另一个程序exec家族替换进程的地址空间和代码
进程怎么自己结束exit()/_exit()终止进程并发送退出状态
父进程怎么等子进程结束wait()/waitpid()回收子进程资源,获取退出状态

这四个问题恰好对应一篇进程控制的完整骨架。别小看这条主线,很多新手写多进程程序时最迷茫的,就是“不知道当前这段代码是谁在跑”“子进程里的变量改了为什么父进程没反应”“子进程退出了为什么僵尸去不掉”。说到底,全是这四条主线上的问题。

2. fork() 详解:进程诞生的地方

2.1 fork 的返回值是理解双执行流的钥匙

fork()大概是整个体系里最反直觉的系统调用:调用一次,返回两次。在父进程中调用fork(),内核复制出一个子进程,然后父子各自从fork()的下一条指令继续执行。区别在于返回值:

  • 父进程返回子进程的 PID(大于 0);
  • 子进程返回 0;
  • 失败返回 -1。

所以典型的fork()用法一定是这样的:

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { printf("子进程运行,PID=%d\n", getpid()); } else { printf("父进程运行,PID=%d,子进程 PID=%d\n", getpid(), pid); } return 0; }

很多人第一次看这段代码会困惑:“为什么 printf 会执行两次?”不是 printf 执行了两次,而是fork()之后,进程变成了两个,每个进程都继续执行了 printf。我在实际工作中经常用一句话向同事解释:fork 之后,代码分裂成了两条时间线,唯一能把两条时间线区分开的,就是那个返回值。

2.2 fork 到底复制了什么

这里常有一个误区:以为fork()是把父进程整个复制一份。早期实现确实如此,但现在 Linux 的fork()使用的是写时复制(COW,Copy-On-Write)技术。

写时复制的意思是:fork 出来的子进程并不立即获得父进程地址空间的完整副本,而是与父进程共享同一个物理内存页,并且这些页被标记为只读。只有当某一方要写入某个页时,内核才会真正复制这个页,让两边各自拥有独立副本。

所以那行著名的代码:

int counter = 0; pid_t pid = fork(); if (pid == 0) { counter = 10; // 子进程写入,触发复制,改的是子进程自己的页 } else { // 父进程这里的 counter 仍是 0 } printf("counter=%d\n", counter);

父进程和子进程的counter是两回事。子进程修改它,父进程完全感知不到。这一点在做多进程并发处理时特别重要——进程间不共享普通变量,要共享数据必须用管道、共享内存、消息队列等 IPC 机制。

需要复制的当然不只是用户空间的内存。fork()还会复制:

  • 文件描述符表(所以父子进程可以共享同一个打开的文件偏移量)
  • 信号处理器设置
  • 当前工作目录、根目录
  • 环境变量
  • umask 掩码

但不复制的内容包括:

  • PID、PPID(父子互不相同)
  • 子进程自己的task_struct、内核栈
  • 父进程挂起的信号
  • 文件锁(不继承)
  • 定时器

2.3 vfork 与 clone 的使用场景

你可能还会遇到vfork()和clone()。

vfork()是一个历史产物,它保证子进程立即执行exec,因此可以安全地与父进程共享地址空间而不复制页表。子进程在vfork()返回后、调用exec或_exit之前,父进程是被挂起的。现代 Linux 的fork()由于 COW 已经足够高效,vfork()的优势已不明显,但注意用错了会带来严重问题:在vfork()的子进程中,如果修改了父进程的栈变量,后果难以预料。写多线程服务器代码时如果见过某大厂早期版本的线程库,里面确实大量用到vfork()来优化性能,但现在基本被clone()替代。

clone()是 Linux 特有的系统调用,也是pthread_create底层实现的基础。它通过 flags 精确控制父子进程共享哪些资源,比如CLONE_VM(共享地址空间)、CLONE_FILES(共享文件描述符表)、CLONE_SIGHAND(共享信号处理器)等。fork()可以看作是clone()的特定组合,pthread线程也可以看作是用CLONE_VM|CLONE_FILES|CLONE_SIGHAND创建的“轻量级进程”。

// clone 创建线程:共享地址空间和文件描述符 int flags = CLONE_VM | CLONE_FILES | CLONE_SIGHAND; pid_t tid = clone(thread_fn, stack_top, flags, arg);

2.4 fork 失败与受控创建

fork()不是必然成功的。失控的fork()会瞬间吃光系统内存,让你的 SSH 都连不上。线上环境出现过某程序 bug 导致疯狂 fork、把整个机器的进程数打到上限、连ps都执行不了的场景。所以生产环境有两个常见保护措施:

  • ulimit -u:限制单个用户的进程数,可以防止单点失控拖垮全局。
  • cgroup 的 pids 控制器:限制某个容器或进程组能创建的总 PID 数量。

系统层面要留意/proc/sys/kernel/pid_max,默认通常是 32768,超了会报Resource temporarily unavailable。如果你的服务是那种会大量短生命周期子进程的模式,这个值要提前评估好。

2.5 fork 之后,父子怎么分工

既然 fork 后两条时间线,代码上一定要明确分工。常规模式是这样:

pid_t pid = fork(); if (pid == 0) { // 子进程:执行具体业务,比如处理一个新的网络连接 handle_request(); _exit(0); } else if (pid > 0) { // 父进程:继续 accept 下一个连接,或者 wait 子进程 } // 两个孩子都别掉下来,否则父进程会执行子进程的逻辑!

最常见的坑就是漏写 else 分支。比如:

if (pid == 0) { do_child_things(); } do_common_things(); // 这行父进程也会执行!

父进程跟着子进程一起执行do_common_things(),结果逻辑全乱套。我在面试里经常把这个作为考察点,问“这段代码执行后打印几行”,超过一半的人答错。

3. exec 家族:让子进程“改头换面”

3.1 exec 不是创建进程,而是替换进程

fork()创造了进程,但新进程和你当前代码一模一样。如果想让它执行另一个程序(比如在 shell 里输入命令、在 Web 服务器里拉起 CGI 脚本),需要用到exec系列函数。

exec的核心行为是:用一个新的程序文件,完全替换当前进程的地址空间、代码段、数据段、堆和栈。替换之后,进程的 PID 不变化,文件描述符表默认也不关闭(除了设置了FD_CLOEXEC的),但原来的代码就没了。exec 成功后不会返回,成功了就一去不回;只有 exec 失败了,才会返回 -1。

3.2 exec 家族的六个面孔

这些函数长得像、行为像,区别只在参数传递方式和环境变量处理上:

#include <unistd.h> extern char **environ; // l 表示 list:参数以列表形式传入 int execl(const char *path, const char *arg, ... /*, (char *) NULL */); int execlp(const char *file, const char *arg, ... /*, (char *) NULL */); int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */); // v 表示 vector:参数放在字符指针数组中 int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[], char *const envp[]);

简单记忆法:

  • 带l的,参数用逗号一个个列出来,最后以(char *) NULL结尾;
  • 带v的,参数打包成一个argv[]数组;
  • 带p的,第一个参数是文件名,会在PATH环境变量里搜索执行文件,不写p就需要提供全路径;
  • 带e的,可以额外传递自定义环境变量数组,不带e就继承当前环境变量。

注意execl的第一个参数arg是 argv[0],虽然它通常是程序本身的文件名,但实际上内核不会强制它和实际文件路径一致。比如你可以:

execl("/bin/ls", "my_fake_name", "-l", NULL);

ps里看到的进程名就会是my_fake_name。这是一个常用的“改名”小技巧,某些服务为了进程名好看就是这么干的。

3.3 fork + exec 的经典联合作业

Linux 下启动一个新程序,标配做法是 fork 出一个子进程,然后子进程 exec 目标程序:

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { execl("/bin/echo", "echo", "hello from exec", (char *) NULL); // 只有 exec 失败才会走到这里 perror("execl"); _exit(127); } int status; waitpid(pid, &status, 0); return 0; }

注意子进程里 exec 失败后,惯例是_exit(127),这个 127 在 shell 脚本世界里有特殊含义:命令找不到时通常返回 127。不喜欢也没关系,关键是 exec 失败后必须退出,不然子进程就会继续执行 fork 之后的父进程逻辑,这是新手最容易犯的错。

3.4 子进程里推荐用 _exit 而不是 exit

在fork()之后的子进程里退出,网上帖子和教科书会强调用_exit()而不是exit()。原因是exit()是 C 库函数,它除了调用_exit()之外,还会执行:

  • 刷新并关闭stdio缓冲区;
  • 执行atexit()注册的清理函数。

如果子进程是通过 exec 替换了地址空间,这些缓冲区早就没了,无碍。但在 fork 之后直接退出时,父进程如果有未刷新的stdio缓冲区,子进程 flush 一份、父进程也 flush 一份,缓冲区里的内容会出现双重写入或多重写入的问题。

一个非常经典的实际案例是:

printf("before fork\n"); fork(); _exit(0);

输出可能看到before fork出现两次。因为printf到的是标准输出缓冲区,fork 时缓冲区被复制,子进程_exit不做 flush 还好,如果用的是exit(),它会 flush 自己的副本,父进程之后也会 flush 自己的副本。解决方法是 fork 前fflush(NULL)清理缓冲区,或者子进程统一用_exit。

4. exit 与进程终止:主动退出没那么简单

4.1 exit 的状态码是怎么传出去的

进程终止分三种大方向:

  • 正常退出:main里return 0,或者调用exit(0)/_exit(0);
  • 异常退出:收到信号(如 SIGKILL、SIGSEGV);
  • 被杀死:由其他进程发送信号。

无论哪种,内核最终都会记录该进程的退出状态(exit status)。exit()函数的整数参数虽然只有 8 位有效(0 到 255),但它已经能表达足够多的业务意义了。

一个关键点:exit(0)的值是 0 的时候,不是“状态码为 0、正常结束”,而是“退出状态为 0、正常结束”。如果进程是被信号终止的,它没有退出状态可言——此时状态码这个字段存的不是退出状态,而是“哪个信号弄死了它”。

怎么区分?靠waitpid返回的状态字里的标志位。用系统提供的宏来判断:

宏含义
WIFEXITED(status)是否正常退出
WEXITSTATUS(status)正常退出时的退出码,仅在WIFEXITED为真时使用
WIFSIGNALED(status)是否被信号杀死
WTERMSIG(status)杀死它的信号编号,仅在WIFSIGNALED为真时使用
WIFSTOPPED(status)是否处于停止状态(常用于作业控制)
WSTOPSIG(status)导致停止的信号
WIFCONTINUED(status)是否从停止状态恢复(需要WCONTINUED配合)
#include <sys/wait.h> int status; pid_t pid = waitpid(-1, &status, 0); if (WIFEXITED(status)) { printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf("子进程被信号 %d 杀死\n", WTERMSIG(status)); }

这个宏体系是进程控制的必备基础,排查“程序为什么退出、怎么退出的”全指望它。

4.2 return 与 exit 的细微差别

main()里return 0和exit(0)表面上结果一样,但如果程序里注册了atexit函数或者在 main 之后还有清理逻辑(其实 C++ 全局对象的析构、C 库的清理),两者就有区别了:

  • return 0会走正常的函数返回流程,触发栈展开、全局析构等;
  • exit(0)是直接从当前调用点退出进程,跳过当前函数栈中未执行的部分(局部变量的析构函数不会执行)。

多进程编程时,子进程里通常建议用_exit,一是避免缓冲区双写,二是避免执行父进程里注册过的清理逻辑——子进程已经成了完全独立的进程,清理也应该独立完成,不该背着父进程的包袱退出。

4.3 SIGCHLD:内核怎么通知父进程“你孩子没了”

子进程终止时,内核会向父进程发送一个SIGCHLD信号。默认情况下父进程忽略它,这没问题,但如果父进程用signal()或sigaction()注册了 SIGCHLD 处理函数,就可以在子进程退出时得到通知,并在处理函数里调用waitpid回收。

#include <signal.h> #include <sys/wait.h> #include <unistd.h> void on_child_exit(int sig) { int status; pid_t pid; // 非阻塞回收所有已终止的子进程 while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { printf("回收子进程 %d\n", pid); } } int main(void) { struct sigaction sa = {0}; sa.sa_handler = on_child_exit; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; sigaction(SIGCHLD, &sa, NULL); pid_t pid = fork(); if (pid == 0) { sleep(2); _exit(3); } // 父进程继续做别的事 while (1) sleep(10); }

注意SA_NOCLDSTOP是为了不让子进程的停止状态也触发 SIGCHLD,尤其是调试时 Ctrl+Z 挂起子进程这种情况。SA_RESTART则避免系统调用被信号中断后返回EINTR,减少代码分支判断的麻烦。

5. wait / waitpid:父进程的“收尸”义务

5.1 内核里子进程的资源为什么没人回收就变僵尸

子进程退出后,绝大部分资源(内存、打开的文件等)会立刻释放,但它的task_struct里还剩着一小块“尸体”——进程描述符、PID、退出状态等。这些要等父进程调用wait()或waitpid()来领取。如果父进程一直不回收,子进程就变成僵尸进程(Z 状态)。

僵尸进程不占 CPU、不占内存,但它占着一个 PID 槽位。如果大量僵尸堆积,最终会导致新进程fork()失败。我在生产环境见过一个服务,父进程逻辑写崩了,曾经一只僵尸都不回收,两周后 PID 分配耗尽,服务直接不可用。重启才能恢复。

反过来,如果父进程先退出,子进程会变成孤儿进程。孤儿进程不会被杀死,而是被init 进程(1 号进程)收养,由 init 负责回收。现代 Linux 系统里这个角色由 systemd 或者特定容器内 PID 1 进程承担。所以:

  • 父进程健在,父进程回收子进程 → 正常;
  • 子进程先死,父进程不回收 → 僵尸;
  • 父进程先死,子进程被 init 收养 → 孤儿,init 负责回收。

5.2 wait() 与 waitpid() 的行为差异

wait()太简单,只提供一个功能:阻塞等待任意一个子进程结束,然后返回它的 PID。它的问题很明显:不能指定收哪个孩子、不能设置超时、不能非阻塞。

pid_t wait(int *status);

waitpid()则提供完整控制力:

pid_t waitpid(pid_t pid, int *status, int options);

pid参数的语义比较特殊,很多人记不牢:

参数值语义
pid > 0等指定 PID 的子进程
pid == -1等任意子进程(等价于 wait)
pid == 0等与调用者同进程组的任意子进程
pid < -1等该进程组 ID 为-pid的进程组内任意子进程

options常用两个:

  • 0:阻塞等待;
  • WNOHANG:非阻塞模式,如果没有子进程退出,立即返回 0;
  • WUNTRACED:即使子进程只是被停止(如 SIGSTOP),也返回;
  • WCONTINUED:子进程从停止状态恢复后也返回(需要WUNTRACED配合)。

5.3 安全的回收模式:信号 + 非阻塞循环

实际项目中,正确姿势往往是 SIGCHLD 信号处理器里用waitpid(-1, &status, WNOHANG)循环收割:

void reap_children(int sig) { int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 处理退出状态 } }

为什么要循环?因为信号是不可靠的,多个子进程同时退出时,可能只凑到一个 SIGCHLD,非阻塞循环确保一次性收完所有已死去的子进程。

这个模式是生产环境下的“标准答案”,只要记住别在信号处理器里做重活(不可重入的库函数都要避免),完成度就很高了。

5.4 忽略 SIGCHLD 的副作用

有一个偷懒方案:直接signal(SIGCHLD, SIG_IGN),告诉内核“我不关心子进程退出”。这样设置后,子进程退出时内核会直接回收它的 task_struct,不产生僵尸。这个方案对很多简单脚本非常方便。

signal(SIGCHLD, SIG_IGN);

但代价是:你再也拿不到子进程的退出状态了。如果某个子进程执行失败,你完全不知道它为什么失败。在需要了解退出码、判断业务成功与否的场合,这个方案不可取。

6. 僵尸与孤儿:最经典的进阶坑位

6.1 复现一只僵尸

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid == 0) { printf("子进程,PID=%d,即将退出\n", getpid()); _exit(0); } // 父进程故意不调用 wait,直接睡 30 秒 printf("父进程,PID=%d,子进程 PID=%d\n", getpid(), pid); sleep(30); return 0; }

运行期间再开一个终端执行:

ps -eo pid,ppid,stat,cmd | grep [z]ombie_demo

会看到子进程的 STAT 是Z,CMD 栏带有(僵尸)或者defunct字样。

想清理它?只要父进程退出、init 收养并回收,僵尸自然消失。所以最快的验证方式就是等待 30 秒后父进程退出,再执行一次ps,僵尸就没了。

6.2 最隐蔽的僵尸来源:父进程没 etc SIGCHLD handler

很多网络服务程序里,主循环fork出子进程处理连接,但如果主循环里忘了waitpid或忽略 SIGCHLD,一旦请求量起来,僵尸会以惊人的速度堆积。测压时经常一眼看到成排的Z进程。这不是内核 bug,而是程序 bug。

诊断手段很简单:

ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/ {print}'

找出所有僵尸,查看它们的 PPID,再定位到那个父进程去看它的代码。

6.3 双重 fork 技术:彻底摆脱父进程守护逻辑

有一种常见需求:父进程 fork 出子进程后,子进程再 fork 一次,把孙子进程交给 init 收养。这种“双重 fork”常被用来做守护进程脱壳。

原理:

  1. 父进程 fork 出子进程;
  2. 子进程 fork 出孙子进程;
  3. 子进程立即退出,孙子进程变成孤儿,被 init 收养;
  4. 父进程退出,不与孙子进程产生任何关联;
  5. 孙子进程完全脱离原控制终端和进程组,独立存活。
// 标准 daemon 化第一步:脱离终端 pid_t pid = fork(); if (pid < 0) exit(1); if (pid > 0) exit(0); // 父进程退出 // 这里是第一个子进程 setsid(); // 创建新会话,完全脱离控制终端 pid_t pid2 = fork(); if (pid2 < 0) exit(1); if (pid2 > 0) exit(0); // 第一个子进程退出 // 孙子进程成为孤儿,由 init 收养,继续存活

加上umask(0)、chdir("/")、关闭标准三个文件描述符,就是经典的 daemon 化流程。daemon(0, 0)这个库函数内部做的就是这件事。

提示:如果你用的是 systemd 来管理服务,不建议自己在代码里做 daemon 化。systemd 自己就能把进程放入独立 cgroup 和会话,你自作主张双重 fork 反而会增加 systemd 管理的复杂度。很多现代项目都选择Type=simple,前台运行,让 systemd 直接托管。

6.4 孤儿进程组与 SIGHUP

控制终端挂断时,内核会给会话首进程发送 SIGHUP。如果前台进程组还在运行、没人处理 SIGHUP,整个进程组可能一起挂掉。这就是为什么nohup有用:它让进程忽略 SIGHUP,从而在终端断开后继续存活。

但nohup不等于 daemon 化。nohup 命令 &只是忽略了 SIGHUP 并后台运行,进程仍然可能因为读写终端的文件描述符而出错。daemon 化则会关闭所有终端相关描述符,彻底“断水断电”。线上服务该用哪种方式,得看具体场景。

7. 实操体验:从最小 demo 到排查工具

7.1 搭建最小实验环境

学习进程控制最有效的方式就是亲手跑几个小实验。不需要任何第三方库,纯 C 语言即可。我强烈建议你准备一个干净的 Linux 环境(虚拟机、容器都行),以下代码全部基于gcc编译:

gcc -o fork_demo fork_demo.c ./fork_demo

把ps、top、strace都准备好,这是排查进程问题时最常用的三个工具。

7.2 用 strace 看系统调用的真实面貌

strace能告诉你程序到底调了哪些系统调用、参数和返回值是什么。这对理解进程控制尤其好用。比如跑一段 fork 程序:

strace -f -e trace=process,clone,fork,vfork ./fork_demo

输出你能看到:

clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f...) = 12345

现代 glibc 的fork()底层调用的是clone(),不是直接调用fork()系统调用(内核里其实没有独立的fork系统调用,全都映射到clone上了)。这也是面试经常考的细节:fork()→clone()内核实现。

7.3 用 top 观察进程状态迁移

top里的 S 列(STAT 列)很直观,常见状态包括:

状态含义对应内核宏
R运行中或就绪队列里TASK_RUNNING
S可中断睡眠(等待资源)TASK_INTERRUPTIBLE
D不可中断睡眠(多为 IO 等待,比如磁盘 IO)TASK_UNINTERRUPTIBLE
Z僵尸EXIT_ZOMBIE
T停止(收到 SIGSTOP/SIGTSTP 或调试器暂停)TASK_STOPPED
X死亡状态,你在 top 里基本看不到EXIT_DEAD

D 状态特别值得注意。它常见于 NFS 挂载后网络故障、大量磁盘 IO 等待等场景。D 状态进程不能被常规信号终止——对 SIGKILL 都无反应,因为它在内核态卡住了,信号只有在返回用户态时才会被处理。如果 D 进程长期不退,多半是底层 IO 卡死,只能从存储层面排查,而不是反复 kill。

7.4 实战:实现一个简单的进程池脚手架

把上面所有知识合起来,写一个简单的进程池骨架,父进程维护几个常驻 worker 进程,通过管道分配任务:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> #include <signal.h> #define WORKER_NUM 4 int main(void) { pid_t pids[WORKER_NUM]; int pipes[WORKER_NUM][2]; struct sigaction sa = {0}; sa.sa_handler = SIG_IGN; // 简单起见,忽略 SIGCHLD 让它自动回收 sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; sigaction(SIGCHLD, &sa, NULL); for (int i = 0; i < WORKER_NUM; i++) { if (pipe(pipes[i]) < 0) { perror("pipe"); exit(1); } pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { // 子进程:从管道读任务,模拟执行 close(pipes[i][1]); // 关掉写端 char buf[64]; ssize_t n; while ((n = read(pipes[i][0], buf, sizeof(buf) - 1)) > 0) { buf[n] = '\0'; printf("worker[%d] 收到任务: %s\n", getpid(), buf); sleep(1); } close(pipes[i][0]); _exit(0); } // 父进程:关闭读端,记录子进程 pid close(pipes[i][0]); pids[i] = pid; } // 发几个任务 for (int i = 0; i < 8; i++) { int worker = i % WORKER_NUM; char msg[32]; snprintf(msg, sizeof(msg), "task-%d", i); write(pipes[worker][1], msg, 18); } // 关闭写端,等等 worker 们退出 for (int i = 0; i < WORKER_NUM; i++) { close(pipes[i][1]); } for (int i = 0; i < WORKER_NUM; i++) { waitpid(pids[i], NULL, 0); } return 0; }

这里刻意用了 SIG_IGN 让内核自动回收子进程,代码里不需要额外 wait。但在真实业务里,如果你需要知道每个 worker 的退出状态,还是得用 SIGCHLD 处理器 + WNOHANG 循环那套模式。两种方式怎么选,是很好的思考题。

8. 常见问题与排查技巧实录

8.1 僵尸进程怎么都清不掉

清不掉只有一个原因:僵尸的父进程还活着,并且不回收。

ps -eo pid,ppid,stat,cmd | awk '$3=="Z" {print}'

看准 PPID,确认父进程是谁。如果父进程是 init(PID 1)但僵尸还在,那说明系统异常;正常情况下 init 会很快回收。如果父进程是你自己的程序,检查它是否调用了 wait/waitpid,或者是否设置了SIG_IGN。临时应急可以 kill 掉父进程,孤儿会被 init 接管回收,但这是治标不治本。

8.2 fork 或者 pthread_create 突然报 EAGAIN

Resource temporarily unavailable的常见原因:

  1. 进程数达到ulimit -u上限,执行ulimit -u查看;
  2. PID 空间耗尽,cat /proc/sys/kernel/pid_max查看,普通机器 32768,容器环境可能更小;
  3. 内存不足,COW 的 fork 虽然共享页,但页表本身仍需分配,极端内存压力下 fork 会失败;
  4. pthread 的线程栈分配失败,常见于虚拟内存或 overcommit 设置不当。

排查顺序:先看ps -eLf | wc -l统计当前线程数,再比ulimit -u,然后看free -h内存情况。生产环境遇到过容器场景下 pid_max 没调、线程池一拉起来就 EAGAIN 的问题,调大 pid_max 才行。

8.3 父进程退出了,子进程为什么还活着

这正说明了进程的独立性。子进程不是父进程的一部分,它是独立实体。父进程退出后,子进程成为孤儿,被 init 收养,继续运行直到自己结束。想让子进程跟着父进程死,就得用PID 命名空间 / 进程组信号等手段。比如 shell 作业控制里kill -TERM -$pgid可以发给整个进程组,或者使用 systemd 的KillMode=control-group让服务退出时杀掉整个 cgroup 里的进程。

8.4 kill -9 为什么杀不死某些进程

如果一个进程处于 D 状态(不可中断睡眠),它在内核态没有返回用户态,SIGKILL 无法被传递,直到 IO 事件完成。这种情况的经典场景是 NFS 服务端不可达、内核卡在网络栈读取上,或者磁盘控制器故障。处理方式:

  • 先确认确实是 D 状态:top里看到一堆D;
  • 排查底层 IO:dmesg看内核日志、iostat -x 1看磁盘、检查 NFS 挂载是否失联;
  • 如果确定是 NFS 失联,尝试恢复 NFS 服务端,或者强制卸载挂载点;
  • 最后手段是重启容器/机器。

8.5 子进程里 print 的内容没按预期出现

这多半是缓冲区问题,跟 4.1 节讲的一样。子进程退出用了_exit,那它printf进stdio缓冲区的内容根本没 flush,就这么没了。解决办法:

  • 子进程退出前主动fflush(NULL);
  • 或者直接用write()系统调用,绕过 stdio 缓冲;
  • 或者 stderr 上打印——stderr 默认无缓冲,立刻输出。

8.6 进程状态一下子变成 R 但 CPU 又很低

R 状态不一定是“正在计算”,也可能是“在就绪队列里排队”。如果 CPU 占用率不高但大量 R 状态,通常意味着线程非常多、上下文切换频繁,比如滥用线程池、每个请求干一点点活就切换线程。排查时用pidstat -w 1看上下文切换数据,或者vmstat看cs列。

8.7 守护进程无法启动或者启动两次就崩

最常见原因是忘了设置setsid(),导致它仍然关联原控制终端,终端关闭时收到 SIGHUP 直接退出。另一个常见问题是工作目录没切到根目录,后续相对路径操作全乱。daemon 化时要严格执行四步:

  1. fork()后父进程退出;
  2. setsid()新会话;
  3. 二次fork()防止重新获取控制终端;
  4. umask(0)和chdir("/");
  5. 重定向标准输入/输出/错误到/dev/null或日志文件。

推荐直接调用daemon(0, 0),但如果你在现代化环境用 systemd 管理服务,这条路可以不走,前台运行更简单。

写在最后

进程控制是整个 Linux 编程里最不该含糊的地带,因为所有看似神奇的进程行为——僵尸、孤儿、守护、多进程协作——背后的机制不过是fork、exec、exit、wait这四件事的组合。我实际排查过不少线上问题,最终都归结到父进程没回收、信号没处理、exec 失败没退出这类基础环节。建议你把这四个系统调用族亲手各写一个 demo 跑通,再用strace观察一遍,感受会完全不一样。最后提一个技巧:排查任何进程异常时,先在脑子里过一遍“它是谁、它本该在哪、它现在在哪、谁应该对它负责”,问题通常就定位了大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询