CPython由于GIL的存在,Python服务端程序通常在子进程中处理请求。使用子进程时,可能会遇到以下两种情况:
- 子进程结束后变成僵尸进程,占用进程号。
- 子进程占用父进程LISTEN的端口号,导致父进程重启时失败。
下文将在docker python:2.7
环境下,模拟一种服务端场景,以复现这两个问题,并提出解决方案。
1. 场景模拟
假设我们在master.py
中启动了一个HTTP Server,处理/?iter=<iterations>
请求,并将迭代次数<iterations>
作为参数,启动子进程worker.py
。master.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的端口号了,父进程也可以成功重启: