我们在使用 Python Celery(使用多处理)时遇到了一个问题,即大型周期性(计划)任务会在短时间内消耗大量内存,但由于工作进程在池的整个生命周期中都存在 (
MAX_TASKS_PER_CHILD=None
),内存没有被垃圾收集(即它被“高水位”保留)。
(Heroku 使这个问题进一步恶化,它会分配大量恒定的内存并将其转换为交换,从而降低性能。)
我们发现,通过设置
MAX_TASKS_PER_CHILD=1
,我们可以在每个任务之后分叉一个新进程(Celery 工作实例),并且内存会被正确地垃圾收集。甜甜的!
但是,有很多文章提出了相同的解决方案,但我没有发现任何缺点。 在每项任务之后分叉一个新流程有哪些潜在的缺点?
我的猜测是:
1. CPU 开销(但可能是微小量)
2. 分叉时可能出现的错误(但我找不到任何相关文档)
除了重复分叉导致 CPU 开销明显增加(如果工作线程为每个任务完成足够的工作,这没什么大不了的)之外,一个可能的缺点是父进程的大小继续增长。如果是这样,它会增加所有子进程的大小(这些子进程正在分叉一个越来越大的父进程)。这并不重要(大概会写入很少的内存,因此需要很少的复制,实际内存使用不会成为主要问题),但是 IIRC,Linux 过度使用启发法假设 COW 内存最终会被复制,即使您远未实际上超过私有页面的启发式限制,您也可以调用 OOM 杀手。
在 Python 3.4 及更高版本上,您可以通过在程序启动时显式将 multiprocessing
启动方法设置为
forkserver
(在执行工作线程不依赖的任何工作之前)来避免此问题,这会将工作线程从单独的服务器进程不应大幅增加大小。
int
和
float
这样的简单“叶”类型)都会被触及。这样做会导致包含它们的页面被复制,因此您实际上消耗了父级和子级中的内存。 在3.4中,对于长时间运行的子进程没有好的解决方案,唯一的选择是:
MAX_TASKS_PER_CHILD=1
,这样即使进程确实执行 COW 副本,它们也会快速退出并被重新绑定到父进程的新进程替换,并且不会自行消耗内存。
import gc
位于文件顶部,在尽可能初始化之后,但在创建第一个
Process
或
Pool
对象之前,运行:
gc.freeze() # Moves all existing tracked objects to permanent generation,
# so they're never looked at again, in parent or child
文档进一步建议尽快在父级中禁用GC,在
freeze
之前禁用
fork
,并在子级中重新启用
gc
,以避免由其他预
fork
垃圾回收触发的COW可以通过触发 COW 的新分配来填充内存间隙(您在父级中泄漏了一些内存,以换取最大限度地减少子级中的取消共享),因此更完整的解决方案可能如下所示:
# Done as early as possible in the parent process to minimize freed gaps
# in shared pages that might get reused and trigger COW
gc.disable() # Disables automatic garbage collection
# Done immediately before forking
gc.freeze() # Moves all existing tracked objects to permanent generation so GC
# never touches them
with multiprocessing.Pool(initializer=gc.enable) as pool: # Reenables gc in each
# worker process on launch
# Do stuff with pool
# Outside with block, done with pool
gc.enable() # Optionally, if you never launch new workers,
# reenable GC in parent process
CPython bug #31558 上阅读有关此功能的基本原理和预期用例的更多信息,它描述了问题,创建了 gc.freeze
(和相关函数)并解释了预期用例。