守护进程Daemon是 Linux 服务端开发绕不开的一个概念。简单说它是脱离终端、自己独立在后台运行、生命周期不随用户登录退出而结束的进程。这篇文章我想结合自己做 Linux 服务端开发的经历把守护进程的原理、实现步骤、常见坑和 systemd 时代的取舍一次讲清楚。适合刚接触 Linux 系统编程的开发者也适合写服务端程序时想搞懂后台运行机制的读者。不求面面俱到但希望你看完能写一个真正靠得住的 Daemon而不是“看起来像”的 Daemon。1. 守护进程到底是什么先搞清它在系统里的位置1.1 从一次终端关闭事故说起我在一个模拟项目里接手过这么个情况一个数据同步程序在终端里./sync_agent跑着一切正常。可只要远程会话一断开程序就跟着没了数据同步断了半宿都没人发现。问题不在程序逻辑而在于它没有做“守护化”它的一生都被绑定在了一个终端会话上。要理解守护进程得先理解三个绕不开的基础概念进程组Process Group、会话Session和终端Terminal。可以这么想终端是操作台上的一块屏幕进程组就是围在这块屏幕前干活的小分队会话则是包含这些小队的一个作业现场。正常情况下进程一启动就被分配到一个进程组进程组属于某个会话会话关联着一个控制终端。当控制终端关闭内核会给会话里的所有进程发送挂断信号SIGHUP默认处理就是终止进程。这也是绝大多数程序“一关终端就死”的根本原因。普通程序从诞生那天起就打上了终端的烙印。1.2 守护进程在躲什么进程、会话与终端的关系守护进程的职责就是切断这一切羁绊。它不绑定终端不在任何交互会话里挂名启动之后父进程退出自己变成孤儿进程由系统的进程管理器收养。它的生命周期通常和系统同长开机启动、关机结束中间不因用户操作而变化。最常见的守护进程就是你每天离不开的那些系统服务比如 SSH 服务、定时任务服务、Web 服务、日志服务。它们没有一个是在终端里前台跑着的全都是系统启动时就默默拉起来的守护进程。我第一次意识到这一点是在刚学 Linux 时把 ssh 服务手动停掉然后发现自己的远程连接直接断了——原来维持我连上这台机器的本身就是一个后台的守护进程。这里有一个容易混淆的点用nohup命令加上放到后台或者用CtrlZ挂起的任务只是“后台任务”不是“守护进程”。后台任务依然属于发起它的那个会话终端一关照样收到 SIGHUP。守护进程的关键不是“在后台”而是“脱离会话”。1.3 前台进程、后台任务与守护进程一张表看清差异为了把三者的区别说透我用一张表来对比这样后面再讲实现步骤时你就知道每一步到底在解决哪个问题。类型是否占用终端终端关闭后是否退出生命周期谁在管理它前台进程是是随操作结束用户终端后台任务/nohup否但属于原会话多数会退出nohup只是忽略 SIGHUP随会话结束用户终端会话守护进程否否与系统同长系统服务管理器这张表也解释了为什么用nohup启动程序“有时行有时不行”nohup只是让进程忽略 SIGHUP 信号但它仍然挂在原会话里。如果终端关闭时还有其他更复杂的信号或控制终端属性变化程序照样可能挂掉。要真正解决问题还是得走守护进程这条路。2. 从普通进程到守护进程核心五步拆解下面进入最核心的部分。经典的手工守护化流程就是先fork再调setsid再做一次fork接着处理工作目录、权限掩码、文件描述符。我用 C 语言来讲因为 C 语言的系统调用最直接你看懂了这套流程用其他语言实现其实都是同样的逻辑。2.1 第一次 fork让父进程先谢幕为什么一定要fork因为setsid有一个硬性限制调用者不能是进程组组长。如果你直接在终端里启动一个程序这个程序天然就是它所在进程组的组长此时直接调setsid会失败。解法就是先fork。父进程退出留下子进程继续执行。子进程被父进程生出来时进程组 ID 和父进程相同但关键在于子进程是一个新的进程它的 PID 不等于它所在进程组的 PGID所以它不是进程组组长。只有在这种情况下setsid才能成功。这段代码通常是这样的pid_t pid fork(); if (pid 0) { exit(1); // fork 失败 } if (pid 0) { exit(0); // 父进程退出终端看起来命令已结束 }你可能会疑惑父进程直接退出子进程不就成了孤儿吗对这正是我们要的。孤儿进程会被 init 进程现代系统里通常是 systemd收养后续它就不再受原终端的控制。这一步也常被叫作“第一次 fork ”不是形式主义它是给setsid铺路的。2.2 setsid把进程从老会话里拔出来setsid()是整个“守护化”中最实质性的一步。调用成功后当前进程会同时做三件事成为新会话的首进程Session Leader、成为新进程组的组长、并且这个新会话没有控制终端。代码就一行if (setsid() 0) { exit(1); }这一步的意义相当于把进程从原来那个和终端纠缠的会话里整个拔了出来放进一间完全独立、没有屏幕的屋子里。从此以后当用户关掉终端终端发不出 SIGHUP 给这个进程因为它已经不在那个会话里了。这也是守护进程“脱离终端”的根本保障。2.3 第二次 fork一种防御性的再斩断第二次fork不是必需的但很多教材和我在实际工程中都会做。原因在于setsid之后当前进程成为了会话首进程。如果以后它不小心打开了一个终端设备它作为会话首进程是有可能重新获得一个控制终端的。为了避免这种“一遍守护化之后又被拉回去”的隐患我们会再做一次fork。这样最终真正干活的进程不是会话首进程它永远不会自动获取控制终端。这段代码和第一次类似pid_t pid fork(); if (pid 0) { exit(1); } if (pid 0) { exit(0); // 中间进程退出留下最终守护进程 }如果你对自己的程序非常了解确信它绝不会去打开终端设备这一步可以省略。但做了它就是一个很标准、很防御性的写法。我在生产代码里通常会保留因为多出来的几行代码可以防住未来有人往程序里加“打开设备”的逻辑。2.4 换目录与调权限避免被别人的规矩限制进程会继承父进程的工作目录。如果这个工作目录是一个被挂载的文件系统守护进程长期持有它会导致你无法卸载该文件系统如果工作目录后来被删掉进程还会在“不存在的目录”里操作行为会变得很奇怪。所以守护进程要把工作目录换掉通常是换到根目录或者换到程序自己的约定目录。换到根目录最安全if (chdir(/) 0) { exit(1); }接着是umask。文件权限掩码决定新建文件时的默认权限。如果守护进程启动时继承了某个用户设置的umask比如0022那它创建文件时就不会带组写权限。很多服务在安装部署后出现“奇怪的文件权限问题”根源就是这个。我一般习惯在守护进程入口一开始就把umask设成0后续代码里创建文件时在open调用里明确写出权限位让权限完全由程序自己控制。这是一个“把命运握在自己手里”的细节umask(0);2.5 关闭文件描述符与重定向别让标准 I/O 害了进程进程从父进程那里继承了标准输入、标准输出、标准错误还可能继承了各种打开的文件句柄。在守护进程里你不希望它占着父进程的日志文件、配置文件的句柄不释放也不希望它保留一个已经不存在的终端设备作为标准输出。最稳妥的做法是先把所有文件描述符全部关闭再把0、1、2这三个标准文件描述符重定向到/dev/null。简单的实现可以这样long maxfd sysconf(_SC_OPEN_MAX); for (long fd 0; fd maxfd; fd) { close(fd); } int fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd STDERR_FILENO) { close(fd); } }为什么一定要重定向而不是直接不管因为某些库函数在标准输出不可用时行为会变得很诡异。比如printf的缓冲逻辑、某些日志库默认输出到 stderr一旦 stderr 指向的是一个已关闭的描述符可能直接导致程序在运行中崩溃。把这三个描述符定向到黑洞程序后续哪怕误写了标准输出也不会造成副作用。2.6 一个可以直接编译的守护进程框架把上面的步骤串起来就是一个标准的守护进程框架。我这里给一个最小可用的版本主程序里做了一个简单的循环并通过 syslog 记录启动信息#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/stat.h #include sys/types.h #include fcntl.h #include syslog.h static void daemonize(void) { pid_t pid; long maxfd; int fd; pid fork(); if (pid 0) { exit(EXIT_FAILURE); } if (pid 0) { exit(EXIT_SUCCESS); } if (setsid() 0) { exit(EXIT_FAILURE); } pid fork(); if (pid 0) { exit(EXIT_FAILURE); } if (pid 0) { exit(EXIT_SUCCESS); } umask(0); if (chdir(/) 0) { exit(EXIT_FAILURE); } maxfd sysconf(_SC_OPEN_MAX); for (long i 0; i maxfd; i) { close(i); } fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd STDERR_FILENO) { close(fd); } } } int main(void) { openlog(mydaemon, LOG_PID | LOG_NDELAY, LOG_DAEMON); daemonize(); syslog(LOG_INFO, mydaemon started); while (1) { sleep(10); syslog(LOG_INFO, mydaemon is running); } closelog(); return 0; }编译和运行都很简单gcc -o mydaemon mydaemon.c然后用./mydaemon启动再用ps -eo pid,ppid,sid,tty,cmd | grep mydaemon观察你会看到它的tty是?ppid是1说明它已经成功脱离了原始终端。3. 写守护进程最容易翻车的几个地方我的排查记录光会写框架还不够我在实际项目里见过很多“看起来是守护进程、实际上问题一堆”的情况。下面这几个坑都是真实攒下来的经验。3.1 标准输出没处理磁盘被日志填满之前提过一个数据同步程序它在启动时并没有关闭标准输出运维为了方便排查用nohup ./sync_agent /tmp/sync.log 来启动。结果这个程序本身没做日志轮转/tmp/sync.log以每天几个 G 的速度增长最后直接把系统盘写满了。排查过程也很典型先df -h看到根分区使用率 100%再用du -sh /tmp/*找到那个巨大的日志文件。问题不在于日志该不该记而在于这个进程既然要做守护进程就应该把标准输出重定向到自己可控的位置或者交给 syslog 管理而不是任由父 shell 的重定向结果牵着鼻子走。教训就是前面第五步真的不能省。不管启动时有没有人给你重定向文件你自己都必须把标准输出、标准错误握在自己手里。3.2 配置文件相对路径失效chdir 后的连锁反应另一个常见问题出在chdir(/)之后。守护进程的工作目录变成了根目录那些用相对路径加载的配置文件就会全部失效。我曾经排查过一个服务明明二进制就在/opt/app/bin/下配置文件放在/opt/app/etc/程序启动后却老是报“找不到配置文件”。后来定位到原因代码里用的是相对路径etc/app.conf而进程已经chdir(/)了自然就去根目录下找etc/app.conf了。解决办法有两种要么代码里统一改用绝对路径加载配置要么不把工作目录改到根目录而是改成程序自己的安装目录。如果你维护的老代码改不动启动脚本里可以先用cd /opt/app ./bin/run.sh这种方式控制工作目录。关键是改chdir之前一定要把和路径相关的影响想清楚。3.3 单实例保护别让两个“自己”打架守护进程通常应该保证只有一个实例在跑。否则脚本或监控工具重复调起程序就会出现两个进程抢同一个端口或者同时写同一个文件的情况轻则报错重则数据错乱。常见的做法是 PID 文件加锁。我推荐用文件锁flock而不是单纯写 PID 文件因为文件锁能真正防止并发启动。逻辑是启动时先openPID 文件然后对它加写锁。如果加锁失败说明已经有实例在运行直接退出。int lock_fd open(/var/run/mydaemon.pid, O_CREAT | O_RDWR, 0644); if (lock_fd 0) { exit(EXIT_FAILURE); } if (flock(lock_fd, LOCK_EX | LOCK_NB) ! 0) { exit(EXIT_FAILURE); // 已有实例在跑 }注意要把这个锁文件描述符保留到进程退出不要关闭它。有些实现会把 PID 写进文件但那只对“读文件判断”这种方式有帮助有了flock即使 PID 文件里的 PID 是旧的也不影响互斥效果。3.4 信号处理杀不死的守护进程守护进程没有终端你用CtrlC当然杀不死它这是正常的。但有一种更隐蔽的情况程序里注册了SIGINT或SIGTERM的处理函数但处理函数里什么也不做结果kill pid发过去进程毫无反应看起来就像“杀不死”。我在排查一个老遗留服务时遇到过这种状态。那个程序的信号处理函数里只打了一行printf而标准输出已经被定向到了/dev/null等于处理了信号但什么都没发生程序继续跑。定位时用strace -p pid看系统调用才发现进程卡在循环里信号处理函数根本没有改变任何状态变量。正确的做法是在信号处理函数里只设置一个标志位主循环检查到这个标志位后做清理退出。信号处理函数中不能调用printf、malloc这类不安全函数这是写信号处理的基本功。static volatile sig_atomic_t g_stop 0; static void handle_signal(int sig) { g_stop 1; }然后主循环里if (g_stop) break;这样收到 SIGTERM 时就能优雅退出。3.5 调式优先级让程序先别守护化守护进程一旦起来标准输出、标准错误全都没了靠printf调试是不可能的。我的经验是调试阶段先不调用daemonize()前台运行看输出等逻辑稳定了以后再加上守护化步骤重测一遍启动、退出、信号处理。如果非得调试一个已经跑起来的守护进程我用得比较多的是这两个方法strace -p pid看系统调用定位卡在哪一步以及gdb -p pid附加进去看逻辑状态。不过附加 gdb 到运行中的进程要小心有些环境会因此把进程暂停生产环境谨慎操作。4. systemd 时代还要不要手写守护进程现在换一个问题你已经会手工写守护进程了但在新版 Linux 系统上还有必要每次都用那套fork setsid fork的流程吗答案很明确多数场景不需要了systemd 已经把这些脏活包办了。4.1 手工 daemon 是历史产物systemd 把脏活包办了fork、setsid、关闭文件描述符这些操作本质上是在“没有系统服务管家”的时代程序自己给自己找的活路。现代 Linux 发行版基本都用 systemd 来管理系统服务你只需要告诉 systemd“我这个程序应该怎么跑”它就会帮你完成进程的守护化、日志收集、崩溃重启等一堆事情。写一个 service 文件放到/etc/systemd/system/下然后systemctl start mydaemon系统就帮你把它跑起来了。手工 daemon 里那些umask、chdir、重定向代码在 systemd 语境下变成了 service 文件里的配置项或干脆不再需要。这不是说手工 daemon 就完全没用了在一些嵌入式环境、容器精简镜像、没有 systemd 的旧系统里手工 daemon 依然有它的价值。但在通用 Linux 服务器上我强烈建议优先用 systemd。4.2 Typeforking 还是 Typesimple决定你的服务能不能被管理用 systemd 托管程序最常踩的坑就是Type写错。这里有两个值要搞清楚Typesimplesystemd 会认为ExecStart启动的进程就是主服务进程程序应该一直前台运行。如果你的程序没有自守护化就一直跑在终端前那就用这个。Typeforkingsystemd 会认为ExecStart启动后父进程会很快退出真正的服务进程是它 fork 出来的子进程。这时你通常要提供PIDFile让 systemd 知道该跟踪哪个 PID。如果你的程序已经实现了完整的手工 daemon 流程但 service 文件里写了Typesimplesystemd 会以为主进程父进程已经退出了于是判定服务启动失败把子进程也一并清理掉。反过来如果程序是前台运行却写成Typeforkingsystemd 会等不到父进程退出最后报启动超时。一个比较典型的 service 文件长这样[Unit] DescriptionMy Daemon Service Afternetwork.target [Service] Typeforking PIDFile/run/mydaemon.pid ExecStart/usr/local/bin/mydaemon ExecReload/bin/kill -HUP $MAINPID Restarton-failure [Install] WantedBymulti-user.target注意一个细节PIDFile指向的文件必须是由服务进程自己创建的并且服务退出时要能删除。如果程序不写这个文件systemd 在forking模式下可能会认错主进程。4.3 迁移到 systemd 的三个注意点如果你有一个老的手工守护进程程序想迁到 systemd 下管起来我建议先检查三件事第一程序里是否重复做了守护化。如果程序里面已经有完整的daemonize()那 service 文件用Typeforking如果想省事也可以让程序去掉守护化逻辑、前台运行service 文件用Typesimple。两者选一个不要两边都做否则 systemd 管理起来会很别扭。第二标准输出到底去了哪里。systemd 默认会把服务进程的标准输出和标准错误接到 journal 日志里。如果程序里原来把 stdout、stderr 都重定向到了/dev/null那即使托管给了 systemd你也看不到它的任何输出。为了让日志可见迁移时最好把程序里“硬定向到 /dev/null”的逻辑去掉或者至少提供一个可配置项。第三停止服务时的信号处理。systemd 停止服务时默认给主进程发 SIGTERM。如果你的程序没处理这个信号就是直接“被杀”如果你自定义了处理函数却没有真正退出systemd 会等TimeoutStopSec时间后强制 SIGKILL。所以优雅退出逻辑最好在设计阶段就做好。5. 让守护进程“活得更体面”日志、退出与自启守护进程写完能跑只是起点真正让人省心的是把日志、退出策略和自启管理都做好否则运维阶段会很难受。5.1 日志优先用 syslog别自己造轮子老式守护进程最推荐的记录方式是用 syslog程序里通过openlog、syslog、closelog把日志交给系统统一管理由系统的日志服务负责写文件、轮转、持久化。这样程序本身不需要关心日志文件路径、权限、切割这些由系统统一处理。openlog(mydaemon, LOG_PID | LOG_NDELAY, LOG_DAEMON); syslog(LOG_INFO, mydaemon started);如果程序被 systemd 托管syslog的日志也会被 systemd 收集进 journal统一用journalctl -u mydaemon查看。能交给系统日志体系处理的就不建议自己搞一套日志文件。自己写日志文件很容易踩坑文件没轮转导致磁盘满、写入时权限不对、多进程同时写把日志搞乱。这些事情系统工具都已经解决得很好了没必要重复造轮子。5.2 优雅退出信号处理与 PID 文件守护进程的退出策略核心是两项内容处理 SIGTERM 做优雅退出以及清理自己的 PID 文件。优雅退出的标准写法前面已经提到设置一个volatile sig_atomic_t标志位主循环检测后退出。退出前要做的清理包括关闭打开的文件、释放内存、删除临时文件、删除 PID 文件。注意删除 PID 文件这个动作要放在“确认自己将要退出”的时候不要在信号处理函数里调用unlink应该放在主循环退出后的收尾阶段。关于 PID 文件我建议配合文件锁一起使用。只写 PID 不锁文件的问题是如果上一个实例是非正常退出的PID 文件里写着一个已经不存在的进程号新启动的实例读取这个 PID 后可能误判为“已有实例”导致服务起不来。用文件锁就能避免这种“死 PID”问题。5.3 存活策略与老系统的监控方案守护进程也不是真的“永生”它可能崩溃、被 OOM Killer 杀掉、或者因为异常而僵死在某种状态里。如果你在 systemd 下配置Restarton-failure就能自动拉起异常退出的进程这是最简单可靠的方式。如果是在没有 systemd 的老旧系统上常见的兜底方案是crontab每分钟跑一个检查脚本检查 PID 文件对应进程是否还活着如果没有就重新拉起。我见过不少老系统就是这么干的。这个方案虽然不如 systemd 优雅但在受限环境下确实有效。这里有一个需要小心的点检查脚本和守护进程之间可能产生“竞争条件”。比如脚本发现进程没在准备启动新实例但旧进程其实正在退出过程中或者 PID 文件还没被清理。为了降低这种风险我建议检查脚本直接看进程是否存在而不是只依赖 PID 文件里的值如果进程存在就什么都不做哪怕 PID 文件里的 PID 对不上也别贸然清掉否则容易误杀。最后再分享一个我自己坚持了很多年的小习惯每次写完守护进程我都会用这条命令检查它是否真的完成了“脱胎换骨”ps -eo pid,ppid,sid,tty,cmd | grep -E PID|mydaemon看输出时重点盯三个值ppid是不是1sid是不是它自己的会话号tty是不是?。三个条件都满足这个进程才算真正从终端手里挣脱出来了。如果其中任何一个不对那就回到文章前面的步骤一步步排查到底哪一步还没做到位。守护进程这个主题说到底就是要彻底理解进程与终端、会话的关系然后把这种理解落到每一个系统调用里。