一、进程守护
UDP多用于直播、联机游戏、语音通话等长期在线的实时业务,而UDP的所有异常容错逻辑都需要应用层自行实现,程序更容易因bug发生崩溃、闪退。为了保障服务不间断运行,部署UDP服务时一般会搭配进程守护。 进程守护的核心作用是持续监控UDP服务进程,一旦检测到进程异常退出、卡死,就会自动重启程序;同时支持开机自启,避免服务器重启后业务中断。
不只是UDP服务需要进程守护,TCP服务同样离不开它。只要是部署在服务器上、需要 7×24 小时不间断对外提供服务的后台程序,都建议配置进程守护,TCP 与 UDP 在此处没有区别。
1.1 进程组、作业、会话
在将进程守护前先介绍几个概念:进程组、作业、会话
1.1.1 什么是进程组
之前我们提到了进程的概念,其实每一个进程除了有一个进程ID (PID) 之外还属于一个进程组。进程组是一个或者多个进程的集合,一个进程组可以包含多个进程。每一个进程组也有一个唯一的进程组ID (PGID),并且这个PGID类似于进程ID,同样是一个正整数,可以存放在 pid_t 数据类型中。

由于管道 | 会让整条管道里所有命令,在同一个进程组里面。管道首进程作为整个进程组的组长。
所以当同时启动三个 sleep 进程,查看进程信息,三者属于同一个进程组,进程组 ID (PGID) 相同。这三个进程由同一个 bash 创建,属于同父的子进程,也就是兄弟进程。而管道首进程sleep 10000就是进程组组长。
如果ctrl^C终止进程,在同一个进程组中的这三个进程都会被终止。


而在Linux中,前后台的管理,是以进程组为单位的!当我们输入ctrl^C命令终止进程时,是向前台进程组发送信号。
在命令末尾加上&,可以将整条命令作为后台作业运行,此时这条管道内的所有进程构成一个后台进程组,终端可以继续输入别的指令。

其中,sleep 1000这个进程自成一个进程组。而进程ID前面的1就是作业号。

1.1.2 什么是作业
作业是Shell提出的概念,用来管理一组关联的命令。一个作业对应一个进程组,作业内可以包含一个或多个进程。作业分为前台作业与后台作业,前台作业占用终。在命令末尾添加&,可以将作业放到后台执行,Shell 可继续接收新指令。
输入命令jobs就可以查看我们之前启动的作业:

输入指令fg+%+作业号(或fg+作业号),可将指定作业对应的进程组切换为前台作业。输入ctrl+z可以将作业转为后台作业,此时该作业被暂停,进入休眠状态。而bash会被转为前台作业,继续读取用户输入的命令。当输入bg+%+作业号时,会唤醒被暂停的作业,继续在后台执行:

1.1.3 什么是会话
在查看进程时,有一项叫做SID,而这个SID就是会话号,即session id。

那什么是会话呢?
会话是操作系统内核提供的顶层进程管理单元,由登录终端创建,用于隔离不同终端的任务。一个登录终端对应一个会话,一个会话内可以容纳多个进程组。用户远程登录Linux服务器并完成身份验证后,操作系统就会为当前的登录终端创建一个独立会话。在查看bash进程时,我们会发现会话ID与bash进程的ID相同。由于我启动了两个会话,所以就有两个不同的bash。

操作系统为了区分不同会话,会为每一个会话分配唯一的会话标识SID。而会话创建后产生的第一个进程就是bash进程(即会话首进程)。操作系统会将会话创建的第一个进程的PID作为该会话的SID。
所以我们后续在bash命令行中创建作业和进程组,无非就是在会话内创建作业和进程组。而一个会话内至少要存在一个进程组(bash),可以同时存在多个进程组和作业。
1.2 引入守护进程
在一个会话内部,一次只允许有一个前台进程组,可以允许存在多个后台进程组。无论前台还是后台进程组,都可以向终端文件(控制终端)写入数据(打印数据),而只有前台进程组能够从终端文件中读取数据(读取键盘输入)。
当我们关闭Xshell、退出登录时,当前会话会被销毁,会话内所有进程组与作业都会受到信号影响。网络服务如果作为该会话下的进程或作业运行,在用户登录、注销操作时,其运行状态也会受到影响。所以为了让Linux中的网络服务不受用户登录、注销操作的影响,我们就需要让网络服务自成会话!
而自成会话我们需要使用到的接口是--setsid()

