资讯详情 Linux进程控制实战:从fork、exec到守护进程的全面解析
📅 2026/10/13 13:14:49
很多人刚开始接触 Linux 下的进程控制时最纠结的一个认知问题是进程到底是个什么东西 是那个正在运行的程序吗是那一串 ps 命令输出的列表吗还是说它其实就是代码在内存里跑起来的那个活体我当时学的时候也绕了很久直到被 fork 那个著名的返回两次折腾到彻底失眠才真正把进程这层窗户纸捅破。这篇文章我不会按教科书顺序给你念知识点而是从实际编码和排查的角度把进程控制这条线完整拆一遍从进程的底层身份到 fork 的写时拷贝从 exec 替换程序面孔到进程退出的完整走向最后再看看守护进程这层进阶玩法。全文偏实战每个环节都有代码、有原理、有我踩过的坑适合刚学完 C 语言、想深入理解 Linux 编程或者正在准备面试、需要系统梳理进程知识的朋友。1. 进程的本质不是程序而是运行中的上下文1.1 从一条命令的诞生看进程到底是什么你在终端里敲一条./hello按下回车系统究竟做了哪些事很多人以为是把 hello 程序跑起来但这句话太笼统了。真实的过程是shell 进程调用 fork 复制出一个几乎一模一样的子进程然后这个子进程调用 exec 把 hello 程序的代码段、数据段加载进自己的地址空间替换掉原来 shell 的代码最后从入口点开始执行。所以进程到底是什么进程是程序的一次运行实例它承载的不只是代码还包括当前执行到哪一行程序计数器、变量存在哪里地址空间、打开了哪些文件文件描述符表、和内核交互的身份信息uid/gid、以及操作系统用来调度它的全部元数据进程控制块 PCB。我用一个生活化的类比程序就像一本菜谱而进程是某个厨师照着这本菜谱做菜的全过程。菜谱是静态的、可以复印无数份但做菜这个过程有做到哪一步了的进度、有灶台上正在用的锅碗瓢盆、有厨师个人习惯的切菜手法——这些动态的状态就是进程上下文。同一个可执行文件可以被同时运行 100 次就有 100 个进程它们的代码相同但各自状态完全不同。1.2 进程 ID身份标识与父子血缘每个进程在内核里都有一个唯一的进程 IDPID这是它区别于其他进程的身份证号。同时每个进程还有父进程 IDPPID记录着我是谁生出来的。Linux 的进程树顶层是 systemd在新系统里通常是它传统系统里是 init所有进程都是它的后代。获取这两个 ID 的接口很简单#include stdio.h #include unistd.h int main() { printf(PID: %d\n, getpid()); printf(PPID: %d\n, getppid()); return 0; }运行结果里那个 PPID 就是你的 shell 进程的 PID。这个父子关系不是摆设它是进程控制里几乎所有操作的根基父进程负责回收子进程的尸体子进程被杀死后会通知父进程派代表来处理善后。相关系数贯穿 fork、wait 和信号机制后面全都会用到。1.3 去看看真实的内核数据结构procfs光说 PCB 太抽象其实你随时可以打开 Linux 的/proc目录触摸到一个活的进程。每个 PID 对应/proc/PID一个子目录里面有进程的全部家当ls /proc/1/ cat /proc/1/status | head -20/proc/1/status里面会显示 Name、State、Pid、PPid、Umask、Capabilities 等信息。/proc/1234/maps能看这个进程的虚拟内存布局/proc/1234/fd/能看到它打开的所有文件描述符。我强烈建议你写程序的时候偶尔去/proc翻一翻因为很多进程疑难杂症——比如文件打不开、内存暴涨、僵尸进程——都能从这里找到第一手线索比瞎猜有用得多。2. fork一次调用两个返回值父子进程从此分道扬镳2.1 fork 的底层机制复制与写时拷贝fork 是 Linux 下创建子进程最核心的接口原型极其简单pid_t fork(void);但它的行为诡异到让每个初学者震惊调用一次返回两次。在父进程中返回子进程的 PID在子进程中返回 0出错时返回 -1。所以 fork 之后一定要立刻对返回值做判断用返回值区分当前是父还是子#include stdio.h #include unistd.h #include sys/types.h int main() { pid_t pid fork(); if (pid 0) { perror(fork error); return 1; } else if (pid 0) { printf(我是子进程我的 PID 是 %d我爹是 %d\n, getpid(), getppid()); } else { printf(我是父进程我的 PID 是 %d刚生了个儿子 %d\n, getpid(), pid); } // 注意这里父子都会执行到 printf(这句话父子都会打印PID %d\n, getpid()); return 0; }为什么能返回两次因为 fork 的核心动作是复制内核把当前进程的地址空间、寄存器上下文、文件描述符表、信号处理设置全部复制一份生成一个新的 PCB然后让父子两个进程从 fork 调用的下一条指令继续各自执行。子进程拿到的是 fork 时刻父进程内存的快照初始状态几乎一模一样包括变量当前的值。如果每次 fork 都完整复制所有内存那遇到几百 MB 的进程频繁 fork 会非常低效。所以 Linux 用了**写时拷贝Copy-on-WriteCOW**技术fork 时并不真的复制物理内存页而是让父子进程共享同一个物理页且把这些页标记为只读。谁先尝试写入谁就触发缺页异常内核这才把这一页复制一份让写入方拿到自己的副本。这样做的好处是如果子进程 fork 完立刻 exec 执行新程序那么整个地址空间根本不需要复制任何物理页省掉了大量搬运。这里有个常见的面试追问点fork 之后父子进程谁先执行答案是不确定由内核调度器决定。你不能在代码里假设子进程一定先跑或父进程一定先跑要想控制执行顺序必须用 wait 或信号量同步否则就会写出依赖调度顺序的薛定谔式并发 bug。2.2 fork 复制了什么文件描述符与缓冲区的坑fork 复制的不只是变量。文件描述符表也会复制如果父进程已经打开了一个文件子进程里这个 fd 是有效的而且和父进程指向同一个文件描述共享文件偏移量。这个特性可以用来做很多设计但也埋了著名的坑。什么坑stdio 缓冲区的复制。C 语言的printf是库函数它先把数据写进用户态的缓冲区等缓冲区满或遇到换行符、fflush 时再真正调用 write 写入内核。如果父进程在 fork 之前先 printf 了一段没有换行符的内容缓冲区里的数据会被子进程继承导致这段数据被打印两次。来个具体例子#include stdio.h #include unistd.h int main() { printf(只会打印一次吗); // 注意没有 \n pid_t pid fork(); if (pid 0) { printf([子进程]\n); } else { printf([父进程]\n); } return 0; }你可能会以为输出是只会打印一次吗[父进程] 只会打印一次吗[子进程]但实际可能是只会打印一次吗出现两次。原因是第一句printf只是把内容写进了缓冲区fork 时缓冲区被完整复制给了子进程最后父子各自在退出时 flush各打了一遍。解决办法很简单fork 之前调用fflush(NULL)清空所有缓冲或者让第一句 printf 带上换行符。这个坑在写涉及日志的服务程序时特别常见稍不注意日志就重复了排查半天还以为是程序逻辑写错了。2.3 fork 的合法用法与性能考量多数教科书讲 fork 只是演示创建一个子进程但真实项目里直接用裸 fork 的时候并不多反而经常是配合 exec 使用shell 就是这么干的。单独 fork 而不 exec 的合法场景包括需要完全保留父进程内存映像做并行处理的服务器模型早期 Apache 的 prefork 模式、以及需要在子进程里做隔离性操作的辅助任务。还有一点必须记住fork 不是免费的。虽然 COW 省掉了物理内存复制但创建进程仍然需要分配内核数据结构、复制页表、建立新的调度实体。在现代 Linux 上 fork 一次大概几微秒到几十微秒但如果是高频创建销毁进程的场景比如每秒上万个请求都各自 fork这开销依然不可忽视。这也是为什么后来出现了 posix_spawn、vfork 以及线程等替代方案。vfork 是个历史遗留的奇葩它保证子进程先执行且父进程会阻塞直到子进程 exec 或 exit子进程共享父进程的地址空间不复制页表。它的设计初衷是子进程马上要 exec 了别浪费功夫复制。但现在的 Linux fork 用了 COW 之后vfork 在性能上已经没什么优势而且它共享地址空间容易引发奇怪的问题基本可以忘掉它看到老代码里用了也建议改成 fork。3. exec 家族给进程换一张执行面孔3.1 从 shell 执行程序到 execve 系统调用如果 fork 是复制一个自己那 exec 就是把自己替换成另一个程序。它做的事情非常暴力把当前进程的代码段、数据段、堆栈整个替换成新程序的映像然后重新初始化堆从头开始执行新程序的 main 函数。进程的 PID 不变但人已经换了——这就是 shell 执行命令的完整流程先 fork 出一个子进程再在子进程里 exec 你要运行的程序。exec 不是一个函数而是一族函数它们的底层都是同一个系统调用execve区别只在于参数形式和环境变量的处理方式。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 execvpe(const char *file, char *const argv[], char *const envp[]);命名规则很好记名字里带l表示参数以列表形式逐个给出list带v表示参数以数组形式给出vector带p表示会自动在 PATH 环境变量中搜索可执行文件不需要写完整路径带e表示可以自己指定环境变量数组。最常用的是execlp和execvp因为它们可以像 shell 一样直接写命令名。举个例子在 C 代码里调用系统命令ls -l /tmp#include stdio.h #include unistd.h int main() { printf(执行 exec 之前我是 %d\n, getpid()); execlp(ls, ls, -l, /tmp, NULL); // 如果 exec 成功以下代码永远不会执行 perror(exec 失败); return 1; }execlp的第一个参数是程序名第二个参数ls是 argv[0]后面每个参数对应一个 argv 项最后必须以NULL结尾用来标记参数结束。注意 argv[0] 不一定和程序名相同但惯例上都写成程序名shell 脚本里常见的$0就是这个值。3.2 exec 成功之后的事谁能用 exec 做什么exec 成功后当前进程的代码段、数据段、堆栈全部被替换进程从新程序的入口重新开始。这意味着 exec 之后的那一行代码永远不会执行除非 exec 失败返回。所以 exec 是有去无回的它要么不成功要么整个进程就变成另一个人了。这个特性导致一个经典认知误区有人以为 fork exec 之后父进程会同时运行旧代码和新代码。实际上父进程在 fork 返回后继续走父进程的分支子进程调用 exec 后变成全新的程序两个进程各走各的互不干扰。父进程只有在子进程里 exec 失败时才会收到一个失败的信号后面会讲否则就只能在 wait 的时候知道子进程干了什么然后结束了。还有一个实际项目里经常踩的坑exec 之后当前进程打开的文件描述符默认是保留下来的除非设置了 FD_CLOEXEC。这意味着如果父进程开了一个文件或一个 socket子进程 exec 后仍然持有这个 fd这会导致资源泄漏父进程想关闭 fd 关不掉因为子进程还攥着同一个引用。解决方法是给 fd 设置FD_CLOEXEC标志这样 exec 的时候内核自动帮你关闭int fd open(data.txt, O_RDONLY | O_CLOEXEC);现代 Linux 中凡是可能被 exec 继承的 fd 我都建议加上O_CLOEXEC这是写服务程序的基本卫生习惯。早期项目里因为少这个标志导致 socket 句柄泄漏查了大半天最后发现是 fork 出来的子进程里带着监听 fd调整了很久才理顺。3.3 fork exec 的完整实战写一个极简 shell 雏形把 fork 和 exec 组合起来就可以写一个极简的交互式命令执行器了。逻辑很简单读取用户输入fork 一个子进程子进程 exec父进程等待子进程结束然后继续读下一条命令。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define MAX_LINE 1024 int main() { char line[MAX_LINE]; while (1) { printf(mini-shell ); fflush(stdout); if (fgets(line, MAX_LINE, stdin) NULL) { break; // CtrlD 退出 } // 去掉行尾换行符 line[strcspn(line, \n)] \0; if (strlen(line) 0) continue; pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } else if (pid 0) { // 子进程执行用户命令 execlp(line, line, NULL); perror(exec 失败); exit(127); // 命令找不到的经典退出码 } else { // 父进程等待子进程结束 int status; waitpid(pid, status, 0); if (WIFEXITED(status)) { printf(子进程退出码: %d\n, WEXITSTATUS(status)); } } } return 0; }这个程序虽然简陋但麻雀虽小、五脏俱全包含了进程控制的四大核心操作fork创建、exec替换、wait等待、exit结束。你可以在上面继续加功能支持参数拆分用strtok把输入拆成 argv、支持cd内建命令因为 exec 不会改变父进程的当前工作目录、支持管道用pipedup2……这些是很多公司面试里手写 mini-shell 的原型。4. 进程的归途exit、wait 与僵尸进程的完整体检4.1 进程退出的两条路径main 返回和 exit/_exit进程结束无非两种方式正常结束和异常结束。正常结束包括 main 函数 return、调用exit、调用_exit或_Exit异常结束包括收到信号比如 CtrlC 的 SIGINT、Segmentation fault 的 SIGSEGV、调用abort等。exit和_exit看起来都是退出但差别很大_exit是系统调用直接在内核层面终止进程。exit是库函数它会先调用用户注册的atexit回调清理标准 I/O 缓冲区然后再调用_exit。这就意味着如果进程里用了printf但内容还在缓冲区没 flush你用_exit退出就永远看不到这些输出而exit会把缓冲区清空。这也是 fork 缓冲区问题的延续——父子进程各自退出时 flush 各自的缓冲区副本。正常退出的进程会把退出状态传给父进程这个状态值只有 0~255 的低 8 位有效。约定俗成 0 表示成功非 0 表示某种错误命令行查看时会显示为$?的值。比如 shell 里执行一个程序然后echo $?能看到退出码。4.2 僵尸进程是怎么养成的进程结束之后并没有立即从内核里消失。内核会保留这个进程的 PCB只把尸体留给父进程来收——这个状态就叫僵尸进程Zombie。僵尸进程不再占用任何内存或 CPU但它占着一个 PID 条目内核里的 task_struct 也还挂着。僵尸进程出现的根本原因是父进程没有调用 wait 回收子进程的退出状态。子进程退出时内核向父进程发送 SIGCHLD 信号同时把退出状态保存着等待父进程来认领。如果父进程一直不认领子进程就会一直保持僵尸状态。来看这个经典养僵尸的代码#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { // 子进程立刻退出 printf(子进程退出PID%d\n, getpid()); _exit(0); } else { // 父进程睡觉不回收子进程 printf(父进程继续睡觉PID%d\n, getpid()); sleep(30); } return 0; }运行后另开一个终端执行ps -ef或者ps -o pid,ppid,state,cmd能看到那个子进程的状态是Z这就是僵尸。僵尸进程不浪费 CPU但累积多了会耗尽 PID 空间导致无法创建新进程。有个很极端的段子一个生产服务器 PID 耗尽查下来是有个程序忘了 wait日积月累养了几万个僵尸。4.3 正确的收尸姿势wait 与 waitpid 全解父进程必须用wait或waitpid来回收子进程的退出状态。这两个函数会阻塞父进程直到某个子进程结束然后读取它的退出状态并让内核彻底清理掉子进程的 PCB。#include sys/wait.h pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options);wait是最简单的版本等待任意一个子进程结束。waitpid更灵活可以指定等待某个子进程pid 参数可以设置 options 实现非阻塞等待还可以知道到底是哪个子进程结束了。关键在status宏的解析。子进程到底怎么死的都编码在这个 status 里int status; waitpid(pid, status, 0); if (WIFEXITED(status)) { // 正常退出 printf(退出码: %d\n, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { // 被信号杀死的 printf(被信号 %d 杀死\n, WTERMSIG(status)); if (WCOREDUMP(status)) { printf(产生了 core dump\n); } } else if (WIFSTOPPED(status)) { // 被暂停配合 WUNTRACED 使用 printf(被信号 %d 暂停\n, WSTOPSIG(status)); }这里有个实际排查中很常见的现象你启动了一个守护服务几天后发现进程挂了用echo $?怎么都查不出原因其实 $? 只保留最后一条命令的退出码。正确做法是在父进程里用waitpidWIFSIGNALED把被哪个信号杀死记录下来比如被 SIGKILL9杀和有 core dump 的 SIGSEGV11是完全不同的问题前者多半是 OOM killer 或手动 kill后者多半是野指针。waitpid的 options 里最常用的是WNOHANG它让waitpid不阻塞如果没有子进程退出就立即返回 0。这在写非阻塞轮询的服务器里非常关键——你不希望父进程因为等一个不听话的子进程而卡死整个服务器。4.4 批量回收子进程回收一个还是回收全部wait只回收一个子进程如果有多个子进程同时退出你只调用一次 wait其他子进程就只能继续变僵尸。所以在一个收到 SIGCHLD 信号处理函数里调用 wait 时经常需要用循环配合WNOHANG把所有已退出的子进程收干净#include signal.h #include sys/wait.h #include stdio.h void sigchld_handler(int sig) { int status; pid_t pid; // 循环回收所有退出的子进程不阻塞 while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(回收了子进程 %d\n, pid); } }这段代码是真实项目里的标准答案很多面试官会问你信号处理函数里为什么不用 wait 而用 waitpid WNOHANG答案就是避免阻塞信号处理函数、同时回收所有已退出的子进程。还有一个坑如果子进程收到信号被杀死我们需要在 SIGCHLD 处理函数里做完整的 WIFEXITED / WIFSIGNALED 判断否则日志里永远只有子进程退出四个字看不出死因。4.5 孤儿进程与 init 收养如果父进程先于子进程退出子进程会变成孤儿进程被 1 号进程通常是 systemd收养。收养之后它的 PPID 会变成 1未来退出时由 1 号进程负责回收所以孤儿进程不会变成僵尸。这是系统设计上的兜底机制任何节点都会有一个父节点负责收尸。但这里有个容易被忽略的点如果你的程序故意抛弃子进程比如 daemon 化就必须确保子进程的父进程退出它才不会被挂在你的进程树下。后面讲守护进程时会再提到这个二次 fork技巧。5. 守护进程脱离终端的背后幽灵5.1 守护进程要解决什么问题守护进程daemon是长期在后台运行、没有控制终端、不受用户登录退出影响的进程。你熟悉的 sshd、nginx、cron 全部是守护进程。守护进程解决的核心问题是失去联系一个进程如果依附于某个终端可能因为终端关闭被挂断信号SIGHUP干掉所以它必须完全脱离会话和终端。标准的 daemon 化流程是教科书里固定的一套组合拳我当年第一次看到的时候一脸懵后来一步步拆开才明白每一招的意义。5.2 从 fork 到 setsid完整守护进程创建步骤一个经典的 daemon 化代码框架长这样#include stdio.h #include stdlib.h #include unistd.h #include fcntl.h #include sys/types.h #include sys/stat.h void daemonize() { // 第一步fork让父进程退出 pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { // 父进程退出让子进程变成孤儿被 init 收养 _exit(0); } // 第二步创建新会话脱离原终端 if (setsid() 0) { perror(setsid); exit(1); } // 第三步再次 fork确保不再获得控制终端 pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { _exit(0); } // 第四步改变工作目录防止占用挂载点 chdir(/); // 第五步重置文件权限掩码 umask(0); // 第六步关闭所有文件描述符 for (int i 0; i sysconf(_SC_OPEN_MAX); i) { close(i); } // 第七步把标准输入/输出/错误重定向到 /dev/null int fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd STDERR_FILENO) close(fd); } }每一招的目的拆解如下第一次 fork 父进程退出确保子进程不是会话组长session leader这样后续setsid才能成功。同时父进程退出后子进程变成孤儿与原始终端摆脱血缘关系。setsid让子进程成为新会话的首进程、新进程组的组长并且没有控制终端。这是整个流程里最关键的一步相当于让进程和终端彻底分家。第二次 fork防止子进程现在是会话组长重新获取控制终端。当一个没有控制终端的会话组长打开一个终端设备时可能自动获得该终端作为控制终端。再 fork 一次让孙子进程成为非会话组长就能避免这个潜在风险。chdir(/)避免把当前工作目录锁定在某些目录上比如一个可以卸载的 U 盘挂载目录否则你永远无法卸载那个分区。umask(0)确保进程创建的文件不会带上从父进程继承来的默认权限限制防止创建出来的文件权限和期望不符。关闭所有 fd 并重定向到 /dev/null避免继承无用的终端、日志文件句柄也避免后面往标准输出写东西时因为终端不存在而报错。我实际写守护进程时还有一个习惯手动打开一个日志文件并记录 PID 到 pidfile。网上很多示例代码为了演示简洁都忽略了这个但真实部署时你要是没有日志出问题连查的方向都没有。比如用 openlog/syslog 把日志打到系统日志或者自己 open 一个文件写到固定路径。pidfile 用于后面 stop 脚本能精确找到进程 ID 去 kill。5.3 单实例守护进程文件锁防止多开守护进程还有个经典需求是保证全局只有一个实例在跑。如果用户手滑启动两次服务可能会把同一个数据目录搞坏。通用的做法是用文件锁#include fcntl.h int fd open(/var/run/myservice.pid, O_CREAT | O_RDWR, 0666); if (fd 0) { exit(1); } // 尝试加独占写锁 struct flock lock; memset(lock, 0, sizeof(lock)); lock.l_type F_WRLCK; lock.l_whence SEEK_SET; if (fcntl(fd, F_SETLK, lock) 0) { // 已经有别的进程持有锁 fprintf(stderr, 服务已经在运行\n); exit(1); }这个锁不是别人伪造的 pidfile 文件那种表面检查而是内核级的文件锁进程崩了锁自动释放天然防呆。如果你需要做得规范一点还可以把 pidfile 里的 PID 顺便写进去让运维脚本能用它 kill 进程。5.4 守护进程的退出与清理守护进程常驻后台它退出通常不是因为 main 函数跑完了而是收到了信号。标准做法是注册信号处理函数处理 SIGTERM优雅停机。SIGTERM 是kill命令默认发的信号所以你的守护进程必须响应它否则 kill 出来毫无反应运维只能上 SIGKILL。一个简单的优雅退出框架#include signal.h static volatile sig_atomic_t running 1; void handle_signal(int sig) { if (sig SIGTERM || sig SIGINT) { running 0; } } int main() { signal(SIGTERM, handle_signal); signal(SIGINT, handle_signal); while (running) { // 主循环干活 sleep(1); } // 退出前清理资源把 pidfile 删掉、关闭 socket、写日志 unlink(/var/run/myservice.pid); return 0; }这里用sig_atomic_t类型声明 running 是保证信号处理函数和主循环之间的读写是原子的不会出现编译器优化导致的看不到变量变化问题。教科书上都建议信号处理函数里只做最简单的动作比如置一个标志位不要在信号处理函数里做复杂逻辑这一点实际项目里必须遵守否则很容易引入未定义行为。6. 进程控制的信号机制与真实运维场景6.1 信号是进程控制中的短信电话进程控制的最后一环其实是信号signal。信号的本质是操作系统向进程发送的异步事件通知相当于给进程发一条短信你要退出了SIGTERM、你非法访问内存了SIGSEGV、你收到中断键了SIGINT、你的子进程死了SIGCHLD。不同信号的默认行为各不相同信号编号默认行为常见触发源SIGHUP1终止进程终端挂断 / 重新加载配置SIGINT2终止进程CtrlCSIGQUIT3终止并产生 coreCtrl\SIGKILL9强制终止不可捕获kill -9SIGSEGV11终止并产生 core非法内存访问SIGPIPE13终止进程写管道但读端已关闭SIGTERM15终止进程kill 默认SIGCHLD17忽略子进程退出这里有个特别容易在服务开发中踩的坑SIGPIPE。当你向一个已经关闭的 socket 或管道写入数据时系统会发 SIGPIPE 给进程默认行为是杀掉进程。很多网络服务刚上线的时候频繁崩溃查到最后就是没处理 SIGPIPE客户端一断开服务端 write 就触发 SIGPIPE 自杀。解决方案是在 main 里加一行signal(SIGPIPE, SIG_IGN); // 忽略 SIGPIPE让 write 返回 EPIPE 错误自行处理忽略之后write 会返回 -1 并设置 errno 为 EPIPE你就可以在应用层判断对端断了该清理连接清理连接而不是整个进程陪葬。6.2 信号处理的艺术注册和使用中的边界C 语言里注册信号处理的传统接口是signal但实际项目里我更推荐sigaction因为它语义可移植性更好、行为可预测。看一个对比// 传统 signal 接口 signal(SIGINT, handler); // sigaction 接口 struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); // 不屏蔽其他信号 sa.sa_flags 0; // 默认标志 sigaction(SIGINT, sa, NULL);sigaction 的好处是可以精确控制在处理信号时屏蔽哪些信号sa_mask、可以设置重启被中断的系统调用SA_RESTART、可以获取之前安装的处理函数等。比如 SA_RESTART 的作用是当你阻塞在 read/accept 上时收到信号默认行为会使这些系统调用返回 EINTR 错误导致程序莫名中断设置了 SA_RESTART 后内核会自动帮你重启这些调用行为更接近直觉。真实开发中我写信号处理函数有三条铁律不在信号处理函数里调用 printf、malloc、fork 等非异步信号安全函数。因为信号可能随时打断主线程正在进行的 malloc/free导致堆结构错乱。要记录日志就用write往 fd 里写或者只置标志位。信号处理函数越短越好最好只是置一个全局标志让主循环去检查。别试图在信号处理函数里做复杂业务逻辑因为你不知道这个信号是什么时候来的主流程的数据处于什么状态。6.3 一个真实的排查案例进程频繁神秘消失我之前遇到过一次特别经典的排障某服务进程跑几十秒就悄无声息地消失日志里一点错误都没有。第一反应是有没有 core dump看 /proc/sys/kernel/core_pattern 发现 core 被 pipe 给 systemd-coredump 处理了于是用coredumpctl查看看到进程是被 SIGSEGV 杀掉的而且产生了 core。然后我用 gdb 加载 core 文件backtrace 一看卡在 stdio 的某个加锁函数里。再一查有人在多个线程里同时调用 printf而没有做线程同步导致 stdio 内部锁被打破一个线程 free 了另一个线程正在用的缓冲区触发 SIGSEGV。这个案例说明一个道理进程被信号杀死时光看业务日志是没用的要先确定死因。Linux 排查进程死因的几个顺序动作是查 core看有没有 corecore 里的 backtrace 直接定位崩溃点、查消息日志/var/log/messages 或 journalctl能看到 OOM killer 记录的杀掉了谁、占用多少内存、看进程退出码或者父进程记录的 WTERMSIG。这套流程配合 SIGCHLD 处理函数里打印退出原因能帮你解决大部分进程怎么又没了的问题。如果连 core 都看不到多半是被 SIGKILL 强杀的这时候第一怀疑对象是 OOM killer。查journalctl -k | grep -i oom看看有没有你的进程名或者去数一下系统可用内存是多少、是不是被别的进程吃光了。我见过太多神秘消失最后都是内存超限被 OOM killer 收割而不是代码逻辑问题。6.4 父子进程协作用信号和 waitpid 做优雅退出最后把进程控制串起来一个带优雅退出机制的父子进程协作模型大概是这样的父进程流程注册 SIGTERM/SIGINT 处理函数设置一个全局 running 标志。fork 出子进程去干活子进程 exec 或者跑业务循环。父进程在 waitpid 循环里等待子进程同时用 WNOHANG 轮询检查 running 标志。收到退出信号时父进程先给子进程发 SIGTERM然后回收它的退出状态。子进程流程由于继承了父进程的信号处理子进程也要处理 SIGTERM负责清理自己的资源再退出。退出前通过 exit code 向父进程汇报我是清理完才走的还是被强杀的。这套模型不管是写启动脚本、系统服务还是网络服务框架都是非常通用的底座。你可以在这个基础上不断加功能心跳检测、进程看门狗、日志回滚……但核心始终是那四板斧fork、exec、wait、signal。我在实际项目里还总结过一个经验能用 shell 脚本或者 systemd Unit 解决的进程守护问题尽量不要自己造轮子。systemd 自带Restarton-failure、KillModemixed这些参数能帮你处理很多服务崩溃重启、优雅停机的需求。你仍然需要理解 fork/wait/signal 这些底层机制因为很多疑难杂症的根因恰恰藏在系统的这层管道工事里。