Времена жизни сервисов
Времена жизни: Singleton, Scoped, Transient — и почему их путаница ломает приложения.
Суть: при регистрации сервиса вы задаёте его время жизни: Singleton (один на всё приложение), Scoped (один на HTTP-запрос), Transient (новый при каждом запросе зависимости). Неправильный выбор — частый источник трудноуловимых багов.
Контейнер должен решить: создавать ли объект заново или переиспользовать. Время жизни задаёт это правило. Понять три варианта критично — особенно потому, что с ними связана классическая ошибка captive dependency.
Три времени жизни
| Время жизни | Метод | Сколько живёт | Для чего |
|---|---|---|---|
| Singleton | AddSingleton | Всё приложение | Кэш, конфиг, без состояния запроса |
| Scoped | AddScoped | Один HTTP-запрос | DbContext, сервисы запроса |
| Transient | AddTransient | Каждое получение | Лёгкие, без состояния |
Картина в рамках запросов
Запрос A: scope A открыт |-- Scoped: экземпляр S1 (один на весь запрос A) |-- Transient: T1, T2, T3 (новый на каждое внедрение) scope A закрыт -> Scoped S1 уничтожается Запрос B: scope B -> Scoped: экземпляр S2 (другой!) Singleton: один и тот же на A, B и все остальные запросы
Как работает под капотом
На каждый HTTP-запрос ASP.NET Core создаёт scope (область). Scoped-сервис кэшируется внутри этого scope: сколько бы раз его ни попросили в одном запросе — вернётся один и тот же объект. По завершении запроса scope уничтожается, а с ним и Scoped-объекты (включая Dispose). Singleton живёт в корневом контейнере и общий для всех. Transient создаётся каждый раз заново.
Главная ловушка — captive dependency: если Singleton зависит от Scoped, то Scoped-объект «застрянет» в Singleton навсегда, переживёт свой запрос и сломает логику (например, один DbContext на всё приложение — катастрофа). ASP.NET Core в Development специально проверяет это и бросает ошибку при старте.
# Имитация трёх времён жизни на счётчиках создания
created = {"singleton": 0, "scoped": 0, "transient": 0}
singleton_instance = None
def get_singleton():
global singleton_instance
if singleton_instance is None:
created["singleton"] += 1
singleton_instance = object()
return singleton_instance
def handle_request(scoped_cache):
# scoped: один на запрос
if "svc" not in scoped_cache:
created["scoped"] += 1
scoped_cache["svc"] = object()
s = scoped_cache["svc"]
# transient: новый каждый раз
created["transient"] += 1
return s
for _ in range(2): # два запроса
cache = {}
handle_request(cache)
handle_request(cache) # тот же scoped, но новый transient
get_singleton()
print(created) # singleton:1, scoped:2 (по запросу), transient:4
Попробуй сам ▶ — singleton создан 1 раз, scoped — по разу на запрос (2), transient — каждый вызов (4). Это и есть разница времён жизни.
Частые ошибки
- DbContext как Singleton. Он Scoped по природе: не потокобезопасен и накапливает трекинг. Регистрируйте Scoped (
AddDbContextуже делает так). - Singleton, зависящий от Scoped. Captive dependency — Scoped застрянет в Singleton.
- Хранить состояние запроса в Singleton. Оно «протечёт» между пользователями.
Best practices
- По умолчанию выбирайте Scoped для сервисов, работающих с данными запроса.
- Singleton — только для потокобезопасных, не зависящих от запроса вещей (кэш, конфиг).
- Доверяйте проверке контейнера в Development — она ловит captive dependency на старте.
Как scope связан с HTTP-запросом
Слово «scoped» обретает смысл, когда понимаешь, что такое scope в вебе. На каждый входящий HTTP-запрос ASP.NET Core открывает дочернюю область из корневого контейнера — это и есть scope. Все Scoped-сервисы, запрошенные в рамках обработки этого запроса, кэшируются в его пределах: сколько раз ни попроси — вернётся один экземпляр. Когда ответ отправлен, scope закрывается, и все созданные в нём Scoped-объекты освобождаются, включая вызов Dispose у тех, кто реализует IDisposable. Именно поэтому DbContext регистрируют как Scoped: один контекст на запрос, корректно освобождаемый в конце.
Singleton живёт в корневом контейнере и общий для всех запросов и потоков — отсюда требование потокобезопасности. Transient создаётся заново при каждом разрешении, даже несколько раз в пределах одного запроса. Понимание этих трёх режимов и того, что они привязаны к жизненному циклу запроса, — основа корректной работы с состоянием в веб-приложении.
Captive dependency и правило «не длиннее родителя»
Главная ловушка — нарушение правила «зависимость не должна жить дольше того, кто её держит». Если Singleton получает в конструктор Scoped-сервис, этот Scoped будет создан один раз и захвачен Singleton'ом навсегда — он переживёт свой запрос и станет фактически Singleton'ом. Для DbContext это катастрофа: один общий контекст на всё приложение не потокобезопасен, копит трекинг и приводит к трудноуловимым багам и утечкам. ASP.NET Core в Development специально валидирует scope при старте и бросает ошибку, если ловит такую конструкцию.
Практические рекомендации просты. Для сервисов, работающих с данными запроса (репозитории, сервисы поверх DbContext), берите Scoped. Для потокобезопасных, не зависящих от запроса вещей (кэш в памяти, чтение конфигурации) — Singleton. Transient — для лёгких сервисов без состояния. Если Singleton'у всё же нужен Scoped, не внедряйте его напрямую: внедрите IServiceScopeFactory и создавайте короткий scope вручную на время операции. Так вы соблюдаете правило времён жизни и избегаете захвата.
Итог: Singleton/Scoped/Transient задают, как часто пересоздаётся объект; их путаница (особенно captive dependency) — реальный источник багов. Дальше — конфигурация и логирование как сервисы.