Linux多进程与守护进程编写:从fork到setsid的完整实验指南 Linux实验多进程、守护进程的编写但凡系统学习过操作系统原理的人迟早会在实验课上碰到一个经典组合多进程编程和守护进程编写。这个实验看起来是中规中矩的教学任务但真要动手把代码写出来、跑起来、验证效果很多人会卡在一堆“看起来能编译实际行为却不对”的问题上——fork 完代码打印了两遍子进程明明还在运行却成了“孤儿”守护进程一退终端就没了影子日志文件死活写不进去……我当年做这个实验时也踩过不少坑后来在带新人时也反复看到同样的疑问。这篇文章就打算把“Linux实验多进程、守护进程的编写”这个题目拆透先说清楚为什么这两个东西要放在一起做再带着你一步步写出一个能真正跑起来的守护进程样例最后把实验中最容易翻车的几个问题整理成一份速查表。内容尽量贴实际操作代码都是常见的 C 实现讲原理时也不绕弯适合正在做实验的学生、刚接触 Linux 编程的开发者以及想快速回顾这套知识点的同行参考。1. 为什么多进程和守护进程会被摆在一起1.1 从实验任务的设计意图说起很多课程把这个实验命名为“多进程、守护进程的编写”并不是随意把两个知识点拼在一起而是因为这两者在 Linux 进程模型里有天然的递进关系多进程是基础守护进程是它的一个典型应用形态。先看多进程。进程是程序运行时的载体一个程序从 main 函数开始跑默认只有一个进程在 CPU 上执行指令。如果想让多个任务“同时推进”比如一边接收用户输入一边后台写日志就需要通过fork()创建子进程让父子进程各干各的事情。这也是 Linux 下实现并发最底层的机制——进程是独立调度的单位每个进程有自己的地址空间、文件描述符表和进程号。再看守护进程。所谓的 daemon英文原意是“后台驻留进程”它仍然属于多进程世界的一员绝大多数守护进程的诞生流程就是从某个前台进程fork()出一个子进程然后由子进程执行脱终端、改工作目录、重定向标准输入输出等工作。只不过守护进程在形态上更“讲究”它要求进程能脱离控制终端独立运行对终止信号有抵抗力生命周期通常跨越整个系统运行时间。把两者放在同一个实验里本质上是在训练你两条能力第一条掌握fork()带来的进程分裂机制和资源继承关系第二条掌握让子进程成长为“合格后台服务”的完整步骤。前者是幕后原理后者是前台表现从父进程的视角看守护进程就是一个“加了配置的多进程程序”。1.2 单进程模型为什么不够用为了激发对多进程的需求理解可以设一个场景。假设要写一个简单的采集程序每隔 5 秒采集一次硬件状态写入日志同时每隔 1 秒把当前时间打印到屏幕。如果只在单进程里用一个无限循环你就得在两种任务的间隔之间做取舍程序结构会被时间约束折腾得很别扭。更麻烦的是一旦某个任务阻塞比如磁盘写入卡住了另一个任务就被连带拖住整个程序停摆。用多进程思路解决就干脆很多父进程负责一个任务fork()出的子进程负责另一个任务两者互不阻塞调度由内核完成开发者也只需要分别维护两套逻辑。再往外延伸网络服务器每来一个连接就fork()一个子进程处理Shell 里用管道连接多个命令时会产生多个进程这些场景的底层都靠“创建进程 分配职责”这套机制。所以多进程解决的核心问题可以概括成三个并行性——多个任务能在多核 CPU 上真正同时执行隔离性——一个进程崩溃不会直接弄垮另一个进程的内存空间结构清晰——每个进程各司一职代码不容易纠缠成一团。1.3 守护进程实际上是“多进程的进阶用法”前面说过守护进程往往由 fork 的产物演变而来。为什么不能直接让前台普通进程常驻后台关键就在于“控制终端”这层关系。当你在终端里启动一个前台程序它和终端之间有一条会话连接这带来两个问题一是终端窗口关闭时内核会向会话的前台进程组发送挂断信号SIGHUP普通进程会被直接终止二是终端产生的输入输出会在前台进程和用户之间直接交互后台服务的状态展示会变得混乱。守护进程的修炼目标就是斩断和终端的关联让进程变成“无主”的后台常驻任务。要做到这一步仍然需要fork()——实际上是两次 fork。第一次 fork 不是必须但通常建议做因为它让子进程保证不是进程组组长为后续setsid()创造条件第二次 fork 则是可选但常见的加固手段防止进程重新获取控制终端。守护进程的核心改造动作全部建立在多进程创建的基础上。2. 多进程编程必须先啃透的 fork 机制2.1 fork 调用到底发生了什么fork()是 Linux 里最容易被低估的系统调用。一句话描述调用一次返回两次。调用者会拿到一个 pid_t 类型的返回值父进程拿到的是子进程的 PID子进程拿到的是 0。从这个瞬间开始两个进程并行运行但它们运行的代码是从 fork 返回那一刻才“分道扬镳”的——之前执行的代码在父子进程里是完全一样的副本。用代码来演示这个奇特的语义#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } else if (pid 0) { printf(子进程我是你爹吗我的 PID 是 %d\n, getpid()); } else { printf(父进程我的 PID 是 %d子进程 PID 是 %d\n, getpid(), pid); } printf(这行谁执行PID %d\n, getpid()); return 0; }很多人第一次看这段代码的反应是完了fork 之后下面那条打印语句不是只执行一次吗但运行结果会让你后背发凉——那行 printf 在父子进程里各执行了一遍。原因在于 fork 复制了完整的进程地址空间包括代码段、数据段、堆栈以及程序计数器 PC。子进程不是从头开始执行 main 函数而是从 fork 返回值开始继续执行同一条指令流。所以判断 fork 后该走哪条分支唯一可靠的方式就是看返回值0 是子进程正数是父进程。值得注意的还有 fork 的复制策略。现代 Linux 用写时复制技术fork 的时候并不真把内存全部拷贝一份只是把页表标记为只读并共享物理内存只有某一方真正写入时才会触发缺页中断复制对应的内存页。这就让 fork 的开销远低于表面想象频繁创建进程成为可能。2.2 子进程和父进程的资源如何继承fork 不只是复制了内存还复制了一大堆内核对象。子进程会继承父进程的文件描述符表这意味着父进程打开的文件、socket、标准输入输出在子进程里是可用的子进程也会继承当前工作目录、环境变量、信号处理方式、umask 等属性。这个“继承”机制在实际编程中要非常小心。我在实验里常见的一个问题就是父进程和子进程共享一个日志文件描述符两边同时写日志内容互相覆盖。原因就是两个进程各自维护了独立的内核文件对象偏移量也不对——这里需要分清共享和复制的差异。如果 fork 后直接使用同一个 fd它们共享同一个打开文件描述open file description内核里的文件偏移是同一个并发写请求由于各自用户态缓冲区不一致还是会乱序甚至覆盖。所以多进程共同操作同一文件时要么用加锁要么用独立的文件描述符。另一个需要注意的资源是父进程的当前工作目录。子进程会把工作目录也继承过来后面做守护进程时我们反而要把目录改成根目录防止被挂载的文件系统阻碍卸载。2.3 看透僵尸进程与孤儿进程多进程编程绕不开的两个“诡异”现象僵尸进程和孤儿进程。僵尸进程是指子进程已退出但父进程还没调用wait()或waitpid()回收它的退出状态。此时子进程的进程描述符仍然占据内核资源用ps能看到状态为Z的进程。它不是真的还在执行只是因为父进程没有“收尸”而变成了一具残留的尸体。僵尸进程无法被 kill 杀掉因为它的使命只剩等待父进程读取退出码。如果父进程一直不回收僵尸进程会越积越多最终耗尽进程号。ps -ef | grep defunct这一条命令就能看到僵尸进程命令行通常带defunct标识。孤儿进程则相反父进程先退出子进程被内核变成“孤儿”由 PID 为 1 的 init/systemd 进程收养。但孤儿进程并不是坏事它反而能正常继续运行只是失去了原来的父进程。很多守护进程的设计恰恰利用了这一点——父进程先退出子进程被 init 收养彻底摆脱终端与管理终端的关系。理解了这对概念再看多进程编程的核心守则就清楚了每个 fork 出去的子进程父进程都要计划好回收方式。要么用wait()阻塞等待要么注册信号处理程序在收到SIGCHLD信号时回收绝不能让子进程变成永远杀不掉的僵尸。3. 守护进程的整个编写过程为什么是那几步3.1 第一道工序fork 一次并让父进程退出守护进程的流程从两次 fork 开始标准步骤是调用fork()父进程退出exit(0)让子进程继续。在子进程中调用setsid()创建新会话脱离控制终端。调用fork()再次可选但建议让 setsid 后的会话首进程退出确保后续进程不再可能重新获取控制终端。改变工作目录到根目录或其他安全目录。重设文件权限掩码 umask。关闭标准输入、输出、错误或重定向到日志文件。第一次 fork 父进程退出的用意是什么这里有一个容易忽略的点如果直接在当前 shell 命令下调用setsid()不小心会让 shell 所在的会话脱钩产生副作用。而父进程退出后子进程成为孤儿由 init 收养此时调用setsid()的进程必然不是进程组组长可以顺利创建新的会话。这个“放心调用”的条件让我们第一次 fork 显得很有必要。pid_t pid; pid fork(); if (pid 0) { exit(EXIT_FAILURE); } if (pid 0) { exit(EXIT_SUCCESS); // 父进程退出 }这里还有一个面试题常问的点为什么第二次 fork 是可选的关键是 setsid 之后子进程成为了新会话的会话首进程也是进程组组长。按照规则会话首进程是有资格重新打开终端设备的。如果一个前台程序误打误撞 open 了一个 tty 设备它可能会重新获得控制终端从而重新受到终端信号的干扰。为了避免这个隐患再 fork 一次让 setsid 产生的进程退出第二次 fork 的子进程不再是会话首进程也就永久失去了重新获得控制终端的机会。3.2 第二步setsid 的完整含义setsid()是守护进程所以成为“守护”的分水岭。它的作用是让调用进程成为新会话的会话首进程同时成为新进程组的组长并且该会话没有控制终端。一个术语可能让人头晕会话、进程组、控制终端。可以这样理解会话是一个或多个进程组的集合一个会话可以和某个终端设备建立连接这个终端称为控制终端。进程组则是多个进程的集合组里的进程通常能接收同一个终端的信号。普通的前台进程组必须从控制终端读取输入所以当用户按 CtrlC 时前台进程组的所有进程都会收到 SIGINT 信号。调用setsid()之后我们的进程脱离了原来的进程组和会话成为新会话的老大也失去了控制终端。这个动作保证了一个关键属性这个进程再也收不到来自终端键盘的中断信号终端关闭时也不会收到 SIGHUP。从物理层面切断了和“用户界面”的关联。这里需要纠正一个常见误解setsid()并不等同于 fork 加父进程退出。它只是改变进程的会话成员资格但调用setsid()的进程本身必须不是进程组组长否则函数调用失败。这就是为什么第一步先 fork因为子进程的 PID 等于原来的进程组 ID 的概率极低子进程基本不可能成为进程组组长这为 setsid 的成功调用扫清了障碍。3.3 第三步umask、chdir 与标准 IO 重定向脱离终端只是守护进程的“功课”之一还有三个收尾动作经常被忽略但它们对程序长期稳定运行至关重要。第一个是重设文件权限掩码umask(0)。如果不重设进程会继承启动者设置的 umask 值比如常见的022这意味着将来创建的文件默认没有组和其他用户的写权限。对守护进程来说权限策略应该由程序自己控制而不是继承自不确定的客户端环境所以把它归零。umask(0);第二个是chdir(/)把当前工作目录切换到根目录。为什么必须做因为进程如果停留在某个已挂载目录比如/home/user系统运维人员想卸载那个分区时进程当前工作目录会阻止卸载因为内核认为有进程在使用这个目录。切换到根目录后进程不会持有任何挂载点的引用这会让系统管理省很多麻烦。如果守护进程需要访问特定数据目录可以自己 open 路径恢复到目标目录但初始工作目录切到根目录仍是标准做法。第三个是标准输入输出重定向。守护进程没有终端如果它调用printf()输出到 stdout而 stdout 还指向一个已注销的终端轻则输出丢失重则触发错误。更稳妥的做法是显式把标准输入重定向到/dev/null标准输出和标准错误重定向到日志文件或/dev/null让进程的逻辑要么安静运行要么把信息准确写入日志。int fd open(/dev/null, O_RDWR); dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); close(fd);如果希望保存日志可以 open 一个文件再把 stdout 和 stderr 指向它。这样即使程序里出现未预期的内部错误信息也能被日志捕获而不会因为缺少终端输出而被忽略。3.4 单实例守护进程与信号处理实验做到标准流程跑通后还可以加两道巩固性的配置单实例运行限制和信号处理逻辑。单实例限制是为了防止同一守护进程被重复启动。常见方法是用文件锁——打开一个固定的 PID 文件用flock()对它加锁如果加锁失败说明已经有另一个实例在运行新进程直接退出。这是一个非常实用的技巧真实服务里几乎都会用到。int pidfile_fd open(/var/run/my_daemon.pid, O_RDWR | O_CREAT, 0644); if (flock(pidfile_fd, LOCK_EX | LOCK_NB) -1) { fprintf(stderr, daemon already running\n); exit(EXIT_FAILURE); }信号处理则是让守护进程退出得更优雅。Linux 的默认行为中进程收到 SIGTERM 会直接终止不做清理工作。很多守护进程会注册 SIGTERM、SIGINT 的处理函数设置一个全局退出标志在主循环中检查该标志然后保存状态、关闭资源再退出。有些守护进程还要处理 SIGHUP用它来重新读取配置文件而不是退出。因为 SIGHUP 在脱离终端后通常表示“挂断”或配置刷新信号善加利用可以做出像 nginx 那样平滑重载的效果。4. 一次完整的守护进程实验实录4.1 一份可运行的示例程序把前面讲的所有机制汇到一起这里给出一份可以完整编译运行的守护进程示例代码它会每隔 2 秒往/tmp/daemon_demo.log写入一行带时间戳的记录。#include stdio.h #include stdlib.h #include string.h #include fcntl.h #include signal.h #include unistd.h #include sys/types.h #include sys/stat.h #include sys/file.h #include time.h #define LOG_FILE /tmp/daemon_demo.log #define PID_FILE /tmp/daemon_demo.pid #define RUN_INTERVAL 2 static volatile sig_atomic_t g_running 1; static void handle_signal(int sig) { if (sig SIGTERM || sig SIGINT) { g_running 0; } } static void write_log(const char *msg) { FILE *fp fopen(LOG_FILE, a); if (fp NULL) { return; } time_t now time(NULL); struct tm *tm_info localtime(now); char time_buf[32]; strftime(time_buf, sizeof(time_buf), %Y-%m-%d %H:%M:%S, tm_info); fprintf(fp, [%s] %s\n, time_buf, msg); fclose(fp); } int main(int argc, char *argv[]) { pid_t pid; // 第一次 fork pid fork(); if (pid 0) { exit(EXIT_FAILURE); } if (pid 0) { exit(EXIT_SUCCESS); // 父进程退出 } // 创建新会话脱离控制终端 if (setsid() -1) { exit(EXIT_FAILURE); } // 第二次 fork防止重新获得控制终端 pid fork(); if (pid 0) { exit(EXIT_FAILURE); } if (pid 0) { exit(EXIT_SUCCESS); } // 设置工作目录和文件权限掩码 umask(0); chdir(/); // 重定向标准输入输出 int nullfd open(/dev/null, O_RDWR); dup2(nullfd, STDIN_FILENO); dup2(nullfd, STDOUT_FILENO); dup2(nullfd, STDERR_FILENO); if (nullfd STDERR_FILENO) { close(nullfd); } // 单实例检查 int pidfd open(PID_FILE, O_RDWR | O_CREAT, 0644); if (pidfd 0) { exit(EXIT_FAILURE); } if (flock(pidfd, LOCK_EX | LOCK_NB) -1) { exit(EXIT_FAILURE); } // 信号处理 signal(SIGTERM, handle_signal); signal(SIGINT, handle_signal); // 主循环 write_log(daemon started); while (g_running) { write_log(daemon is running); sleep(RUN_INTERVAL); } write_log(daemon stopped); exit(EXIT_SUCCESS); }这份代码覆盖了前面讲的所有关键步骤两次 fork、setsid、umask、chdir、标准 IO 重定向、单实例锁、信号处理。你可以在自己的实验环境里直接编译运行。4.2 编译、启动与状态检查编译很简单gcc -Wall -o daemon_demo daemon_demo.c启动时直接在当前 shell 里执行./daemon_demo你会发现命令瞬间返回终端回到提示符——因为父进程已经退出了真正干活的子进程跑在后台。这时用ps查看进程状态ps -ef | grep daemon_demo你会看到类似这样的输出root 3145 1 0 10:30 ? 00:00:00 ./daemon_demo注意第三列 PPID父进程 ID变成 1说明它被 init 进程收养已成为真正的孤儿式守护进程。TTY 这一列显示的?说明它没有控制终端这正是我们希望看到的。接着检查日志cat /tmp/daemon_demo.log每两秒多一行“daemon is running”说明进程在稳定运转。用 PID 文件来管理它是更标准的做法kill $(cat /tmp/daemon_demo.pid)这里注意我们的代码虽然打开了 PID 文件但还没有把当前 PIDgetpid()写入文件。如果想让进程管理更正规可以加上一句dprintf(pidfd, %d\n, getpid());。这属于实验后可以自行完善的细节。4.3 验证守护进程是否真的“守护”验证一个守护进程是否合格有几个非常明确的测试点。第一关闭启动它的终端窗口进程不应退出。打开一个 SSH 会话启动守护进程后关闭该会话再重新登录查看进程它仍然在运行。这是 SIGHUP 被斩断的直接证明。第二向它发送 SIGTERM进程能优雅退出。手动执行kill -TERM $(cat /tmp/daemon_demo.pid)稍等后查看日志最后一行应该写着“daemon stopped”。说明信号处理生效了不是被暴力终止的。第三重复启动同一守护进程第二个实例应该失败退出。在同一环境下再执行一次./daemon_demo由于文件锁被占用第二个实例会直接退出不会出现两个进程抢写同一日志的混乱场景。这个特性虽然没有体现在表面输出里但在真实场景中能避免大量重复启动灾难题。5. 实验中最容易翻车的问题与排查速查表5.1 高频问题和对照表我见过太多同学在实验中卡住其实翻来覆去就是几个固定问题。整理成速查表方便你对照排查现象可能原因解决思路fork 后printf打印了两次对 fork 的复制语义不熟没判断 PID 再执行逻辑所有 fork 后的代码必须放在pid0或pid0分支中出现大量僵尸进程父进程没调用wait()或waitpid()注册SIGCHLD处理函数或父进程主动回收守护进程启动后随终端关闭退出缺少setsid()或调用前仍是进程组组长保证先 fork 再 setsid检查返回值日志文件权限不对忘记umask(0)继承了用户 umask在创建文件前调用umask(0)守护进程启动失败但无提示标准错误被重定向到/dev/null错误信息丢失调试阶段先保留 stderr 到日志文件或先不重定向第二次启动还能成功运行缺少单实例锁用flock()加 PID 文件锁kill 后进程仍在运行信号被忽略或进程处于不可中断睡眠检查进程状态为D还是S确认信号处理注册/tmp/daemon_demo.log一直不更新标准输出重定向也影响了错误信息程序可能在早期就退出在write_log里打印 stderr或临时去掉重定向观察5.2 排查这类问题的通用思路面对一个行为异常的守护进程别急着上调试器。先从进程是否存在开始排查ps -ef | grep看进程状态如果找不到进程问题大概率出在早期步骤里如果进程存在但不写日志检查文件权限和路径如果进程存在但无法退出看它是否处于D状态不可中断的睡眠通常因为 IO 阻塞。日志是最好的朋友。很多守护进程在调试阶段把 stderr 指到日志文件跑出问题再看日志往往一眼就能锁定原因。不要把/dev/null当作唯一目标。另一个实用的排查方法是前台运行调试。不要一上来就做完整的 fork setsid先写一个普通前台循环进程确认逻辑正常后逐步加入守护化流程。加一步测一步一旦出问题就知道是你引入的那一步引起的这比一次写完再回头找 bug 省时得多。5.3 实验完成后值得再想一步的内容做完这个实验不妨再思考一个问题现代 Linux 系统里systemd 已经提供了更便捷的服务管理方式为什么要继续学习手写守护进程我的看法是手工编写守护进程让你真正理解 systemd 那些参数在底层对应什么操作——比如Typeforking对应的就是我们先 fork 再让父进程退出的模式StandardOutputjournal对应着标准 IO 重定向逻辑PIDFile对应 PID 文件写法。没有手工实现过一遍你对服务管理器设计意图的理解始终隔着一层纱。往深了说还可以对比线程模型和进程模型的取舍。多进程重点在隔离性和稳定性上胜出一个子进程崩溃其他进程不受影响线程则胜在共享内存开销小。真实开发里如 nginx 采用多进程模型、Redis 采用单进程事件循环都是基于各自场景的取舍。这个实验给的是最初的支点后面能撬起多少东西就看你在工作中怎么组合这些原语了。最后再分享一个小技巧写守护进程时预留一个“前台调试模式”是绝对值得的。我通常会在代码里加一个-f参数表示 foreground用户传这个参数时跳过 fork、setsid 这些步骤直接用前台模式跑。这样调试的时候不用反复看日志文件直接看终端输出出错信息一目了然而且正式部署时又能稳定走守护流程。这个习惯我从做第一次守护进程实验一直用到现在救了很多次急。