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函數填充新的可執行代碼進來。我們上面的例子就是這種。
