守护进程:后台的隐形守护者

说到守护进程,我脑子里第一个蹦出来的画面是——深夜两点,服务器机房,我一个人盯着终端。那时候刚入行,负责一个支付网关服务。白天跑得好好的,一到晚上就莫名其妙挂掉。后来才发现,问题出在进程没有正确守护化,终端一关,进程就跟着没了。嗯,从那以后,我彻底搞懂了守护进程的重要性。

什么是守护进程?

守护进程,英文叫 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 的用法很简单,三步走:

  1. 调用 openlog() 打开日志连接
  2. 调用 syslog() 写入日志
  3. 调用 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 对应的错误描述字符串。

守护进程的完整生命周期

下面这张图展示了守护进程从创建到退出的完整流程:

守护进程生命周期 fork() 创建子进程 父进程 exit() 子进程继续运行 setsid() 创建新会话 chdir("/") 改变工作目录 umask(0) 重设权限掩码 关闭文件描述符 + 重定向 I/O 初始化阶段 守护进程运行中 syslog 日志输出

实际项目中的注意事项

创建守护进程看起来简单,但实际项目中坑不少。我挑几个常见的说说:

  • PID 文件:守护进程启动时,应该把自己的 PID 写入 /var/run/ 下的文件。这样其他工具可以知道它是否在运行,也方便停止服务。
  • 信号处理:守护进程通常要处理 SIGTERM(优雅退出)、SIGHUP(重载配置)、SIGUSR1/SIGUSR2(自定义操作)等信号。
  • 单实例运行:有些守护进程只允许运行一个实例。可以通过 PID 文件加文件锁来实现。
  • 日志轮转:日志文件会越来越大,需要定期轮转。可以用 logrotate 工具,或者在程序里自己实现。

曾经踩过的坑:有一次我写的守护进程在系统关机时没有正确处理 SIGTERM 信号,导致数据没来得及写盘就挂了。后来我加了一个信号处理函数,收到 SIGTERM 后先 flush 数据,再退出。这个教训让我养成了习惯——任何守护进程都必须优雅退出。

守护进程是系统编程的基础技能。掌握了它,你就能写出真正能在生产环境长期运行的服务。下一节我们会深入讨论进程间通信,到时候守护进程会作为我们的实验平台。


公众号:蓝海资料掘金营,微信deep3321