资讯详情

资讯详情

Linux进程生命周期全解析:fork、exec、wait与僵尸进程实战指南

写进程这块内容我一直觉得是Linux系统编程里最容易被低估的一课。很多人觉得进程嘛不就是fork一下、exec一下、wait一下吗结果真到写多进程服务、排查僵尸进程、处理信号竞争的时候才发现基础没打牢。这篇我打算把进程的生命周期从头到尾掰开揉碎讲一遍从内核视角看进程是什么到fork、exec、wait这些核心API的底层行为再到实际开发中你会踩到的各种坑一套流程走下来你再去读那些开源项目的多进程代码会轻松很多。适合谁看呢正在学Linux系统编程的初学者或者写过一些多进程代码但总感觉哪里没想透的开发者这篇文章能帮你把碎片化的知识串起来。1. 进程到底是什么——别再把进程和程序混为一谈1.1 从内核视角看进程的本质先从最基础的概念说起。程序是磁盘上一个静态的文件它只是一堆指令和数据的集合。进程是程序被加载到内存后内核为它创建的一个动态执行实体。这个区别很关键但很多人只是背下来了并没有真正理解。你可以把程序想象成一本菜谱躺在书架上什么也不干。进程是厨师照菜谱做菜的这个过程——厨师会用到灶台、锅铲、食材这些就是进程拥有的资源。同一个菜谱可以被多个厨师同时照着做这就对应着同一个程序可以跑出多个进程。内核管理进程靠的是一个核心数据结构在Linux里叫task_struct你搜资料的时候可能看到的是进程控制块PCB或者叫进程描述符。每个进程在内核里都有一份task_struct它记录了进程的PID、状态、打开的文件描述符表、内存地址空间、信号处理函数、CPU上下文等等。说白了内核不关心你程序里逻辑写得多漂亮它只看这份task_struct进程调度器就是在这些task_struct之间切换分配CPU时间片。所以说你学习进程操作本质上就是在学习怎么正确地操作这份task_struct以及内核为它配套的机制。比如fork()是在内核里复制一份task_structexec是在已有的task_struct上重新加载程序wait是让父进程去读子进程的退出状态。1.2 进程状态机运行、睡眠、僵尸之间怎么流转进程状态这个概念很多人学的时候一带而过但实际排查问题的时候超级有用。你执行ps aux看到的进程状态列STAT每一列都对应着内核里进程状态机的一个节点。Linux进程最基本的状态有这几个RTASK_RUNNING运行态或就绪态、STASK_INTERRUPTIBLE可中断睡眠、DTASK_UNINTERRUPTIBLE不可中断睡眠、ZEXIT_ZOMBIE僵尸态、TTASK_STOPPED停止态还有Iidle内核线程。内核源码里一个进程从创建到退出要经历的状态变化大致是这样的新进程被fork()创建后进入R态或S态等待调度器分配CPU。如果进程调用了read()等阻塞式IO它会从R态进入S态等数据就绪后被唤醒回到R态。这个S态可以接收信号所以叫可中断睡眠。D态比较特殊进程一般在跟磁盘交互的时候进入这种状态不响应普通信号你kill它也没用只能等IO完成。进程调用exit()退出后资源会被回收但task_struct还被保留着这时候进程进入Z态等待父进程调用wait()来读取退出码然后彻底释放。如果父进程用SIGSTOP或SIGTSTP信号进程会进入T态这就是调试器打断点时进程进入的状态。我经常喜欢用一句话总结进程退出不等于进程消失它得等父进程确认过死讯才真正消失。理解不了这句话你就理解不了僵尸进程为什么会存在。2. fork()是进程创建的唯一入口——也是新手最容易翻车的地方2.1 fork的调用方式和返回值为什么要判断三次在Linux里创建一个新进程的系统调用是fork()。其他语言层面的进程创建接口比如Python的os.fork()、Java里通过ProcessBuilder最终调用的系统调用底层都归结到它这里。fork()的学名叫“一次调用两次返回”。调用一次它会复制当前进程创建出一个子进程。然后父进程返回一次子进程也返回一次。区别在于返回值父进程收到的是子进程的PID子进程收到的是0。如果创建失败父进程收到-1。这个返回值设计是整套进程编程的基石所以标准写法一定是判断三次#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { // fork失败比如进程数达到系统上限 perror(fork error); return 1; } else if (pid 0) { // 子进程执行到这里 printf(I am child, pid %d, ppid %d\n, getpid(), getppid()); } else { // 父进程执行到这里pid就是子进程的PID printf(I am parent, pid %d, child pid %d\n, getpid(), pid); } // 注意这里父进程和子进程都会执行到 return 0; }编译跑一下gcc fork_demo.c -o fork_demo ./fork_demo你会看到类似这样的输出I am child, pid 24321, ppid 24320 I am parent, pid 24320, child pid 24321注意输出的顺序不确定因为父子进程谁先被调度是内核的调度器决定的。如果你多跑几次可能输出顺序会互换。这一点很重要——fork之后父进程和子进程的执行顺序是未知的写代码时绝对不能依赖某一个先执行。还有一个很多人会问的问题为什么子进程要返回0而父进程返回子进程PID因为子进程拿到父进程PID很简单直接getppid()就行而父进程可能有很多子进程它必须知道是哪个子进程返回了。0这个值在进程系统里永远不会被分配给用户态进程因为PID 0是内核的调度器swapper进程所以拿0作为“我是子进程”的暗号是安全的。2.2 写时复制COW和进程继承关系那fork()到底复制了多少东西如果你看老教材会看到“子进程复制了父进程的地址空间”这种话。但现代Linux早就不这么干了用的是写时复制Copy On WriteCOW。原理用大白话说就是fork的时候内核只是把父进程的页表复制了一份父子进程的虚拟内存映射到相同的物理内存页这些页被标记为只读。这个时候并没有真正复制内存数据。只有当父子进程中有一个要写某个页面时内核才真正把那一页复制一份然后让写的那一方指向新页。所以fork的开销大幅降低创建速度快得惊人。这个机制也解释了为什么fork之后父子进程的变量是独立的。你看到的现象是子进程修改一个局部变量父进程里的同名变量完全不受影响。原因是写入操作触发了COW子进程写的已经是一份独立拷贝了。从继承关系上说子进程会继承父进程几乎所有的资源包括环境变量和命令行参数文件描述符表这一点巨坑后面细说信号处理函数但挂起的信号不会继承当前工作目录、root目录、文件模式创建掩码umask真实用户ID、有效用户ID、组ID但也有一些东西不继承比如进程PID、PPID、挂起的信号、文件锁、以及用pthread_create创建的线程。如果父进程是多线程程序fork()的子进程里只会保留调用fork的那个线程。注意写多线程服务时用fork()要格外小心因为你可能带着一大堆锁的状态复制出子进程如果这些锁正处于被其他线程持有的状态子进程里的副本就永远是加锁状态你在子进程里再对这堆锁操作就直接死锁了。所以很多服务端程序会在fork之后立刻exec用一个全新程序替换掉当前进程从根源上避开这种问题。3. exec家族用exec给进程换一个“灵魂”3.1 execve和前五个包装函数怎么选fork()会创建一个和父进程几乎一样的子进程但在大多数场景里子进程是为了去执行一个全新的程序。比如你在shell里敲ls命令shell先fork出一个子进程再在子进程里执行/bin/ls。这个“执行新程序”的动作就是exec系列函数干的。严格来说Linux的系统调用只有一个execve()。其他五个是glibc封装出来的变体方便不同场景用int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char * const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *pathname, char *const argv[], char *const envp[]);记它们的规律很简单。看名字带llist的是参数以列表形式传入逐个写清楚最后以NULL结尾。带vvector的是参数以数组形式传入就是argv[]。带ppath的是可以在PATH环境变量指定的路径里搜索要执行的程序。带eenvironment的是可以自己传入环境变量数组envp[]不带e的就继承当前进程的环境变量。举个例子在子进程里执行ls -l /usr// 使用 execlp带p意味着会去PATH里找ls execlp(ls, ls, -l, /usr, NULL); // 使用 execl必须写全路径 execl(/bin/ls, ls, -l, /usr, NULL); // 使用 execv char *argv[] {ls, -l, /usr, NULL}; execv(/bin/ls, argv); // 使用 execve自制环境变量 char *envp[] {PATH/bin, HOME/home, NULL}; execve(/bin/ls, argv, envp);我最常用的经验是如果在写工具类程序用execlp或execvp最省事因为它会走PATH搜索如果要执行的是路径中带斜杠的固定程序直接用execl写全路径不依赖环境变量更稳定。3.2 exec成功不返回失败才返回很多初学exec的人会困惑为什么我exec之后还要写perror不是直接跳到新程序执行了吗这里必须深刻理解一句话exec成功时不返回当前进程它直接把当前进程的代码段、数据段、堆、栈全部替换成新程序的内容然后从新程序的入口开始执行。只有exec失败的时候才会返回-1并保留原进程。所以标准写法是这样pid_t pid fork(); if (pid 0) { // 子进程 execl(/bin/ls, ls, -l, NULL); // 只有exec失败才会走到这里 perror(execl failed); exit(EXIT_FAILURE); }如果execl执行成功后面的perror永远不会执行进程已经不是你的程序了。如果exec失败你必须处理错误并exit()否则子进程会继续往下执行原来父进程的代码那就要出大事了——你会在进程里看到两套逻辑同时在跑。注意还有一类问题经常出现——exec后文件描述符没关干净。父进程fork之前打开的文件子进程会继承那份文件描述符表。如果这些fd在exec的新程序里用不到它们会一直占用着。比如父进程打开了一个socket连接fork后exec一个子进程子进程也会持有这个socket fd父进程关闭socket后连接并不会真正断开因为子进程还握着它。解决方式是在打开文件时加上FD_CLOEXEC标志通过fcntl设置这样exec时内核会自动关闭这些fd。4. 进程收尸指南wait、waitpid与僵尸进程清理4.1 僵尸进程是怎么产生的为什么必须回收进程都讲究善终。前面提到进程退出时会进入Z状态僵尸态因为内核要保留一份退出状态信息包括退出码、终止信号、资源使用统计等等着父进程来取。如果父进程一直不来取子进程就成了无人认领的尸体一直占着task_struct的位置。僵尸进程本身不占用CPU和内存的强资源但它会占用进程表项。系统能创建的进程数是有限的你可以看看系统限制cat /proc/sys/kernel/pid_max通常输出32768或者更大。如果僵尸进程堆积成千上万个系统会逐渐无法创建新进程表现出来就是fork()返回-1日志里报Cannot allocate memory。写个程序让子进程变僵尸#include stdio.h #include unistd.h #include stdlib.h int main() { pid_t pid fork(); if (pid 0) { printf(child will exit\n); exit(0); } else { // 父进程睡眠60秒不去wait子进程 sleep(60); } return 0; }编译运行后马上另开终端执行ps -ef | grep defunctwww 12580 12579 0 10:30 pts/0 00:00:00 [fork_zombie] defunct或者用ps -ef | grep fork_zombie看到子进程状态是Z。这个defunct就是僵尸进程的标志。要避免僵尸进程父进程必须在子进程退出后调用wait()或waitpid()来回收。下面这段代码演示了基本用法#include stdio.h #include stdlib.h #include sys/wait.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { printf(child pid%d, exit code 42\n, getpid()); exit(42); } int status; pid_t ret wait(status); // 阻塞直到有子进程退出 if (ret 0) { if (WIFEXITED(status)) { printf(child %d exited normally, code%d\n, ret, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf(child %d killed by signal %d\n, ret, WTERMSIG(status)); } } return 0; }WIFEXITED和WEXITSTATUS这些宏就是用来从status里解析退出信息的。只有两种情况子进程正常退出调用exit或从main返回或者被信号杀死。这两种都要优雅处理。4.2 waitpid的非阻塞写法和回收全部子进程wait()有个限制它只能等待任意一个子进程退出而且如果没有任何子进程退出它会一直阻塞在那里。在真实服务里父进程往往有多个子进程而且父进程自己还要继续干活不可能一直傻等这时候就要用waitpid()。waitpid()的核心能力是三个指定等待某个具体的子进程PID。通过WNOHANG选项实现非阻塞轮询。通过WUNTRACED选项关注因信号停止的子进程。典型写法int status; pid_t ret; while ((ret waitpid(-1, status, WNOHANG)) 0) { // 返回-1表示没有子进程退出返回0表示回收了一个子进程 if (WIFEXITED(status)) { printf(worker %ld exit with %d\n, (long)ret, WEXITSTATUS(status)); } }这里-1表示等待任意一个子进程等价于wait的语义但非阻塞。在事件循环里你可以定期调用这段代码把退出的子进程都“收尸”干净。还有一个经典问题子进程退出但父进程没有wait操作系统会怎么做如果父进程先于子进程退出子进程会被挂到init进程PID 1或最近的subreaper进程名下由它负责回收。所以孤儿进程一般不会变僵尸因为init会定期wait收尸。但如果父进程一直活着又不wait子进程就永远当僵尸。4.3 SIGCHLD信号让内核主动通知你收尸这里再提一档进阶操作。与其让父进程轮询waitpid不如让内核主动通知你。每次子进程状态变化退出、被信号停止内核都会给父进程发送SIGCHLD信号。父进程只需要注册一个信号处理函数在信号处理里调用waitpid即可。#include stdio.h #include stdlib.h #include signal.h #include sys/wait.h #include unistd.h void sigchld_handler(int signo) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 循环收尸直到没有更多子进程退出 } } int main() { struct sigaction sa; sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART | SA_NOCLDSTOP; sigaction(SIGCHLD, sa, NULL); pid_t pid fork(); if (pid 0) { sleep(1); exit(0); } // 父进程继续做别的事情 for (int i 0; i 5; i) { sleep(2); printf(parent working...\n); } return 0; }几个细节值得注意信号处理里要用WNOHANG加循环因为SIGCHLD可能合并多个子进程退出只触发一次信号。同时用SA_NOCLDSTOP避免子进程被SIGSTOP等信号暂停时也触发SIGCHLD。SA_RESTART保证某些被信号打断的系统调用能自动重启。5. 实操观察用命令行和代码配合看透进程生命周期5.1 用ps动态观察进程树理论知识讲了一堆实际操作才能真正建立感觉。我建议你动手做一个这样的实验写一个程序fork一个子进程子进程里调sleep(30)父进程用wait(NULL)等待。运行后马上看进程树。ps -ef --forest | grep -A 2 -B 2 proc_life # 或者用pstree更直观 pstree -p | grep proc_life你会看到类似这样的结构bash(1234)───proc_life(20000)───proc_life(20001)proc_life出现了两次PID分别是父和子。这个树状结构在排查多进程服务的时候是个利器比如你怀疑某个服务的子进程没被正确管理pstree -p一下立刻能看到哪些子进程还挂着哪些变成了defunct。再看每个进程的详细状态ps -o pid,ppid,stat,cmd -p 20000,20001STAT列就是前面讲的进程状态。子进程睡眠30秒时它可能是S状态可中断睡眠等待sleep时间到。如果子进程已经退出了但父进程还没来得及wait它是Z状态。我还习惯配合top来观察。top进程列表的最右侧有个COMMAND列如果看到defunct条目说明有僵尸进程堆积。再按z开启颜色标记僵尸进程会显示为红色肉眼可查。5.2 fork循环的经典陷阱和进程数爆炸再聊一个实打实的坑在循环里fork时子进程也会继续执行循环继续创建子进程结果进程数指数爆炸。看这段代码for (int i 0; i 3; i) { fork(); }你猜一共创建了多少个进程答案是7个新进程总共8个进程。因为第一次fork后父子进程都会进入下一轮循环第二轮各自fork一次变成4个第三轮变8个。这还不算最恐怖的如果你在循环里fork后子进程没有立刻exit或exec而是一起继续往下跑业务逻辑很快系统进程数就被你打满了。正确写法是在fork后立刻判断子进程要么exec要么exit不要让子进程继续在父进程的业务流程里游荡for (int i 0; i 3; i) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { // 子进程干自己的事然后退出 printf(worker %d, pid%d\n, i, getpid()); exit(0); } // 父进程继续循环创建下一个子进程 }5.3 常见的进程排查问题速查表最后把我实际工作中遇到的一些常见进程问题整理成一个速查表遇到情况可以对号入座现象可能原因排查/处理方式子进程变成defunct且堆积父进程没有调用wait/waitpid检查父进程代码补wait逻辑或用SIGCHLD信号异步回收fork返回-1日志提示Cannot allocate memory进程表满僵尸太多或内存不足先ps -ef | grep defunct统计僵尸数量kill僵尸进程的父进程让init去收尸kill -9杀不死D状态的进程进程处于不可中断睡眠通常在做磁盘IO等IO完成或重启机器。不要盲目反复kill一个服务启动后出现多个同名进程fork之后没有exec子进程继承了父进程代码还在继续跑检查fork后是否忽略了子进程分支子进程应尽快exec或exit子进程输出重复打印stdin/stdout的缓冲区被父进程fork前写入的数据复制了fork前先fflush(NULL)清空所有缓冲区或者在子进程里重新设置缓冲exec执行新程序失败但没有报错没检查exec返回值导致子进程静默继续子进程exec后必须检查错误并exit绝对不能fall through这些坑里我想重点说下缓冲区重复打印的问题这是很多刚写多进程程序的人踩了无数次的坑。printf是带缓冲的如果父进程在fork之前printf了一段内容但缓冲区还没刷新fork之后这个缓冲区的内容会被父子进程各打印一次导致同样的输出出现了两遍。解决办法是在fork之前调用fflush(NULL)把标准IO缓冲区全部刷掉。写在最后的经验之谈我刚开始写多进程程序那会儿最大的问题就是不自觉地用写单进程的思维去写多进程逻辑总觉得fork之后代码还是按从上到下的顺序跑幻想父进程和子进程像函数调用一样有先来后到。实际上fork之后这段代码就已经分成两个平行世界了你能依靠的只有返回值、wait机制和明确的进程树结构。所以我的建议是学这部分一定要亲手敲代码验证。把这篇里的fork示例、exec示例、wait示例都跑一遍再用ps和pstree盯着进程状态变化看。等你亲手制造过一批僵尸进程又亲手用wait把它们收干净这个知识才算真正长在你身上。最后分享一个我写多进程服务时养成的习惯每个子进程创建后我都会在子进程入口立即打印一行带PID的日志在子进程退出时也打印一行带PID的日志。这个习惯在排查“哪个子进程没退出”“谁变成了僵尸”的时候能省下大量时间。代码多两行排查快十倍值。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →