79、守护进程:如何编写一个守护进程?
守护进程,说白了就是「后台一直跑着、不占终端、不死不活」的那种程序。你想想看,像 sshd、cron、nginx 这些,都是典型的守护进程。它们没有控制终端,没有标准输入输出,默默在后台干活。
我刚开始学嵌入式 Linux 开发时,总觉得守护进程很神秘。后来自己动手写了一个数据采集的 daemon,踩了不少坑。嗯,今天咱们就把这个「神秘面纱」彻底撕掉。
什么是守护进程?
守护进程(Daemon)是一种长期运行的后台进程。它的特点是:
- 没有控制终端(TTY)
- 父进程通常是 init(PID=1)
- 工作目录通常是根目录
/ - 文件权限掩码通常被重置
- 标准输入/输出/错误被重定向到
/dev/null
为什么要这么做?因为守护进程不能因为终端关闭就挂掉,也不能因为用户退出就收到 SIGHUP 信号。它得「独立」出来。
编写守护进程的标准步骤
我习惯把守护进程的创建过程总结为「七步法」。每一步都有它的道理,少一步都可能出问题。
- fork() 创建子进程,父进程退出
- 子进程调用 setsid() 创建新会话
- 再次 fork(),父进程(第一次子进程)退出
- 改变工作目录到 /
- 重置文件权限掩码
- 关闭所有打开的文件描述符
- 重定向标准输入/输出/错误到 /dev/null
为什么需要两次 fork?第一次 fork 是为了让子进程成为孤儿进程,被 init 收养。第二次 fork 是为了防止进程重新获得控制终端。我在项目中遇到过,只做一次 fork 的情况下,某些系统上进程还是能打开终端设备,导致意外行为。
代码实现
下面是一个完整的守护进程模板。我写嵌入式程序时,经常拿这个当「骨架」来用。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
void daemonize() {
pid_t pid;
// 第一步:fork 子进程,父进程退出
pid = fork();
if (pid < 0) {
exit(EXIT_FAILURE);
}
if (pid > 0) {
exit(EXIT_SUCCESS);
}
// 第二步:创建新会话
if (setsid() < 0) {
exit(EXIT_FAILURE);
}
// 第三步:再次 fork,防止重新获得终端
pid = fork();
if (pid < 0) {
exit(EXIT_FAILURE);
}
if (pid > 0) {
exit(EXIT_SUCCESS);
}
// 第四步:改变工作目录
chdir("/");
// 第五步:重置文件权限掩码
umask(0);
// 第六步:关闭所有文件描述符
for (int i = 0; i < sysconf(_SC_OPEN_MAX); i++) {
close(i);
}
// 第七步:重定向标准输入/输出/错误
open("/dev/null", O_RDWR); // stdin
dup2(0, 1); // stdout
dup2(0, 2); // stderr
}
int main() {
daemonize();
// 守护进程的主循环
while (1) {
// 你的业务逻辑写在这里
// 比如:读取传感器数据、处理网络请求等
sleep(1);
}
return 0;
}
关键点:第二次 fork 不是必须的,但强烈建议加上。我曾经在某个 ARM 平台上调试时,只做了一次 fork,结果进程在特定条件下被 SIGHUP 信号杀死了。加上第二次 fork 后,问题再没出现过。
守护进程的信号处理
守护进程通常需要处理几个关键信号:
| 信号 | 默认行为 | 建议处理方式 |
|---|---|---|
| SIGHUP | 终止进程 | 重新加载配置文件 |
| SIGTERM | 终止进程 | 优雅退出,清理资源 |
| SIGINT | 终止进程 | 忽略或优雅退出 |
| SIGCHLD | 忽略 | 回收子进程,防止僵尸 |
我个人习惯在守护进程里注册一个信号处理函数,用 sigaction() 而不是 signal()。为什么?因为 signal() 在不同系统上的行为不一致,而 sigaction() 是 POSIX 标准,更可靠。
避坑指南:我曾经在写一个日志收集守护进程时,忘了处理 SIGCHLD 信号。结果程序跑了三天后,系统里多了几百个僵尸进程。从那以后,我每次写守护进程,第一件事就是注册 SIGCHLD 的处理函数。
守护进程的日志记录
守护进程没有终端,所以日志记录就特别重要。我推荐使用 syslog 机制,它是 Linux 系统自带的日志系统。
#include <syslog.h>
void init_log() {
openlog("mydaemon", LOG_PID | LOG_CONS, LOG_DAEMON);
}
void log_message(const char *msg) {
syslog(LOG_INFO, "%s", msg);
}
// 在 daemonize() 之后调用 init_log()
// 然后就可以用 syslog 写日志了
用 syslog 的好处是:日志会自动写到 /var/log/syslog 或 /var/log/messages,而且支持日志轮转,不会把磁盘撑爆。
守护进程的启动与停止
一个完整的守护进程,通常需要一个启动脚本。我习惯在 /etc/init.d/ 下放一个脚本,或者用 systemd 的 service 文件。
最简单的做法是:在守护进程启动时,把 PID 写入一个文件。这样停止时就可以用 kill $(cat /var/run/mydaemon.pid) 来杀掉它。
void write_pid_file() {
FILE *fp = fopen("/var/run/mydaemon.pid", "w");
if (fp) {
fprintf(fp, "%d\n", getpid());
fclose(fp);
}
}
注意:写 PID 文件时,要确保目录存在。有些系统上 /var/run 是临时文件系统,重启后会清空。建议用 /run 或 /var/run,但要注意权限问题。
守护进程的核心逻辑流程图
下面这张图,是我自己总结的守护进程创建流程。每次写新项目时,我都会对照着检查一遍。
常见问题与避坑
写守护进程时,有几个坑我反复踩过,今天一并说出来:
- 忘记处理 SIGCHLD:如果守护进程会创建子进程,一定要处理 SIGCHLD,否则子进程变成僵尸。
- 直接使用 printf:守护进程没有终端,printf 的输出会丢失。用 syslog 代替。
- 工作目录没改:如果守护进程启动时的工作目录是某个挂载点,卸载时会出问题。
- 文件描述符泄漏:每次打开文件后,记得检查返回值并关闭。守护进程跑几个月后,文件描述符用光是很常见的故障。
我的经验:调试守护进程时,可以先在终端前台运行,加上 -d 参数让它不要 daemonize。等逻辑调通了,再正式以守护进程方式运行。这样能省很多调试时间。
好了,守护进程的核心内容就这些。写一个健壮的守护进程,关键是把那七步走扎实,信号处理好,日志记录好。做到这三点,你的 daemon 就能稳定跑上几个月甚至几年。