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

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

Комментарии (0)