Linux守护进程(Daemon)完全指南:从原理到实战

前言

在服务端编程中,我们经常听到"守护进程"(Daemon)这个词。它是 Linux/Unix 系统中一种长期运行在后台、没有控制终端、默默提供服务的进程。

你有没有想过:

  • 为什么像 sshdhttpdmysqld 这些服务能在系统启动后一直运行,而且不会因为用户退出终端而挂掉?

  • 为什么我们写的网络服务器程序,在终端关闭后也会随之消失?

  • 如何让自己的程序变成像系统服务一样"打不死的小强"?

答案就是:让程序守护进程化


一、什么是守护进程?

守护进程(Daemon) 是在后台运行的特殊进程,它独立于控制终端周期性地执行某种任务或等待处理某些发生的事件。常见的守护进程包括 **sshdhttpdmysqldcrond**等。

1.1 守护进程 vs 普通后台进程

特性 普通后台进程 (&) 守护进程
控制终端 有(继承父进程) 无(TTY=?)
会话关系 属于原会话 独立新会话
用户注销影响 收到SIGHUP可能终止 不受影响
进程组组长 可能是 必须是
标准IO 指向终端 重定向到/dev/null

关键区别 :后台进程只是"在后台跑",但守护进程是"彻底脱离终端独立运行"。

简单来说:守护进程就是割断了和一切终端、用户交互的"孤儿进程",但又被 init 系统(或 systemd)领养


二、前置知识:进程组、会话与控制终端

在深入守护进程之前,必须理解三个核心概念:

2.1 进程组(Process Group)

  • 进程组 是一个或多个进程的集合

  • 每个进程组有唯一的 进程组ID(PGID)

  • 组长进程 的PID等于PGID

  • 进程组的生命周期:从创建到组内最后一个进程离开

    查看进程组信息

    ps -eo pid,pgid,ppid,comm | grep test

2.2 会话(Session)

  • 会话 是一个或多个进程组的集合

  • 每个会话有唯一的 会话ID(SID)

  • 通常由 setsid() 创建新会话

2.3 控制终端(Controlling Terminal)

  • 与会话绑定的终端设备

  • 会话首进程打开终端后,该终端成为会话的控制终端

  • 一个会话最多只能有一个控制终端

  • 前台进程组接收终端输入,后台进程组不受影响


三、守护进程创建五步曲

Step 1:忽略异常信号(信号处理)

复制代码
// 1. 忽略IO,子进程退出等相关的信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);

为什么需要忽略这些信号?

SIGPIPE - 管道破裂信号

  • 触发场景:向一个已关闭的管道或Socket写入数据时

  • 默认行为:终止进程

  • 守护进程处理SIG_IGN(忽略)

  • 原因:网络服务中,客户端可能随时断开连接。如果服务器向已关闭的连接写数据,不忽略SIGPIPE会导致服务器进程直接崩溃!

SIGCHLD - 子进程退出信号

  • 触发场景:子进程终止时向父进程发送

  • 默认行为:忽略(但子进程会变成僵尸进程)

  • 守护进程处理SIG_IGN(忽略)

  • 原因 :在Linux中,将SIGCHLD设置为SIG_IGN后,内核会自动回收子进程资源不会产生僵尸进程。这对于需要频繁创建子进程处理请求的服务器至关重要。

📌 防御性编程守护进程需要长期稳定运行,任何可能导致异常终止的信号都应该被妥善处理。

Step 2:fork() 创建子进程 + 父进程退出

复制代码
// 2. 父进程直接结束
if (fork() > 0)
    exit(0);

这一步的两个核心目的:

目的1:让Shell认为命令已执行完毕

当用户在Shell中执行 ./server 时,如果父进程不退出,Shell会一直等待。父进程退出后,Shell立即显示提示符,子进程在后台继续运行。

目的2:确保子进程不是进程组组长

这是最关键 的一步!setsid() 有一个硬性要求:调用进程不能是进程组组长

  • 父进程通常是进程组组长(PID == PGID)

  • 子进程继承父进程的PGID,但拥有新的PID

  • 因此子进程 PID ≠ PGID ,满足 setsid() 的调用条件

孤儿进程被init收养

// 父进程退出后

