weakref.finalize: «почти IDisposable» для Python-объектов

от автора

Привет, Хабр!

Я не знаю, как у вас, а у меня перед глазами все еще маячат толстенные исходники 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 и сразу делает две слабые ссылки:

Ссылка

На что смотрит

Зачем

self._weakref

на ваш obj

Отслеживать «смерть» объекта

weakref.ref(self)

на сам _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, т.к:

  1. Функция-уборщик хранится как объект-значение в pending; даже если модули уже стёрты, у неё жёсткая ссылка на всё нужное.

  2. Запускается строго после того, как Reference Counting освободит память под объект.

Поэтому callback не обращается к уже None-глобалам — они сохранены в args/kwargs.

Некоторые правила

  1. Не захватывайте self в callback. Это создаст сильную ссылку, объект не умрёт и финализатор не вызовется. Передавайте только примитивы или weakref.proxy.

  2. Храните _Finalizer в атрибуте. Если положить его в локальную переменную и выйти из функции, GC может прибить сторожа первым.

  3. Держите зависимости внутри args. Нужен logger? Передайте его позиционным параметром, а не импортируйте внутри callback: модуля к этому моменту может не быть.

  4. 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? Пишете в комментариях.


Если ваша система не выдерживает нагрузки, запросы из базы данных долго подгружаются, а архитектура приложения становится громоздкой и не гибкой, возможно, пора пересмотреть подход. Эти открытые уроки могут помочь вам решить эти проблемы и улучшить качество работы вашего кода:

Также рекомендуем пройти вступительный тест на знание Python, чтобы проверить, подойдет ли вам программа курса «Python Developer. Professional».


ссылка на оригинал статьи https://habr.com/ru/articles/922874/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *