Паттерны проектирования Паттерны ресурсов: Object Pool, RAII и владение временем жизни
0%

Паттерны ресурсов: Object Pool, RAII и владение временем жизни

Паттерны ресурсов: Object Pool, RAII и владение временем жизни

Предыдущая глава закончилась на том, что composition root собирает граф объектов и раздаёт зависимости. Осталась вторая половина вопроса, о которой каталог GoF почти молчит: кто и когда всё это освобождает.

Причина молчания понятна. В 1994 году каталог писали на C++, где деструктор вызывается сам, и проблема казалась решённой языком. Потом пришли языки со сборкой мусора, и половина индустрии уверовала, что «память освобождается автоматически, значит, освобождается всё». Это неверно: сборщик мусора управляет памятью, а не ресурсами. Файловый дескриптор, соединение с БД, блокировка, временный файл, дескриптор сокета, мьютекс, транзакция — всё это нужно вернуть явно и вовремя.

Симптомы, по которым узнают проблему в проде, одинаковы во всех языках: too many open files, FATAL: sorry, too many clients already, таймауты «на ровном месте» при живом CPU, медленная утечка памяти под нагрузкой, зависший SELECT ... FOR UPDATE, который держит блокировку с прошлой пятницы. За всеми ними стоит одна ошибка проектирования: не назначен владелец времени жизни ресурса.


Карта главы


Что такое ресурс строго

Ресурс — это сущность, у которой есть ограниченный источник, парные операции «взять/вернуть» и внешний по отношению к процессу эффект от невозврата. Три признака вместе:

Признак Память (обычный объект) Соединение с БД
Ограниченный источник почти неограничена, GC подберёт max_connections = 100, дальше отказ всем
Парные операции new без явного free acquire обязан иметь release
Внешний эффект нет сервер держит процесс, память, блокировки

Отсюда практическое следствие: ресурс нельзя доверить сборщику мусора. Сборщик запускается, когда кончается память, а не когда кончаются дескрипторы. Классическая ситуация: приложение на Java или Python держит 400 МБ кучи и спокойно живёт, при этом уже исчерпало пул соединений — GC не видит причин что-либо делать.

Детерминированное освобождение против финализатора


Владение: единственный вопрос, на который надо ответить

Прежде чем выбирать механизм, ответьте: кто владелец. Владелец — тот, кто обязан освободить.

Две самые дорогие ошибки — зеркальные:

  • Утечка: владелец не назначен, никто не закрывает. Ресурсы кончаются через часы или дни.
  • Двойное освобождение / использование после закрытия: владельцев двое. Соединение вернули в пул, а старая ссылка продолжает через него читать — и получает чужой ответ. Это худший случай: баг проявляется как порча данных, а не как исключение.

Именно поэтому в Rust владение вынесено в систему типов: компилятор проверяет, что у значения ровно один владелец, а Drop вызывается ровно один раз (см. владение в Rust). В остальных языках это соглашение, которое надо писать в сигнатуре и проверять на ревью.


Один паттерн под шестью именами

RAII, with, using, try-with-resources, defer, scope guard — это одна идея: привязать освобождение к выходу из области видимости, включая выход по исключению.

Язык Механизм Момент освобождения Ошибка при закрытии
C++ деструктор (RAII) конец области видимости, гарантированно деструктор не должен бросать
Rust Drop конец области владения, проверено компилятором Drop не возвращает ошибку — для fallible-закрытия делают явный close()
Python with + __enter__/__exit__ выход из блока исключение из __exit__ заменяет исходное — осторожно
Java try-with-resources выход из try ошибки закрытия попадают в suppressed
C# using / await using конец блока Dispose не должен бросать
Go defer выход из функции, а не из блока Close() возвращает ошибку — её надо обработать

Разница между «конец блока» и «конец функции» — не мелочь. Классическая утечка в Go:

// ПЛОХО: файлы закроются только когда функция закончится — после всех 10 000 итераций.
func processAll(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close()      // 10 000 отложенных вызовов, 10 000 открытых дескрипторов
        process(f)
    }
    return nil
}