// 子进程成为"孤儿进程"

// 被 init/systemd (PID=1) 收养

// PPID 从原父进程变为 1

Step 3:setsid() 创建新会话(核心步骤)

复制代码
// 3. 只能是子进程,孤儿了,父进程就是1
setsid(); // 成为一个独立的会话

setsid()三大作用:

作用 说明
创建新会话 调用进程成为新会话的会话首进程(Session Leader)
创建新进程组 调用进程成为新进程组的组长(PGID = PID)
切断终端关联 如果之前有控制终端,联系被彻底切断
关键验证点:
  • TTY=? :没有控制终端

  • TPGID=-1:没有前台进程组

  • SID=PID=PGID:自己是会话首进程和进程组组长

⚠️ 为什么必须先fork再setsid?

如果直接调用 setsid(),而调用进程恰好是进程组组长,调用会失败返回-1。通过 fork() 确保子进程不是组长,这是 setsid() 成功的前提条件。

Step 4:chdir("/") 切换工作目录

复制代码
// 4. 每一个进程都有自己的CWD,是否将当前进程的CWD更改成为/根目录
if (nochdir == 0)
    chdir("/");
为什么要切换到根目录?

场景假设 :你的服务器程序在 /mnt/usb/server/ 目录下启动

  • 如果不切换目录,守护进程的CWD一直是 /mnt/usb/server/

  • 这个目录位于USB设备上

  • 管理员想卸载USB设备:umount /mnt/usb

  • 失败! 因为守护进程占用着该目录

  • 守护进程可能运行数月甚至数年,USB设备一直无法卸载

根目录的优势

复制代码
// 5. 方法2:打开/dev/null,重定向标准输入、标准输出,标准错误到/dev/null
if (noclose == 0)
{
    int fd = ::open(dev.c_str(), O_RDWR);
    if (fd < 0) {
        LOG(LogLevel::FATAL) << "open " << dev << " errno";
        exit(OPEN_ERR);
    }
    dup2(fd, 0);  // stdin
    dup2(fd, 1);  // stdout
    dup2(fd, 2);  // stderr
    close(fd);
}
  • 根目录总是存在且可访问

  • 不会被卸载

  • 守护进程可以在任何环境下运行

💡 参数 nochdir 的作用
nochdir=0 表示"需要切换目录"(默认行为)
nochdir=1 表示"不切换目录"(特殊需求)

Step 5:重定向标准IO到 /dev/null

为什么要重定向?不能直接用close(0/1/2)吗?
方案 做法 问题
方案1:直接关闭 close(0); close(1); close(2); ❌ 后续open()可能分配到0/1/2,导致混乱
方案2:重定向到/dev/null dup2(fd, 0/1/2) ✅ 保持fd连续性,安全静默
/dev/null 是什么?
  • 特殊字符设备文件

  • 写入操作:数据被内核直接丢弃,不占用磁盘空间

  • 读取操作:立即返回EOF(文件结束)

  • 作用:让守护进程有合法的fd 0/1/2,但无实际IO

为什么保持fd 0/1/2很重要?
  1. 库函数假设:很多第三方库假设标准IO可用,直接关闭可能导致崩溃

  2. 文件描述符分配规则 :Linux总是分配最小的可用fd。如果0/1/2被关闭,下次open()会返回0,这可能被误认为是stdin

  3. SIGPIPE防护:向已关闭的fd写入会触发SIGPIPE,重定向到/dev/null则安全静默

💡 参数 noclose 的作用
noclose=0 表示"需要重定向"(推荐,默认行为)
noclose=1 表示"不重定向"(调试用,保留终端IO)


四、执行流程详解:


五、完整代码解析

复制代码
#pragma once

#include <iostream>
#include <cstdio>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/fcntl.h>
#include "Log.hpp"
#include "Common.hpp"

using namespace LogModule;

const std::string dev = "/dev/null";

