CPython由于GIL的存在,Python服务端程序通常在子进程中处理请求。使用子进程时,可能会遇到以下两种情况:

  • 子进程结束后变成僵尸进程,占用进程号。
  • 子进程占用父进程LISTEN的端口号,导致父进程重启时失败。

下文将在docker python:2.7环境下,模拟一种服务端场景,以复现这两个问题,并提出解决方案。

1. 场景模拟

假设我们在master.py中启动了一个HTTP Server,处理/?iter=<iterations>请求,并将迭代次数<iterations>作为参数,启动子进程worker.pymaster.py代码如下:

import sys
import SimpleHTTPServer
import SocketServer

from urlparse import urlparse, parse_qs
from subprocess import Popen


class Handler(SimpleHTTPServer.SimpleHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'Hello world!\n')

        iter = parse_qs(urlparse(self.path).query)['iter'][0]
        Popen(['python', 'worker.py', str(iter)])


def main(port):
    SocketServer.TCPServer.allow_reuse_address = True
    httpd = SocketServer.TCPServer(('', port), Handler)
    httpd.serve_forever()


if __name__ == "__main__":
    main(int(sys.argv[1])) # port

worker.py中,每隔1秒打印一次Worker echo,打印<iterations>次后结束退出。代码如下:

import sys
import time


def main(iters):
    for i in range(iters):
        time.sleep(1)
        print('Worker echo [%d/%d]' % (i + 1, iters))
    print('Worker exit')


if __name__ == "__main__":
    main(int(sys.argv[1])) # iterations

在下文中,会通过python master.py 5000启动HTTP Server,监听5000端口号,并通过curl "http://localhost:5000/?iter=20"启动子进程。

2. 问题A. 子进程退出为何成为僵尸进程?

下图复现了子进程成为僵尸进程的场景:

子进程成为僵尸进程

产生僵尸进程的原因是,在子进程退出后,父进程没有wait,进而删除其在操作系统process table中的表项。解决方案是处理signal.SIGCHLD信号,调用os.waitpid。对master.py的修改如下:

if __name__ == "__main__":
    import os
    import signal

    def handler_sig_child(signum, frame):
        try:
            while True:
                pid, status = os.waitpid(-1, os.WNOHANG)
                if pid == 0:
                    break
        except OSError:
            pass

    signal.signal(signal.SIGCHLD, handler_sig_child)

    main(int(sys.argv[1]))

修改后,再次进行实验,发现子进程可以成功退出:

子进程成功退出

3. 问题B. 子进程竟占用端口号,导致父进程无法重启!

下图复现了子进程占用父进程端口号的问题,在父进程尝试重启时,会因为端口号被子进程使用,导致重启失败:

子进程占用端口号,导致父进程无法重启

产生此问题的原因是,在使用Popen的时候,子进程会继承父进程打开的文件描述符。需要传入close_fds=True参数,从而不会占用父进程监听的端口号。对master.py的修改如下:

Popen(['python', 'worker.py', str(iter)], close_fds=True)

修改后,再次进行试验,发现子进程不会占用父进程LISTEN的端口号了,父进程也可以成功重启:

避免继承文件描述符