前言
在服务端编程中,我们经常听到"守护进程"(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 <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 -> /