Паттерны ресурсов: Object Pool, RAII и владение временем жизни
Предыдущая глава закончилась на том, что composition root собирает граф объектов и раздаёт зависимости. Осталась вторая половина вопроса, о которой каталог GoF почти молчит: кто и когда всё это освобождает.
Причина молчания понятна. В 1994 году каталог писали на C++, где деструктор вызывается сам, и проблема казалась решённой языком. Потом пришли языки со сборкой мусора, и половина индустрии уверовала, что «память освобождается автоматически, значит, освобождается всё». Это неверно: сборщик мусора управляет памятью, а не ресурсами. Файловый дескриптор, соединение с БД, блокировка, временный файл, дескриптор сокета, мьютекс, транзакция — всё это нужно вернуть явно и вовремя.
Симптомы, по которым узнают проблему в проде, одинаковы во всех языках: too many open files,
FATAL: sorry, too many clients already, таймауты «на ровном месте» при живом CPU, медленная утечка
памяти под нагрузкой, зависший SELECT ... FOR UPDATE, который держит блокировку с прошлой пятницы.
За всеми ними стоит одна ошибка проектирования: не назначен владелец времени жизни ресурса.
Карта главы
ресурсов)) Владение Владелец один Передача владения Заимствование на время вызова Детерминированное освобождение RAII / деструктор Scope Guard / defer with / using / try-with-resources Освобождение при исключении Переиспользование Object Pool валидация при выдаче сброс состояния при возврате таймаут и обратное давление максимальное время жизни Кэш экземпляров Flyweight Страховка Финализатор как диагностика Cleaner / PhantomReference Детектор утечек по времени удержания
Что такое ресурс строго
Ресурс — это сущность, у которой есть ограниченный источник, парные операции «взять/вернуть» и внешний по отношению к процессу эффект от невозврата. Три признака вместе:
| Признак | Память (обычный объект) | Соединение с БД |
|---|---|---|
| Ограниченный источник | почти неограничена, GC подберёт | max_connections = 100, дальше отказ всем |
| Парные операции | new без явного free |
acquire обязан иметь release |
| Внешний эффект | нет | сервер держит процесс, память, блокировки |
Отсюда практическое следствие: ресурс нельзя доверить сборщику мусора. Сборщик запускается, когда кончается память, а не когда кончаются дескрипторы. Классическая ситуация: приложение на Java или Python держит 400 МБ кучи и спокойно живёт, при этом уже исчерпало пул соединений — GC не видит причин что-либо делать.
Владение: единственный вопрос, на который надо ответить
Прежде чем выбирать механизм, ответьте: кто владелец. Владелец — тот, кто обязан освободить.
старый больше не трогает"] D -->|Нет, заимствование| F["Не закрывать!
Ресурс переживёт вызов"] C --> G["Освобождение привязать к области видимости"] E --> G F --> H["Документировать в сигнатуре:
кто закрывает, знать обязаны оба"]
Две самые дорогие ошибки — зеркальные:
- Утечка: владелец не назначен, никто не закрывает. Ресурсы кончаются через часы или дни.
- Двойное освобождение / использование после закрытия: владельцев двое. Соединение вернули в пул, а старая ссылка продолжает через него читать — и получает чужой ответ. Это худший случай: баг проявляется как порча данных, а не как исключение.
Именно поэтому в 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 из десяти полей — очевидно наоборот.
почти бесплатна, GC её любит"] S -->|Да| Q2{"Дорог из-за чего?"} Q2 -->|"Внешний ресурс:
сокет, соединение, поток"| P1["Пул оправдан.
Плюс он ограничивает нагрузку на внешнюю систему"] Q2 -->|"Большая аллокация:
буфер на мегабайты"| P2["Пул буферов (ArrayPool, sync.Pool)"] Q2 -->|"Дорогая инициализация:
компиляция регулярки, загрузка модели"| P3["Это не пул, а кэш:
объект неизменяем и разделяем"] P1 --> R["Обязательны: валидация, сброс состояния,
таймаут, максимальное время жизни"] P2 --> R2["Обязателен сброс длины/содержимого
перед возвратом"]
Обратите внимание на третью ветку: если объект неизменяем, повторное использование — это не пул, а разделяемый кэш или Flyweight. Пул нужен именно тогда, когда объект изменяем и эксклюзивен: во время работы им владеет ровно один потребитель.
Жизненный цикл объекта в пуле
(сервер перезапущен, TCP порван) Занят --> Возвращается: release() Возвращается --> Свободен: состояние сброшено Возвращается --> Уничтожается: сброс невозможен
(незакрытая транзакция, ошибка протокола) Свободен --> Уничтожается: превышен max_idle_time
или max_lifetime Уничтожается --> [*] Занят --> Утечка: не вернули за leak_timeout Утечка --> [*]: логируем стек владельца
Каждое состояние здесь оплачено чьей-то ночной сменой:
- Проверяется. База перезапустилась — в пуле лежат мёртвые соединения. Без проверки при выдаче
первые N запросов после рестарта получают ошибку. Дешёвая проверка — «сокет не закрыт»,
дорогая и надёжная —
SELECT 1; поэтому проверяют «мягко»: пингуют только соединения, простоявшие дольше порога. - Возвращается → сброс состояния. Самый коварный класс багов. Соединение вернулось в пул с
незакрытой транзакцией, с изменённым
search_path, с временной таблицей, сSET LOCAL statement_timeout. Следующий потребитель получает эти настройки в наследство. Для HTTP-клиента аналог — оставшийся заголовокAuthorizationот прошлого пользователя; это уже не баг, а инцидент безопасности. - max_lifetime. Соединение, живущее вечно, мешает балансировке (после добавления реплики трафик не перераспределится) и натыкается на таймауты промежуточных прокси и firewall. Практика: время жизни соединения должно быть меньше, чем таймаут простоя на стороне сервера и балансировщика.
- Утечка. Пул не может отличить «долгий запрос» от «забыли вернуть». Поэтому в приличных пулах
есть
leakDetectionThreshold: если объект не вернулся за N секунд, в лог печатается стек того, кто его взял. Это единственный способ найти виновника в большом коде.
Выдача с таймаутом — это обратное давление
Иначе это неограниченный буфер
и отложенный отказ alt освободилось за 2 с P-->>H: conn else таймаут P-->>H: PoolTimeout — быстрый отказ Note over H: Лучше 503 за 2 секунды,
чем 30 000 висящих запросов end end H->>D: SELECT ... D-->>H: строки H->>P: release(conn) P->>P: сброс состояния, вернуть в набор
Это ровно тот же приём, что и ограниченная очередь в паттернах конкурентности: пул с таймаутом — механизм обратного давления. Он превращает перегрузку из «всё зависло» в «часть запросов честно отклонена», и это единственно правильное поведение под пиком.
Реализация: пул с валидацией, сбросом и таймаутом
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), если в массиве были
чувствительные данные.
Когда пул делает хуже
Честная часть, без которой глава была бы рекламой.
- Пул мелких объектов почти всегда медленнее аллокации. В языках с поколенческим сборщиком (JVM, .NET, Go) аллокация в молодом поколении — это сдвиг указателя, а смерть молодого объекта бесплатна: он просто не копируется. Пул превращает короткоживущие объекты в долгоживущие, они переезжают в старое поколение, и вы получаете больше работы сборщику, а не меньше. Подробно — память и сборка мусора.
- Пул ломает анализ убегания. JIT умеет размещать не убегающий объект на стеке; объект, пришедший из пула, убегает по определению.
- Пул добавляет синхронизацию. Общий пул — это точка конкуренции: под нагрузкой блокировка
пула может стоить дороже, чем сама аллокация. Отсюда пулы «по потоку» (
sync.Poolустроен именно так, per-P) и thread-local буферы. - Пул скрывает утечку до последнего. Пока объектов хватает, утечка не видна; при пике всё встаёт разом. Поэтому метрики пула (занято/свободно/ожидающих/время ожидания) — обязательны, а не «когда-нибудь потом».
- Пул с неограниченным ростом — не пул. Если при исчерпании создаётся новый объект, вы просто отложили отказ и потеряли обратное давление.
Финализатор — это диагностика, а не освобождение
Соблазн: «поставлю финализатор, и если забыли закрыть — само закроется». Реальность:
- Момент вызова недетерминирован: может не наступить никогда (при выходе процесса финализаторы не гарантированы).
- Финализатор удлиняет жизнь объекта минимум на один цикл сборки и грузит сборщик.
- В 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в Go —SetMaxOpenConns,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
withstatement» — 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.Pool— pkg.go.dev/sync#Pool, раздел про очистку при GC. - Go,
database/sql— pkg.go.dev/database/sql#DB.SetConnMaxLifetime. - Douglas Schmidt et al., «Pattern-Oriented Software Architecture, vol. 3: Patterns for Resource Management», 2004 — единственный каталог, посвящённый ресурсам целиком.
Что дальше
Мы разобрали объекты, которые дороги в создании. Следующая глава — про объекты, которые дороги в
изменении: бизнес-правила. Когда условия меняются раз в неделю, if в коде перестаёт быть
уместной формой, и правила превращаются в данные — с деревом объектов, композицией предикатов и
собственным маленьким языком.
Правила как данные: Interpreter, Specification и композиция предикатов