跳至正文
老丹的足迹 —— 代码写给机器,游记写给自己,感悟写给时间
老丹的足迹 老丹的足迹
老丹的足迹 老丹的足迹
  • 首页
  • 示例页面
  • 首页
  • 示例页面
老丹的足迹 老丹的足迹
老丹的足迹 老丹的足迹
  • 首页
  • 示例页面
  • 首页
  • 示例页面

Linux 守护进程(Daemon)完全实现指南:从代码到设计哲学

前言:为什么需要守护进程?

在 Linux 系统中,一个普通的进程生命周期受限于其启动终端。当你关闭 SSH 会话窗口、按 Ctrl+C 或退出 Shell 时,前台进程会随之终止。对于需要 7×24 小时不间断运行的服务(如 Web 服务器、数据库、消息队列),这种默认行为是不可接受的。

守护进程(Daemon) 就是为了解决这个问题而生的特殊进程类型。它脱离终端控制、独立于用户会话、在后台永久运行。本文将从代码实现出发,系统性地讲解守护进程的构建方法,并深入剖析每一步操作的设计意图与必要性。

第一部分:守护进程的核心特征

一个标准的守护进程具备以下四个基本特征,可用 ps 命令验证:

ps -ef | grep nginx
# root      1234     1  0 10:00 ?    00:00:00 nginx: master process
特征表现验证方式
无控制终端TTY 列显示为 ?不接收终端信号(Ctrl+C / SIGHUP)
独立会话SID(会话 ID)等于自身 PIDps -o pid,sid,tty,cmd
父进程为 init/systemdPPID 为 1被 init 收养,自动回收
无继承的文件句柄标准 I/O 指向 /dev/null意外输出不会触发 SIGPIPE

第二部分:标准实现(APUE 经典双 fork 方案)

以下代码遵循《Unix 环境高级编程》(APUE)中定义的经典流程,被 Nginx、Redis、httpd 等主流项目广泛采用。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/resource.h>
#include <fcntl.h>
#include <signal.h>
#include <syslog.h>
#include <errno.h>
#include <string.h>

void daemonize(const char *cmd) {
    int i, fd0, fd1, fd2;
    pid_t pid;
    struct sigaction sa;
    struct rlimit rlim;

    /* ============================================================
     * 步骤1:清除文件权限掩码
     * ============================================================ */
    umask(0);

    /* ============================================================
     * 步骤2:第一次 fork
     * ============================================================ */
    pid = fork();
    if (pid < 0) {
        syslog(LOG_ERR, "fork error: %s", strerror(errno));
        exit(1);
    }
    if (pid > 0) exit(0);   // 父进程立即退出

    /* ============================================================
     * 步骤3:创建新会话(脱离终端的关键)
     * ============================================================ */
    if (setsid() < 0) {
        syslog(LOG_ERR, "setsid error: %s", strerror(errno));
        exit(1);
    }

    /* ============================================================
     * 步骤4:第二次 fork(阻断重新获取终端的可能性)
     * ============================================================ */
    pid = fork();
    if (pid < 0) {
        syslog(LOG_ERR, "second fork error: %s", strerror(errno));
        exit(1);
    }
    if (pid > 0) exit(0);   // 会话领头进程退出

    /* ============================================================
     * 步骤5:切换工作目录到根路径
     * ============================================================ */
    if (chdir("/") < 0) {
        syslog(LOG_ERR, "chdir error: %s", strerror(errno));
        exit(1);
    }

    /* ============================================================
     * 步骤6:关闭所有文件描述符
     * ============================================================ */
    if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
        if (rlim.rlim_max == RLIM_INFINITY)
            rlim.rlim_max = 1024;
        for (i = 0; i < rlim.rlim_max; i++)
            close(i);
    }

    /* ============================================================
     * 步骤7:重定向标准输入/输出/错误到 /dev/null
     * ============================================================ */
    fd0 = open("/dev/null", O_RDWR);
    fd1 = dup(0);
    fd2 = dup(0);

    /* ============================================================
     * 步骤8:忽略 SIGHUP 和 SIGPIPE 信号
     * ============================================================ */
    sa.sa_handler = SIG_IGN;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    sigaction(SIGHUP, &sa, NULL);
    sigaction(SIGPIPE, &sa, NULL);

    /* ============================================================
     * 步骤9:初始化系统日志
     * ============================================================ */
    openlog(cmd, LOG_CONS | LOG_PID, LOG_DAEMON);
    syslog(LOG_INFO, "Daemon %s started successfully.", cmd);
}

第三部分:守护进程启动与热加载完整流程图

上一节给出了完整的代码实现,你可能觉得步骤有点多。在深入剖析每一步的原理之前,我们先通过一张全景流程图,把整个启动过程串起来:

