以前把守护进程与后台任务搞混了,后面看了文章才知道这两者的区别,写此文表达自己对守护进程的理解.
1:什么是守护进程?
所谓守护进程是一种是 Linux 的一种长期运行的后台服务进程,httpd、named、sshd 等服务都是以守护进程 Daemon 方式运行的,通常服务名称以字母d结尾,也就是 Daemon 第一个字母.
- 无需控制终端(不需要与用户交互)
- 在后台运行
- 生命周期比较长,一般是随系统启动和关闭
2:守护进程必要性
通常我们执行任务时是在前台执行,占领了当前终端,此时无法进行操作,就算我们添加了 &符号,将程序放到后台,但也就因为终端断网等问题,导致程序中断。
所要知道的是:在目前的linux上,有了systemd这个服务,这个服务管理工具可以方便我们写在后台运行的程序,甚至可以代替这种守护进程。通过把写服务的配置文件,让systemd监控我们的程序,可以随系统启动而运行,可以设定启动条件,及其的方便。
3:进程组
$ ps -o pid,pgid,ppid,comm | cat
PID PGID PPID COMMAND
10179 10179 10177 bash
10263 10263 10179 ps
10264 10263 10179 cat
- bash:进程和进程组ID都是 10179,父进程其实是 sshd(10177)
- ps:进程和进程组ID都是 10263,父进程是 bash(10179),因为是在 Shell 上执行的命令
- cat:进程组 ID 与 ps 的进程组 ID 相同,父进程同样是 bash(10179)
4:会话组
多个进程构成一个进程组,而会话组是由多个进程组构建而。而进程组又被称为job,会话有前台作业,也会有后台作业;一个会话可以有一个控制终端,当控制终端有输入和输出时都会传递给前台进程组,比如Ctrl + Z
。会话的意义在于能将多个作业通过一个终端控制,一个前台操作,其它后台运行。
那么如何编写守护进程呢?
其实编写守护进程很简单,只需要遵循一下几点即可
1:创建子进程,父进程退出
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
0 49 49 49 pts/2 70 Ss 0 0:00 /bin/bash
49 70 70 49 pts/2 70 R+ 0 0:00 \_ ps axjf
0 17 17 17 pts/1 68 Ss 0 0:00 /bin/bash
17 68 68 17 pts/1 68 S+ 0 0:00 \_ python hello.py
68 69 68 17 pts/1 68 S+ 0 0:00 \_ python hello.py
0 1 1 1 pts/0 1 Ss+ 0 0:00 /bin/bash
进程 fork 后,父进程退出。这么做的原因有 2 点:
- 如果守护进程是通过 Shell 启动,父进程退出,Shell 就会认为任务执行完毕,这时子进程由 init 收养
- 子进程继承父进程的进程组 ID,保证了子进程不是进程组组长,因为后边调用
setsid()
要求必须不是进程组长 - PGID就是进程所属的Group的Leader的PID,如果PGID=PID,那么该进程是Group Leader
2、子进程创建新会话
调用setsid()
创建一个新的会话,并成为新会话组长。这个步骤主要是要与继承父进程的会话、进程组、终端脱离关系。
那么问题来了,为什么进程组组长无法调用setsid()呢?
对于进程组长来说,进程组 ID 已经和 PID 相同了,如果它被允许调用setsid()
的话,它的进程组 ID 会保持不变,会出现:
1:进程组长属于新的会话;
2:老的进程组成员属于旧的会话。
这样情况变成了一个进程组的成员属于不同的会话,Linux想要禁止这种情况的发生。
3、禁止子进程重新打开终端
此刻子进程是会话组长,为了防止子进程重新打开终端,再次 fork 后退出父进程,也就是此子进程。这时子进程 2 不再是会话组长,无法再打开终端。其实这一步骤不是必须的,不过加上这一步骤会显得更加严谨。
4、设置当前目录为根目录
如果守护进程的当前工作目录是/usr/home
目录,那么管理员在卸载/usr
分区时会报错的。为了避免这个问题,可以调用chdir()
函数将工作目录设置为根目录/
。
5、设置文件权限掩码
文件权限掩码是指屏蔽掉文件权限中的对应位。由于使用 fork()
函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此,把文件权限掩码设置为 0,可以大大增强该守护进程的灵活性。通常使用方法是umask(0)
。
6、关闭文件描述符
子进程会继承已经打开的文件,它们占用系统资源,且可能导致所在文件系统无法卸载。此时守护进程与终端脱离,常说的输入、输出、错误描述符也应该关闭,毕竟这个时候也不会使用终端了。
守护进程的出错处理
由于守护进程脱离了终端,不能将错误信息输出到控制终端,即使 gdb 也无法正常调试。常用的方法是使用 syslog 服务,将错误信息输入到/var/log/messages
中。
syslog 是 Linux 中的系统日志管理服务,通过守护进程 syslogd 来维护。该守护进程在启动时会读一个配置文件/etc/syslog.conf
。该文件决定了不同种类的消息会发送向何处。
代码展示
import os
import sys
def daemonize(pid_file=None):
pid = os.fork()
if pid:
sys.exit(0)
os.setsid()
_pid = os.fork()
if _pid:
sys.exit(0)
os.umask(0)
os.chdir('/')
sys.stdout.flush()
sys.stderr.flush()
with open('/dev/null') as read_null, open('/dev/null','w') as write_null:
os.dup2(read_null.fileno(), sys.stdin.fileno())
os.dup2(write_null.fileno(), sys.stdout.fileno())
os.dup2(write_null.fileno(), sys.stderr.fileno())
if pid_file:
with open(pid_file,'w+') as f:
f.write(str(os.getpid()))
if __name__ == "__main__":
daemonize('test.txt')
关于os.dup2这个函数
os.dup2() 方法用于将一个文件描述符 fd 复制到另一个 fd2。Unix, Windows 上可用。
>>> import os
>>> f = open("hello.txt","a")
>>> os.dup2(f.fileno(),1)
>>> f.close()
>>> print("hello world")
>>> print("changed")
cat hello.txt
1
hello world
changed
附加话题
为什么服务器端常常fork两次呢?
因为这是为了避免产生僵尸进程。
当我们只fork()一次后,存在父进程和子进程。这时有两种方法来避免产生僵尸进程:
- 父进程调用waitpid()等函数来接收子进程退出状态。
- 父进程先结束,子进程则自动托管到Init进程(pid = 1)。
目前先考虑子进程先于父进程结束的情况:
- 若父进程未处理子进程退出状态,在父进程退出前,子进程一直处于僵尸进程状态。
- 若父进程调用waitpid()(这里使用阻塞调用确保子进程先于父进程结束)来等待子进程结束,将会使父进程在调用waitpid()后进入睡眠状态,只有子进程结束父进程的waitpid()才会返回。 如果存在子进程结束,但父进程还未执行到waitpid()的情况,那么这段时期子进程也将处于僵尸进程状态。
由此,可以看出父进程与子进程有父子关系,除非保证父进程先于子进程结束或者保证父进程在子进程结束前执行waitpid(),子进程均有机会成为僵尸进程。那么如何使父进程更方便地创建不会成为僵尸进程的子进程呢?这就要用两次fork()了。
父进程一次fork()后产生一个子进程随后立即执行waitpid(子进程pid, NULL, 0)来等待子进程结束,然后子进程fork()后产生孙子进程随后立即exit(0)。这样子进程顺利终止(父进程仅仅给子进程收尸,并不需要子进程的返回值),然后父进程继续执行。这时的孙子进程由于失去了它的父进程(即是父进程的子进程),将被转交给Init进程托管。于是父进程与孙子进程无继承关系了,它们的父进程均为Init,Init进程在其子进程结束时会自动收尸,这样也就不会产生僵尸进程了。