Короткий ответ
Замыкание захватывает не только нужные ему переменные, но неявно удерживает ссылки на весь объект объемлющей области видимости, если он на него ссылается хоть одним атрибутом; если большой объект конфигурации доступен в той же функции, где создаётся callback, он может держаться в памяти через ячейку замыкания даже тогда, когда реально callback'у нужен только один маленький параметр из него — решение: явно скопировать нужное значение в отдельную переменную перед созданием замыкания, не ссылаясь на весь большой объект.
Как это работает подробнее
Замыкание хранит ссылки на переменные объемлющей функции, к которым оно обращается по имени в своём теле — если тело callback обращается, например, к config.retry_limit, то замыкание держит ссылку на весь объект config, а не только на число retry_limit, потому что Python разрешает имя config динамически при каждом вызове, а не копирует значение атрибута заранее. Пока существует хотя бы один живой callback, созданный в этой функции, весь объект config (и всё, что он в себе держит) не может быть собран сборщиком мусора.
Пример кода
class HeavyConfig:
def __init__(self):
self.retry_limit = 3
self.cache = {} # представим, что здесь много данных
def register_callback_leaky(config):
def on_message(msg):
if config.retry_limit > 0: # держит ссылку на ВЕСЬ config
process(msg)
return on_message
def register_callback_fixed(config):
retry_limit = config.retry_limit # копируем только нужное значение
def on_message(msg):
if retry_limit > 0: # замыкание держит только int, не весь config
process(msg)
return on_message
def process(msg):
passЧто использовать на практике
- Практика. Диагностируют такие утечки через профилировщики памяти (
tracemalloc,objgraph), которые показывают, что объекты конфигурации продолжают жить дольше, чем ожидалось, и находят цепочку ссылок до объекта-замыкания. Исправление — скопировать только нужное значение в локальную переменную перед определением callback (retry_limit = config.retry_limit, и использовать в теле именноretry_limit), чтобы замыкание держало ссылку на маленькое неизменяемое значение, а не на весь большой объект.
Подводные камни
- Риски. Создают много callback'ов в цикле настройки, каждый из которых неявно захватывает один и тот же большой объект "на всякий случай" через обращение к его атрибутам внутри тела функции. Также путают эту причину утечки с утечкой из-за циклических ссылок — здесь проблема не в цикле, а в том, что обычная (strong) ссылка из активного callback искусственно продлевает жизнь большому объекту, который давно не нужен целиком.