I. 项目中使用python进程池遇到的问题
进程池+Manager.dict()方式。项目上线后发现了一个致命的问题:
- 某几个进程在启动或者运行中途,突然不打日志了。更头疼的时,每次停止运行的进程还不相同。
最开始大家把死锁住的进程的实现代码review了一遍,没有发现问题;接下来,问题发生时尝试用gdb排查,但是都是c栈调用,完全没有头绪。后来求助Google和知乎,发现了一篇知乎文章也是类似问题:一个关于Python的死锁问题。
回顾工程代码的设计,列出可能导致上述问题:
- 竞争锁时导致某几个进程死锁了。这个问题归结于python的logging虽然是线程安全的,但不是进程安全的。因此设计了一个logging agent,专门设计了一个带锁的Queue,其他进程在调用LOGGER静态方法时,实际是往Queue中放打印日志内容,而logging agent只需要一直不断从Queue中取数据打印到日志文件中。
- 实际cup数量是4个,但是在进程池中塞了7个进程数量,可能导致Process Pool处理不过来。另外有一种说法,是python2.7的Process Pool自带bug。
- 程序中触发了某个意外的错误,导致进程崩溃退出。*(这个概率应该不大,因为不可能每次都是不同的进程意外崩溃;此外,更多情况是在程序启动的时候就‘崩溃’了?)*
- 其他未知原因。
II. 痛定思痛,决定重构试试
重构的原因当然不止上述原因,anyway,已经走上了重构的道路。重构后改进如下:
- 取消logging agent,将日志分开打印;
- 业务不相关的进程分离,最多的一个进程池放了6个进程;
- 更改部分业务逻辑和代码架构;
- 其他(不重要,如更改了命名等);
重构后版本在测试中倒是不再出现进程卡主的情况了,但是在我捣鼓中发现了下述问题:
1. 程序中设置了大小为N的进程池,实际ps -ef | grep ${process_name}发现实际数量总比预期的多2个。
2. kill进程组中某个子进程后,再ps -ef | grep ${process_name}发现进程数量没有减少,而kill掉的进程号确实改变了,观察该进程对应的日志,已经停止输出了。
3.kill多出来的某个进程之后,程序报错,在日志中打印“Broken Pipe”错误。
多出来的进程究竟是什么?**
III. 寻求Pool源码
网上查了好些时候,没有发现相关的说明。连python官网上也没有对此说明,于是只能寻求python源码。
1. Pool的初始化函数__init__
代码中中文备注都是笔者加的,如有错误之处,欢迎斧正。
def __init__(self, processes=None, initializer=None, initargs=(),
maxtasksperchild=None):
self._setup_queues()
# _setup_queues()函数初始化:
# 1. self._inqueue和self._outqueue为SimpleQueue()
# 2. self._quick_put = self._inqueue._writer.send
# 3. self._quick_get = self._outqueue._reader.recv
#
self._taskqueue = Queue.Queue()
self._cache = {}
self._state = RUN
self._maxtasksperchild = maxtasksperchild
self._initializer = initializer
self._initargs = initargs
打断补充几点个人的理解:
1). self._inqueue和self._outqueue的作用:Pool和worker进程的通信管道,管道内容是task,其实就是通过apply,apply_async等方法传递进来的用户真正要运行的函数。
2). self._taskqueue的作用是普通队列,存储task。
3). _cache的功能还没有理解充分,只知道它保存了一个tupe,记录任务执行成功与否和任务执行结果。
4). _state有三种状态,分别为:
- RUN = 0
- CLOSE = 1
- TERMINATE = 2
5). _maxtasksperchild可以设置任务执行次数,默认None,表示任务执行无限次。
if processes is None:
try:
processes = cpu_count()
except NotImplementedError:
processes = 1
if processes < 1:
raise ValueError("Number of processes must be at least 1")
if initializer is not None and not hasattr(initializer, '__call__'):
raise TypeError('initializer must be a callable')
self._processes = processes
# process - 进程池大小,默认是cpu个数或者1。
#
self._pool = []
self._repopulate_pool()
# _repopulate_pool(): 往self._pool加满worker进程
#
继续补充:
1). 经常有网友问,Pool的默认个数是多少,相信这段代码应该非常清晰地告诉了答案:cpu个数或者1。
2). _pool是一个list,上限数量是_processes,存储内容是worker对象。
3). _repopulate_pool()函数可以说是非常吊诡,他的作用就是把_pool填满。但是,后面的代码中_handle_workers方法循环检查_pool中的进程,发现有退出的就清除,然后再调用这个方法,装一个不实际干活的worker进来。(Python2为了把_pool填满,也是操碎了心啊!殊不知,我之前一直用进程数量来确保程序没挂,真是坑!)
self._worker_handler = threading.Thread(
target=Pool._handle_workers,
args=(self, )
)
self._worker_handler.daemon = True
self._worker_handler._state = RUN
self._worker_handler.start()
# _handle_workers线程:守护进程,旨在清理已退出的worker,并启动新的worker替代。
#
self._task_handler = threading.Thread(
target=Pool._handle_tasks,
args=(self._taskqueue, self._quick_put, self._outqueue,
self._pool, self._cache)
)
self._task_handler.daemon = True
self._task_handler._state = RUN
self._task_handler.start()
self._result_handler = threading.Thread(
target=Pool._handle_results,
args=(self._outqueue, self._quick_get, self._cache)
)
self._result_handler.daemon = True
self._result_handler._state = RUN
self._result_handler.start()
self._terminate = Finalize(
self, self._terminate_pool,
args=(self._taskqueue, self._inqueue, self._outqueue, self._pool,
self._worker_handler, self._task_handler,
self._result_handler, self._cache),
exitpriority=15
)
初始化函数在这里end了。最后补充说明:
1). _handle_workers功能已经说过了,不再赘述。不过一定要强调一点的是:在python2.7中,用进程池的话,千万不要相信ps -ef | grep ${process_name}!!!因为只要Pool还在的话,它一定会call出新的无用的worker做花瓶。(捂脸)
2). _handle_tasks功能就是从self._taskqueue中取task,塞到self._inqueue中,让worker取出执行;如果放task失败,则会往_cache放入(False,e),程序片段如下:
try:
put(task)
except Exception as e:
job, ind = task[:2]
try:
cache[job]._set(ind, (False, e))
except KeyError:
pass
3). _handle_results功能就是从self._outqueue取出worker执行完的结果,执行callback(如果有的话)。
4). Finalize不是很明白,但是顾名思义,应该是跟回收进程相关的。
2. 代码实践
了解了源码之后再次验证,写了一个非常简短的demo,命名为pool_test.py:
import multiprocessing
def ProcessWorker(_id):
count = 0
while True:
print "worker %d : %s~" % (_id, count)
count = count + 1
def main():
woker_num = 3
pool = multiprocessing.Pool(processes= woker_num)
for i in range(3):
pool.apply_async(ProcessWorker, (1, ))
pool.close()
pool.join()
if __name__ == '__main__':
main()
运行程序后用ps -ef | grep pool_test查看如下:
Pool的大小明明就设置了3,结果ps出来的进程数是5。
继续查看pid=9140的进程,发现有四个子线程:
开始作了,尝试把pid=9145进程kill掉,再用ps -ef | grep pool_test查看如下:
哎,进程数没有改变!只不过没有9145的进程了,多了一个10594进程;再查看另一个tty,发现没有“worker 2”的输出了,只剩下0和1。可见,10594就是已被杀死的9145的“替身”。
尝试把pid=9140杀死,看看会出现什么:
哈,这下子只剩下真正在干活的两个进程了。替身进程和9140已经不在了。
最后,从表征上来看,9140和9142的作用似乎一样,只要其中一个进程被杀死,另一个进程也会随之死亡。而且用top -H -p 9142查看,也是4个线程。
于是问题来了,9140和9142究竟有什么区别?
留个坑给自己吧,后面慢慢再接着研究。