Привет, Хабр!
Я не знаю, как у вас, а у меня перед глазами все еще маячат толстенные исходники WinForms-эра на C#, где любой порядочный объект, умеющий держать ручку к файлу или сокету, строго реализует IDisposable. Закрыл — молодец, забыл — получи warning от IDE и пару нехороших утечек в production.
В Python, увы-ях, аналогичный контракт традиционно строили на del и контекст-менеджерах. Первый: если объект в циклическом мусоре, финализатор может не вызваться вообще; к тому же при выключении интерпретатора порядок разрушения объектов хаотичен. Второй (with ... as) шикарен, но требует явного вызова, а значит — дисциплины.
С выходом PEP 442 и появлением weakref.finalize мы получили «почти IDisposable» — финализатор, которому не страшны циклы, и который честно отработает даже на shutdown, если правильно обращаться.
Проблемы старого доброго del
С виду del — простой способ реализовать финализацию: при удалении объекта Python вызовет метод, и можно аккуратно закрыть ресурс, удалить временный файл, разорвать соединение. Но если углубиться — это капкан, особенно в больших и долгоживущих системах.
Циклические ссылки
Garbage Collector в Python построен на отслеживании ссылок. Когда два или более объекта ссылаются друг на друга, но больше нигде не используются — это цикл. GC умеет такие вещи вылавливать, но не если внутри замешан del.
Потому что непонятно, в каком порядке вызывать финализаторы у связанных объектов. А если в одном del — ссылка на другой, который уже удалён? И вот Python, не рискуя вызвать проблемы, просто откладывает такие объекты в "неразрешимые". И да, del может так не вызваться вообще.
Хаос при завершении интерпретатора
Когда Python завершается, он начинает по-тихому убирать за собой. Сначала разрушаются модули, потом глобальные переменные, потом объекты. Но порядок этот непредсказуем.
Если внутри del вы обращаетесь, скажем, к глобальному logger, который к этому моменту уже стал None, получите банальное:
AttributeError: 'NoneType' object has no attribute 'info'
А если это был финальный лог о закрытии соединения или удалении временного файла — вы его просто не увидите. Вы даже не узнаете, что произошла ошибка, потому что del проглатывает исключения и тихо умирает в stderr.
Нет гарантий исполнения вообще
Даже если нет циклов, и всё написано по канонам — del не гарантирует исполнение. Сценарии:
Процессу прилетает
kill -9. Ни одинdelв живых не остаётся.Программа уходит в
os._exit()— та же история.Объект был удалён, но
delбросил исключение — оно не прерывает GC, но и работу вы не восстановите.
Даже with не даёт стопроцентной защиты от потерь. Контекстный менеджер хотя бы дает явный контроль. А del — это чистая надежда на порядочный мир.
Как работает weakref.finalize
Если взять обычный weakref.ref, мы получим «прожектор», который глядит на объект, но не мешает его уничтожению. weakref.finalize — это прожектор + кнопка Autoclean: как только объект «погас», Python нажимает кнопку и вызывает указанную функцию-уборщик.
API предельно лаконичный:
import weakref
from pathlib import Path
import shutil, tempfile
def _rm_tree(path):
shutil.rmtree(path, ignore_errors=True)
tmp_dir = Path(tempfile.mkdtemp())
fin = weakref.finalize(tmp_dir, _rm_tree, tmp_dir)
# работаем с tmp_dir ...
del tmp_dir # при следующем GC дерево удалится автоматически
obj— цель наблюдения;callback— что вызвать;args/*kwargs— что передать.
Финализатор возвращает объект-обёртку: вызвали fin() вручную — уборка случилась немедленно; позвали fin.detach() — «самоликвидация» отключена, ответственность на разработчике. Документация честно зовёт это «однострочным эквивалентом del, у которого нет проблем с циклами».
Что происходит под капотом
Вызов weakref.finalize (см. Lib/weakref.py) порождает объект _Finalizer и сразу делает две слабые ссылки:
Ссылка |
На что смотрит |
Зачем |
|---|---|---|
|
на ваш |
Отслеживать «смерть» объекта |
|
на сам |
Чтобы финализатор не умер раньше цели |
Обе ссылки регистрируются в глобальном finalizeregistry — обычный словарь id(weakref) → finalizer. Пока запись живёт в реестре, финализатор гарантированно не «утечёт».
Когда счётчик ссылок obj падает до нуля (или GC догребает до цикла без strong-ссылок), его слабая ссылка дёргает callback-обёртку внутри _Finalizer. Та, в свою очередь, кладёт настоящий callback в список pending — очередь, которая исполняется в том же потоке, но чуть позже, чтобы не нарушать порядок деструкции.
У каждого Finalizer есть флажок called. После первого успешного пуска:
if self._called:
return # защита от двойного вызова
self._called = True
Это защищает от случаев, когда разработчик позвал fin() вручную, а потом объект всё-таки улетел в GC.
При Py_FinalizeEx() CPython обходит реестр finalizeregistry и пытается добить всё, что ещё не вызвалось. Это надёжнее, чем del, т.к:
Функция-уборщик хранится как объект-значение в
pending; даже если модули уже стёрты, у неё жёсткая ссылка на всё нужное.Запускается строго после того, как Reference Counting освободит память под объект.
Поэтому callback не обращается к уже None-глобалам — они сохранены в args/kwargs.
Некоторые правила
Не захватывайте
selfв callback. Это создаст сильную ссылку, объект не умрёт и финализатор не вызовется. Передавайте только примитивы илиweakref.proxy.Храните
_Finalizerв атрибуте. Если положить его в локальную переменную и выйти из функции, GC может прибить сторожа первым.Держите зависимости внутри
args. Нуженlogger? Передайте его позиционным параметром, а не импортируйте внутри callback: модуля к этому моменту может не быть.Callback должен быть идемпотентным. Его могут позвать вручную и из GC: второе срабатывание обязано быть безопасным.
Некоторые паттерны
Автоматическое закрытие асинхронного HTTP-клиента
import aiohttp, asyncio, weakref
from types import TracebackType
from typing import Optional, Type
class ApiClient:
"""Минимальный обёрточный клиент поверх aiohttp.ClientSession."""
def __init__(self, base_url: str) -> None:
self._session = aiohttp.ClientSession(base_url)
# Финализатор закроет сессию, если разработчик забудет.
self._fin = weakref.finalize(
self,
asyncio.run, # вызывать из синхронного мира
self._session.close()
)
async def get_json(self, path: str) -> dict:
async with self._session.get(path) as resp:
resp.raise_for_status()
return await resp.json()
# Опциональная ручная ликвидация (явный is better than implicit)
async def aclose(self) -> None:
if self._fin.alive:
self._fin() # сразу же закрываем
Клиент можно использовать как обычный объект — сборщик всё уберёт. Если же в проекте принят явный контракт «у каждого ресурса есть close()» — метод aclose() детачит финализатор.
Отписка от системных сигналов
import signal, weakref
def _noop(*_): # дефолтный обработчик после отписки
pass
class SigUSR1Handler:
"""Регистрирует и удаляет обработчик SIGUSR1."""
def __init__(self) -> None:
signal.signal(signal.SIGUSR1, self._on_sigusr1)
self._fin = weakref.finalize(
self,
signal.signal, signal.SIGUSR1, _noop
)
@staticmethod
def _on_sigusr1(*_: object) -> None:
print(" SIGUSR1 received")
def detach(self) -> None: # явная отписка вручную
self._fin()
В долговечных сервисах забытый сигнал — это утечка памяти и проблемы при перезагрузках Финализатор дисциплинированно возвращает сигнал в дефолтное состояние.
Комбинация с асинхронным контекстным менеджером для временных директорий
import pathlib, shutil, tempfile, weakref
from contextlib import AbstractAsyncContextManager
class TempDir:
"""Создаёт временную директорию и убирает её при GC."""
def __init__(self) -> None:
self._path = pathlib.Path(tempfile.mkdtemp(prefix="habr_demo_"))
self._fin = weakref.finalize(
self, shutil.rmtree, self._path, ignore_errors=True
)
@property
def path(self) -> pathlib.Path:
return self._path
class AsyncTempDir(AbstractAsyncContextManager):
"""Обёртка, позволяющая писать `async with AsyncTempDir() as p:`."""
def __init__(self) -> None:
self._tmp = TempDir()
async def __aenter__(self) -> pathlib.Path:
return self._tmp.path
async def __aexit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
tb: Optional[TracebackType],
) -> bool:
# Принудительно вызываем финализатор, чтобы не ждать GC
self._tmp._fin()
return False # не подавляем исключения
При локальном тест-ране каталоги исчезнут мгновенно, CI-система не забьёт диск всякими хвостиками.
Управление пулом подключений к PostgreSQL
import asyncio, os, weakref
import psycopg_pool # >= 3.0
class PgPool:
def __init__(self) -> None:
self._pool = psycopg_pool.AsyncConnectionPool(
os.environ["PG_DSN"],
min_size=1, max_size=10,
timeout=30
)
self._fin = weakref.finalize(
self, # завершаем в отдельном event loop
asyncio.run, self._pool.close()
)
async def fetch_one(self, sql: str, *args):
async with self._pool.connection() as conn:
return await conn.fetchrow(sql, *args)
Очистка временных mmap-файлов
import mmap, os, weakref, tempfile
class SharedMemoryBuffer:
def __init__(self, size: int = 4 * 1024):
self._fd = tempfile.TemporaryFile()
self._fd.truncate(size)
self._mmap = mmap.mmap(self._fd.fileno(), size)
self._fin = weakref.finalize(
self,
self._cleanup, self._fd, self._mmap
)
@staticmethod
def _cleanup(fd, mm) -> None:
mm.close()
fd.close()
def write(self, data: bytes, offset: int = 0):
self._mmap.seek(offset)
self._mmap.write(data)
mmap требует аккуратного закрытия обоих дескрипторов; финализатор дает атомарность: либо оба закрылись, либо процесс ещё жив.
Автоматическое выключение ThreadPoolExecutor
import concurrent.futures, weakref
class LazyExecutor:
def __init__(self, workers: int = 4):
self._tp = concurrent.futures.ThreadPoolExecutor(workers)
self._fin = weakref.finalize(
self, self._tp.shutdown, wait=True, cancel_futures=True
)
def submit(self, fn, *a, **kw):
return self._tp.submit(fn, *a, **kw)
Если service-layer забыл вызвать shutdown(), фоновые треды не залипнут при завершении приложения.
Итоги и приглашение к дискуссии
weakref.finalizeгибче del, надёжнее «забытых» контекстных менеджеров и немного похож на IDisposable.
Теперь слово вам. Чем ещё автоматизируете уборку за объектами? Используете ли комбинацию finalize + contextmanager, или всё ещё полагаетесь на del? Пишете в комментариях.
Если ваша система не выдерживает нагрузки, запросы из базы данных долго подгружаются, а архитектура приложения становится громоздкой и не гибкой, возможно, пора пересмотреть подход. Эти открытые уроки могут помочь вам решить эти проблемы и улучшить качество работы вашего кода:
1 июля в 20:00
Шик, блеск, чистота: clean architecture в Python22 июля в 20:00
Подгрузка связей в SQLAlchemy
Также рекомендуем пройти вступительный тест на знание Python, чтобы проверить, подойдет ли вам программа курса "Python Developer. Professional".