概念

守护进程的概念

守护进程,有一种是业务上的叫法,通常指专门用来检测其它进程运行状态的程序,以方便在其它进程异常时自动重新启动。另外一种就是指是一种运行在后台的特殊进程,它独立于控制终端并且周期性的执行某种任务或等待处理某些发生的事件,其目的跟业务概念上的守护进程完全不一样。

Linux系统的程序大体是可以分为两种,一种是在终端里运行的,需要时敲入命令运行起来,关掉终端也就终止了进程;另外一种是,在后台运行,使用特殊的命令来启动,也可以根据配置来周期性的启动。

在Linux上,守护进程主要指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的。如web服务器进程http等。守护进程在后台运行,类似于Windows中的系统服务。

进程组

每个进程都属于一个进程组,进程组中可以包含一个或多个进程。进程组中有一个组长进程(第一个进程),组长的进程 ID 是进程组 ID(PGID)。

  • 当父进程,创建子进程的时候,默认子进程与父进程属于同一进程组
  • 组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关
  • 进程组生存期:进程组创建到最后一个进程离开(终止或转移到另一个进程组)
1
2
3
4
5
6
7
8
9
10
11
12
// 获取当前进程的进程组ID
pid_t getpgrp(void); // 总是返回调用者的进程组ID

// 获取指定进程的进程组ID,如果pid = 0,那么该函数作用和getpgrp一样
pid_t getpgid(pid_t pid); // 成功:0;失败:-1,设置errno

// setpgid函数:改变进程默认所属的进程组。通常可用来加入一个现有的进程组或创建一个新进程组。
/*
** 将参数1对应的进程,加入参数2对应的进程组中。
** 成功:0;失败:-1,设置errno
**/
int setpgid(pid_t pid, pid_t pgid);

注意如下两点:

  1. 如改变子进程为新的组,应在fork后,在exec前。
  2. 权级问题。非root进程只能改变自己创建的子进程,或有权限操作的进程

会话

多个进程组构成一个会话,建立会话的进程是会话的领导进程,即会话首进程,该进程 ID 为会话的 SID。

会话中的每个进程组称为一个作业。会话可以有一个进程组作为会话的前台作业,其它进程组作为后台作业。

一个会话可以有一个控制终端,当控制终端有输入和输出时都会传递给前台进程组,比如Ctrl + Z。会话的意义在于能将多个作业通过一个终端控制,一个作为前台操作,其它后台运行。

创建会话注意事项:

  • 调用进程不能是进程组组长^1 ,该进程变成新会话首进程^2 (session header)
  • 该进程成为一个新进程组的组长进程 。
  • 需有root权限(ubuntu不需要)
  • 新会话丢弃原有的控制终端,该会话没有控制终端^3
  • 若调用进程是组长进程,则出错返回
  • 建立新会话时,先调用fork,父进程终止,子进程调用setsid
1
2
3
4
5
6
// getsid函数:获取进程所属的会话ID,pid为0表示查看当前进程session ID
//成功:返回调用进程的会话ID;失败:-1,设置errno
pid_t getsid(pid_t pid);

// setsid函数:创建一个会话,并以自己的ID设置进程组ID,同时也是新会话的ID
pid_t setsid(void); //成功:返回调用进程的会话ID;失败:-1,设置errno

进程组是一组相关进程的集合,会话是一组相关进程组的集合

linux_daemon_01.png

打开bash时,就是创建了一个会话。在bash中运行的程序,可以是前台进程,也可以是后台进程(加&),bash关联了一个终端,可以接受输入输出并发送给前台进程。

创建守护进程的步骤

普通进程都有关联一个控制终端、会话、进程组。而守护进程都不一样,因此要采用特殊的处理步骤来达到要求。

1. 让程序在后台执

方法是调用fork()产生一个子进程,然后使父进程exit()退出。这么做的原因有以下两点:

  • 如果守护进程是通过 Shell 启动,父进程退出,Shell 就会认为任务执行完毕,之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离,在后台工作。这时子进程由 init 收养。
  • 子进程继承父进程的进程组 ID,保证了子进程不是进程组组长,因为下面将调用setsid(),它要求必须不是进程组长。

2. 调用setsid()创建一个新对话期

在调用了 fork() 函数后,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变。这还不是真正意义上的独立开来,而 setsid()函数,使子进程完全独立出来,脱离其它进程的控制。

3. 再次 fork() 一个子进程,父进程exit()退出

经过以上步骤,进程已经成为一个无终端的会话组长(会话首进程),但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现 。只要再一次通过fork()创建新的子进程,使调用fork的进程退出。这样就禁止了关联控制终端的能力。

4. 关闭不再需要的文件描述符

用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。其实在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。