图例说明:

颜色步骤含义
🔵 蓝色①-③用户启动与首次 fork
🟠 橙色④-⑤脱离终端与二次 fork(核心)
🟢 绿色⑥-⑩环境清理与日志初始化
🟣 紫色⑪-⑭加载配置、注册信号、进入主循环
🔴 红色⑮-⑱运维触发 SIGHUP 后的热加载流程

从图中可以清晰看到:步骤 ①-⑭ 是进程启动阶段,执行一次就结束了;步骤 ⑮-⑱ 是运行阶段,由 SIGHUP 信号随时触发。下面我们就按照图中的标注,逐一解剖每一步的设计意图。

第四部分:逐步骤深度剖析——为什么要这么做?

4.1 步骤②:umask(0) —— 清除文件权限掩码

umask(0);

每个进程都继承一个文件创建掩码(umask),来自父进程(通常是 Shell)。例如 Shell 默认 umask 022,意味着:

创建文件时指定的权限 & ~umask = 实际权限
0666 & ~022 = 0644   // 去掉了 group 和 other 的写权限

如果守护进程要创建日志文件,代码写的是 open("log", O_CREAT, 0666),受 umask 022 影响,实际权限变成 0644——其他用户无法写入。如果日志需要多用户共享写入(如 www-data 组),就会因权限不足而失败。

umask(0) 将掩码清零,让进程创建文件时完全由代码中的 mode 参数决定权限,不受继承值影响。

原则:守护进程不应继承父进程的任何环境设置,所有行为必须由自身代码完全控制。

4.2 步骤③:第一次 fork() —— 为 setsid() 铺路

pid = fork();
if (pid > 0) exit(0);

目的 A:让子进程不是进程组组长

setsid() 的调用条件:调用进程不能是进程组组长。普通 Shell 命令启动的进程,本身就是其进程组的组长(是该组第一个进程),直接调用 setsid() 会失败(EPERM)。

fork() 后:

  • 子进程 PID 与父进程不同
  • 子进程继承父进程的 PGID(即父进程的 PID)
  • 子进程的 PID ≠ PGID → 子进程不是进程组组长 → 满足 setsid() 条件

目的 B:父进程立即退出,Shell 返回提示符

父进程退出后,Shell 认为命令执行完毕,立即返回提示符,不被阻塞。

4.3 步骤④:setsid() —— 核心剥夺操作

if (setsid() < 0) { ... }

setsid() 是守护进程的核心转折点,同时完成三件事:

操作效果意义
创建新会话(Session)调用进程成为会话领头进程进程不再属于任何现有会话
创建新进程组调用进程成为新进程组组长进程独立于父进程的进程组
断开控制终端进程不再有任何控制终端永远收不到终端的 SIGINT、SIGHUP

自此以后:

  • 关闭 SSH 窗口 → 进程收不到 SIGHUP
  • 按 Ctrl+C → 进程收不到 SIGINT
  • 按 Ctrl+\ → 进程收不到 SIGQUIT

4.4 步骤⑤:第二次 fork() —— 终极保险

pid = fork();
if (pid > 0) exit(0);

虽然 setsid() 切断了当前终端,但存在隐患:会话领头进程有资格重新打开终端设备。如果进程意外调用 open("/dev/tty"),会自动成为该终端的控制进程,重新被终端“绑架”。

第二次 fork() 后:

  • 中间进程(第一次 fork 后的子进程)是会话领头进程 → 立即退出
  • 孙进程(第二次 fork 的子进程)不是会话领头进程(父进程才是)

孙进程永远无法获取控制终端——即使主动 open("/dev/tty"),也会返回 ENXIO 错误。这是守护进程的“终极保险”。

fork 次数目的关键点
第一次让 setsid() 能执行成功子进程不是进程组组长
第二次让进程永远无法重获终端孙进程不是会话领头进程

4.5 步骤⑥:chdir(“/”) —— 避免锁定文件系统

if (chdir("/") < 0) { ... }

如果守护进程在 /mnt/usb 目录下启动,而该目录是挂载的 U 盘:

  1. 卸载阻止:用户卸载 U 盘时,内核发现该目录正被进程占用,卸载失败报 device busy
  2. 行为异常:若目录被删除,进程后续相对路径操作(如 fopen("log/file.log"))失败

chdir("/") 把工作目录切换到根目录,根目录永远不会被卸载。

例外:若进程需要读写固定路径(如 MySQL 的 /var/lib/mysql),可 chdir 到该目录,前提是该目录稳定、不会被卸载。

4.6 步骤⑦:关闭所有文件描述符 —— 清除遗产

