Python编写守护进程程序及其原理
Python编写守护进程程序思路
1. fork子进程,父进程退出
通常,我们执行服务端程序的时候都会通过终端连接到服务器,成功连接后会加载shell环境,终端和shell都是进程,shell进程是终端进程的子进程,通过ps命令可以很容易的查看到。在这个shell环境下一开始执行的程序都是shell进程的子进程,自然会受到shell进程的影响。在程序里fork子进程后,父进程退出,对了shell进程来说,这个父进程就算执行完了,而产生的子进程会被init进程接管,从而也就脱离了终端的控制。
2-4步骤的意义
守护进程必须与其运行前的环境隔离开来。这些环境包括未关闭的文件描述符、控制终端、会话和进程组、工作目录以及文件创建掩码等。
这些环境通常是守护进程从执行它的父进程(特别是shell)中继承下来的。
2、修改子进程的工作目录
子进程在创建的时候会继承父进程的工作目录,如果执行的程序是在u盘里的,就会导致u盘不能卸载。比如Nginx就有它的默认工作目录 /etc/nginx/conf.d/default.conf
3、创建进程组
使用setsid后,子进程就会成为新会话的首进程(session leader);子进程会成为新进程组的组长进程;子进程没有控制终端。
4、修改umask
由于umask会屏蔽权限,所以设定为0,这样可以避免读写文件时碰到权限问题。
5、fork孙子进程,子进程退出
经过上面几个步骤后,子进程会成为新的进程组老大,可以重新申请打开终端,为了避免这个问题,fork孙子进程出来。
6、重定向孙子进程的标准输入流、标准输出流、标准错误流到/dev/null
因为是守护进程,本身已经脱离了终端,那么标准输入流、标准输出流、标准错误流就没有什么意义了。所以都转向到/dev/null,就是都丢弃的意思。
守护进程的启动方式有其特殊之处。它可以在系统启动时从启动脚本/etc/rc.d中启动,可以由inetd守护进程启动,可以有作业规划进程crond启动,
还可以由用户终端(通常是shell)执行。
总之,除开这些特殊性以外,守护进程与普通进程基本上没有什么区别。
因此,编写守护进程实际上是把一个普通进程按照上述的守护进程的特性改造成为守护进程。如果大家对进程的认识比较深入,就对守护进程容易理解和编程了。
Linux系统进程的一些概念
这里主要是回答针对下面代码的疑问,为什么要FORK?为什么要设置SID等。
这个“1”号进程就是所有进程的父进程,因为这是CentOS7它得启动机制变化了,如果在CentOS6中那么1号进程则是INIT进程。但不管怎么作用是一样的。
我们平时所理解的守护进程就是你在命令行执行一个程序它自己就在后台运行了,你退出了终端再进去它依然在运行就像Nginx那样。首先我们要知道几个概念
进程ID(PID):就是这个进程的进程号
父进程ID(PPID):该进程的父进程ID号
进程组ID(PGID):进程所在进程组ID,每一个进程都属于一个进程组,一个进程组可以包含多个进程同时包含一个组长进程(如果进程ID和其对应的进程组ID相同则表示该进程是该组的组长)。比如一个程序是多进程的,运行该程序就会启动多个进程,那么这些进程都属于一个进程组,因为你可以针对组来发送信号,其实也就是管理。
会话ID(SID):当有新的用户登录Linux时,登录进程会为这个用户创建一个会话。用户的登录shell就是会话的首进程。会话的首进程ID会作为整个会话的ID。会话是一个或多个进程组的集合,囊括了登录用户的所有活动。
ps -axo pid,ppid,pgid,sid,tty,comm
pts/0是绑定到会话的一个终端设备,这里之所有有pts/1是因为我开了两个连接到Linux的终端,都是通过SSH进行登录的。
pts/0的进程ID是29641,它得PPID和PGID都是一样的,说明它就是进程组29641的组长,为什么呢?因为我通过SSH登录,登录后运行的第一个就是bash也就是和我进行命令交互的程序,所以你可以看到29641的父进程ID是29639它是一个sshd服务。
这里为什么有这么多1172,上面的1172是守护进程,下面的29639是sshd服务派生出来的一个子进程用于负责一个用户的连接,进程ID为1172的sshd它的父进程就是1.
会话组
通常我们执行的命令属于前端任务,也就是和会话绑定,如果会话消失了任务也就是消失了。我这里执行一个ping操作,它会一直执行
我们在另外一个终端查看
它的父进程是29641,不就是我们上面的bash么,而且它的SID也就是会话ID也是29641,因为它属于哪个会话,如果哪个会话消失了,这个ping操作也可以叫做作业,也就是消失了。我们把那个执行ping命令的终端直接关闭,然后在另外的终端上查看,不一会你就看不到那个ping任务了。所以这就是会话。
其实无论是进程组还是会话都属于作业控制。会话ID相同的进程只要会话消失,这些进程也就消失了,也就是结束了。
下面我们来说一下进程组
上面这一条命令其实运行了是两个进程。我们在另外一个终端查看
bash的进程ID是30150,所以由它派生的子进程的父进程ID都是30150,就像下面的tailf和grep.这个不用多数,因为都是在那个会话也就是终端上执行的,所以他们三个的会话ID相同。大家可以看到tailf和grep的进程组ID相同,都是30374说明他们是在一个进程组中,而组长就是tailf的进程其ID为30374。
进程组ID相同我们就可以给进程组发信号比如去结束这个组里所有的进程。这还是作业管理的内容。如下面操作:
kill -SIGTERM 30374
另外一个终端的任务自动就结束了
如何判断你自己当前是哪个终端呢?
关闭终端为什么有些进程不退出呢?
通过SID的演示我们知道,命令行里运行的进程会依赖当前会话,所以进程的运行不受会话影响那么肯定就要脱离之前的会话。另外还需要让进程脱离当前进程可以理解为当前的bash也就是完全隔断父子关系,因为毕竟我们是通过bash来运行的程序,bash又依赖终端pts/N这种,如果bash没了,进程也没了。看下图
还是这个命令这回我们放到后台运行,
可以看到它俩的SID和bash的并不相同
但是这时候如果你关闭这个终端,这个任务也就没了。你可以试一下。
完整代码
# !/usr/bin/env python
# coding: utf-8
# python模拟linux的守护进程
import sys, os, time, atexit, string
from signal import SIGTERM
__metaclass__ = type
class Daemon:
def __init__(self, pidfile="/tmp/Daemon.pid", stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
# 需要获取调试信息,改为stdin='/dev/stdin', stdout='/dev/stdout', stderr='/dev/stderr',以root身份运行。
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
self.pidfile = pidfile
self.applicationName = "Application"
self._homeDir = "/"
# 调试模式是否开启
self._verbose = False
# 用户掩码,默认为0
self._umask = 0
# 获取守护进程掩码
@property
def umask(self):
return self._umask
# 设置守护进程掩码
@umask.setter
def umask(self, umask):
self._umask = umask
# 获取当前是否是调试模式
@property
def VerboseMode(self):
return self._verbose
# 调试模式开关,默认不是调试模式
@VerboseMode.setter
def VerboseMode(self, verboseMode):
self._verbose = verboseMode
# 调试模式和非调试模式设置
def _verbosSwitch(self):
# 调试模式是输出日志到指定文件,这些文件在对象初始化时指定
if self._verbose:
pass
# self.stdin = '/dev/stdin'
# self.stdout = '/dev/stdout'
# self.stderr = '/dev/stderr'
else:
self.stdin = '/dev/null'
self.stdout = '/dev/null'
self.stderr = '/dev/null'
def setApplicationName(self, appName):
self.applicationName = appName
# 获取和设置进程住目录
@property
def HomeDir(self):
return self._homeDir
@HomeDir.setter
def HomeDir(self, homeDir):
self._homeDir = homeDir
# 这个方法的主要目的就是脱离主体,为进程创造环境
def _daemonize(self):
# 第一步
try:
# 第一次fork,生成子进程,脱离父进程,它会返回两次,PID如果等于0说明是在子进程里面,如果大于0说明当前是在父进程里
pid = os.fork()
# 如果PID大于0,说明当前在父进程里,然后sys.exit(0),则是退出父进程,此时子进程还在运行。
if pid > 0:
# 退出父进程,此时linux系统的init将会接管子进程
sys.exit(0)
except OSError, e:
sys.stderr.write('fork #1 failed: %d (%s)\n' % (e.errno, e.strerror))
sys.exit(1)
# 第二、三、四步
os.chdir("/") # 修改进程工作目录
os.setsid() # 设置新的会话,子进程会成为新会话的首进程,同时也产生一个新的进程组,该进程组ID与会话ID相同
os.umask(self._umask) # 重新设置文件创建权限,也就是工作目录的umask
# 第五步
try:
# 第二次fork,禁止进程打开终端,相当于是子进程有派生一个子进程
pid = os.fork()
if pid > 0:
# 子进程退出,孙子进程运行,此时孙子进程由init进程接管,在CentOS 7中是Systemed。
sys.exit(0)
except OSError, e:
sys.stderr.write('fork #2 failed: %d (%s)\n' % (e.errno, e.strerror))
sys.exit(1)
# 第六步
# 把之前的刷到硬盘上
sys.stdout.flush()
sys.stderr.flush()
# 重定向标准文件描述符
si = file(self.stdin, 'r')
so = file(self.stdout, 'a+')
se = file(self.stderr, 'a+', 0)
# os.dup2可以原子化的打开和复制描述符,功能是复制文件描述符fd到fd2, 如果有需要首先关闭fd2. 在unix,Windows中有效。
# File的 fileno() 方法返回一个整型的文件描述符(file descriptor FD 整型)
os.dup2(si.fileno(), sys.stdin.fileno())
os.dup2(so.fileno(), sys.stdout.fileno())
os.dup2(se.fileno(), sys.stderr.fileno())
# 注册退出函数,根据文件pid判断是否存在进程
atexit.register(self.delpid)
pid = str(os.getpid())
file(self.pidfile, 'w+').write('%s\n' % pid)
# 程序退出后移除PID文件
def delpid(self):
os.remove(self.pidfile)
def start(self, *args, **kwargs):
# 检查pid文件是否存在以探测是否存在进程
try:
pid = self._getPid()
except IOError:
pid = None
# 如果PID存在,则说明进程没有关闭。
if pid:
message = 'pidfile %s already exist. Process already running!\n'
sys.stderr.write(message % self.pidfile)
# 程序退出
sys.exit(1)
# 构造进程环境
self._daemonize()
# 执行具体任务
self._run(*args, **kwargs)
def stop(self):
# 从pid文件中获取pid
try:
pid = self._getPid()
except IOError:
pid = None
# 如果程序没有启动就直接返回不在执行
if not pid:
message = 'pidfile %s does not exist. Process not running!\n'
sys.stderr.write(message % self.pidfile)
return
# 杀进程
try:
while 1:
# 发送信号,杀死进程
os.kill(pid, SIGTERM)
time.sleep(0.1)
message = 'Process is stopped.\n'
sys.stderr.write(message)
except OSError, err:
err = str(err)
if err.find('No such process') > 0:
if os.path.exists(self.pidfile):
os.remove(self.pidfile)
else:
print str(err)
sys.exit(1)
# 获取PID
def _getPid(self):
try:
# 读取保存PID的文件
pf = file(self.pidfile, 'r')
# 转换成整数
pid = int(pf.read().strip())
# 关闭文件
pf.close()
except IOError:
pid = None
except SystemExit:
pid = None
return pid
# 重启的功能就是杀死之前的进程,然后再运行一个
def restart(self, *args, **kwargs):
self.stop()
self.start(*args, **kwargs)
# 获取守护程序运行状态
def status(self):
try:
pid = self._getPid()
except IOError:
pid = None
if not pid:
message = "No such a process running.\n"
sys.stderr.write(message)
else:
message = "The process is running, PID is %s .\n"
sys.stderr.write(message % str(pid))
def _run(self, *args, **kwargs):
"""
这里是孙子进程需要做的事情,你可以继承这个类,然后重写这里的代码,上面其他的都可以不做修改
"""
while True:
"""
print 等于调用 sys.stdout.write(), sys.stdout.flush()是立即刷新输出。正常情况下如果是输出到控制台那么会立即输出
但是重定向到一个文件就不会了,因为等于写文件,所以需要进行刷新进行立即输出。 下面使用print 还是 write都是一样的。
"""
# print '%s:hello world\n' % (time.ctime(),)
sys.stdout.write('%s:hello world\n' % (time.ctime(),))
sys.stdout.flush()
time.sleep(2)
if __name__ == '__main__':
daemon = Daemon('/tmp/watch_process.pid', stdout='/tmp/watch_stdout.log')
if len(sys.argv) == 2:
if 'start' == sys.argv[1]:
daemon.setApplicationName(sys.argv[0])
daemon.start()
elif 'stop' == sys.argv[1]:
daemon.stop()
elif 'restart' == sys.argv[1]:
daemon.restart()
elif 'status' == sys.argv[1]:
daemon.status()
else:
print 'unknown command'
sys.exit(2)
sys.exit(0)
else:
print 'usage: %s start|stop|restart|status' % sys.argv[0]
sys.exit(2)
关于fork函数
fork调用一次返回两次其实比较难理解,返回0表示当前运行在子进程中,返回大于0的正整数表示当前运行在父进程中,通过返回值我们可以判断当前运行在哪里。子进程返回0而不是父进程的PID是因为每一个子进程只能有一个父进程,它任何时候都可以获取父进程的PID,但是父进程可能有多个子进程它无法获取各个子进程的ID,所以它要想跟踪所有它的子进程就必须在fork之后返回产生的这个子进程的ID。另外进程调用fork之前所打开的文件描述符在调用fork之后都会复制给子进程,其实他俩的数据是一模一样的(注意是复制给子进程而不是共享也就是相同的东西有两份),就是进程号和内存地址不同。
fork函数有2中典型用法:
- 产生一个自己的副本(子进程),这样多个子进程就可以做相同的事情而当有新的事情要做的时候父进程就派生一个子进程去干。最典型的就是WEB服务器。
- 执行另外的代码,你可以理解为一个新程序和之前的父进程所运行的代码完全不同。因为在类Unix系统上执行一个可执行文件的方式就是fork一个子进程然后调用exec函数填充新的可执行代码进来。我们上面的例子就是这种。