但是调用这个接口有个要求,那就是这个进程不能是进程组的组长:

为了满足这个条件,我们可以调用fork()创建子进程,然后父进程退出,让子进程执行setsid()创建独立会话。而这个调用了setsid()的子进程我们就称之为守护进程!
由于父进程以及退出了,所以此时子进程就是一个孤儿进程。调用了setsid()的子进程除了叫守护进程外,还有个名称,叫做精灵进程------daemon。
这里有个细节,当父进程退出后,也就是进程组组长退出了,子进程的PGID依然是原来进程组的PGID。也就是说即使组长不在了,这个进程组也依然存在。只有当进程组里面的所有的进程都退出了,进程组才会消失。
二、守护进程实现代码
作为守护进程,第一件事就是需要忽略掉某一些信号。使用signal()函数忽略SIGPIPE和SIGCHLD信号。忽略SIGPIPE信号是为了避免套接字断开导致服务退出;而忽略SIGCHLD信号可以让操作系统自动回收子进程,防止僵尸进程。
cpp
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
接下来为了调用接口setsid(),我们先要fork()创建子进程然后父进程退出,让子进程完成后续逻辑。然后再让子进程调用setsid(),设置新会话:
cpp
if (fork() > 0)
exit(0); // 父进程
pid_t id = setsid();
(void)id;
2.1 更改工作路径
我们需要添加代码,使得能够更改守护进程的工作路径:
cpp
if (!isredir)
chdir("/");
通过chdir("/")修改进程工作目录到根目录;当isredir为0时执行该操作。切换到根目录可以防止占用原有目录,同时要求程序使用绝对路径访问所有资源。
当一个项目要部署到服务器上时,项目内包含的可执行程序bin/httpserver、配置文件conf/http.conf、日志目录log以及网页资源目录wwwroot,在部署时需要将整个项目目录拷贝到 Linux 系统的指定目录下。而守护进程在运行时会切换工作目录到根目录,因此程序必须有一个固定的起始路径。于是就需要采用绝对路径的方式来访问配置、静态资源等相关文件,这样才能保证服务能够稳定找到所有的依赖资源。
2.2 重定向
由于用户退出登录后,网络服务需要继续运行,所以网络服务后续产生的日志打印就必须向文件里写。同理,后续我们在对代码进行调试时,需要打印数据。而这些数据不能直接打印到显示器上,而是要写入到系统中的一个特殊字符文件中,这个文件就是**/dev/null**。
/dev/null:凡是写入到这个文件中的内容,全部都会被系统自动丢弃。
/dev/zero:则可以让用户通过读取这个文件获得一些随机数据。
所以我们需要将日志等数据通过重定向的方式重定向到文件/dev/null中:
cpp
if (isdup)
{
close(0);
close(1);
close(2);
}
else
{
int fd = open("/dev/null", O_RDWR);
if (fd >= 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
2.3 完整代码
Daemon.hpp:
cpp
#pragma once
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <fcntl.h>
//isdup为0时进行重定向, isredir为0时更改工作路径
void Daemon(int isdup, int isredir)
{
// 1. 忽略信号
signal(SIGPIPE, SIG_IGN);
signal(SIGCHLD, SIG_IGN);
// 2. 不能是组长
if (fork() > 0)
exit(0); // 父进程
// 3. 设置新会话
pid_t id = setsid();
(void)id;
// 4. 更改守护进程的工作路径
if (!isredir)
chdir("/");
// 5. 重定向0,1,2
if (isdup)
{
close(0);
close(1);
close(2);
}
else
{
int fd = open("/dev/null", O_RDWR);
if (fd >= 0)
{
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
}
三、系统中守护进程的接口
上面的代码是我们手写的实现守护进程,而Linux系统中自带了守护进程的接口供我们调用,这个接口就是daemon()。


当nochdir为0时,说明需要更改工作路径,此时守护进程的工作路径会被改为根目录/。当noclose为0时,说明需要进行重定向,此时就会将文件描述符0(标准输入)、1(标准输出)、2(标准错误)重定向到系统文件/dev/null中。