Слабые ссылки и weakref в Python: когда это реально нужно
Кеш держал в памяти объекты, которые давно должны были умереть, и RSS процесса рос до перезапуска. Разбираем, где weakref решает проблему, а где создаёт новую.

Процесс воркера рос по памяти на 40–60 МБ в час под ровной нагрузкой и падал по OOM раз в сутки. Утечки в привычном смысле не было: tracemalloc показывал, что растёт словарь-кеш, куда клали объекты по ключу и никогда не удаляли. Пока на объект держится хоть одна ссылка из этого словаря, сборщик его не тронет. Замена обычного словаря на weakref.WeakValueDictionary убрала рост в ноль — объекты стали исчезать из кеша, как только на них не остаётся других ссылок. Разбираем механизм на CPython 3.12 и границы, где слабые ссылки помогают, а где ломают логику.
Что делает слабая ссылка
Обычная ссылка увеличивает счётчик ссылок объекта. Пока счётчик больше нуля, объект жив. Слабая ссылка не увеличивает счётчик: она указывает на объект, но не мешает его удалить. Как только исчезает последняя сильная ссылка, объект собирается, а слабая ссылка «протухает» и начинает возвращать None.
import weakref
class Node:
pass
obj = Node()
ref = weakref.ref(obj)
print(ref()) # <__main__.Node object at 0x...> — объект жив
del obj # убираем единственную сильную ссылку
print(ref()) # None — объект собран, ссылка протухла
Ключевая деталь: ref() — это вызов, а не атрибут. Возвращает либо объект, либо None. Никогда не пишите if ref:, ожидая проверить жив ли объект — пустой callable всегда истинен. Проверяйте if ref() is not None:, и сразу сохраняйте результат в локальную переменную, иначе между двумя вызовами объект успеет умереть.
Финализатор — функция, которую weakref вызывает в момент сборки объекта. В отличие от __del__, финализатор через weakref.finalize вызывается ровно один раз и не держит объект живым сам по себе.
Не на любой объект вешается слабая ссылка
Это первое, обо что спотыкаются. Слабую ссылку нельзя создать на int, str, tuple, list и dict — базовые встроенные типы её не поддерживают.
import weakref
weakref.ref([1, 2, 3])
# TypeError: cannot create weak reference to 'list' object
# Работает: обычные экземпляры пользовательских классов,
# set, а также классы, функции и методы.
class Cache:
pass
weakref.ref(Cache()) # ок
Причина в устройстве объекта на уровне C: чтобы поддерживать слабые ссылки, тип должен иметь слот tp_weaklistoffset. У обычных классов он есть по умолчанию. Если вы объявили __slots__ и не добавили в него '__weakref__', слабые ссылки на экземпляры такого класса перестанут работать — TypeError.
class Point:
__slots__ = ('x', 'y') # слабые ссылки сломаются
class Point2:
__slots__ = ('x', 'y', '__weakref__') # снова работают
Где weakref действительно нужен
1. Кеш, который не должен держать объекты живыми
Именно тот случай из начала. Вы хотите переиспользовать объект, пока он кому-то нужен, но не мешать его сборке. Для этого — специализированные словари из weakref.
import weakref
class Session:
def __init__(self, user_id):
self.user_id = user_id
# Значения хранятся слабо: как только на Session
# не остаётся сильных ссылок снаружи, запись исчезает сама.
cache: "weakref.WeakValueDictionary[int, Session]" = weakref.WeakValueDictionary()
def get_session(user_id, active_sessions):
s = cache.get(user_id)
if s is None:
s = Session(user_id)
cache[user_id] = s
active_sessions.append(s) # active_sessions держит сильную ссылку
return s
Пока active_sessions держит объект, он в кеше. Как только его оттуда убрали и других ссылок нет — запись из cache исчезает без вашего участия. Никакого ручного LRU и таймеров.
2. Разрыв циклов «родитель — ребёнок»
Дерево, где ребёнок ссылается на родителя, а родитель на детей, образует цикл. CPython умеет собирать циклы через сборщик поколений, но это дороже: цикл живёт до ближайшего прохода gc, а не освобождается мгновенно по счётчику ссылок. Слабая ссылка на родителя разрывает цикл, и всё дерево освобождается сразу, как только на корень не осталось внешних ссылок.
import weakref
class TreeNode:
def __init__(self, parent=None):
# обратная ссылка на родителя — слабая, цикла нет
self._parent = weakref.ref(parent) if parent is not None else None
self.children = []
@property
def parent(self):
return self._parent() if self._parent is not None else None
3. Наблюдатели и колбэки, которые не должны продлевать жизнь подписчику
Классическая утечка в GUI и событийных системах: объект подписался на сигнал, потом должен быть удалён, но список подписчиков держит на него сильную ссылку — и он живёт вечно. weakref.WeakSet для набора подписчиков решает это: удалили объект — он сам выпал из набора.
4. Финализация внешнего ресурса
weakref.finalize — способ гарантированно закрыть ресурс при сборке объекта, не полагаясь на хрупкий __del__.
import weakref
class TempFile:
def __init__(self, path):
self.path = path
self.handle = open(path, 'w')
# finalizer вызовется ровно один раз при сборке self
self._finalizer = weakref.finalize(self, self.handle.close)
В отличие от __del__, finalize не участвует в циклах как обычный метод и вызывается при завершении интерпретатора, если объект ещё жив.
Слабая ссылка — не инструмент экономии памяти вообще. Это инструмент против одного конкретного класса ошибок: когда контейнер живёт дольше, чем должны жить его элементы. Если вы тянетесь к weakref, чтобы «не думать про память» — вы решаете не ту задачу.
Чем платите
Слабые ссылки — не бесплатная и не всегда уместная замена обычным.
- Недетерминированность. Объект исчезает из
WeakValueDictionaryв момент сборки — вы не контролируете когда. Итерация по такому словарю в многопоточном коде может пропустить только что валидную запись. - Тихие None. Забыли проверить результат
ref()— получитеAttributeErrorнаNoneв самом неожиданном месте, часто на проде под нагрузкой, когда сборка происходит чаще. - Ложное чувство контроля. Если где-то в коде осталась случайная сильная ссылка (замыкание, лог, трейсбек исключения), объект не соберётся, и weakref не спасёт. Отладка таких случаев болезненна.
- Не для встроенных типов. Кеш чисел или строк на weakref не построить — придётся оборачивать в класс.
Обычный dict или слабый: когда что
| Ситуация | Обычный контейнер | weakref-контейнер |
|---|---|---|
| Кеш, объекты нужны, пока используются снаружи | течёт память | подходит |
| Кеш с явным TTL и ограничением размера | подходит (LRU/lru_cache) | лишняя недетерминированность |
| Обратная ссылка на родителя в дереве | создаёт цикл | подходит |
| Набор подписчиков события | держит их живыми | WeakSet подходит |
| Хранение ключей-строк или чисел | единственный вариант | невозможно |
| Нужен гарантированный порядок и стабильный размер | подходит | размер плавает |
Как понять, что проблема именно в этом
Прежде чем тянуть weakref, убедитесь, что растёт именно контейнер, переживающий свои элементы. Дайте нагрузку и снимите два снимка:
import gc, tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
# ... прогон нагрузки ...
gc.collect() # исключаем эффект отложенной сборки циклов
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, 'lineno')[:10]:
print(stat)
Если верхние строки указывают на присваивание в словарь или добавление в список, который живёт весь процесс, — это ваш случай. Дополнительно посмотрите, кто держит объект: gc.get_referrers(obj) покажет всех, кто ссылается сильно. Если там неожиданный список или замыкание — weakref не поможет, надо убирать саму ссылку.
Что делать дальше
Начните с малого: замените ровно один протекающий контейнер на WeakValueDictionary или WeakSet и повторите замер памяти под той же нагрузкой. Не переводите на weakref всё подряд — большинство кешей выигрывают от честного LRU через functools.lru_cache с maxsize, а не от слабых ссылок. Если объекты хранят внешние ресурсы, добавьте weakref.finalize и уберите __del__. В проде следите за двумя вещами: RSS процесса и числом записей в подозрительном контейнере — если размер контейнера перестал коррелировать с ростом памяти, замена сработала. Точный список того, что поддерживает слабые ссылки в вашей версии, всегда сверяйте в документации модуля weakref.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: weakref — Weak references (Python documentation), gc — Garbage Collector interface (Python documentation)
Частые вопросы
У меня класс с __slots__, и weakref падает с TypeError. Что делать?
Чем weakref.ref отличается от WeakValueDictionary?
Помогает ли weakref от циклических ссылок сам по себе?
А если у меня другая версия Python — 3.8 или 3.13?
Заменил обычный dict на WeakValueDictionary, а память всё равно растёт. Почему?
Можно ли класть в WeakValueDictionary строки или числа?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.