那些永不消逝的进程 (转)
?
本文緣起于最近幾天筆者實現的一段代碼,目的是利用 python 在 Linux 中實現一個常駐內存的后臺守護進程負責向其他進程提供服務,起初筆者自信的認為 multiprocessing.Process 類的 daemon 屬性應該符合要求,于是乎不假思索的揮毫寫下測試代碼如下:
清單 1 永不消逝的進程 v0
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | from multiprocessing import Process from time import sleep def child_process(): ????# 子進程函數 ????# 建立一個進程,每隔一秒鐘輸出"child's still alive." ????while (1): ????????print("child's still alive.") ????????sleep(1) def main(): ????# 主進程函數 ????p = Process(target=child_process) ????p.daemon = True???????????????????????????? # 設置 daemon 屬性為 True ????p.start() ????sleep(10)?????????????????????????????????? # 休眠 10 秒后結束 ????print("Main process ends.") ????print("Will child process live forever?")?? # 我們期待子進程繼續活著,但事實上…… if __name__ == "__main__": ????main() |
這段程序的目的很簡單:主進程(main())利用 Process 對象 fork 出一個子進程(child_process()),設上 daemon 屬性,然后結束自己,期待著子進程可以就這么活下去,安靜地每隔 1 秒輸出一行"child's still alive"。
當然,如果運行結果如前所述的話,那筆者估計也不會作此拙作了。
實際的運行結果是:伴隨著"will child process live forever?",子進程的輸出也戛然而止,這意味著子進程最終也還是隨著父進程的消亡而消逝了。
這個問題著實困擾了筆者一陣,直到在Process 文檔中的 daemon 屬性下看到了這句描述:
When a process exits, it attempts to terminate all of its daemonic child processes.
真相大白,原來這里的 daemon 并非真正意義上的守護進程,而是"守護父進程的進程",當父進程結束的時候,"守護著"它的進程也會被自動銷毀。
于是,在感嘆寫程序切不可望文生義的同時,筆者也不得不開始琢磨如何自己動手豐衣足食,另辟蹊徑來實現守護進程了。
所幸,在 Linux 下,想要實現出一個不死的進程,辦法還是很多的。
從 nohup 說開去
開始這一部分正文之前,先說一小段題外話:若干年前,筆者剛剛才加工作之時,曾經參加過一款基于嵌入式 Linux 的模塊的研發。如今回想起來,當時最為印象深刻的,就是這個模塊的軟件系統極其龐雜,限于開發服務器的性能,一次完整的編譯,有時候竟需要半個小時甚至更久的時間。倘若有幸在臨近下班時分下載一份全新的代碼進行編譯,那欲哭無淚的畫面實在是美的令人不忍直視。
那時筆者尚屬菜鳥,于是便數次毫無懸念地在 terminal 前面等待滿長的編譯結束直到華燈初上。直到有一天一位過路神仙給筆者支了個招:
nohup make &
關機!下班!
然后,等到筆者次日懵懵懂懂的回到辦公室打開電腦,編譯完畢的二進制文件早已安安靜靜的躺在服務器的硬盤里了。
——知識就是力量!
好,題外話告一段落,現在咱們來看一看這 nohup 的力量到底來自哪里:
解密 nohup
一般而言,man 命令是了解絕大部分 Linux 命令的絕佳入口,但是打開 nohup 的 man page,卻只能發現寥寥數語:
nohup - run a command immune to hangups, with output to a non-tty
這樣簡略的信息只怕是不夠我們理解其原理的,幸而 nohup 是 GNU Coreutil 的一部分,本著死代碼不說謊的原則,筆者又尋到了源碼,卻發現其實現出人意料的簡單(其實現摘要如清單 2 所示,中文注釋為筆者所加):
清單 2 nohup.c
| 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 | int main (int argc, char **argv) { ??/* …… */ ??if (ignoring_input) ????{ ??????/* 重定向標準輸入到/dev/null */ ????} ??if (redirecting_stdout || (redirecting_stderr && stdout_is_closed)) ????{ ??????/* 重定向標準輸出到文件 */ ????} ??if (redirecting_stderr) ????{ ??????/* 重定向標準錯誤到文件 */ ????} ??/* 忽略 SIGHUP 信號 */ ??signal (SIGHUP, SIG_IGN); ??/* 執行 cmd */ ??char **cmd = argv + optind; ??execvp (*cmd, cmd); ??/* …… */? ??return exit_status; } |
拋去一堆重定向帶來的視覺雜訊,我們不難發現,在創建一個新的進程執行以參數形式傳入的 cmd 之前(execvp),nohup 忽略了 SIGHUP 信號,這意味著,作為 nohup 子進程被執行的命令,如果其自身不做任何特殊處理(例如重新為 SIGHUP 信號綁定一個 handler),同樣會繼承其父進程對所有信號的處理方式,即對 SIGHUP 信號不聞不問。
結合從 man page 中得到的信息,我們很容易將"immune to hangups"和"signal (SIGHUP, SIG_IGN)"等同起來,但是,為什么忽略了 SIGHUP 信號的子進程就不會隨著父進程的結束而消逝?在什么樣的場景下,一個進程會收到 SIGHUP 信號呢?
要回答這個問題,我們首先要了解 Linux 系統中描述進程關系(Process Relationships)的兩個非常重要的術語:進程組(Process Group)和會話(Session)。
進程組和會話
在開始枯燥的術語介紹之前,先讓我們來看一看在一個真實的 Linux 環境下的進程組和會話到底長什么樣:
清單 3 利用 ps -j 顯示進程組和會話信息
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | #首先遠程 SSH 登陸一臺 Linux 服務器 $ ssh zhang@9.115.241.18 #然后打開一個后臺進程直接進入休眠 $ sleep 1000 & [1] 23661?????????????? #這里的 23661 號進程就是我們的研究對象 #接下來我們利用 ps j 命令來查看一下當前 login shell 進程 ($$) 和 23661 進程的作業(job)相關信息 $ ps j 23661 $$ PPID??? PID PGID??? SID TTY TPGID?? STAT??? UID TIME??? COMMAND 4721??? 21682?? 21682?? 21682?? pts/20????? 23856?? Ss????? 1000??? 0:00??? -bash 21682?? 23661?? 23661?? 21682?? pts/20????? 23856?? S?????? 1000??? 0:00??? sleep 1000 #上表的返回值中,PID 指進程 id;PPID 指父進程 PID;PGID 指進程組 id #SID 指會話 id;TTY 指會話的控制終端設備;COMMAND 指進程所執行的命令 #TPGID 指前臺進程組的 PGID。 #由于當前掌握著控制終端的是 ps 進程,故上述兩個進程的 TPGID 都為 23856。 |
由清單 3 最后的 ps 結果可以發現若干貌似巧合的結果,例如進程 23661 的 PGID 恰好等于 PID;又比如進程 23661(sleep 1000)和 21682(login shell 進程)共享同一個 SID(亦即 login shell 的 PID)。在接下來的內容里筆者將通過對進程組和會話的解讀,向讀者展示這些巧合的背后到底隱藏了怎么樣的設計。
進程組和會話都是 Unix 早期被引入的概念,其中進程組的設計在早期 AT&T Unix 發行版中就已初見端倪;而會話則要略晚一些,其設計雛形直到 SVR4 才被引入。
本著先來后到的原則,筆者先來介紹進程組:
- 顧名思義,進程組就是一系列相互關聯的進程集合,系統中的每一個進程也必須從屬于某一個進程組;
- 每個進程組中都會有一個唯一的 ID(process group id),簡稱 PGID;PGID 一般等同于進程組的創建進程的 Process ID,而這個進進程一般也會被稱為進程組先導(process group leader),同一進程組中除了進程組先導外的其他進程都是其子孫;
- 進程組的存在,方便了系統對多個相關進程執行某些統一的操作,例如,我們可以一次性發送一個信號量給同一進程組中的所有進程。
在早期 Unix 的設計中,進程組主要是用于終端訪問控制(control terminal access)。以 SVR3 為例,一個比較典型的應用場景是:每當有一個終端通過某一 TTY 來訪問服務器,一個包含了 login shell 進程的進程組就會被建立起來,因此進程組先導一般是為該終端而建的 shell 進程。當時還沒有作業控制的概念,于是所有在該 shell 中被建立的新進程都會自動的隸屬于同一進程組之下。同時該 tty 也會被設置為該進程組下所有進程共有的控制終端 (Controlling Terminal) ,所有的進程可以同時對控制終端進行讀寫。下圖大致反映了當有終端用戶接入時早期 Unix 環境下的進程布局:
圖 1. 早期 Unix(SVR3)下的進程組設計
誠然,以事后諸葛亮的眼光來看,這樣的設計是存在不少弊端的,比如進程組對控制終端缺乏有效的管理手段;再比如所有進程無差別共享控制終端的設計會帶來災難性的混亂。
于是在 SVR4 之后,作業控制(job control)的概念被提了出來,會話的設計也隨即被引入了進來:
- 會話是一個若干進程組的集合,同樣的,系統中每一個進程組也都必須從屬于某一個會話;
- 一個會話只擁有最多一個控制終端(也可以沒有),該終端為會話中所有進程組中的進程所共用。當然和早期設計中所有進程都可以無差別讀寫控制終端不同,這一次,進程被以進程組為單位劃分為兩類:前臺進程組(foreground process group)和后臺進程組(background process group)。一個會話中前臺進程組只會有一個,只有其中的進程才可以和控制終端進行交互;除了前臺進程組外的進程組,都是后臺進程組;
- 和進程組先導類似,會話中也有會話先導(session leader)的概念,用來表示建立起到控制終端連接的進程。在擁有控制終端的會話中,session leader 也被稱為控制進程(controlling process),一般來說控制進程也就是登入系統的 shell 進程(login shell);
- 為了支持作業控制,很多 shell 工具也做了相應的修改:在執行一個新的命令時,新生成的進程都會被置于一個和 Shell 進程不一樣的全新的進程組之下;
一言以蔽之,新的設計將控制終端(tty 或 pty)的訪問和控制完全置于了會話的管理之下,最大限度的避免了舊設計所帶來的弊端。下圖反映了在引入了會話的設計之后,有終端用戶訪問系統時進程的大致布局。
圖 2. 進程組和會話
現在我們再來回顧一下清單 3 中的那些"巧合":
大致搞清了進程組和會話之后,現在我們再回到最初的那個問題:信號 SIGHUP 在這一設計體系下到底扮演了什么角色?
SIGHUP,如其字面所述,這是一個用來描述 "掛斷" 狀態的信號,也就是說,當終端連接被關閉或無法維系之時,就需要這個信號出場了。具體來講,每當:
- 終端連接中斷時,SIGHUP 會被發送到控制進程,后者會在將這個信號轉發給會話中所有的進程組之后自行了斷;
- 控制進程被關閉時,SIGHUP 會被直接發送給會話中所有的進程組;
順便說一句,一般進程對于 SIGHUP 信號的默認處理也同樣是終結自己。
這樣一來,筆者當年的困惑就被解答了:用于編譯的 make 程序沒有對 SIGHUP 信號做任何特殊處理,所以當終端連接中斷時(遠程終端應用程序被關閉),慢悠悠的編譯進程也就這么被終止了。
永不消逝的進程 v1:
下面讓我們通過一個實際例子來看一看一個被 nohup 處理過的進程在其所屬的會話的控制進程收到 SIGHUP 信號時會發生些什么:
清單 4 實例:一個 nohup 處理過的進程
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #首先遠程 SSH 登陸一臺 Linux 服務器 $ ssh zhang@9.115.241.18 #然后打開一個后臺進程 (27871) 直接進入休眠 $ nohup sleep 1000 & [1] 10590 #利用 ps j 命令來查看一下 27871 進程的作業(job)相關信息 $ ps j 10590 PPID??? PID PGID??? SID TTY TPGID?? STAT??? UID TIME??? COMMAND 10417?? 10590?? 10590?? 10417?? pts/20????? 10655?? S?????? 1000??? 0:00??? sleep 1000 #一切正常,現在斷開連接,關閉會話 $ exit #最后再在遠程運行 ps -j 命令來檢查 10590 進程當前的狀態 $ ssh zhang@9.115.241.18 ‘ps -j’ PPID??? PID PGID??? SID TTY TPGID?? STAT??? UID TIME??? COMMAND 1?? 10590?? 10590?? 10417?? ??????? -1? S?? 1000??? 0:00??? sleep 1000 |
比較一下 exit 前后的 ps 輸出,可以發現:
- 由于原有父進程 10417 已死,10590 變成了孤兒進程(orphan);
- 由于 pts/20 隨著會話 10417 被關閉了,TTY 和 TPGID 被置為了?和-1;
- 而原有的 PGID 和 SID 保持不變,只不過 process group leader 和 session leader 分別變成了 10590;
因此,我們得出了結論:一個忽略了 SIGHUP 信號的進程,在它所屬的會話的控制進程被終結之后依舊可以繼續運行;但此時由于原有控制終端已經不再存在了,它便不再有終端輸入或輸出的能力;此外,原有的會話依舊存在,只不過會話先導(session leader,由于此時的會話中已沒有任何終端,因此不能稱之為控制進程了)變為該進程。
總而言之,看起來 SIGHUP 基本可以滿足筆者的需求了,下文的清單 5 是筆者對清單 1 中例程略作修改之后的結果,這一次,即使 shell 進程被關閉,child process 仍然可以繼續在后臺歡快的運行,稱得上擁有"不死之身"了。有興趣的讀者可以在自己的環境里嘗試一下。
清單 5 永不消逝的進程 v1
| 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 | import signal import time from multiprocessing import Process from os import getpid def child_process(): ????# child process ????# 忽略 child process 的 SIGHUP 信號 ????signal.signal(signal.SIGHUP, signal.SIG_IGN) ????print("child process's pid: %d" % getpid()) ????while (1): ????????print("child's still alive.") ????????time.sleep(1) def main(): ????p = Process(target=child_process) ????""" ????這里就不能再設置 daemon 屬性為 True 了, ????因為如果 daemon 屬性為 True,則 Process 進程結束時會自動 terminate 所有的子進程 ????這樣就沒 SIGHUP 什么事了 ????""" ????p.start() ????# parent process ????print("Parent process ends here.") ????print("Will child process live forever?") if __name__ == '__main__': ????main() |
看著 ps 輸出里的 client process 在那里悶聲發大財,是不是有點小激動?好,現在到了潑冷水的時候了,下面咱們來探討一下用屏蔽 SIGHUP 實現出的"不死"進程都有些什么痛腳。
nohup 的局限性
咱們先來說結論,用屏蔽 SIGHUP 的方式來實現守護進程,平時個人用用偷著樂還行;真要做成通用的解決方案,那還是有一定差距的。
最容易想到的問題來自于易用性:顯而易見,控制進程被殺之后帶來的最直接的軟肋就是控制終端就此失效,這給想要獲取程序的運行狀態的用戶帶來了一個難題。以前文的'nohup make &'為例,nohup 會很體貼的默認將應用程序的 stdout 或 stderr 轉到文件 nohup.out 之中,但如若這個文件被刪,那就欲哭無淚了。因此為守護進程提供一個穩定的執行結果輸出方案是非常必要的;
獲取了輸出,那下一步要考慮的就是如何對守護進程輸入了:一般來說,配置文件是這類問題的入門機配置。但是,如何告訴一個正在運行的進程去重新載入用戶剛剛修改過的配置呢?如果你不打算額外實現點什么,那么最簡便的方法就是利用操作系統已經實現好了的機制:信號。
那么問題又來了,使用什么信號量告訴程序重載配置文件比較好呢?很遺憾,解決這個問題的常規方式還是 SIGHUP,理由也比較充分:POSIX 中信號量的確是定義了不少,但卻各司其職;且守護進程本就沒有控制終端了,那不用 SIGHUP 用誰?
于是我們又開始要面對一個小小的悖論了:屏蔽 SIGHUP 真的好嗎?
當然,上面說的兩點之外,我們還需要面對一籮筐的問題:比如守護進程的工作目錄無法被 umount;再比如絕對不可以允許守護進程再偷偷的擁有一個控制終端;再比如如何清理那些遺留在系統邊邊角角的守護進程……
成功?我們才剛上路呢。
守護著 Service 的進程
在上一章中,筆者詳細向讀者們介紹了進程組和會話的前世今生、工作原理、以及 nohup 是如何基于這些工作原理來創建出守護進程的;誠然,nohup 的方法并不完美,絕非鐵板一塊,所以最后,筆者又狠狠的"黑"了一把 nohup。當然這并不代表筆者認為 nohup 不好,恰恰相反,在很多場景,屏蔽 SIGHUP 是非常便捷的實現守護進程的手段;但這個手段并不適合所有的場景,比如:服務(service)。
在 Linux 中,服務是最需要的守護進程的,大部分的服務的生命周期都伴隨著 init 進程的開啟直到系統重啟始終。顯而易見,利用 nohup 的手法來實現這樣的進程是不靠譜的,畢竟服務進程連控制終端都沒有,所謂的屏蔽 SIGHUP 也就無從談起了。
那么用于服務的守護進程應該如何實現呢?
其實我們什么都不用做,因為 glibc 為已經把實現守護進程所需的絕大部分工作都封裝到 daemon 函數中了。
daemon()
daemon()的函數原型如下:
int daemon (int nochdir, int noclose)
這個函數的使用非常簡單,甚至比 fork()還要方便:只要調用一次,當前進程自動變成守護進程。
作為一個喜歡打破沙鍋問到底的工程師,我想讀者們應該和我一樣很好奇 daemon 到底做了些什么?幸而函數的實現并不長,我們很容易就可以一窺究竟(中文注釋為筆者所加):
清單 6 daemon.c
| 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 | int daemon (int nochdir, int noclose) { ????int fd; ????/* 步驟 1: fork 出一個新的子進程,用以開啟新的會話 */ ????switch (__fork()) { ????case -1: ????????return (-1); ????case 0: ????????break; ????default: ????????_exit(0); ????} ????/* 步驟 2: 開啟一個新的會話 */ ????if (__setsid() == -1) ????????return (-1); ????if (!nochdir) ????????/* 步驟 3: 把進程當前的執行路徑換到根目錄 */ ????????(void)__chdir("/"); ????if (!noclose) { ????????/* 步驟 4: 將當前進程的標準輸入、輸出和錯誤都重定向到/dev/null */ ????????struct stat64 st; ????????if ((fd = open_not_cancel(_PATH_DEVNULL, O_RDWR, 0)) != -1 ????????????&& (__builtin_expect (__fxstat64 (_STAT_VER, fd, &st), 0) ????????????== 0)) { ?????????????????/* …… */ ????????????????(void)__dup2(fd, STDIN_FILENO); ????????????????(void)__dup2(fd, STDOUT_FILENO); ????????????????(void)__dup2(fd, STDERR_FILENO); ????????????????/* …… */ ????} ????} ????return (0); } |
在解讀上述代碼之前,有一些比較容易讓人困惑的坑是要注意一下的:
- open_not_cancel 是一個宏定義,根據不同的操作系統選擇指向 openat 或 open 系統調用。當然,在 daemon 中這兩個系統調用沒有差別;
- __builtin_expect 是 gcc 中獨有的一種機制,主要用于告知編譯器其中所包含的代碼最有可能的返回值,以協助編譯器據此進行優化。在 Linux 內核中這種機制被大規模使用;
從代碼中我們可以看到 daemon 的實現主要分為四個步驟,簡單概括起來就是:一、建立一個新進程(fork)并為之開啟一個新的會話(setsid);二、其他。
從筆者的表述中大家應該可以意識到建立一個新的會話的重要性了,由清單 6 可以猜到 setsid 便是用于建立新會話的系統調用。在執行完 setsid 之后,內核會做以下幾件事:
- 建立一個新的會話,當前進程會成為新會話的會話先導(session leader);
- 當前進程由原有進程組撤出,創建出一個新的進程組,當前進程成為新進程組的進程組先導;
但是這里的實現還是有些略微的讓人感到匪夷所思,為什么一定要:1、先 fork 出一個新的進程;2、再殺死父進程;3、最后再調用 setsid 呢?
這主要是由于 setsid 的正常調用有一個前提條件:它要求調用它的進程不可以是一個進程組先導(progress group leader),否則將返回錯誤;因此,通常在執行 setsid 之前都會先調用一次 fork 并殺死父進程,因為 fork 出的子進程必然和父進程在同一個進程組之內,且進程組先導必然不為子進程(要么是父進程要么是其他進程),因此邏輯上如此創建出的新進程之上運行 setsid 一定能夠成功。
另一個值得注意的是當前進程在運行了 setsid()之后不在會關聯任何的控制終端,因為由 setsid()創建出的新會話默認是沒有控制終端的——這符合我們對于服務進程的預期,但是也帶來了一個爭議:雖然由 daemon()建立的新會話沒有控制終端,但它也沒有辦法阻止開發者在之后的實現中另開一個。對于一個通用的 API 來說,這的確不是個好事。
要解決這一問題并非沒有可能,兩部 Linux/Unix 開發方面的經典磚頭:TLPI(The Linux Programming Interface,參考文獻 4)和 APUE(Advanced Programming in Unix Environment,參考文獻 5)都提到了 System-V 下的解決方案:在 setsid()之后再 fork 出一個新的子進程,并殺死原有父進程——這么做之后,根據 System V 的規則,用戶便無法再在這樣的進程上下文中開啟控制終端了。
然而很遺憾,源自 BSD 的 daemon()并沒有將這一設計加入進來,因為同樣的機制在 BSD 下無效。因此在 BSD 系的 Unix 下(如 FreeBSD,NetBSD 等),我們就只有祈禱開發者會自覺的在開啟終端時加上 O_NOCTTY 了;不過由于 Linux 是參照 System-V 的接口定義設計其行為的,所以我們還是可以在調用了 daemon()之后再按照 System-V 的方案做一遍以防不測。
setsid()之外
討論完了 setsid()的話題,現在我們再來討論一下 daemon()實現中相對不那么重要的"其他"步驟:
- 切換工作進程:daemon 函數會可選的將進程的工作目錄切換到根目錄。這一步被設為可選,因為本質上它并不會影響到守護進程的運行。但問題是工作目錄所屬的文件系統會無法被 umount,尤其是對于那些由 shell 啟動的守護進程:回想一下在 windows 下莫名無法被移除的 U 盤給人帶來的困擾,這的確是夠令人厭煩的。
當然,其實我們也不一定非要將守護進程的工作目錄切換到根目錄,只要切換到那些在系統運行的過程中絕對不會被 umount 的目錄,例如/tmp,也是可以接受的。尤其是對于一些在運行的過程中需要利用文件來存儲運行時信息的守護進程,這時候將工作目錄遷移至記載著運行時信息的目錄,例如/var/XXX,會是一個非常好的主意。
- 將 stdin/stdout/stderr 重定向到/dev/null:誠然,守護進程是不可以擁有控制終端的,所以按理說標準輸入輸出和錯誤應該是沒有任何作用才是。但是作為一個通用的 API,如果選擇將這些文件描述符直接關閉也不合時宜,因為如果這樣在進程后續的上下文中如果有任何打開文件的操作,那么 0、1、2 這些約定俗成的描述符就會在不經意間誤作它用,這對程序安全是一個隱患。所以一個比較理性的做法是將這些描述符重定向到/dev/null 上,這樣無論后續的上下文如何操作文件都不會有任何負面影響,且任何針對標準輸入、輸出和錯誤的 IO 操作都不會導致系統報錯。
永不消逝的進程 v2:
總而言之,有了上一章的理論鋪墊,筆者將清單 5 中的例程進化了一次,如清單 7 所示:
清單 7 永不消逝的進程 v2
| 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 | #! /usr/bin/python import time import sys import os import logging def child_process(): ????logging.info("child process's pid: %d" % os.getpid()) ????while (1): ????????logging.info("child's still alive.") ????????time.sleep(1) def fork_and_exit_parent_proc(): ????# 因為 multiprocessing.Process 的既定設計是只在子進程中運行 target 參數所指向的函數對象 ?????# 因此這里我們必須回歸傳統的 fork ????pid = os.fork() ????if pid > 0: ????????os._exit(0) def become_daemon(target): ????# 1. 第一次 fork ????fork_and_exit_parent_proc() ????# 2. 創建新會話 ????os.setsid() ????# 3. 第二次 fork ????fork_and_exit_parent_proc() ????# 4. 將工作目錄切換至 '/' ????os.chdir('/') ????# 5. 重定向標準輸入、輸出、錯誤至/dev/null ????fd = os.open(os.devnull, os.O_RDWR) ????os.dup2(fd, sys.stdin.fileno()) ????os.dup2(fd, sys.stdout.fileno()) ????os.dup2(fd, sys.stderr.fileno()) ????# 6. 因為標準輸出不可用,這里筆者又額外定義了一個 log 文件以接收守護進程的輸出 ????logging.basicConfig(filename='/var/log/mylog.log', level=logging.INFO) ????target() def main(): ????becomeDaemon(target=child_process) if __name__ == '__main__': ????main() |
在清單 7 中,筆者并未直接調用 daemon(),這主要是因為 python 的標準庫中并未包含對 daemon()的直接封裝(其實 python 中也提供了其他方案,筆者下文中會有提及)。此外,由于上述實現中有很多調用都需要系統管理員權限,因此必須要以 root 或者 sudoer 的身份才可以執行。
讀到這里讓我們再回到上一章末尾處的思考:服務級的守護進程縱有千般好,畢竟需要用到系統管理員級的權限;而屏蔽 SIGHUP 縱有千般不是,一個的普通用戶權限即可驅動——故而技術無貴賤,不同技術適用于不同場合而已。
最后,除去上述步驟之外,還有一些上述代碼中并未實現,但對于守護進程大有裨益的工作,筆者也羅列如下:
- 利用 umask 限制進程新建文件的權限位,以確保運行時創建的文件不會被外界非法修改,從而改變守護進程的運行時行為。例如 openssh 的服務進程就禁止了新建文件的用戶組和其他用戶的寫行為;
- 關閉所有非必需的自父進程繼承而來的文件描述符。誠然,這一部分工作不一定必須由 daemon 之類的 API 直接實現,但考慮到守護進程長期運行的特性,又是必須完成的;
- 實現一個外界同守護進程交互的方案。顯而易見,守護進程一般游離于終端之外,除開 ps 或查詢/proc 之類的方法之外難覓其蹤。自然用戶會需要一個接口來對其進行控制。順便一提,縱然 Linux 下的進程間交互手段五花八門,不過由于許多約定俗成的習慣,信號量始終是最為行之有效的控制守護進程方案之一。例如上文中提到過的 SIGHUP 就通常用于通知守護進程重新載入配置文件(準確的說,是用于通知守護進程重新初始化),絕大部分傳統的守護進程實現都會支持這一功能;
- 實現一個守護進程輸出自身運行時狀態的方案,理由同上。很多筆者可能會說這部分工作可以利用系統的 log 服務來完成。其實這里還有一個約定俗成的做法,就是大多數守護進程都會在/var/run 下創建一個 xxx.pid 文件以方便利用腳本來追蹤進程的運行狀態。例如,如果想要查詢當前系統的 rsyslogd 進程的 pid,直接在/var/run 下查詢 rsyslogd.pid 文件的內容即可;
總而言之守護進程的實現要考慮到非常多的因素,畢竟你實現的是一個長時間在操作系統的后臺蹦跶的程序,哪怕有一點點小的要素沒有考慮到都會導致系統運行效率的降低、死機甚至被居心叵測的黑客實施攻擊。雖然 daemon()這樣的通用 API 一定程度上減輕了我們的工作量,但是前面還是會有很多額外的坑在等待著我們,所謂路漫漫其修遠兮……
回到 python
現在讓我們再回到本文最開始的地方:筆者最初的目的其實只是實現一個通用且穩定的守護進程,但是現在,看看清單 5 中簡陋的的 v1 版和清單 7 中偷工減料的 v2 版,不禁仰聲長嘆:難道就沒有一個能讓人樂得逍遙且又面面俱到的 v3 版么?
Python 的標準庫中并沒有封裝 glibc 中的 daemon()函數,這個筆者在前文有提到過,但這不代表社區中沒人考慮過這個問題:PEP-3143就詳細探討了一個守護進程庫的解決方案。
PEP-3143 的設計非常簡明扼要:一個 DaemonContext 類就可以基本可以提供開發者們需要的一切,其主要接口和屬性定義如下表所示:
表 1 DaemonContext 類的定義
一切看起來都挺美好的,唯一的缺陷是:這個 PEP 被 defer 了,直到今天也沒有進入標準庫。不過不要緊,因為這個 PEP 還有一個參考實現 python-daemon,在pip中很容易找到源碼。
終章:永不消逝的進程 v3
憑借 python-daemon,我們的永不消逝的進程終又可以再進化一層,筆者將修改后的源碼列于清單 8 之中,作為本章的結尾以饗讀者:
清單 8 永不消逝的進程 v3
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | #! /usr/bin/python import time import os import logging import daemon def child_process(): ????logging.info("child process's pid: %d" % os.getpid()) ????while (1): ????????logging.info("child's still alive.") ????????time.sleep(1) def main(): ????# DaemonContext 實現了__enter__() 和__exit__(),因此我們可以一句話搞定整個 daemon context ????with daemon.DaemonContext(): ????????# daemon 目前不支持 log,所以這部分工作只能我們手動初始化 ????????logging.basicConfig(filename='/var/log/mylog.log', level=logging.INFO) ????????child_process() if __name__ == '__main__': ????main() |
結束語
后臺守護進程是 Linux/Unix 系統中非常重要的"地下工作者"。本文從 Linux/Unix 的進程組和會話的機制入手,詳細的介紹了基于這些機制之上的兩種截然不同的實現守護進程的手法。在深入解讀這些奇淫巧技的同時,筆者也更希望讀完本文的朋友們能夠觸類旁通,對 Linux/Unix 系統的進程間關系能有更深一層的認識。
參考資源
轉載于:https://www.cnblogs.com/roadmap99/p/6878280.html
總結
以上是生活随笔為你收集整理的那些永不消逝的进程 (转)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 读书笔记之启示录
- 下一篇: JavaScript语言中文参考手册.c