杀死 subprocess.Popen 的子子孙孙

杀死 subprocess.Popen 的子子孙孙

2014 年 11 月 09 日

Python 标准库 subprocess.Popen 是 shellout 一个外部进程的首选,它在
Linux/Unix 平台下的实现方式是 fork 产生子进程然后 exec
载入外部可执行程序。

于是问题就来了,如果我们需要一个类似“夹具”的子进程(比如运行 Web
集成测试的时候跑起来的那个被测试 Server),
那么就需要在退出上下文的时候清理现场,也就是结束被跑起来的子进程。

最简单粗暴的做法可以是这样:

process_fixture.py

 @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 分裂自立门户。

前文的例子经过修改是这样的:

better_process_fixture.py

 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 工具。

Posted by Jiangge Zhang

2014 年 11 月 09 日
Python
System Programming
Process

Measure

Measure

David_Li

我还没有学会写个人说明!

暂无评论

相关推荐