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

2026-06-27 19:42:47 | 龙魂传承

前言

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

你有没有想过:

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

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

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

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

一、什么是守护进程?

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

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很重要?

库函数假设:很多第三方库假设标准IO可用,直接关闭可能导致崩溃

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

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

💡 参数 noclose 的作用 :

noclose=0 表示"需要重定向"(推荐,默认行为)

noclose=1 表示"不重定向"(调试用,保留终端IO)

四、执行流程详解:

五、完整代码解析

复制代码

#pragma once

#include

#include

#include

#include

#include

#include

#include

#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 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 -> /