本文将以tcp服务器代码为基本,讲述如何将进程守护进程化,后台运行

1.守护进程

所谓守护进程,就是和其他进程没有关系的进程;其独立运行于系统后台,除非自己退出或收到信号终止,否则会一直运行下去

1.1 进程组

在我们使用的bash中,同一时刻只会有一个前台进程组

【Linux】实现守护进程 | 以tcpServer为例_linux

如图,当一个前台进程开始运行之后,我们没有办法在当前终端开启第二个前台进程

在运行的命令后面加&,临时让当前进程在后台运行。注意,此时tcp虽然在后台运行了, 但对于它而言,stdin/stdout/stderr的文件描述符依旧指向的是当前bash的输入输出,所以它的日志依旧会打印到当前终端上。

ps命令查看当前进程的信息,其中ppid是当前进程的父进程,也就是当前bash,pid是进程编号,pgid是进程的组编号,可以看到这个组编号和grep命令的组编号是不同的。

【Linux】实现守护进程 | 以tcpServer为例_重定向_02

我们用这个c语言的代码调用两次fork,相当于创建了3个子进程。

#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    fork();
    fork();
    sleep(100);
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

此时再来查看进程信息,能看到这4个进程的进程组pgid是相同的,而且和第一个test的pid相同;这说明第一个test就是父进程,后面的3个都是子进程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GiI0XFdN-1681715550272)(https://img.musnow.top/i/2023/02/63e45da7770c3.png)]

1.2 进程会话

这里还有一个我们之前没有太多了解的信息,进程的sid是什么?

还是上面的例子,在图中能看到,我们执行的test和grep的sid都是相同的,而且都等于第一个test进程的ppid(bash的pid)

【Linux】实现守护进程 | 以tcpServer为例_重定向_03

这表明图中的5个进程同属于一个进程会话,这个会话就是我们当前打开的bash,并用sid来表示进程会话;

这也是为什么我们登录linux的时候一定会有一个终端,linux系统就是创建会话并加载bash,来给用户提供服务的。

既然存在会话,那就肯定会有会话的资源上限。一旦满了,就会开始杀掉一些进程

./test &
  • 1.

即便我们用&让进程在后台运行,其也有可能收到会话的创建/关闭的影响而被操作系统干掉🧐比如我们将当前正在运行进程的bash关掉,其前台进程会被直接终止,后台进程也会受到影响(有可能终止有可能不终止,取决于系统)

这和我们对tcp服务器的需求不一致:我们需要的是让tcp服务器的进程能一直稳定的在后台运行,让操作系统别去管它;除非系统内存满了,负载重到实在没有办法的时候,操作系统才能过来把他刀了。

为了不让守护进程受到进程会话的影响,我们就必须让其能够独立出来,自成一个进程组和一个新会话

👆这种独立的进程,就可以被称为守护进程/精灵进程

2.实现

2.1 自己写

别以为写这个很难哦,实际特别简单!

2.1.1 setsid

这里需要用到的setsid接口,其作用如名字一般,是设置当前进程的进程会话组

#include <unistd.h>
       pid_t setsid(void);
  • 1.
  • 2.

但是调用这个函数有一个要求:调用的进程不能是进程组的组长!

比如下图中,第一个test就是进程组的组长,它不能调用这个函数。会报错

【Linux】实现守护进程 | 以tcpServer为例_重定向_03

那要怎么让自己不成为进程组的组长呢?很简单,创建一个子进程就ok了!

if (fork() > 0)
        exit(0);//父进程直接退出
  • 1.
  • 2.
2.1.2 重定向到dev/null

如果你不知道什么是/dev/null,简而言之,这是一个linux下的数据垃圾桶。和windows的回收站会存放删除的资料不同,这个垃圾桶是个黑洞,丢进去的东西不会被存放,是直接丢弃的!

守护进程需要把默认的0.1.2文件描述符都重定向到dev/null,是因为设置成独立的进程组和进程会话了之后,当前进程是没有和bash关联的。

此时,默认这个0 1 2所指向的bash是无效的!如果不重定向,使用cout打印的时候,就会引发异常(可以理解为往一个不存在的文件中写内容),服务器直接退出了,无法实现守护进程。

重定向了之后,所有的打印输出都会被丢到/dev/null这个文件垃圾桶中,也就不需要担心上述的问题。

if ((fd = open("/dev/null", O_RDWR)) != -1) // fd == 3
    {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        // 6. 关闭掉不需要的fd
        // 因为fd只是临时用于重定向,操作完毕了就可以关掉了
        if(fd > STDERR_FILENO) 
            close(fd);
    }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

你可能会疑惑,那日志信息也被丢到垃圾桶里面了,怎么办?

很简单,因为我们服务器的日志都统一使用了log.hpp里面的logging函数,所以只需要对logging函数的输出重定向到日志文件里面,就ok了!

2.1.3 chdir(选做)

这个操作的目的是修改工作路径。作为服务器进程,很多日志信息是存放在/etc/目录而不是当前路径下的,为了安全,也应该使用绝对路径而不用相对路径,避免出现工作目录切换而导致的无法读写文件的问题

不过,如果使用绝对路径,即便我们不修改工作目录,也是能正常访问的;所以这个操作是选做的

2.1.4 信号捕捉

自己写这个函数有个好处,那就是我们可以在里面自定义捕捉一些信号,给这些信号 加上自己的自定义方法;

比如SIGPIPE就是管道的信号,当管道的读端关闭的时候,写端会被终止;此时写端就会收到这个信号。如果不对这个信号进行SIG_IGN忽略,我们的服务器会直接终止!

signal(SIGPIPE, SIG_IGN);
  • 1.

除了这个信号,我们还可以对2号或者3号信号进行自定义捕捉,设定退出信号,让服务器能够安全退出(保存日志信息到磁盘,释放资源等;虽然进程退出之后操作系统会帮我们干这些事,但我们这么写能让项目更规范)

2.1.5 完整代码
#pragma once

#include <iostream>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h> 
#include <fcntl.h> // O_RDWR 需要

void daemonize()
{
    int fd = 0;
    // 1. 忽略SIGPIPE (管道读写,读端关闭,写端会收到信号终止)
    signal(SIGPIPE, SIG_IGN);
    // 2. 更改进程的工作目录
    // chdir(); // 可以改,可以不改
    // 3. 让自己不要成为进程组组长
    if (fork() > 0)
        exit(0);
    // 4. 设置自己是一个独立的会话
    setsid();
    // 5. 重定向0,1,2
    if ((fd = open("/dev/null", O_RDWR)) != -1) // fd == 3
    {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        // 6. 关闭掉不需要的fd
        // 因为fd只是临时用于重定向,操作完毕了就可以关掉了
        if(fd > STDERR_FILENO) 
            close(fd);
    }
    // 这里还有另外一种操作,就是把stdin/stdout/stderr给close了
    // 但是这样会导致只要有打印输出的代码,进程会就异常退出
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.

没错,就这一点点代码,就能让我们的tcp服务器变成守护进程!

【Linux】实现守护进程 | 以tcpServer为例_linux_05

此时我们的客户端依旧能正常连接服务端,获取结果

【Linux】实现守护进程 | 以tcpServer为例_bash_06

2.2 nohup

no hang up(不挂起),用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行。用nohup命令执行一个进程,就能让这个进程成为不受终端退出影响的进程

nohup ./test &
  • 1.

此时,nohup会在当前目录下创建一个nohup.out文件,用于记录test进程的输出信息(如果通过了>>>执行了重定向,则不会创建)

【Linux】实现守护进程 | 以tcpServer为例_bash_07

通过ps可已看到,当前test进程的进程会话还是和bash相同,但我们关闭当前bash,这个test进程依旧能正常运行,只不过父进程会变成操作系统1,我们的目的也算是达到了

【Linux】实现守护进程 | 以tcpServer为例_运维_08

2.3 deamon接口

linux系统中有一个接口daemon,可以帮我们实现守护进程

#include <unistd.h>
       int daemon(int nochdir, int noclose);
  • 1.
  • 2.

了解过守护进程的写法了之后,这两个参数的作用就很明显了

  • 第一个参数nochdir表明是否需要修改工作目录;如果设置为0,则切换工作目录到/系统根目录
  • 第二个参数noclose表明是否需要重定向基础io到/dev/null;设置为0则重定向

以下是man手册中的说明

If nochdir is zero, daemon() changes the calling process's current working directory to the root directory ("/"); otherwise, the  cur‐
rent working directory is left unchanged.

If noclose is zero, daemon() redirects standard input, standard output and standard error to /dev/null; otherwise, no changes are made
to these file descriptors.
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

我们直接用一个简单代码来演示

#include <unistd.h>

int main()
{
    //不需要修改工作目录,第一个参数设为1
    //因为没有进行打印,重定向设置成1,不进行重定向
    int ret = daemon(1,1);
    sleep(100);
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

运行之后可以看到,这个进程的父id是操作系统,其自成一个进程组和进程会话;和我们自己写的函数作用相同

【Linux】实现守护进程 | 以tcpServer为例_#include_09

3.重定向log

因为守护进程把输入输出丢到了垃圾捅里面,所以我们就需要重定向日志的输出

#define LOG_PATH "./log.txt" //工作路径下的log.txt

// 这个类只用于重定向,不需要在里面加其他东西
class Logdup
{
public:
    Logdup()
        :_fdout(-1),_fderr(-1)
    {}
    Logdup(const char* pout=LOG_PATH,const char* perr="")
        :_fdout(-1),_fderr(-1)
    {
        //如果只传入了第一个pout,则代表将perr和pout重定向为一个路径
        umask(0);
        int logfd = open(pout, O_WRONLY | O_CREAT | O_APPEND, 0666);
        assert(logfd != -1);
        _fdout = _fderr = logfd;//赋值可以连等
        //判断是不是空串
        if(strcmp(perr,"")!=0)//不相同,代表单独设置了err的路径
        {
            logfd = open(perr, O_WRONLY | O_CREAT | O_APPEND, 0666);
            assert(logfd != -1);
            _fderr = logfd;
        }
        dup2(_fdout, 1);//重定向stdout
        dup2(_fderr, 2);//重定向stderr
    }

    ~Logdup()
    {
        if(_fdout!= -1)
        {
            fsync(_fdout);
            fsync(_fderr);
            // 先写盘再关闭
            close(_fdout);
            close(_fderr);
        }
    }
private:
    int _fdout;//重定向的日志文件描述符
    int _fderr;//重定向的错误文件描述符
};c
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.

做完这一切之后,我们运行服务器,的确创建了log.txt文件,可里面空空如也

【Linux】实现守护进程 | 以tcpServer为例_#include_10

这是因为我们的数据其实都被写道了缓冲区里面,我们需要在logging里面添加一个刷新机制,才能让数据尽快写入到硬盘中,避免日志丢失

fflush(out); // 将C缓冲区中的数据刷新到OS
    fsync(fileno(out));// 将OS中的数据写入硬盘
  • 1.
  • 2.

此时再运行服务器,就能看到日志很快被写入文件里面了。

【Linux】实现守护进程 | 以tcpServer为例_运维_11

over

搞定啦!