本文首发于 慕雪的寒舍
本文将以tcp服务器代码为基本,讲述如何将进程守护进程化,后台运行
1.守护进程
所谓守护进程,就是和其他进程没有关系的进程;其独立运行于系统后台,除非自己退出或收到信号终止,否则会一直运行下去
1.1 进程组
在我们使用的bash中,同一时刻只会有一个前台进程组
如图,当一个前台进程开始运行之后,我们没有办法在当前终端开启第二个前台进程。
在运行的命令后面加&,临时让当前进程在后台运行。注意,此时tcp虽然在后台运行了, 但对于它而言,stdin/stdout/stderr
的文件描述符依旧指向的是当前bash的输入输出,所以它的日志依旧会打印到当前终端上。
用ps
命令查看当前进程的信息,其中ppid
是当前进程的父进程,也就是当前bash,pid
是进程编号,pgid
是进程的组编号,可以看到这个组编号和grep命令的组编号是不同的。
我们用这个c语言的代码调用两次fork,相当于创建了3个子进程。
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
fork();
fork();
sleep(100);
return 0;
}
此时再来查看进程信息,能看到这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)
这表明图中的5个进程同属于一个进程会话,这个会话就是我们当前打开的bash,并用sid来表示进程会话;
这也是为什么我们登录linux的时候一定会有一个终端,linux系统就是创建会话并加载bash,来给用户提供服务的。
既然存在会话,那就肯定会有会话的资源上限。一旦满了,就会开始杀掉一些进程。
./test &
即便我们用&让进程在后台运行,其也有可能收到会话的创建/关闭
的影响而被操作系统干掉🧐比如我们将当前正在运行进程的bash关掉,其前台进程会被直接终止,后台进程也会受到影响(有可能终止有可能不终止,取决于系统)
这和我们对tcp服务器的需求不一致:我们需要的是让tcp服务器的进程能一直稳定的在后台运行,让操作系统别去管它;除非系统内存满了,负载重到实在没有办法的时候,操作系统才能过来把他刀了。
为了不让守护进程受到进程会话的影响,我们就必须让其能够独立出来,自成一个进程组和一个新会话
👆这种独立的进程,就可以被称为守护进程/精灵进程
2.实现
2.1 自己写
别以为写这个很难哦,实际特别简单!
2.1.1 setsid
这里需要用到的setsid接口,其作用如名字一般,是设置当前进程的进程会话组
#include <unistd.h>
pid_t setsid(void);
但是调用这个函数有一个要求:调用的进程不能是进程组的组长!
比如下图中,第一个test就是进程组的组长,它不能调用这个函数。会报错
那要怎么让自己不成为进程组的组长呢?很简单,创建一个子进程就ok了!
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
这个文件垃圾桶中,也就不需要担心上述的问题。
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
忽略,我们的服务器会直接终止!
signal(SIGPIPE, SIG_IGN);
除了这个信号,我们还可以对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了
// 但是这样会导致只要有打印输出的代码,进程会就异常退出
}
没错,就这一点点代码,就能让我们的tcp服务器变成守护进程!
此时我们的客户端依旧能正常连接服务端,获取结果
2.2 nohup
no hang up(不挂起),用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行。用nohup命令执行一个进程,就能让这个进程成为不受终端退出影响的进程
nohup ./test &
此时,nohup会在当前目录下创建一个nohup.out
文件,用于记录test进程的输出信息(如果通过了>
或>>
执行了重定向,则不会创建)
通过ps可已看到,当前test进程的进程会话还是和bash相同,但我们关闭当前bash,这个test进程依旧能正常运行,只不过父进程会变成操作系统1
,我们的目的也算是达到了
2.3 deamon接口
linux系统中有一个接口daemon
,可以帮我们实现守护进程
#include <unistd.h>
int daemon(int nochdir, int noclose);
了解过守护进程的写法了之后,这两个参数的作用就很明显了
- 第一个参数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.
我们直接用一个简单代码来演示
#include <unistd.h>
int main()
{
//不需要修改工作目录,第一个参数设为1
//因为没有进行打印,重定向设置成1,不进行重定向
int ret = daemon(1,1);
sleep(100);
return 0;
}
运行之后可以看到,这个进程的父id是操作系统,其自成一个进程组和进程会话;和我们自己写的函数作用相同
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
做完这一切之后,我们运行服务器,的确创建了log.txt文件,可里面空空如也
这是因为我们的数据其实都被写道了缓冲区里面,我们需要在logging里面添加一个刷新机制,才能让数据尽快写入到硬盘中,避免日志丢失
fflush(out); // 将C缓冲区中的数据刷新到OS
fsync(fileno(out));// 将OS中的数据写入硬盘
此时再运行服务器,就能看到日志很快被写入文件里面了。
over
搞定啦!