守护进程:后台的隐形守护者
说到守护进程,我脑子里第一个蹦出来的画面是——深夜两点,服务器机房,我一个人盯着终端。那时候刚入行,负责一个支付网关服务。白天跑得好好的,一到晚上就莫名其妙挂掉。后来才发现,问题出在进程没有正确守护化,终端一关,进程就跟着没了。嗯,从那以后,我彻底搞懂了守护进程的重要性。
什么是守护进程?
守护进程,英文叫 daemon。说白了,就是一直在后台跑着的进程。你平时看不到它,但它一直在干活。比如你电脑上的 sshd、httpd、crond,这些都是典型的守护进程。
守护进程有几个关键特征:
- 没有控制终端——你关掉终端窗口,它不会受影响
- 父进程是 init——PID 为 1 的那个进程收养了它
- 在后台运行——不占用你的终端输入输出
- 生命周期长——通常从系统启动一直跑到系统关机
你想想看,如果一个服务必须依赖终端才能活着,那服务器重启之后谁去开终端?所以守护进程是系统服务的基石。
核心要点:守护进程 ≠ 后台进程。后台进程只是把 & 符号加在命令后面,它仍然属于当前终端会话。终端关闭时,后台进程会收到 SIGHUP 信号,然后挂掉。守护进程则完全脱离终端控制。
创建守护进程的标准步骤
创建守护进程有一套固定的流程。我在项目中反复用过,每次都是这六步。少一步都不行,顺序也不能乱。
第一步:fork 子进程,父进程退出
为什么要 fork?因为我们要让子进程成为孤儿进程,被 init 进程收养。父进程退出后,子进程就在后台继续运行了。
pid_t pid = fork();
if (pid < 0) {
// fork 失败,直接退出
exit(EXIT_FAILURE);
}
if (pid > 0) {
// 父进程退出
exit(EXIT_SUCCESS);
}
// 子进程继续执行
这里有个细节:fork 之后,子进程会继承父进程的很多东西,包括文件描述符、环境变量、当前目录等等。这些我们后面要一一清理。
第二步:创建新会话(setsid)
这一步是关键。调用 setsid() 会让子进程:
- 成为新会话的首进程
- 成为新进程组的组长
- 失去控制终端
if (setsid() == -1) {
// 创建会话失败,退出
exit(EXIT_FAILURE);
}
我个人习惯在 setsid 之后再做一次 fork。为什么?因为会话首进程有可能重新申请打开控制终端。再 fork 一次,子进程就不是会话首进程了,彻底杜绝了重新获取终端的可能。
我的经验:有些系统上,如果进程已经是进程组组长,setsid 会失败。所以一定要确保 fork 出来的子进程不是进程组组长。标准做法就是先 fork,子进程再调用 setsid。
第三步:改变工作目录
子进程继承了父进程的当前工作目录。如果这个目录是挂载点,你就没法卸载它了。所以要把工作目录切到一个不会出问题的地方。
chdir("/"); // 切换到根目录
当然,你也可以切到其他目录,比如 /var/run 或者 /tmp。但根目录是最安全的。
第四步:重设文件权限掩码
umask 决定了新创建文件的默认权限。守护进程可能会创建文件,如果继承了父进程的 umask,权限可能不对。
umask(0); // 清空掩码,让守护进程自己控制权限
我曾经遇到过一个坑:守护进程写日志文件,结果其他进程读不了。查了半天,发现是 umask 设得太严了。从那以后,我每次创建守护进程都会显式调用 umask(0)。
第五步:关闭所有文件描述符
子进程继承了父进程打开的所有文件描述符。这些描述符可能指向终端、网络连接、普通文件等等。不关掉它们,会有资源泄漏。
// 获取系统最大文件描述符数
long maxfd = sysconf(_SC_OPEN_MAX);
if (maxfd == -1) {
maxfd = 1024; // 保守估计
}
// 关闭所有文件描述符
for (int i = 0; i < maxfd; i++) {
close(i);
}
嗯,这里要注意:有些系统上 sysconf(_SC_OPEN_MAX) 可能返回很大的值,比如 10 万。循环关闭所有描述符会有点慢。但为了安全,这点开销值得。
第六步:重定向标准输入/输出/错误
守护进程没有终端,所以 stdin、stdout、stderr 这三个文件描述符(0、1、2)应该指向 /dev/null。否则,如果守护进程不小心往 stdout 写了东西,可能会引发奇怪的问题。
open("/dev/null", O_RDWR); // 占用 fd 0
dup(0); // 占用 fd 1
dup(0); // 占用 fd 2
或者更简洁的写法:
int fd = open("/dev/null", O_RDWR);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > STDERR_FILENO) {
close(fd);
}
完整的守护进程创建模板
把上面六步串起来,就是一个完整的守护进程创建函数。我每次写新项目都会直接复用这个模板:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
void daemonize() {
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);
}
// 第三步:改变工作目录
chdir("/");
// 第四步:重设文件权限掩码
umask(0);
// 第五步:关闭所有文件描述符
long maxfd = sysconf(_SC_OPEN_MAX);
if (maxfd == -1) {
maxfd = 1024;
}
for (int i = 0; i < maxfd; i++) {
close(i);
}
// 第六步:重定向标准 I/O
open("/dev/null", O_RDWR); // stdin
dup(0); // stdout
dup(0); // stderr
}
注意:上面的模板是最简版本。实际项目中,你可能还需要处理信号、记录 PID 文件、设置进程名称等。不要直接复制到生产环境,要根据具体需求调整。
syslog 日志系统
守护进程没有终端,怎么输出日志?用 syslog。这是 Unix/Linux 系统提供的标准日志机制。
syslog 的用法很简单,三步走:
- 调用 openlog() 打开日志连接
- 调用 syslog() 写入日志
- 调用 closelog() 关闭连接(可选,进程退出时自动关闭)
#include <syslog.h>
int main() {
// 打开日志,设置进程名和选项
openlog("mydaemon", LOG_PID | LOG_CONS, LOG_DAEMON);
// 写入日志
syslog(LOG_INFO, "守护进程启动成功");
syslog(LOG_WARNING, "配置文件未找到,使用默认配置");
syslog(LOG_ERR, "无法打开端口 8080: %m");
// 关闭日志
closelog();
return 0;
}
syslog 的日志级别从高到低:
| 级别 | 含义 | 使用场景 |
|---|---|---|
| LOG_EMERG | 紧急 | 系统不可用,比如磁盘满了 |
| LOG_ALERT | 警报 | 需要立即处理,比如数据库连接池耗尽 |
| LOG_CRIT | 严重 | 关键错误,比如内存分配失败 |
| LOG_ERR | 错误 | 一般错误,比如文件打开失败 |
| LOG_WARNING | 警告 | 潜在问题,比如配置项已弃用 |
| LOG_NOTICE | 通知 | 重要事件,比如服务启动/停止 |
| LOG_INFO | 信息 | 常规信息,比如客户端连接 |
| LOG_DEBUG | 调试 | 调试信息,生产环境通常关闭 |
openlog 的 options 参数有几个常用选项:
- LOG_PID:每条日志都记录进程 PID,方便排查
- LOG_CONS:如果 syslog 服务不可用,直接写到控制台
- LOG_NDELAY:立即打开连接,而不是等到第一次写日志
- LOG_NOWAIT:写日志时不等待子进程(一般不用管)
我的习惯:调试阶段用 LOG_DEBUG 级别,上线后改成 LOG_INFO。这样既能看到关键信息,又不会被调试日志刷屏。另外,%m 这个格式化符号特别好用,它会自动替换成 errno 对应的错误描述字符串。
守护进程的完整生命周期
下面这张图展示了守护进程从创建到退出的完整流程:
实际项目中的注意事项
创建守护进程看起来简单,但实际项目中坑不少。我挑几个常见的说说:
- PID 文件:守护进程启动时,应该把自己的 PID 写入 /var/run/ 下的文件。这样其他工具可以知道它是否在运行,也方便停止服务。
- 信号处理:守护进程通常要处理 SIGTERM(优雅退出)、SIGHUP(重载配置)、SIGUSR1/SIGUSR2(自定义操作)等信号。
- 单实例运行:有些守护进程只允许运行一个实例。可以通过 PID 文件加文件锁来实现。
- 日志轮转:日志文件会越来越大,需要定期轮转。可以用 logrotate 工具,或者在程序里自己实现。
曾经踩过的坑:有一次我写的守护进程在系统关机时没有正确处理 SIGTERM 信号,导致数据没来得及写盘就挂了。后来我加了一个信号处理函数,收到 SIGTERM 后先 flush 数据,再退出。这个教训让我养成了习惯——任何守护进程都必须优雅退出。
守护进程是系统编程的基础技能。掌握了它,你就能写出真正能在生产环境长期运行的服务。下一节我们会深入讨论进程间通信,到时候守护进程会作为我们的实验平台。
公众号:蓝海资料掘金营,微信deep3321