做Linux系统编程免不了要和进程打交道。不管你是写后台服务、嵌入式程序还是自己折腾工具,进程管理、进程结束和exec函数这三大块都是绕不过去的基础。很多初学者刚接触的时候,被fork和exec搞得晕头转向,尤其是exec家族那一堆变体,每个都长得差不多,用起来却千差万别。这篇博文我会把进程从创建到退出的完整生命周期讲清楚,再逐个拆解exec函数的使用场景和坑点,最后用一个模拟守护进程管理器的项目把这些知识串起来,保证你看完能直接用。
这篇文章适合两类人:一类是刚开始学Linux系统编程、对fork/exec/wait只有模糊概念的学生党,另一类是已经在写业务代码、但遇到进程异常退出或僵尸进程问题不知道怎么排查的开发者。我尽量少讲空泛的理论,多放能跑、能复现、能排查的实操内容。
1. 为什么进程管理和exec是系统编程的地基
1.1 你写的大多数程序,背后都在“制造”进程
很多人觉得进程管理是操作系统内部的事,跟应用层程序员无关。但这个想法在你第一次需要启动一个外部程序、挂起一个任务、守护一个服务的时候就会被推翻。Linux的设计哲学是“一切皆文件,程序靠进程跑”。你的IDE、编译器、shell脚本、容器运行时,底层全都在和进程打交道。
举个最直观的例子:你在终端里输入ls,看似简单,但背后发生了一整套流程。shell先fork出一个子进程,子进程再去exec加载ls这个可执行文件,父进程则在wait系统调用上等着子进程结束。这一套流程如果你不懂,后面遇到“命令执行完但结果不对”“子进程变僵尸”“程序莫名其妙被替换掉了”这类问题,就只能瞎猜。
exec函数在这个链条里扮演的角色,是让一个进程“换脸”。它能用一个新的程序镜像覆盖当前进程的地址空间,相当于让一个进程原地变成另一个程序。理解了这一层,你就明白了为什么进程管理和exec必须放在一起学。它们一个是创建新生命,一个是让生命焕然一新,中间的衔接靠的正是父子进程关系和退出机制。
1.2 学习这个主题需要的基础
我不建议完全零基础的人直接啃这块。你最好先掌握基本的C语言语法和指针,会编译运行一个C程序,也接触过ps、top这类查看进程的命令。fork、wait、exec都是系统调用,需要你对“用户态、内核态、系统调用”有一点点感知,哪怕只有一个模糊的印象都行。
如果这些基础还没有,也别慌,先跟着下面的例子把代码跑起来,看输出,再回头补理论。Linux系统编程和纯业务开发不一样,它特别强调“亲手试错”。读一百遍文档,比不上自己写一个fork程序看一次进程树的变化。
2. 进程的一生:从fork到exit,Linux进程管理的核心脉络
2.1 进程生命周期与状态切换
先建立一个整体认知:进程不是一直“活着”的,它有出生、运行、休眠、死亡。在Linux里,一个进程从被创建到消失,至少经历几个状态:运行态(R)、睡眠态(S或D)、停止态(T)、僵尸态(Z)。你用ps -l看到的STAT列,就是当前状态。
进程的出生几乎都是通过fork或它的变体clone完成的。fork会创建一个新的进程,这个新进程是调用者进程(父进程)的副本。两个进程从fork返回的那一刻起,拥有各自独立的地址空间、堆栈、文件描述符表,但实际上物理内存是共享的,只有写入的时候才会真正复制,这是写时拷贝技术。
我在遇到fork初期,最容易犯的错是以为子进程会从main开始执行。其实不是,fork返回之后,子进程是从“fork调用的下一行代码”继续跑的。而且父子进程会各自返回一次,这就是为什么你看到if (pid == 0)子进程分支,而父进程走另一个分支。这个“一次调用,两次返回”的模型,是整个并发和进程管理的基础。
进程退出时也不会立刻消失,它会先变成僵尸态,等待父进程通过wait系统调用获取它的退出状态,然后才彻底释放资源。如果父进程一直不调用wait,僵尸进程就会一直留在进程表里,累积多了会影响系统创建新进程。这是后面要重点排查的问题。
2.2 fork的写时拷贝,父子进程到底共享什么
fork刚出现的时候,实现很粗暴:把父进程的整个地址空间逐字节复制一份给子进程。这在内存紧张的老旧系统上是巨大的开销。现代的Linuxfork用的是写时拷贝技术,英文是Copy On Write,简称COW。
COW的思想是:父子进程先共享同一份物理内存,并把这些内存页标记为只读。只要双方都没有写入,大家共用一份数据,效率极高。一旦有一方试图写入,缺页异常触发,内核才真正复制这一页内存,给写入方一份私有副本。
这个设计对fork场景极其友好。因为绝大多数程序fork出去的子进程,下一秒就调用exec去执行一个新程序,原来的地址空间根本用不上。如果没有COW,这个fork再exec的组合会白白拷贝一大堆无用的数据。所以COW不是可选的优化,而是现代Linux能让fork+exec这么流畅的关键原因。
插一句,vim这类编辑器也用了这个思路。你在vim里开一个文件,它实际fork一个子进程出来做备份,父子进程共享文件缓冲,直到有一方修改才复制。理解了COW,你对“父子进程到底共享什么”这个问题的答案会清晰很多:共享的是物理内存页和打开的文件描述符表,不共享的是进程ID、父进程ID、独立的地址空间。
3. 进程结束的三种姿势:exit、_exit、return到底有什么区别
3.1 exit与_exit的实现差异
程序结束时,你通常会写return 0,或者调用exit(0),有时候又看到别人用_exit(0)。这三者的区别,我用自己的代码踩过坑之后才算彻底明白。
exit是标准C库提供的函数,它做的事情比较多:先调用通过atexit注册的清理函数,再刷新所有未写入的stdio缓冲流(比如还没写到文件的printf内容),最后才调用系统调用_exit进入内核终止进程。
_exit和exit不一样,它是系统调用级别的退出,直接告诉内核把这个进程结束,不做任何清理工作,不调用清理函数,不刷新stdio缓冲区。如果你在子进程里用了printf,然后在同一行调用_exit,这些输出可能就不会真的写出去,因为缓冲区还没刷新就没了。
return则完全不同,它是从当前函数返回。只有在main函数里执行return,才会隐式触发exit流程。在其他函数里return只是回到调用者,和进程退出没有任何关系。很多人搞混这个,是因为他们把main里的return和所有函数里的return混为一谈。
实操里有个很重要的选择场景:如果你在fork出的子进程中exec失败,你需要向父进程报告错误。这时推荐用_exit(127),因为子进程几乎不需要清理什么,而且_exit不会碰stdio缓冲区,避免了重复刷新共享文件描述符可能带来的问题。
| 机制 | 是否调用清理函数 | 是否刷新stdio缓冲 | 是否立即进入内核终止 | 适用场景 |
|---|---|---|---|---|
| return | 仅main中会触发exit | 仅main中会触发刷新 | 是 | 正常结束 |
| exit | 是 | 是 | 是 | 正常结束,需要清理 |
| _exit | 否 | 否 | 是 | 子进程exec失败等场景 |
我自己见过的典型案例是:某个程序用了atexit注册了一个清理函数,清理函数里要删临时文件、写审计日志。后来在某个子进程路径里为了省事写了_exit,结果清理函数全没执行,临时文件堆积,线上磁盘被塞满。从那以后,我对exit和_exit的选择就特别小心。
3.2 僵尸进程与孤儿进程:wait、waitpid怎么用
僵尸进程这个词吓到过不少人。其实僵尸就是进程终止后留下的一个残骸,它的所有资源都已经释放了,只保留进程表里的一条记录,记录着退出码和终止原因。为什么需要这个残骸?因为父进程需要知道“孩子是怎么死的”,是正常退出还是被信号杀掉。
父进程获取这个信息的唯一途径就是wait或waitpid。wait会阻塞直到任意一个子进程终止,然后返回退出状态。waitpid更灵活,你可以指定等待某个特定PID的子进程,还可以通过WNOHANG选项实现非阻塞查询。
写代码时要注意那几个宏。WIFEXITED(status)判断子进程是否正常退出,WEXITSTATUS(status)获取退出码,WIFSIGNALED(status)判断是否被信号终止,WTERMSIG(status)获取信号编号。这几个宏是用来解析status整数的标准手段,千万不要自己去读status的原始值,不同架构下的位布局不一样,宏才是可移植的方式。
waitpid(-1, &status, 0)表示等待任意子进程,waitpid(pid, &status, WNOHANG)表示不阻塞地查询指定子进程。如果有大量子进程需要处理,官方推荐在SIGCHLD信号处理函数里配合while (waitpid(-1, NULL, WNOHANG) > 0)循环收割,这样不会漏掉任何一个退出的子进程。
孤儿进程是什么呢?当父进程先于子进程退出,子进程会被init进程收养,变成孤儿进程,然后由init负责收割。现代系统上init就是PID为1的进程。所以孤儿进程并不可怕,系统自有人管。但僵尸进程不一样,如果没人管,它就一直赖在进程表里。你可以在ps -l的STAT列看到大量Z状态进程,这就是父进程偷懒没调用wait的后果。
4. exec函数家族解析:六种变体的选择与应用场景
4.1 execve与其他变体的差异
exec家族在Linux man手册里有统一的名字:execve是真正的系统调用,另外几个execl、execv、execlp、execvp、execle、execvpe都是基于它封装出来的库函数。它们的共性是执行成功就不返回,直接让新程序接管当前进程;只有失败的时候才返回-1并设置errno。
这六个变体的命名规则,很多教程讲得云里雾里,我用拆字母的方式给你理清:
l表示参数列表(list),参数个数在写代码时是确定的一个个列出来,以NULL结尾。比如execl("/bin/ls", "ls", "-l", NULL)。v表示参数数组(vector),用一个char *argv[]数组传递参数,数组以NULL结尾。比如execv("/bin/ls", argv)。p表示使用PATH环境变量搜索可执行文件。比如execlp("ls", "ls", "-l", NULL),不用写完整路径,系统会在PATH里逐目录找。e表示可以自己指定环境变量,传递一个char *envp[]数组,替代继承自父进程的环境变量。
于是execl、execv、execlp、execvp、execle、execvpe这六个函数,本质上就是“参数是列表还是数组 + 是否用PATH搜 + 是否自定义环境变量”三个维度的组合。
| 函数 | 参数形式 | 是否用PATH | 自定义环境变量 |
|---|---|---|---|
| execl | 列表 | 否 | 否 |
| execv | 数组 | 否 | 否 |
| execlp | 列表 | 是 | 否 |
| execvp | 数组 | 是 | 否 |
| execle | 列表 | 否 | 是 |
| execvpe | 数组 | 是 | 是 |
实际开发里怎么选?如果需要动态构造参数,参数个数不固定,用v结尾的最合适。你在代码里循环拼一个argv数组,再传给execvp,这比用execl一个参数一个参数地主进来灵活得多。l结尾适合写死的参数,比如你自己知道要启动的程序就固定三个参数。p结尾适合你希望兼容用户PATH设置的场景,比如用户自己把可执行文件装到了~/.local/bin,你只需要传个命令名,系统自己找。但如果你的程序对路径有严格固定要求,那就别用p,直接用绝对路径,免得被环境变量影响。
我一直强调一个细节:exec成功之后,原进程的代码不再执行了。因此,在调用exec之前打开的文件描述符,默认会被新程序继承。如果你想关掉某个fd,必须设置FD_CLOEXEC标志,或者在exec前手动close。FD_CLOEXEC这个名字翻译过来就是“close on exec”,执行exec时自动关闭。这个标志特别重要,否则可能会把监听socket意外传给新程序,造成端口被莫名其妙占用的问题。
4.2 fork与exec的组合使用:子进程的执行模式
fork和exec单独用都有局限。fork能创建子进程,但子进程和父进程跑同一份代码,如果你想让子进程去执行另一个程序,光靠fork做不到。exec能让进程换成新程序,但如果直接在当前进程执行exec,当前进程立刻被替换,原来的代码就没了。于是“干活”的组合就诞生了:先fork出一个子进程,在子进程里exec新程序,父进程继续跑自己的逻辑。
这个组合的价值在于隔离和灵活性。你把一个外部程序丢到子进程里跑,它出问题不会影响父进程。同时你还能在exec之前设置标准输入、输出、错误重定向,这样就能把子进程的输出引到文件或者管道里。shell里的管道、重定向,底层都是这个套路。
当然,如果你只是想临时执行一条命令并且不在乎返回值,用system()更省事,它内部封装了fork、exec和wait。但system()会调用/bin/sh -c来解释命令字符串,如果字符串里混入了外部可控内容,就有被注入的风险。你自己拼接命令时,必须确认字符串里面没有任何用户输入,或者干脆不用system(),改用fork+exec的方式,把参数数组直接传给exec,这样就没有shell解析注入的问题了。
那么问题来了:为什么不直接fork一个子进程,让子进程立即exec而要等呢?因为有些业务逻辑需要在exec前做一些准备。比如把网络连接fd传给子进程的stdin/stdout,比如设置子进程的进程组,比如修改信号掩码。这些操作必须在exec之前完成,因为exec成功之后原程序的控制权就没了。
一个思考题:如果fork之后忘记调用exec,而是直接return,会发生什么?子进程会把父进程剩下的代码全都再执行一遍。这既是很多诡异bug的来源,也是理解fork和exec关系的重要切入点。你要牢记:fork和exec是两次独立调用,缺一不可。
4.3 system()与posix_spawn,两个现成的封装
除了手动fork+exec+wait,标准库里还有两个封装好用的接口。system()我前面提过,它简单到只要传一个字符串就行,内部走的就是/bin/sh -c。它的最大优点是把shell的命令组合能力直接用起来,重定向、管道、通配符都能上。但它的缺点我也反复强调过:经过shell解析,如果命令字符串里有外部输入,就可能被注入额外参数或命令。
posix_spawn()是POSIX标准定义的函数,在很多系统上被实现为一个“轻量级的fork+exec”。它可以接受一个属性对象(posix_spawnattr_t),用来控制子进程的信号掩码、进程组、调度策略等。如果你在用多线程程序,那么在fork后不调用exec的情况下,线程库的状态可能不安全,posix_spawn这种经过精心设计的函数会更合适。不过大多数教材和实际Unix代码还是以手写fork+exec为主,因为更直观,也更符合传统的Unix风格。
5. 完整实操项目:模拟守护进程管理器的核心逻辑
5.1 需求拆解与整体设计
理论讲了那么多,现在做一个能跑的项目来巩固。我选一个场景:写一个简单的守护进程管理器。它的功能是:启动一个指定的目标程序,如果目标程序异常退出,自动重新拉起它;如果目标程序正常退出,管理器也退出。
这个场景是真实世界中很多服务管理工具(像supervisor、systemd的部分功能)的缩影。我把需求拆成几个模块:
- 守护化:让管理器自己后台运行,不占用终端。
- 启动目标程序:fork一个子进程,子进程exec目标程序。
- 监控退出状态:父进程waitpid等待子进程结束,拿到退出状态。
- 重启策略:根据退出码判断是非正常退出,决定是否重新拉起,重启间隔防抖。
整体流程大致是这样:
管理器启动 -> fork并退出父进程,完成守护化 -> 进入主循环 -> fork子进程 -> 子进程exec目标程序 -> 父进程waitpid等待子进程结束 -> 分析退出状态 -> 如果正常退出(0),结束循环 -> 如果异常退出,sleep 2秒,继续循环这个设计最核心的点是:管理器进程自己不能因为目标程序退出就退出,它要一直活着,持续监控、持续拉起。如果在同一个进程里直接exec目标程序,那管理器自己就变成目标程序了,谁来继续守护?所以必须用fork隔离出子进程来运行目标程序。
5.2 核心代码实现与关键点解析
我用C语言写一个核心骨架,你可以直接编译运行看看效果。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> #include <errno.h> // 目标程序的路径和参数列表,注意以NULL结尾 static char *target_proc[] = {"/usr/bin/example_server", "-p", "8080", NULL}; // 守护化:让当前进程变成后台守护进程 static int daemonize() { pid_t pid = fork(); if (pid < 0) { return -1; } if (pid > 0) { // 父进程直接退出,让子进程被init收养 exit(0); } // 创建新会话,脱离控制终端 if (setsid() < 0) { return -1; } // 第二次fork,确保进程不再是会话首进程,防止将来重新获得控制终端 pid = fork(); if (pid < 0) { return -1; } if (pid > 0) { exit(0); } // 切换工作目录、清理文件掩码 chdir("/"); umask(0); return 0; } int main(void) { if (daemonize() < 0) { fprintf(stderr, "daemonize failed\n"); exit(1); } // 打开日志文件,O_CLOEXEC防止exec时泄漏 int logfd = open("/tmp/daemon_mgr.log", O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); if (logfd < 0) { exit(1); } dup2(logfd, 1); dup2(logfd, 2); int nullfd = open("/dev/null", O_RDONLY); dup2(nullfd, 0); if (logfd > 2) close(logfd); if (nullfd > 2) close(nullfd); fprintf(stdout, "[daemon] started, pid=%d\n", getpid()); while (1) { pid_t child = fork(); if (child < 0) { fprintf(stderr, "[daemon] fork failed: %s\n", strerror(errno)); break; } if (child == 0) { // 子进程:执行目标程序 execvp(target_proc[0], target_proc); // exec只有失败才会返回,到这里说明出错了 fprintf(stderr, "[daemon] execvp failed: %s\n", strerror(errno)); _exit(127); } // 父进程:等待子进程结束 int status = 0; pid_t ret = waitpid(child, &status, 0); if (ret < 0) { fprintf(stderr, "[daemon] waitpid failed: %s\n", strerror(errno)); break; } if (WIFEXITED(status)) { int code = WEXITSTATUS(status); fprintf(stdout, "[daemon] child exited normally, code=%d\n", code); if (code == 0) { // 退出码为0,正常结束,任务完成 break; } } else if (WIFSIGNALED(status)) { fprintf(stdout, "[daemon] child killed by signal %d\n", WTERMSIG(status)); } // 非正常退出,重启目标程序,2秒防抖 fprintf(stdout, "[daemon] restarting target in 2s...\n"); sleep(2); } fprintf(stdout, "[daemon] exiting, bye\n"); return 0; }代码并不长,但里面埋了好几个关键点,我逐个解释。
第一,daemonize函数的两次fork。第一次fork让父进程退出,子进程继续执行。接下来的setsid让子进程创建一个新会话,脱离原先所在的终端。第二次fork是再次让当前进程的父进程退出,这样当前进程的父进程变成init,同时确保当前进程不是会话首进程,从而防止它以后重新打开终端设备时获得控制权。这两次fork做到了尽量不要依赖终端,是标准守护进程的常用做法。
第二,waitpid和状态解析。在父进程分支中,waitpid(child, &status, 0)会一直阻塞,直到指定的子进程退出。然后通过WIFEXITED和WIFSIGNALED来拆分status里的信息。如果目标程序是被信号杀掉的,比如用kill命令杀掉,WIFSIGNALED会成立,就能拿到信号值。如果退出码非0,会被认为是异常退出,需要重启。
第三,execvp和_exit的配合。子进程里调用execvp之后,如果成功,后续代码永远不会执行。如果返回,说明执行失败。这时候用_exit(127)退出,为的是不执行atexit清理、不刷新stdio缓冲,直接干净退出。父进程waitpid能得到这个退出码,然后决定是否重启。
第四,fd的继承问题。我在打开日志文件时用了O_CLOEXEC标志。这样即使子进程后来exec成功,日志fd也不会被带进新程序;如果子进程exec失败,原先继承的fd也会因为后续的_exit而释放。在你的真实项目里,凡是你不希望被exec泄漏出去的fd,都应该加上O_CLOEXEC。我用第一行代码做演示,是因为很多人会在写网络服务时踩到“端口被占用”的坑,原因就是accept的fd被不知不觉传给了子进程的某个程序。
这个简易守护进程已经能完成“拉起来、监控、异常重启”的核心流程。你如果想去跑,把target_proc换成自己写的一个会常驻的测试程序,比如一个每隔几秒写一行日志的脚本,然后用kill杀掉它的PID,观察守护进程是否会自动把它拉起来。
当然了,真实的守护进程还要考虑日志轮转、配置热更新、优雅停机、同时监控多个进程等。这些我在这里就不展开了,但今天这个骨架,你改动起来并不难。
6. 常见问题与排查技巧实录
照惯例,这一节必须是干货。下面这些问题是初学进程管理的朋友问得最多的,也是我自己踩过的坑,整理成速查表,方便你以后排查。
6.1 进程管理问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| fork后子进程和父进程内容重复 | fork返回后的分支没写好 | 检查pid == 0和pid > 0两个分支 |
| waitpid一直阻塞 | 子进程还没退出,或等待的PID不对 | 用ps确认目标PID状态 |
| 父进程退出后子进程还在跑 | 没设置SIGCHLD处理,或者误用了sqrt之类 | 确认退出逻辑,必要时显式kill |
| 进程状态显示Z(僵尸) | 父进程没有调用wait | 补上wait/waitpid,或用waitpid(-1...)循环收割 |
| exec执行成功但程序行为异常 | fd被继承导致新程序操作了不该操作的fd | 给fd加FD_CLOEXEC,或exec前显式close |
| execvp启动不了目标命令 | PATH没有包含目标目录 | echo $PATH查路径,或改用绝对路径 |
| exec返回EACCES | 可执行文件没有执行权限 | chmod +x 目标文件 |
| exec返回ENOEXEC | 文件格式不是可执行文件 | file 目标文件查格式 |
| system()执行时发现命令被注入 | 命令字符串混入了外部输入 | 放弃system,改用fork+execv+参数数组 |
| 守护进程一启动就被终止 | 没有调用setsid,收到SIGHUP信号 | 补上setsid,或用nohup启动 |
这些现象里,最典型的就是僵尸进程。我特别想多说一句:僵尸状态不是bug的病理表现,它是一个进程的遗骸。你只要让父进程调用wait或者waitpid,遗骸就会被清理掉。如果不主动处理,积累多了会达到进程数上限,新进程就再也fork不出来了。
6.2 我真正踩过的大坑:exec失败后的失控
我在写一个自动化部署工具时,有一段代码是启动某个目标服务程序。当时我用了execv,但是没有检查它的返回值。我的想法是:既然exec成功就直接变成新程序了,那么失败才返回,可我当时根本没意识到,程序居然在exec失败后会继续往下执行后续代码。
结果就是,目标服务程序不存在的时候,我这个部署工具没有报错,反而继续往下跑,把后面的配置文件也覆盖了。那段时间排错排得特别痛苦,后来我才逐渐形成一个铁律:凡是调用exec系列函数,紧跟其后必须写错误处理分支,哪怕只是打日志退出,也绝不能让它裸奔。这个习惯帮我挡掉了不少麻烦。
第二个大坑是fd泄漏。有次我写了一个代理进程,它accept一个连接之后,fork一个子进程去处理这个连接。子进程里为了省事没有马上close监听fd,后来才发现,每当某个请求被放到子进程里处理时,它会意外干扰另一个连接。原因就是子进程继承了监听socket,多个子进程同时持有同一个socket,状态互相干扰。解决办法是给listenfd和clientfd都加上FD_CLOEXEC,并且在子进程逻辑里显式关闭不需要的fd。
第三个大坑是关于退出码的。我见过一些脚本判断守护进程健康状态时,只要进程能退出就认为正常,但退出码定义根本不统一。有的程序正常退出返回0,异常退出返回0,异常返回非0也很乱。结果自动化监控脚本经常误判。建议你一定把“0表示正常,非0表示各种异常”的约定写清楚,并且在代码开头集中定义退出码宏。这个约定不仅影响你写的程序的运维监控,还会影响你使用waitpid拿到的退出状态时能不能正确判断。
6.3 附送两个实战排障小技巧
第一个技巧是善用/proc文件系统。排查一个进程是不是僵尸,除了看ps,还可以直接cat /proc/<pid>/status,里面有个State字段。Z是僵尸,D是不可中断睡眠,R是运行,S是睡眠。如果你发现某个进程的父进程也不见了,想确认它是不是被init收养,看PPid字段就知道了。
第二个技巧是善用strace跟踪系统调用。strace -f ./你的程序能够把fork、execve、waitpid这些系统调用挨个打出来。我之前排查过一个exec失败却找不到原因的问题,用strace看到execve返回了ENOENT,立刻知道是可执行文件路径出错了。在系统编程的调试里,strace几乎是我的第一反应工具,很多诡异现象一跟踪就原形毕露。
说个我实际的体会:系统编程的很多玩笑题,其实都是因为没搞清进程退出的优先级和顺序导致的。你如果能把“fork之后谁负责什么”“exit和_exit到底区别在哪”“wait怎么收割”这几个问题彻底想明白,再晦涩的报错都只是过程的问题,它不会动摇你的底层认知。
这篇内容到这里就收尾了。我没有绕弯子,写的基本都是我实际跑过、踩过的场景。Linux系统编程这件事,光看文档是不够的,代码必须亲手敲、进程必须亲手查、崩溃必须亲手理过一遍才算真正进门。希望这篇经验总结能帮你少踩几个坑,把这个主题稳稳地吃下来。