因此从终端输入的字符不可能到达守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。(关闭失去价值的输入、输出、报错等对应的文件描述符)

1
2
for (i=0; i < MAXFILE; i++)
close(i); // 全部关闭

首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符

5. 将当前目录更改为根目录

这一步也是必要的步骤。使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/dev”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让”/“作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp。改变工作目录的常见函数是chdir。(避免原父进程当前目录带来的一些麻烦)。

6. 在子进程中调用umask()重设文件权限掩码为0

文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限(就是说可读可执行权限均变为7)。
由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此把文件权限掩码重设为0即清除掩码(权限为777),这样可以大大增强该守护进程的灵活性。通常的使用方法为umask(0)。(相当于把权限开放)

7. 守护进程退出处理

当用户需要外部停止守护进程运行时,往往会使用 kill 命令停止该守护进程。所以,守护进程中需要编码来实现 kill 发出的signal信号处理,达到进程的正常退出。

另外对于某些服务器进程,在请求到来时往往生成子进程处理请求。如果父进程不等待子进程结束,子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。(这个处理不一定非得有)

典型代码

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <sys/stat.h>

int main()
{
/* 1、创建子进程,父进程退出 */
if (fork() != 0)
{
exit(0);
}

/* 2、setsid()创建会话 */
setsid();

/* 3、再次fork,父进程退出,即使新进程不再是会话首进程 */
if (fork() != 0)
{
exit(0);
}

/* 4、让根目录成为子进程的工作目录 */
chdir("/");

/* 5、清空掩码,大大增强该守护进程的灵活性 */
umask(0);

/* 6、清空所有文件描述符,让其不占用系统资源 */
int maxfd = getdtablesize();
int i = 0;
for (; i < maxfd; ++i)
{
close(i);
}
// 忽略子进程退出信号,避免未处理导致子进程处于僵尸进程
signal(SIGCHLD,SIG_IGN);

// !!!后面这里就是主业务代码!!!
/* 每隔5s将当前时间写入日志文件 */
while (1)
{
FILE* fp = fopen("/home/zy/Learn/a.log", "a+");
if (fp == NULL)
{
break;
}

time_t tv;
time(&tv);
fprintf(fp, "Time is %s", asctime(localtime(&tv)));
fclose(fp);
sleep(5);
}

exit(0);
}

也可以直接通过库daemon函数来创建来守护进程。

伪业务守护进程脚本

1
2
3
4
5
6
7
8
9
#!/bin/sh
while true;
do
count=$(ps -ef | grep -c server) #查找当前的进程中,计算server程序的数量
if [ $count -lt 3 ]; then #判断服务器进程的数量是否小于3(根据实际填上你的服务器进程数量)
server start #这里填入需要重启的服务器进程
fi
sleep 2 #睡眠2s,周期性地检测服务器程序是不是崩溃了
done

hohub 和 &

sighup(挂断)信号在控制终端或者控制进程死亡时向关联会话中的进程发出,默认进程对SIGHUP信号的处理时终止程序,所以我们在shell下建立的程序,在登录退出连接断开之后,会一并退出。

nohup,故名思议就是忽略SIGHUP信号,一般搭配& 一起使用,&表示将此程序提交为后台作业或者说后台进程组。

nohup与&启动的程序, 在终端还未关闭时,完全不像传统的守护进程,因为其不是会话首进程且持有终端,只是其忽略了SIGHUP信号,

从nohup源码就可以看到,其实nohup只做了3件事情

  1. dofile函数将输出重定向到nohup.out文件
  2. signal函数设置SIGHUP信号处理函数为SIG_IGN宏(指向sigignore函数),以此忽略SIG_HUP信号
  3. execvp函数用新的程序替换当前进程的代码段、数据段、堆段和栈段。

execvp 函数执行后,新程序(并没有fork进程)会继承一些调用进程属性,比如:进程id、会话id,控制终端等。

在终端关闭后,nohup起到类似守护进程的效果,但是跟传统的守护进程还是有区别的

  1. nohup创建的进程工作目录是你执行命令时所在的目录
  2. 0 1 2 标准输入 标准输出 标准错误 指向nohup.out文件
  3. nohup创建的进程组中,除首长进程的父进程id变为1之外,其余进程依然保留原来的会话id、进程组id、父进程id,都保持不变

使用 nohup 和 &,可以使得关闭终端后依然可以使得程序继续运行

参考资料

  1. https://blog.csdn.net/linkedin_35878439/article/details/81288889
  2. http://www.ruanyifeng.com/blog/2016/02/linux-daemon.html
  3. http://www.ruanyifeng.com/blog/2016/03/systemd-tutorial-commands.html
  4. https://blog.csdn.net/ZYZMZM_/article/details/89436310