if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
    if (rlim.rlim_max == RLIM_INFINITY)
        rlim.rlim_max = 1024;
    for (i = 0; i < rlim.rlim_max; i++)
        close(i);
}

从父进程(Shell)继承的文件描述符是“定时炸弹”:

文件描述符通常指向风险
0(stdin)终端若读取,会阻塞
1(stdout)终端若输出到已关闭终端,触发 SIGPIPE 导致死亡
2(stderr)终端同上
3,4,5,…父 Shell 打开的文件/管道/socket资源泄露或逻辑干扰

流程:

  1. getrlimit(RLIMIT_NOFILE) 获取系统允许的最大文件描述符数
  2. 循环从 0 到最大值-1,逐一 close()
  3. 若返回 RLIM_INFINITY(无限),设安全值 1024 防止死循环

4.7 步骤⑧:重定向到 /dev/null —— 吞噬所有 I/O

fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);

关闭文件描述符后,0、1、2 是空置的,需指向安全设备——/dev/null(Linux 的“黑洞设备”):

操作/dev/null 的行为效果
读(read)立即返回 EOF(0 字节)不会阻塞
写(write)成功返回,数据被丢弃不会报错,不触发 SIGPIPE

从此守护进程“哑巴了”——所有意外的输入输出都被安全吞噬。

4.8 步骤⑨:忽略 SIGHUP 和 SIGPIPE —— 堵死后门

sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGHUP, &sa, NULL);
sigaction(SIGPIPE, &sa, NULL);

忽略 SIGHUP:虽然已脱离终端,但若会话领头进程意外退出,会话会收到 SIGHUP 并广播给所有子进程。孙进程可能收到该信号,显式忽略以堵死后门。

忽略 SIGPIPE(网络服务必须做):

  • 守护进程常为网络服务器
  • 客户端断开后,服务端 write() 会触发 SIGPIPE,默认杀死整个进程
  • 忽略后,write() 仅返回 -1,程序自行处理错误,服务不崩溃

原则:守护进程不应因客户端的异常行为而自杀。

4.9 步骤⑩:openlog() —— 建立日志通道

openlog(cmd, LOG_CONS | LOG_PID, LOG_DAEMON);
syslog(LOG_INFO, "Daemon %s started successfully.", cmd);

守护进程没有终端,不能使用 printf(输出到 /dev/null 被丢弃)。必须通过系统日志记录信息:

  • openlog():建立与 syslogd/journald 的连接
  • syslog():写入日志消息,由系统统一收集
  • 日志写入 /var/log/syslog 或通过 journalctl 查看

注意:openlog() 必须在关闭文件描述符(步骤⑦)之后调用,否则日志连接使用的描述符可能被错误关闭。

第五部分:常见问题与陷阱

Q1:为什么我的守护进程还是收到了 SIGTERM?

SIGTERM 是 kill 命令的默认信号,守护进程应该响应它并优雅退出(关闭连接、保存状态)。setsid() 不阻止 SIGTERM,只阻断终端相关信号(SIGHUP、SIGINT、SIGQUIT)。

Q2:fork() 后文件描述符会怎样?

子进程继承父进程的所有文件描述符副本,指向相同的内核文件表项。关闭它们不影响父进程(因父进程已退出),但不关闭会一直占用内核资源。

Q3:如何防止守护进程被 kill -9 杀死?

无法防止。SIGKILL 是内核强制终止,进程无法捕获或忽略。唯一“防护”是服务具备自动恢复能力(如 systemd 的 Restart=always)。

Q4:Nginx 为什么不进行第二次 fork?

Nginx 只做一次 fork + setsid,没有第二次 fork。因为 Nginx 主进程职责是管理 worker,不会主动打开终端设备,作者认为风险足够低。这说明第二次 fork 是强化措施,非绝对必须。

第六部分:为什么不直接用 daemon() 函数?

Linux 提供了封装好的 daemon(int nochdir, int noclose) 函数,一行代码即可:

daemon(0, 0);  // 0 = 自动 chdir("/") + 关闭标准 I/O

生产级程序(Nginx、Redis、MySQL)几乎从不使用它:

原因一:不可控

不同系统的 daemon() 实现存在差异,为确保所有 Linux 内核版本下行为一致,大项目选择显式实现每一步。

原因二:日志初始化顺序

守护进程需配合 syslog。若先 openlog() 再 daemon(),daemon() 循环关闭所有描述符会把日志连接的 fd 也关闭。自己实现可精确控制顺序。

第七部分:现代 systemd 下的新范式

在 systemd 环境中,守护进程的实现正在发生根本性变化。

systemd 的核心哲学

systemd 不推荐“双 fork”传统模式,推崇 “前台运行 + 服务管理”:

  • 程序不进行任何 fork
  • 程序不调用 setsid
  • 程序不关闭文件描述符(systemd 处理)
  • 直接前台运行,日志输出到 stdout/stderr
  • systemd 负责进程管理、日志收集、崩溃重启

systemd 服务文件示例

# /etc/systemd/system/myapp.service
[Unit]
Description=My Application Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/myapp --foreground
Restart=always
RestartSec=5
User=myapp
Group=myapp

[Install]
WantedBy=multi-user.target

传统 vs systemd 对比

维度传统双 fork 守护进程systemd 托管的前台进程
代码复杂度需实现 9 个步骤什么都不用做
日志处理必须用 syslog直接 printf,systemd 自动收集
进程管理依赖 PID 文件systemd 自动监控重启
兼容性所有 Unix/Linux仅 systemd 系统

如何兼容两种环境?

  1. 默认行为:前台运行(不 fork),日志输出到 stdout/stderr
  2. 提供选项:--daemon 参数触发传统双 fork 模式
  3. systemd 服务文件不加 --daemon
  4. SysV init 脚本加上 --daemon
# systemd: 直接前台运行
ExecStart=/usr/bin/myapp

# SysV init: 守护进程模式
start() {
    /usr/bin/myapp --daemon
}

第八部分:总结

守护进程实现的核心原则

原则对应操作
不继承父进程的任何状态umask(0)、chdir("/")、关闭所有文件描述符
独立于任何终端setsid() + 第二次 fork()
安全的 I/O 行为重定向到 /dev/null
对异常信号免疫忽略 SIGHUP、SIGPIPE
可观测性使用 syslog 记录日志

实现步骤速查表

步骤函数一句话解释
①启动进程用户操作入口
②umask(0)不受父进程 umask 影响
③fork() + 父进程退出让子进程满足 setsid 条件
④setsid()脱离终端
⑤第二次 fork() + 父进程退出防止重新获取终端
⑥chdir("/")不锁定任何文件系统
⑦关闭所有文件描述符清除继承资源
⑧重定向到 /dev/null安全吞噬 I/O
⑨忽略 SIGHUP/SIGPIPE杜绝意外自杀
⑩openlog() + syslog()建立日志通道

最终建议

开发场景推荐做法
编写系统基础组件(自研中间件)按 APUE 标准手撸双 fork 流程
业务服务(运行在 systemd 环境)不要 fork,直接前台运行,用 systemd 管理
快速验证脚本直接调用 daemon(0, 0)
跨平台兼容需求提供 --daemon 参数,按需切换模式

守护进程的实现是一个看似简单、实则精妙的工程实践。每一步操作背后都有深厚的设计哲学——独立性、鲁棒性、可观测性,这三者共同构成了一个合格守护进程的基石。


本文完整代码可直接用于生产环境,适配不同 Linux 发行版的行为差异。

作者

老丹

关注我
其他文章
上一个

深入解析libdbus:D-Bus的底层C语言基石

下一个

Linux 服务热加载完全实现指南:基于 SIGHUP 信号的配置动态更新

关于博主

    老丹是一名C/C++后台开发工程师,信奉“无抽象不设计,无性能不生产”。

  • 技术栈:Modern C++、Linux环境编程、多线程/并发、网络编程等。
  • 信条:能用constexpr解决的问题绝不拖到运行时,能靠RAII避免的泄漏绝不写析构。
  • 正在填坑:从解封装到渲染的C++全链路实现,正在驯服FFmpeg与H.264/H.265。
  • 输出原则:这里的每一段代码都经过-Wall -Wextra -Werror -O2的洗礼。

近期文章

  • Ubuntu 防火墙迁移指南:从 UFW 到 firewalld 的完整实践 2026年9月12日
  • Nano 编辑器完全操作指南:从入门到熟练 2026年9月12日
  • SSCG:让自签名证书不再“危险”的生成工具 2026年9月12日
  • Ubuntu Samba 服务安装与配置完全指南 2026年9月12日
  • 从零开始:用 Docker 部署 Jellyfin 并启用英特尔核显硬件加速 2026年9月11日

文章分类

  • C/C++开发 (22)
  • Docker容器 (5)
  • Linux工具包 (17)
  • Linux服务配置 (50)
  • Linux系统 (16)
  • OpenWrt路由 (3)
  • Shell脚本 (3)
  • 代码管理 (1)
  • 安防技术 (4)
  • 数据安全 (36)
  • 未分类 (1)
  • 网络协议 (25)
  • 计算机理论 (23)
  • 音视频技术 (5)
联系我们:📍 地址:中国·广东省深圳市   |   ✉️ 邮箱:support@tanglinux.com   |   💬 QQ:870866607
版权所有:老丹的足迹粤ICP备2026061170号-1       公安备案图标 粤公网安备44030002013274号