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