搞 Linux 服务端开发这几年,我发现自己不断回到一个起点:进程控制。不管你写的是 Nginx 这样的高性能服务,还是一个简单的脚本调度器,最终都绕不开 fork、exec、exit、wait 这套基本功。我见过不少同事,八股文背得滚瓜烂熟,一碰上僵尸进程堆积、子进程输出丢失、守护进程莫名被终端带走这种线上问题,立刻没了方向。这篇东西不打算讲理论大课,我想用一条完整的链路——进程怎么诞生、怎么换壳、怎么退出、怎么被回收——把 Linux 进程控制串起来讲,里面穿插我自己踩过的坑和验证过的排查思路,给刚入门的读者一条能直接上手的路,也给做服务端或者运维的朋友补一层底层视角。
1. 进程控制全景:先看懂进程的一生
1.1 进程不只是一个"正在运行的程序"
很多人把进程理解成"运行中的程序",这个说法对,但很容易让人忽略一个关键事实:进程其实是内核里的一份数据结构,加上一块独立的地址空间。
程序本身是磁盘上的静态文件,一堆指令和数据的集合,它自己是不会"跑"的。当你敲下./app或者调用fork()之后,内核会为这个实例创建一个task_struct,也就是通常说的进程控制块(PCB),里面记录着 PID、PPID、状态、打开的文件描述符表、信号掩码、内存布局、CPU 时间片等等。这份数据才是进程身份的核心,它跟着整个生命周期走。
进程之间是有"血缘"关系的。每个进程都有父进程,父进程又挂在内核的进程树某个节点下。你在任意一台 Linux 机器上执行:
ps -eo pid,ppid,stat,comm --sort=pid | head -20就能看到这个树的其中几层。PID 为 1 的进程是所有进程的祖先,它负责收养那些"父母先走"的孤儿,这个机制在后面回收僵尸的时候非常重要。
顺带说一句,PID 是进程的身份标识,但它不是地址,更不是句柄。fork()返回的是 PID,waitpid()接收的也是 PID,可你要真正操作一个进程,还得通过信号、/proc 文件系统这些手段。所以理解"进程是内核数据结构"这个前提,后面看问题会清楚很多。
1.2 进程控制到底在控制什么
把进程的一生拆开看,无非四个动作:创建、执行新程序、退出、等待回收。对应的系统调用分别是fork/vfork/clone、exec家族、exit/_exit、wait/waitpid。
我习惯把这四个动作叫作"进程控制四大件"。它们就是进程生老病死的四个阶段:
- 创建:
fork()复制出一个几乎一模一样的子进程,父子各自从 fork 返回处继续跑。 - 换壳:
exec系列用新的程序替换当前进程的地址空间,PID 不变,但代码、数据、堆栈全换成新程序的。 - 退出:进程主动或被动结束,内核回收大部分资源,但会保留一个僵尸条目等待父进程来收尸。
- 回收:父进程通过
wait系列拿到子进程的退出状态,并彻底释放最后那点残留。
除了这四个系统调用,进程状态本身也需要"控制"。你在ps里看到的 R、S、D、Z、T 这些状态,背后都对应着不同的调度行为。比如 D 状态(不可中断睡眠)是磁盘 IO 等待,这种进程连kill -9都杀不掉;Z 状态就是僵尸,资源已经释放,只留下一个 PCB 占着 PID 坑位。
我建议想深入的同学先建立这个架子,再逐个击破。很多面试题问"进程和线程的区别""fork 和 exec 的区别""僵尸进程怎么产生的",说到底考察的就是这张图有没有在你脑子里成型。
2. 进程的诞生:fork 与写时拷贝
2.1 fork 一次调用,两次返回
fork()大概是整个 Linux 进程控制里最反直觉的一个函数,因为它"调用一次,返回两次"。
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); return 1; } if (pid == 0) { printf("我是子进程,我的 pid=%d,父进程 pid=%d\n", getpid(), getppid()); } else { printf("我是父进程,我的 pid=%d,子进程 pid=%d\n", getpid(), pid); } return 0; }运行结果大概是:
我是父进程,我的 pid=10086,子进程 pid=10087 我是子进程,我的 pid=10087,父进程 pid=10086注意输出的顺序不一定固定,父子进程谁先抢到 CPU 完全是调度器说了算。这里的关键是:fork()会把父进程的用户态地址空间完整复制一份给子进程,然后两个进程从fork()的返回点继续执行。父进程拿到的返回值是子进程的 PID,子进程拿到的返回值是 0,返回负数则创建失败。
为什么设计成这种奇怪的返回值?因为子进程需要一种方式告诉"自己是谁",而父进程需要知道"新孩子是谁"。0 这个值在 POSIX 里被约定为子进程的标识,同时还能在 if 语句里和父进程分支区分开,代码写起来非常自然。
我在面试别人时经常问一个问题:fork()之后,代码是从main开头重新执行,还是从fork返回处继续?答案是后者。内核只是复制了当前的上下文,程序计数器在父进程里指向 fork 返回处,在子进程里也指向同一个地方,就好比同一个剧本两拨演员同时演,但从那个分岔点开始各走各的。
2.2 写时拷贝(COW)到底解决什么问题
远古时代的fork确实会把父进程的整个地址空间原样复制一份,包括代码段、数据段、堆、栈。想想看,一个占着 2GB 内存的服务,fork 一下就得腾出 2GB,太浪费了,而且绝大多数场景下子进程紧接着就去 exec 换壳,前面拷贝的全白干。
现代 Linux 的fork用的是写时拷贝技术。fork()刚返回时,父子进程的物理内存页是共享的,并且被标记为只读。无论谁想写某个页面,都会触发缺页异常,内核这才把该页复制一份给写入方,然后把两边都恢复为可写。
这个机制带来的好处是:fork本身变得很廉价,速度只和页面表项数量相关,和进程实际占用的物理内存关系不大。但代价是"复制"动作从 fork 时刻推迟到了第一次写入。如果你的代码在 fork 后立刻往一个大数组里写数据,还是会触发真实的页面拷贝,只是分批次发生。
基于这个特性,使用fork时有两件事要特别注意:
- 子进程修改自己的局部变量、全局变量,不会影响父进程。两者已经各自独立,只是共享只读页。
- 共享的只读页包括代码段。如果父子进程同时执行同一个函数,这段代码的指令页是共享的,节省物理内存。
顺带提一句,fork失败最常见的原因是进程数达到系统上限,或者内存不足。系统上限可以用ulimit -u查看,内存层面则和vm.overcommit_memory这类内核参数有关。跑大量并发子进程的服务,要监控这两个指标,否则线上会突然冒出Cannot allocate memory的报错。
2.3 fork 之后最常见的坑
第一坑:标准 IO 缓冲区的复制。printf默认是行缓冲,而重定向到文件时是全缓冲。如果你在 fork 前用printf打了日志但没刷新缓冲区,fork 之后这段还未刷出的数据会被子进程一并复制,结果就是日志输出两遍。解决方法是 fork 前主动fflush(NULL)或使用write这种系统调用级别的输出。
第二坑:文件描述符是共享的。fork复制描述符表,但底层的打开文件描述对象是共享的,特别是文件偏移量。父进程读了文件 100 字节,子进程接着读就是从 100 字节后面继续,而不是从头读。这个特性在实现"多个子进程同时写一个日志文件"时特别容易出问题,两个进程各自 seek 到自己的位置,互相覆盖数据。稳妥做法是每个子进程各自open文件,或者用O_APPEND让内核保证原子追加。
第三坑:多线程进程 fork 的死锁风险。线程 A 持有某个锁然后 fork,子进程里的其他线程并没有被复制,只有发起 fork 的线程活下来,可锁的状态却被原样保留。子进程中如果这个锁是锁住状态,而永远没有另一个线程来释放它,任何加锁操作都会死锁。很多高级框架(比如某些异步库)会在 fork 后主动重新初始化内部锁,就是为了规避这个。
3. 进程的蜕变:exec 系列把程序换成新程序
3.1 exec 家族六个函数怎么选
fork负责造人,exec负责换灵魂。子进程生下来之后,如果想运行一个完全不同的程序(比如 shell 里执行ls),就需要调用exec家族。它会把当前进程的地址空间清空,加载新程序的可执行文件,重新初始化堆栈,但 PID、父进程关系、打开的文件描述符这些标识性属性保持不变。
exec家族一共六个函数:
| 函数名 | 参数形式 | PATH 搜索 | 环境变量 |
|---|---|---|---|
execl | 列表(参数末尾 NULL 结尾) | 否 | 继承当前环境 |
execlp | 列表 | 是 | 继承当前环境 |
execle | 列表 | 否 | 自定义 envp |
execv | 数组(argv) | 否 | 继承当前环境 |
execvp | 数组 | 是 | 继承当前环境 |
execvpe | 数组 | 是 | 自定义 envp |
命名规则其实很直白:l是 list,v是 vector(数组),p是 PATH 搜索,e是 environment。挑一个场景,如果要在子进程里启动/bin/ls,最常用的写法是:
#include <unistd.h> #include <stdio.h> #include <stdlib.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { execl("/bin/ls", "ls", "-l", NULL); // 如果 exec 成功,这里永远不会执行 perror("execl"); exit(127); } // 父进程继续 wait(NULL); return 0; }必须记住:exec成功时没有返回值,因为它已经不存在于原来的程序里了,当然也就没法返回。只有失败时才返回 -1,所以 exec 调用之后一定要紧跟错误处理,不然程序带着半初始化状态继续跑,后果很难预料。
3.2 exec 之后内核做了什么
exec的核心动作是替换地址空间,但这个过程比想象中复杂。内核要做的包括但不限于:
- 解析并加载新程序的 ELF 文件,建立新的代码段、数据段、堆、栈;
- 丢弃旧的地址空间映射,但保留 PID、PPID 等进程标识;
- 默认保留所有打开的文件描述符(除非描述符设置了
FD_CLOEXEC); - 重置部分信号处理器为默认行为,特别是那些被捕获的信号;
- 保留进程的当前工作目录、根目录、文件模式掩码。
这里有一个隐含要点:exec后,原本由父进程传入的环境变量、命令行参数,会通过argv和envp传给新程序。所以很多程序靠环境变量做配置是可行的,但你要清楚它是从哪一层传下来的。
另外一个和运维强相关的问题:动态链接库搜索。exec在加载依赖.so文件时,依赖LD_LIBRARY_PATH和系统默认路径。我曾经遇到过线上服务换了一台机器后起不来,日志里只有error while loading shared libraries,排查方法就是ldd /path/to/binary,看哪个.so找不到。这个问题和 exec 的加载机制直接相关,值得记在心里。
3.3 exec 实操里的路径与环境变量坑
先说 PATH 搜索。execlp和execvp在不指定路径时,会按PATH环境变量逐个目录查找可执行文件。看起来方便,实际上有坑:
- 如果
PATH被设置成了一个包含空字符串的值,POSIX 规定空字符串表示当前目录,这相当于把当前目录加入搜索路径,有安全隐患。 - 如果
PATH本身为空,系统会使用默认的/bin:/usr/bin,不同发行版行为略有差异。 - 别人通过环境变量传给你的
PATH不一定是你以为的样子,多进程服务里尤其要小心。
再说环境变量。execle和execvpe可以自定义环境变量表,但这个表必须是完整的,不要指望系统帮你补上PATH、HOME。很多新手用这两个函数时不传PATH,新程序一启动就找不到外部命令,一脸懵。所以除非你明确知道新程序需要哪些变量,否则优先用execl/execvp继承当前环境。
还有一个容易忽略的坑:exec之后文件描述符的继承问题。默认情况下,所有未设置FD_CLOEXEC的描述符都会带到新程序里。这会造成一种隐蔽的资源泄漏:你 fork 了一个子进程去跑外部命令,子进程 exec 后,原本那些不相关的 socket、日志句柄还在它手里,如果外部命令一直不退出,这些描述符就被白白占着。正确做法是给关键描述符加上FD_CLOEXEC,或者从 Linux 3.10 开始,open时直接传O_CLOEXEC。
我自己的习惯是:能用execvp就用execvp,参数写成数组,别人读代码一目了然;除非明确知道路径,否则不要硬编码/usr/bin/xxx,因为不同系统路径可能不同。还有,exec 家族返回失败时,记得把errno打出来,至少能快速判断是权限问题还是文件不存在问题。
4. 进程的终点:退出与回收
4.1 exit 与 _exit 到底差在哪
进程的结束有两种,正常结束和异常结束。正常结束一般是调用了exit()或者从main返回,异常结束则是收到信号或者调用了abort()。
标准库的exit()和系统调用_exit()(_Exit())之间的区别,是很多人忽略的细节。exit()是标准库提供的,它做三件事:
- 调用所有通过
atexit()注册的退出处理函数; - 刷新并关闭所有标准 IO 流;
- 调用
_exit()真正进入内核退出流程。
而_exit()是系统调用,直接让内核回收资源,不做任何用户态清理。这意味着,如果你用printf写了日志还没 flush,调_exit()会直接丢数据。
一个常见的场景:fork出的子进程 exec 失败时,应该用什么退出?
if (pid == 0) { execl("/tmp/no_such_program", "demo", NULL); _exit(127); // 推荐用 _exit,而不是 exit }为什么?因为子进程从 fork 出来时,拷贝了父进程标准 IO 缓冲区的状态,如果此时调exit(),它会先 flush 一遍父进程留下的缓冲数据,造成日志输出重复。而_exit()直接进内核,所有用户态缓冲区一概不管,干净利落。
4.2 退出码的江湖规矩
进程退出时会带一个退出状态码,范围 0~255。main函数的返回值就是这个码,约定俗成是 0 表示成功,非 0 表示失败。
但在 shell 里看到的退出码,和真正意义上的exit code不完全是一回事。当一个进程被信号杀死时,shell 会显示130这类数值,它的规则是128 + 信号编号。比如SIGINT是 2,所以进程被 Ctrl+C 干掉后,退出码是 130;SIGKILL是 9,退出码是 137。
排查问题时,我经常用这个对照表快速判断进程是被杀还是正常退出:
| 退出码 | 常见含义 |
|---|---|
| 0 | 正常退出 |
| 1 | 一般错误 |
| 2 | 误用命令/参数错误 |
| 126 | 找到了但无法执行(权限) |
| 127 | 命令不存在 |
| 128+n | 被编号为 n 的信号杀死(如 137=SIGKILL,143=SIGTERM) |
| 255 | 一般表示退出码溢出或 exec 失败 |
在实际排查中,如果你发现某个服务总是以 137 退出,不要只看业务日志,先确认有没有谁对它发了kill -9,可能是 OOM Killer 干的,也可能是监控脚本误操作。
4.3 wait/waitpid 与僵尸进程
进程退出后,内核不会立刻把 PCB 扔掉,而是把它转换成僵尸状态,保留退出状态、资源使用统计等少量信息,等待父进程调用wait()或waitpid()来"收尸"。只有被 wait 之后,这个进程的最后一个痕迹才会从系统里彻底消失。
如果父进程不 wait 会发生什么?子进程变成僵尸进程,PID 被占用,ps里显示为Z。大量僵尸进程最直接的后果是 PID 耗尽,新进程 fork 不出来。
waitpid相比wait更灵活,我几乎不用裸的wait。几个关键点:
#include <sys/wait.h> pid_t waitpid(pid_t pid, int *status, int options);pid参数可以传 -1 表示任一子进程,也可以传具体 PID 等指定进程;options传入WNOHANG时,如果没有子进程退出,立即返回 0,不会阻塞;status不是简单整数,要用宏来解析:WIFEXITED判断是否正常退出,WEXITSTATUS取退出码,WIFSIGNALED判断是否被信号杀死,WTERMSIG取信号编号。
更高效的方案是借助SIGCHLD信号。子进程退出时,内核会给父进程发送SIGCHLD,父进程可以在信号处理函数里调用waitpid(WNOHANG)批量回收,这样就不用阻塞在哪。但信号处理函数里能安全调用的函数有限,waitpid属于允许列表中的家伙。
#include <signal.h> #include <sys/wait.h> #include <unistd.h> void sigchld_handler(int sig) { int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 回收一个子进程 } }这里用while循环是因为同一时间可能有多个子进程退出,而SIGCHLD不会排队,一个信号丢了就等于丢了一次回收机会,必须循环 wait 到返回 0 或 -1。
只写一遍还不行,有一个很隐蔽的坑:如果父进程设置了SIGCHLD的信号处理函数,但同时不阻塞waitpid,信号处理里已经回收了的子进程,外面的waitpid(-1)就会返回 -1 并设置errno为ECHILD。所以代码里要习惯性判断这个错误,不要以为是子进程创建失败。
5. 实战:手写一个可靠的守护进程
5.1 理论:守护进程需要干哪些事
守护进程(daemon)是后台运行、不依赖终端的进程。它和普通进程最核心的区别是:没有控制终端。如果你不想让进程因为终端关闭就被 SIGHUP 干掉,就得让它彻底脱离。
标准步骤是四件事:
- 调用
fork(),父进程退出。这一步让子进程变成孤儿,由 init 进程收养,从而脱离原会话和进程组。 - 调用
setsid()。让子进程成为新会话的首进程,完全脱离控制终端。这一步是"去终端化"的关键。 - 再
fork()一次。这是很多教程里会写但解释不清的步骤。第一轮 fork 后,进程是会话首进程,如果它之后打开一个终端设备,内核会认为它是"会话控制进程",可能重新获得控制终端。第二轮 fork 让这个进程不再有成为会话首进程的能力,彻底断绝和终端的关联。 - 调整工作环境:
chdir("/")避免占用某个目录导致无法卸载;umask(0)让后续创建文件的权限完全由程序控制;重定向标准输入输出到/dev/null或日志文件。
这里面的setsid()是核心,但我发现很多教程只是把代码丢出来,不讲为什么。它在系统层面的效果是:当前进程变成新会话的首进程,PID 等于会话 ID,同时成为新进程组的组长,与原来的控制终端彻底拜拜。
5.2 可运行的守护进程示例代码
我把这个完整代码贴出来,它可以直接编译跑:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <signal.h> #include <errno.h> void daemonize() { pid_t pid; // 第一轮 fork pid = fork(); if (pid < 0) { exit(1); } if (pid > 0) { exit(0); // 父进程退出,子进程被 init 收养 } // 新建会话 if (setsid() < 0) { exit(1); } // 第二轮 fork,避免重新获得控制终端 pid = fork(); if (pid < 0) { exit(1); } if (pid > 0) { exit(0); } // 改变工作目录 if (chdir("/") < 0) { exit(1); } // 清空文件创建掩码 umask(0); // 重定向标准输入输出到 /dev/null int fd = open("/dev/null", O_RDWR); if (fd < 0) { exit(1); } dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd > STDERR_FILENO) { close(fd); } } int main() { daemonize(); // 模拟守护进程工作:写日志 FILE *fp = fopen("/tmp/mydaemon.log", "a"); if (!fp) { return 1; } for (int i = 0; ; i++) { fprintf(fp, "daemon running, count=%d\n", i); fflush(fp); sleep(2); } return 0; }编译:
gcc -o mydaemon mydaemon.c执行后,终端会立刻返回,但mydaemon还在后台跑。用ps验证:
ps -eo pid,ppid,sid,stat,comm | grep mydaemon你会发现它的 PPID 是 1,SID 是它自己的 PID,S 状态或 D 状态,并且和终端没有任何关联。
5.3 部署与验证的实操细节
守护进程的日志重定向是我踩过最多坑的地方。把STDERR也重定向到/dev/null之后,程序里的perror全部失效,任何错误输出你都看不到。更合理的做法是把标准输出和标准错误重定向到专门的日志文件:
int logfd = open("/var/log/mydaemon.log", O_WRONLY | O_CREAT | O_APPEND, 0644); dup2(logfd, STDOUT_FILENO); dup2(logfd, STDERR_FILENO);这样至少能看到崩溃前的痕迹。但注意 dup2 之后,printf的输出会进日志文件,不要指望它出现在终端。
还有一个常见的启动脚本问题:很多人直接在前台跑守护进程,然后发现关掉终端进程就没了。原因很简单,如果程序自己完成了 daemonize,启动命令必须以&或者交由 systemd/init 管理。你如果在 shell 里手动执行它,终端虽然看起来退出了,但进程已经变成守护进程,脚本命令返回后 shell 就与你没关系了。
生产环境更推荐的做法是用 systemd 管理而不是自己写死 daemonize,因为 systemd 能提供崩溃自动重启、日志收集、资源限制。但前提是你依然要明白 daemonize 的原理,否则 systemd 的Type=forking配置你会一头雾水。
6. 常见问题与排查技巧实录
6.1 僵尸进程横飞怎么清
线上最常遇到的进程控制问题之一就是僵尸进程。它的典型特征是ps里的 STAT 是Z,COMMAND 后面跟着<defunct>标记。
排查顺序:
# 找出所有僵尸进程 ps -eo pid,ppid,stat,comm | awk '$3 ~ /^Z/'输出里第一列是僵尸进程的 PID,第二列是它的父进程 PID。接下来看这个父进程是什么:
- 如果父进程 PID 是 1,说明僵尸已经被 init 接管,但 init 回收不及时,这种一般不用管,内核很快会处理;
- 如果父进程是你的服务,那说明你的代码只 fork 不 wait,需要修复。
常见的修复方法有两个:最彻底的是保证每个 fork 的子进程都有对应的waitpid;另一个是给子进程的执行单独 fork 一个"中间人":专门派一个子进程再 fork 真正的干活的孙进程,中间人wait完就退出,这样即使孙进程变成僵尸,也是由中间人负责,而中间人自己不会积攒僵尸。
曾经有一次线上事故,某个服务每处理一个请求就 fork 一个子进程去跑外部命令,但完全没有 wait,跑了几天后 PID 耗尽,服务彻底无法 fork 新进程。我们用上面的命令定位到父进程后,重启了服务,然后临时用脚本定时收割僵尸,才救了急。根治办法仍然是改代码,非阻塞waitpid配合SIGCHLD是最标准的路子。
6.2 进程数量暴涨拖垮服务器
比僵尸更危险的是 fork 炸弹——一个进程不断 fork 自己,短时间内进程数指数增长,直接把系统资源耗尽。
遇到这种情况,先冷静,然后快速限制并清理:
# 限制当前用户最大进程数 ulimit -u 100 # 找到失控进程 ps -eo pid,ppid,comm --sort=-pid | head -20 # 批量终止(谨慎,会波及所有相关进程) pkill -9 -f "进程名关键字"这个场景提醒我们两点:一是生产环境要在 systemd 的 service 文件里设置TasksMax或者用ulimit提前限制,防止单个服务把整个机器拖垮;二是程序里对 fork 的结果一定要判断返回值,我见过不少 fork 失败后没有检查返回值的代码,子进程全是 -1 还能继续跑,逻辑早就乱了。
进程数量另一个隐蔽来源是短生命周期的子进程创建太频繁。每次 fork 虽然用了写时拷贝不贵,但多线程程序中 fork 的成本并不低,线程库的锁、内存映射、信号相关状态都要处理。如果业务上只是想让一个外部命令执行一次,优先考虑线程池或者直接用现成的进程池框架,不要频繁 fork。
6.3 进程卡在 D 状态怎么处理
D 状态是不可以中断的睡眠,一般意味着进程在内核里等待 IO 完成。最典型的是网络文件系统(NFS)挂载的卷卡住、磁盘硬件故障、内核 IO 子系统异常。
ps -eo pid,stat,wchan:20,comm里可以看到wchan列,显示进程阻塞在内核的哪个函数上,靠这个能猜到大概的 io 等待方向。
对 D 状态进程执行kill -9常常无效,因为内核还没从等待中返回,信号处理无处下手。我遇到过一次 NFS 服务端挂掉,所有读写该共享目录的服务进程全部 D 住,业务几乎瘫痪。最终解决是把挂载点强制卸载,让内核把等待的 IO 返回错误,进程们才陆续苏醒然后报错退出。
这里有个重要教训:不要把关键服务的日志目录放在容易卡住的网络文件系统上。如果必须用网络存储,确认高可用性和超时配置,不然一旦底层抖动,你的进程会像石头一样卡在那里,怎么都杀不死。
6.4 子进程输出丢失和孤儿进程疑云
用popen或自写管道捕获子进程输出时,经常发现输出不完整。一个典型原因:子进程写了很多数据,把管道缓冲填满了,而父进程在waitpid阻塞等待,没有去读管道,于是父子进程互相死等,经典死锁。解决办法是先读管道再 wait,或者用select同时监听多个数据源,不要图省事在 wait 之后再读。
孤儿进程则是另一种情况:父进程先退出,子进程被 init 收养。一般不会有问题,但如果父进程退得干净,新程序又依赖父进程留下的工作目录、环境变量、临时文件,那就可能报各种奇怪的错误。另外,如果父进程持有管道的写端,管道另外一端由子进程在读,父进程一退,子进程的read会立刻返回 EOF,很多程序没处理这种情况就直接退出了,表现就是"子进程怎么突然没了"。
排查这类问题,我一般先用pstree -ap PID看父子关系,再确认进程的真实父进程是不是 1,最后配合strace -f -p PID跟踪系统调用,基本一两分钟内能锁定原因。strace是进程控制排查里的神兵利器,强烈建议熟练掌握。
我自己这几年用得最多的组合就是:ps定位状态,/proc/PID/status看细节,strace看行为,wait回收逻辑看代码。进程控制不像业务代码那样花哨,但每次线上出问题绕来绕去,最后八成都能落到这四个系统调用和它们的衍生问题上。把这几个基本功练扎实,比背多少命令都管用。