// ХОРОШО: область видимости ресурса = одна итерация. Явная функция создаёт границу.
func processAll(paths []string) error {
    for _, p := range paths {
        if err := processOne(p); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    // Именованный возврат позволяет не потерять ошибку закрытия:
    // для записи это критично — данные могут не долететь до диска именно на Close.
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()
    return process(f)
}

Ошибка при закрытии — не формальность. Для файла, открытого на запись, Close() — это момент, когда буфер сбрасывается на диск. Проглоченная ошибка здесь означает молча потерянные данные.

Python-версия того же паттерна и типичная ловушка с исключением внутри __exit__:

from contextlib import contextmanager
import psycopg


@contextmanager
def transaction(pool):
    """Область видимости = транзакция. Выход любым путём закрывает её корректно."""
    conn = pool.getconn()
    try:
        with conn.transaction():      # commit при штатном выходе, rollback при исключении
            yield conn
    finally:
        # finally выполнится и при исключении, и при return, и при GeneratorExit.
        pool.putconn(conn)            # соединение обязано вернуться в пул ВСЕГДА


# Использование: ни одной явной проверки, ни одного шанса забыть.
with transaction(pool) as conn:
    conn.execute("UPDATE orders SET status = 'paid' WHERE id = %s", [order_id])

Ловушка: если в блоке finally (или в __exit__) выбрасывается своё исключение, оно затирает исходное — и вы теряете настоящую причину падения. Правило: код освобождения должен быть максимально скучным и не бросать. Если он может бросить — логируйте и подавляйте, оставляя исходное исключение.


Object Pool: когда переиспользование окупается

Object Pool — паттерн, при котором дорогие в создании объекты не уничтожаются после использования, а возвращаются в набор для повторной выдачи. Он попал в мировые каталоги позже GoF (его каноническое описание — у Кирка Пеппердайна и в POSA), но встречается в проде постоянно.

Условие окупаемости одно и оно арифметическое: стоимость создания и уничтожения должна быть сравнима со стоимостью полезной работы или превышать её. Для соединения с PostgreSQL это очевидно: TCP-рукопожатие плюс TLS плюс аутентификация плюс fork бэкенда — десятки миллисекунд против доли миллисекунды на короткий SELECT. Для объекта Order из десяти полей — очевидно наоборот.

Обратите внимание на третью ветку: если объект неизменяем, повторное использование — это не пул, а разделяемый кэш или Flyweight. Пул нужен именно тогда, когда объект изменяем и эксклюзивен: во время работы им владеет ровно один потребитель.

Жизненный цикл объекта в пуле

Каждое состояние здесь оплачено чьей-то ночной сменой:

  • Проверяется. База перезапустилась — в пуле лежат мёртвые соединения. Без проверки при выдаче первые N запросов после рестарта получают ошибку. Дешёвая проверка — «сокет не закрыт», дорогая и надёжная — SELECT 1; поэтому проверяют «мягко»: пингуют только соединения, простоявшие дольше порога.
  • Возвращается → сброс состояния. Самый коварный класс багов. Соединение вернулось в пул с незакрытой транзакцией, с изменённым search_path, с временной таблицей, с SET LOCAL statement_timeout. Следующий потребитель получает эти настройки в наследство. Для HTTP-клиента аналог — оставшийся заголовок Authorization от прошлого пользователя; это уже не баг, а инцидент безопасности.
  • max_lifetime. Соединение, живущее вечно, мешает балансировке (после добавления реплики трафик не перераспределится) и натыкается на таймауты промежуточных прокси и firewall. Практика: время жизни соединения должно быть меньше, чем таймаут простоя на стороне сервера и балансировщика.
  • Утечка. Пул не может отличить «долгий запрос» от «забыли вернуть». Поэтому в приличных пулах есть leakDetectionThreshold: если объект не вернулся за N секунд, в лог печатается стек того, кто его взял. Это единственный способ найти виновника в большом коде.

Выдача с таймаутом — это обратное давление

Это ровно тот же приём, что и ограниченная очередь в паттернах конкурентности: пул с таймаутом — механизм обратного давления. Он превращает перегрузку из «всё зависло» в «часть запросов честно отклонена», и это единственно правильное поведение под пиком.

Реализация: пул с валидацией, сбросом и таймаутом

import threading
import time
from dataclasses import dataclass
from typing import Callable, Generic, TypeVar
from contextlib import contextmanager

T = TypeVar("T")


class PoolTimeout(RuntimeError):
    """Объект не удалось получить за отведённое время — это штатный отказ, а не сбой."""


@dataclass
class Pooled(Generic[T]):
    item: T
    created_at: float
    last_used_at: float


class ObjectPool(Generic[T]):
    """Пул с фиксированным потолком, валидацией при выдаче и сбросом при возврате.

    Сложность: acquire/release — O(1) амортизированно (стек свободных).
    Память: O(max_size) объектов + очередь ожидающих потоков.
    """

    def __init__(
        self,
        factory: Callable[[], T],
        close: Callable[[T], None],
        validate: Callable[[T], bool],
        reset: Callable[[T], None],
        max_size: int = 20,
        max_lifetime: float = 1800.0,
    ) -> None:
        self._factory, self._close = factory, close
        self._validate, self._reset = validate, reset
        self._max_size, self._max_lifetime = max_size, max_lifetime
        self._free: list[Pooled[T]] = []
        self._in_use = 0
        self._lock = threading.Condition()

    def _destroy(self, item: T) -> None:
        try:
            self._close(item)
        except Exception:
            pass          # закрытие мёртвого объекта не должно ронять вызывающего

    def _acquire(self, timeout: float) -> T:
        deadline = time.monotonic() + timeout
        with self._lock:
            while True:
                # LIFO, а не FIFO: горячие объекты остаются горячими,
                # лишние успевают протухнуть по max_lifetime и закрыться.
                while self._free:
                    p = self._free.pop()
                    if time.monotonic() - p.created_at > self._max_lifetime:
                        self._destroy(p.item)
                        continue
                    if not self._validate(p.item):
                        self._destroy(p.item)
                        continue
                    self._in_use += 1
                    return p.item

                if self._in_use < self._max_size:
                    self._in_use += 1
                    break  # создаём вне блокировки: factory может ходить по сети

                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise PoolTimeout(f"нет свободных объектов за {timeout} с")
                self._lock.wait(remaining)

        try:
            return self._factory()
        except Exception:
            with self._lock:
                self._in_use -= 1
                self._lock.notify()
            raise

    def _release(self, item: T) -> None:
        try:
            self._reset(item)          # сброс состояния — до возврата в набор
        except Exception:
            # Сбросить не удалось — объект непригоден: уничтожаем и освобождаем место.
            with self._lock:
                self._in_use -= 1
                self._lock.notify()
            self._destroy(item)
            return
        now = time.monotonic()
        with self._lock:
            self._in_use -= 1
            self._free.append(Pooled(item, created_at=now, last_used_at=now))
            self._lock.notify()

    @contextmanager
    def borrow(self, timeout: float = 2.0):
        """Единственный публичный способ взять объект: вернуть его нельзя забыть."""
        item = self._acquire(timeout)
        try:
            yield item
        finally:
            self._release(item)

Ключевое проектное решение — не давать метода acquire() наружу. Публичен только контекстный менеджер borrow(). Тогда «забыть вернуть» физически труднее, чем вернуть: паттерн RAII надет поверх пула, и две техники усиливают друг друга.

Пул буферов: другой случай, другие правила

// sync.Pool — НЕ пул ресурсов. Это подсказка сборщику мусора:
// содержимое может быть выброшено в любой момент (полностью очищается при GC).
// Поэтому годится только для того, что не жалко потерять: временные буферы.
var bufPool = sync.Pool{
    New: func() any {
        b := make([]byte, 0, 64*1024)
        return &b // храним указатель: иначе каждый Put аллоцирует интерфейсную обёртку
    },
}

func render(w io.Writer, rows []Row) error {
    bp := bufPool.Get().(*[]byte)
    buf := (*bp)[:0] // ОБЯЗАТЕЛЬНО обнулить длину: иначе к чужим данным допишем свои
    defer func() {
        if cap(buf) <= 1<<20 { // не возвращаем в пул разросшиеся буферы — иначе память не отдастся
            *bp = buf
            bufPool.Put(bp)
        }
    }()

    for _, r := range rows {
        buf = appendRow(buf, r)
    }
    _, err := w.Write(buf)
    return err
}

Три правила, которые нарушают чаще всего: класть в sync.Pool указатели, а не значения; обнулять длину при получении; не возвращать аномально разросшиеся буферы. Аналог в .NET — ArrayPool<T>.Shared с обязательным Return(array, clearArray: true), если в массиве были чувствительные данные.


Когда пул делает хуже

Честная часть, без которой глава была бы рекламой.

  1. Пул мелких объектов почти всегда медленнее аллокации. В языках с поколенческим сборщиком (JVM, .NET, Go) аллокация в молодом поколении — это сдвиг указателя, а смерть молодого объекта бесплатна: он просто не копируется. Пул превращает короткоживущие объекты в долгоживущие, они переезжают в старое поколение, и вы получаете больше работы сборщику, а не меньше. Подробно — память и сборка мусора.
  2. Пул ломает анализ убегания. JIT умеет размещать не убегающий объект на стеке; объект, пришедший из пула, убегает по определению.
  3. Пул добавляет синхронизацию. Общий пул — это точка конкуренции: под нагрузкой блокировка пула может стоить дороже, чем сама аллокация. Отсюда пулы «по потоку» (sync.Pool устроен именно так, per-P) и thread-local буферы.
  4. Пул скрывает утечку до последнего. Пока объектов хватает, утечка не видна; при пике всё встаёт разом. Поэтому метрики пула (занято/свободно/ожидающих/время ожидания) — обязательны, а не «когда-нибудь потом».
  5. Пул с неограниченным ростом — не пул. Если при исчерпании создаётся новый объект, вы просто отложили отказ и потеряли обратное давление.

Финализатор — это диагностика, а не освобождение

Соблазн: «поставлю финализатор, и если забыли закрыть — само закроется». Реальность:

  • Момент вызова недетерминирован: может не наступить никогда (при выходе процесса финализаторы не гарантированы).
  • Финализатор удлиняет жизнь объекта минимум на один цикл сборки и грузит сборщик.
  • В Java finalize() объявлен устаревшим (JEP 421) и заменён на Cleaner; в Python __del__ вызывается в непредсказуемом потоке и молча глотает исключения.

Правильное применение — сигнализация:

import traceback
import warnings


class Connection:
    def __init__(self, sock):
        self._sock = sock
        self._closed = False
        self._opened_at_stack = "".join(traceback.format_stack(limit=8))

    def close(self) -> None:
        if not self._closed:
            self._sock.close()
            self._closed = True

    def __del__(self):
        # Не «чиним» утечку, а громко о ней сообщаем: с местом, где ресурс был взят.
        if not self._closed:
            warnings.warn(
                f"соединение не закрыто; взято здесь:\n{self._opened_at_stack}",
                ResourceWarning,
                stacklevel=2,
            )
            self.close()          # аварийное закрытие — страховка, а не механизм

Запуск тестов с python -W error::ResourceWarning превращает такие предупреждения в падения — и утечка находится на CI, а не в проде. В .NET тот же смысл несёт классический Dispose pattern с GC.SuppressFinalize, в Java — Cleaner, в Go — runtime.SetFinalizer (использовать только для диагностики).


Типичные ошибки

Ошибка Как выглядит Что делать
defer в цикле Дескрипторы копятся до конца функции Вынести тело итерации в функцию
Проглоченная ошибка Close() Молча потерянные данные при записи Возвращать ошибку закрытия наружу
Возврат в пул без сброса Чужая транзакция, чужой Authorization reset() до возврата; не смог — уничтожить
Нет валидации при выдаче Волна ошибок после рестарта БД Проверять «холодные» объекты перед выдачей
Бесконечный acquire() Под пиком висит всё приложение Таймаут + ограниченная очередь ожидания
Соединения живут вечно Трафик не перебалансируется, рвутся по таймауту прокси max_lifetime меньше таймаутов инфраструктуры
Пул мелких объектов Стало медленнее, GC грузится больше Не пулить дешёвое; мерить, а не верить
Финализатор как механизм закрытия «Иногда течёт», воспроизводится раз в месяц Финализатор — только предупреждение
Публичный acquire() без borrow() Однажды кто-нибудь забудет вернуть Единственный вход — контекстный менеджер
Пул на процесс × 40 процессов 40 × 20 = 800 соединений при max_connections=100 Считать суммарный потолок; ставить pgbouncer

Последняя строка стоит отдельного упоминания: пул считается по всему флоту, а не по одному процессу. Классический отказ в проде выглядит так: увеличили число подов в Kubernetes с 10 до 40, база легла — при том что каждый под «всего лишь» держит 20 соединений.


Как это выглядит в проде

  • HikariCP (Java) — эталон в проектировании пула: maximumPoolSize, maxLifetime, leakDetectionThreshold, отказ от валидации ради скорости там, где это безопасно. Документация github.com/brettwooldridge/HikariCP читается как учебник по пулам.
  • database/sql в GoSetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime. Умолчание «неограниченно» для открытых соединений — известная ловушка новичка.
  • PgBouncer — пул уровня инфраструктуры: пул пулов, обязательный при десятках инстансов приложения (см. производительность БД).
  • ArrayPool<T> / RecyclableMemoryStream в .NET, ByteBuf с подсчётом ссылок в Netty — пулы буферов в горячих сетевых путях.
  • Пулы потоков — тот же паттерн для другого ресурса, разобран в паттернах конкурентности.
  • Метрики: занятые/свободные объекты, время ожидания выдачи (p99), число таймаутов, число «утёкших». Без них пул — чёрный ящик, а с ними — самый информативный индикатор перегрузки.

Мини-итог

  • Ресурс — не память: у него ограниченный источник, парные операции и внешний эффект. Сборщик мусора не освобождает ресурсы вовремя.
  • Первый вопрос проектирования — кто владелец. Утечка и двойное освобождение — две стороны ненаписанного ответа.
  • RAII, with, using, try-with-resources, defer — один паттерн: освобождение привязано к области видимости и выполняется при любом выходе, включая исключение.
  • В Go defer привязан к функции, а не к блоку: ресурс в цикле требует отдельной функции.
  • Object Pool окупается, когда создание сравнимо со стоимостью работы: соединения, потоки, крупные буферы. Для мелких объектов пул обычно вредит.
  • Обязательные части пула: валидация при выдаче, сброс состояния при возврате, ограниченный потолок, таймаут ожидания, максимальное время жизни, детектор утечек, метрики.
  • Пул с таймаутом — это обратное давление: честный отказ вместо бесконечного ожидания.
  • Финализатор — средство диагностики. Механизм освобождения — область видимости.

Источники

  • Bjarne Stroustrup о RAII — «Resource management», включая знаменитый ответ, почему в C++ нет finally.
  • Andrei Alexandrescu, Petru Marginean, «Simplifying the D&E of Exception-Safe Code» — оригинальный Scope Guard, drdobbs.com.
  • PEP 343, «The with statement» — peps.python.org.
  • JEP 421, «Deprecate Finalization for Removal» — openjdk.org, и java.lang.ref.Cleaner как замена.
  • Microsoft, «Implementing a Dispose method» — learn.microsoft.com.
  • HikariCP, «About Pool Sizing» — github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing: почему маленький пул часто быстрее большого.
  • Go, sync.Poolpkg.go.dev/sync#Pool, раздел про очистку при GC.
  • Go, database/sqlpkg.go.dev/database/sql#DB.SetConnMaxLifetime.
  • Douglas Schmidt et al., «Pattern-Oriented Software Architecture, vol. 3: Patterns for Resource Management», 2004 — единственный каталог, посвящённый ресурсам целиком.

Что дальше

Мы разобрали объекты, которые дороги в создании. Следующая глава — про объекты, которые дороги в изменении: бизнес-правила. Когда условия меняются раз в неделю, if в коде перестаёт быть уместной формой, и правила превращаются в данные — с деревом объектов, композицией предикатов и собственным маленьким языком.

Правила как данные: Interpreter, Specification и композиция предикатов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов