Python 標准庫 subprocess.Popen 是 shellout 一個外部進程的首選,它在 Linux/Unix 平台下的實現方式是 fork 產生子進程然后 exec 載入外部可執行程序。
於是問題就來了,如果我們需要一個類似“夾具”的子進程(比如運行 Web 集成測試的時候跑起來的那個被測試 Server), 那么就需要在退出上下文的時候清理現場,也就是結束被跑起來的子進程。
最簡單粗暴的做法可以是這樣:
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args)
try:
yield
finally:
# 無論是否發生異常,現場都是需要清理的
proc.terminate()
proc.wait()
if __name__ == '__main__':
with process_fixture(['python', 'SimpleHTTPServer', '8080']) as proc:
print('pid %d' % proc.pid)
print(urllib.urlopen('http://localhost:8080').read())
那個 proc.wait() 是不可以偷懶省掉的,否則如果子進程被中止了而父進程繼續運行, 子進程就會一直占用 pid 而成為僵屍,直到父進程也中止了才被托孤給 init 清理掉。
這個簡單粗暴版對簡單的情況可能有效,但是被運行的程序可能沒那么聽話。被運行程序可能會再 fork 一些子進程來工作,自己則只當監工 —— 這是不少 Web Server 的做法。 對這種被運行程序如果簡單地 terminate,也即對其 pid 發 SIGTERM, 那就相當於謀殺了監工進程,真正的工作進程也就因此被托孤給 init,變成畸形的守護進程…… 嗯沒錯,這就是我一開始遇到的問題,CI Server 上明明已經中止了 Web Server 進程了,下一輪測試跑起來的時候端口仍然是被占用的。
這個問題稍微有點棘手,因為自從被運行程序 fork 以后,產生的子進程都享有獨立的進程空間和 pid,也就是它超出了我們觸碰的范圍。好在 subprocess.Popen 有個 preexec_fn 參數,它接受一個回調函數,並在 fork 之后 exec 之前的間隙中執行它。我們可以利用這個特性對被運行的子進程做出一些修改,比如執行 setsid() 成立一個獨立的進程組。
Linux 的進程組是一個進程的集合,任何進程用系統調用 setsid 可以創建一個新的進程組,並讓自己成為首領進程。首領進程的子子孫孫只要沒有再調用 setsid 成立自己的獨立進程組,那么它都將成為這個進程組的成員。 之后進程組內只要還有一個存活的進程,那么這個進程組就還是存在的,即使首領進程已經死亡也不例外。 而這個存在的意義在於,我們只要知道了首領進程的 pid (同時也是進程組的 pgid), 那么可以給整個進程組發送 signal,組內的所有進程都會收到。
因此利用這個特性,就可以通過 preexec_fn 參數讓 Popen 成立自己的進程組, 然后再向進程組發送 SIGTERM 或 SIGKILL,中止 subprocess.Popen 所啟動進程的子子孫孫。當然,前提是這些子子孫孫中沒有進程再調用 setsid 分裂自立門戶。
前文的例子經過修改是這樣的:
import signal
import os
import contextlib
import subprocess
import logging
import warnings
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args, preexec_fn=os.setsid)
try:
yield
finally:
proc.terminate()
proc.wait()
try:
os.killpg(proc.pid, signal.SIGTERM)
except OSError as e:
warnings.warn(e)
python 3.2 之后 subprocess.Popen 新增了一個選項 start_new_session, Popen(args, start_new_session=True) 即等效於 preexec_fn=os.setsid 。
這種利用進程組來清理子進程的后代的方法,比簡單地中止子進程本身更加“干凈”。基於 Python 實現的 Procfile 進程管理工具 Honcho 也采用了這個方法。當然,因為不能保證被運行進程的子進程一定不會調用 setsid, 所以這個方法不能算“通用”,只能算“相對可用”。如果真的要百分之百通用,那么像 systemd 那樣使用 cgroups 來追溯進程創建過程也許是唯一的辦法。也難怪說 systemd 是第一個能正確地關閉服務的 init 工具。
