【Linux】实现守护进程 | 以tcpServer为例
慕雪年华

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

1.守护进程

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

1.1 进程组

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

image

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

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

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

image

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

1
2
3
4
5
6
7
8
9
10
11
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
fork();
fork();
sleep(100);
return 0;
}

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

image

1.2 进程会话

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

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

image

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

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

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

1
./test &

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

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

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

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

2.实现

2.1 自己写

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

2.1.1 setsid

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

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

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

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

image

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

1
2
if (fork() > 0)
exit(0);//父进程直接退出

2.1.2 重定向到dev/null

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

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

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

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

1
2
3
4
5
6
7
8
9
10
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);
}

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

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

2.1.3 chdir(选做)

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

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

2.1.4 信号捕捉

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

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

1
signal(SIGPIPE, SIG_IGN);

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

2.1.5 完整代码

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
#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了
// 但是这样会导致只要有打印输出的代码,进程会就异常退出
}

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

image

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

image

2.2 nohup

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

1
nohup ./test &

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

image

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

image

2.3 deamon接口

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

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

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

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

以下是man手册中的说明

1
2
3
4
5
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
6
7
8
9
10
#include <unistd.h>

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

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

image

3.重定向log

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

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
#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

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

image

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

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

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

image

over

搞定啦!