Python 多进程 Pool 永久阻塞

04 Apr, 2016

先说结论,使用 multiprocessing.Pool 时应该注意确保工作进程不要因为严重的错误(如段错误)和人为的 kill 而挂掉,或者抛出不能被 Exception 捕获的异常,比如调用 sys.exit. 如果出现上述情况,主进程会永远阻塞在 pool.join() 上。

最近一个运行良久的程序突然间经常无法退出,开始怀疑某个子进程卡住,不过通过 ps 发现所有子进程已经退出,只是还未被父进程回收,显然父进程阻塞在对每个子进程调用 join 之前了。

$ ps --ppid=8772
  PID TTY          TIME CMD
 8773 pts/1    00:00:00 python <defunct>
 8774 pts/1    00:00:00 python <defunct>
 8775 pts/1    00:00:00 python <defunct>
 8776 pts/1    00:00:00 python <defunct>
 8777 pts/1    00:00:00 python <defunct>

因为跑在 Python2.6(没错,就是这么古老的版本) 上,所以这里拿 2.6 的代码看看,根据 pool.join,可能是 _task_handler_result_handler 两者或其中一者没有退出,先用 gdb 进去看看现场:

(gdb) info threads
  2 Thread 0x7f4062891700 (LWP 8779)  0x00007f406de6982d in read () from /lib64/libpthread.so.0
* 1 Thread 0x7f406e633700 (LWP 8772)  0x00007f406de68a00 in sem_wait () from /lib64/libpthread.so.0

只有两个线程,主线程没啥可说的, 关键是线程2,看到 read 调用感觉多半是阻塞在这里:

(gdb) thread 2
[Switching to thread 2 (Thread 0x7f4062891700 (LWP 8779))]#0  0x00007f406de6982d in read () from /lib64/libpthread.so.0
(gdb) bt
#0  0x00007f406de6982d in read () from /lib64/libpthread.so.0
#1  0x00007f40667810bb in ?? () from /usr/lib64/python2.6/lib-dynload/_multiprocessing.so

调用栈没啥可用信息, _task_handler 里面并没有涉及需要调用 read 的操作,倒是 _result_handler 需要从 Queue 里面读取数据,而这个 SimpleQueue 是由 pipe 实现的,难道是阻塞在了读 pipe 上?先看看打开的文件:

$ lsof -p 8772
....
python  8772 root    0u   CHR  136,1      0t0      4 /dev/pts/1
python  8772 root    1u   CHR  136,1      0t0      4 /dev/pts/1
python  8772 root    2u   CHR  136,1      0t0      4 /dev/pts/1
python  8772 root    3r  FIFO    0,8      0t0  20370 pipe
python  8772 root    4w  FIFO    0,8      0t0  20370 pipe
python  8772 root    5r  FIFO    0,8      0t0  20373 pipe
python  8772 root    6w  FIFO    0,8      0t0  20373 pipe

具体 result handler 读的是哪一个 pipe,先在 gdb 中看看 read 调用的第一个参数:

(gdb) info all-registers
...
rdx            0x4      4
rsi            0x7f406289028c   139914507780748
rdi            0x5      5

这里的 rdi 寄存器中的值就是 read 的 fd,读端是 5,想必写端就是 6 了,result handler 中有两处读 pipe,到底阻塞在哪处还不是很清楚,不过可以试试朝里面写点东西,看看结果:

(gdb) call write(6, "hello", 5)
$1 = 5
(gdb) c
Continuing.
[Thread 0x7f4062891700 (LWP 8779) exited]

Program exited normally.

啊哈,退出了,看来前面的猜测是对的。Python 进程打印出来的日志:

Exception in thread Thread-2:
Traceback (most recent call last):
  File "/usr/lib64/python2.6/threading.py", line 532, in __bootstrap_inner
    self.run()
  File "/usr/lib64/python2.6/threading.py", line 484, in run
    self.__target(*self.__args, **self.__kwargs)
  File "/usr/lib64/python2.6/multiprocessing/pool.py", line 282, in _handle_results
    task = get()
MemoryError

显然,是阻塞在了第二个读 pipe上。

Pool 执行与结果返回

通常使用 Pool 的方法是指定进程池的大小,再将数个任务扔进去,最后等待结果,典型的用法如下:

def run():
    pool = Pool(processes=5)
    for i in xrange(5):
        pool.apply_async(worker, (i,)) # 这里可以不使用结果
    pool.close()
    pool.join()

if __name__ == '__main__':
    run()

ApplyResult 用来存放结果,每个结果有个唯一的 job id,cache 以 job id 来索引结果,task handler 会将 job id 等放到 task 队列中,当工作进程做完一个 job 后,同样会将 job id 和结果放到 result 队列,result handler 接收后,会从 cache 中将这个 job id 删除,直到 cache 为空。

由此可见,上面阻塞住的情况就是有部分工作进程并没有将结果放到 result 队列,要不是放结果之前的异常没有捕获到导致子进程退出,就是子进程被 kill 掉了,Pool 的实现并没有考虑到这个情况。在 Python2.7 中,Pool 会新增一个进程用来取代异常退出的进程,然而这并没有什么用,只要有 job id 丢失,就会导致 cache 无论如何都不会为空,在这种情况下还会导致所有子进程都不会退出,问题查起来将会更麻烦。

当然,这不是实现的问题,谁叫工作函数会抛出不可被 Exception 捕获的异常,或者被 kill 掉呢(没错,后来发现是工作函数中有一处处理大文件时会出现 Segment fault,然后无情的被系统 kill 掉了,这个锅 Python 得背吧?“这个锅咋不背,谁叫你还在用 2.6 呢!“)。要解决子进程挂掉导致 cache 不为空这种情况应该非常麻烦,怎么知道哪个工作进程获取了哪个 job id 呢,如果不知道,主进程将毫无办法。

试了下 Python3 中的 ProcessPoolExecutor,这个在某个工作进程挂掉后,会停掉所有工作进程,然后抛个异常退出,至少不会永远的阻塞在那里了。