// 将服务进程守护进程的任务
void Daemon(int nochdir, int noclose)
{
    // ========== Step 1: 信号处理 ==========
    // 忽略SIGPIPE:防止向已关闭的socket/pipe写入导致进程终止
    signal(SIGPIPE, SIG_IGN);
    // 忽略SIGCHLD:让内核自动回收子进程,避免僵尸进程
    signal(SIGCHLD, SIG_IGN);

    // ========== Step 2: fork() + 父进程退出 ==========
    // 目的1:让Shell认为命令执行完毕
    // 目的2:确保子进程不是进程组组长,为setsid()做准备
    if (fork() > 0)
        exit(0);  // 父进程直接退出

    // ========== Step 3: setsid() 创建新会话 ==========
    // 调用进程成为:
    // - 新会话的会话首进程 (SID = PID)
    // - 新进程组的组长 (PGID = PID)
    // - 切断与控制终端的所有联系
    setsid();

    // ========== Step 4: 切换工作目录 ==========
    // 防止占用可卸载的文件系统
    // 确保守护进程在任何目录下都能运行
    if (nochdir == 0)
        chdir("/");

    // ========== Step 5: 重定向标准IO ==========
    // 守护进程不从键盘输入,也不需要向显示器打印
    // 网络服务的数据从网卡来,服务器只要有主机即可
    if (noclose == 0)
    {
        // 打开/dev/null(读写模式)
        int fd = ::open(dev.c_str(), O_RDWR);
        if (fd < 0)
        {
            LOG(LogLevel::FATAL) << "open " << dev << " errno";
            exit(OPEN_ERR);
        }
        else
        {
            // 将标准输入(0)、标准输出(1)、标准错误(2)重定向到/dev/null
            dup2(fd, 0);  // stdin → /dev/null
            dup2(fd, 1);  // stdout → /dev/null
            dup2(fd, 2);  // stderr → /dev/null
            close(fd);    // 关闭原始fd(dup2已复制)
        }
    }
}

六、如何使用守护进程函数

复制代码
// ./server port
int main(int argc, char *argv[])
{
    if (argc != 2) {
        std::cout << "Usage : " << argv[0] << " port" << std::endl;
        return 0;
    }
    uint16_t localport = std::stoi(argv[1]);

    // 将当前进程守护进程化
    // 参数1: nochdir=false(0) → 切换工作目录到/
    // 参数2: noclose=false(0) → 重定向标准IO到/dev/null
    Daemon(false, false);

    // 创建服务器对象并启动事件循环
    std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));
    svr->Loop();

    return 0;
}

七、验证守护进程的方法

7.1 使用 ps 命令查看

复制代码
# 查看守护进程信息
ps axj | grep server

# 关键字段说明:
# PPID: 1          ← 被init收养
# PID: 2831        ← 进程ID
# PGID: 2831       ← 进程组ID(等于PID,说明是组长)
# SID: 2831        ← 会话ID(等于PID,说明是会话首进程)
# TTY: ?           ← 无控制终端
# TPGID: -1        ← 无前台进程组
# STAT: S          ← 睡眠状态(后台运行)

7.2 查看文件描述符

复制代码
# 查看进程的文件描述符
ls -l /proc/2831/fd

# 预期输出:
# 0 -> /dev/null
# 1 -> /dev/null
# 2 -> /dev/null
# 3 -> socket:[...]  ← 服务器监听socket
# ...

7.3 查看工作目录'

复制代码
# 查看进程工作目录
ls -l /proc/2831/cwd

# 预期输出:
# cwd -> /
相关推荐
iDao技术魔方1 小时前
Bun v1.3.14 深度解析:Image API、HTTP/3、全局虚拟存储与五十项变革
网络·网络协议·http
阡陌..1 小时前
如何使用samba为Linux设置一个局域网共享盘
linux·运维·服务器
晓山清1 小时前
TCN时序卷积网络详解
网络·人工智能·cnn·时序卷积网络
晴夏。2 小时前
UE5 motion warping 运动扭曲的用途
运维·ue5
霞姐聊IT2 小时前
三大并发技术—进程、线程和协程
linux·运维·网络·操作系统
上海云盾-小余2 小时前
UDP 反射放大攻击溯源:流量特征识别与分层封禁实战
网络·网络协议·udp
tjjingpan2 小时前
HCIP-Datacom Core Technology V1.0_17 IP组播基础
网络
ydyd202604212 小时前
设备管理智能化:易点易动如何搭建运维数据可视化闭环体系
运维·信息可视化