Какие проблемы решает использование времени жизни (lifetime) в ASP.NET Core?

Ответ

В ASP.NET Core Dependency Injection (DI) время жизни сервиса определяет, как долго контейнер будет хранить его экземпляр. Правильный выбор критичен для производительности и корректности работы приложения.

Три основных времени жизни:

  1. Transient – создается новый экземпляр для каждого запроса к контейнеру.

    • Используйте для: легковесных, stateless сервисов.
    • Проблема: может создать нагрузку, если сервис тяжелый и запрашивается часто.
  2. Scoped – один экземпляр создается на область видимости (scope), например, на один HTTP-запрос.

    • Используйте для: сервисов, которым нужен контекст в рамках операции (например, DbContext в Entity Framework Core).
    • Проблема: попытка разрешить Scoped-сервис из Singleton или вне области видимости вызовет исключение.
  3. Singleton – один экземпляр на все время работы приложения.

    • Используйте для: stateless сервисов, кэшей, конфигураций.
    • Проблема: если Singleton зависит от Scoped-сервиса, этот Scoped-сервис неявно становится Singleton, что может привести к утечке памяти или некорректным данным (например, один DbContext на все запросы).

Пример проблемы и решения:

// ПРОБЛЕМА: Singleton, зависящий от Scoped-сервиса.
public class CacheService
{
    private readonly AppDbContext _dbContext; // Scoped-сервис!
    public CacheService(AppDbContext dbContext) => _dbContext = dbContext;
    // _dbContext будет захвачен и жить вечно, вызывая утечку.
}

// РЕШЕНИЕ: Внедрять IServiceScopeFactory и создавать область видимости явно.
public class CacheService
{
    private readonly IServiceScopeFactory _scopeFactory;
    public CacheService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public async Task<string> GetCachedDataAsync()
    {
        using (var scope = _scopeFactory.CreateScope())
        {
            var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            // Работаем с dbContext в его собственной области видимости.
            return await dbContext.Data.FindAsync(1);
        }
    }
}

Ключевые правила:

  • Scoped-сервисы не должны быть разрешены из Singleton-сервисов напрямую.
  • Transient-сервисы не должны внедряться в Singleton-сервисы, если они не предназначены для этого, так как тоже станут де-факто Singleton.
  • Всегда проверяйте граф зависимостей на предмет captive dependencies (захвата зависимостей с более коротким временем жизни).

Ответ 18+ 🔞

Слушай, а вот эта вся хуйня с временем жизни сервисов в ASP.NET Core — это ж надо понимать, а то приложение накроется медным тазом, причём в самый неподходящий момент, когда уже продакшен горит!

Короче, есть три главных способа зарегистрировать твой сервис, и от выбора зависит, не словишь ли ты утечку памяти на гигабайты или исключение в лоб в пятницу вечером.

Первое — Transient (он же временный, одноразовый). Каждый раз, когда кто-то просит этот сервис, контейнер лезет в карман и выдаёт новый, свеженький экземпляр. Как носок — надел, выбросил. Идеально для всякой лёгкой, безгосударственной хуйни, которая не помнит, что было вчера. Но если твой сервис — это монстр, который жрёт ресурсы как не в себя, и его дергают сто раз в секунду, то приложение просто ляжет, потому что создавать такие тяжеловесы каждый раз — это пиздец какой расход.

Второе — Scoped (область видимости, он же "на один запрос"). Вот это уже умнее. На время одного HTTP-запроса (или любой другой созданной тобой области) контейнер выдаёт один и тот же экземпляр. Все, кто в рамках этого запроса его просят, получают одну и ту же ссылку. Классика — твой DbContext из Entity Framework. Ему же нужно отслеживать изменения в рамках одной операции, а не разбрасываться новыми инстансами как дурак. Но тут главное не обосраться: если попробуешь достать Scoped-сервис из Singleton'а или просто из кода, где нет активной области (scope), то получишь исключение прямо в ебало. Контейнер тебе так и скажет: "Чувак, ты че, охуел? Где scope, блядь?".

Третье — Singleton (он же один на всех, он же "царь и бог"). Создаётся один раз при старте приложения и живёт, пока все не вырубят. Все запросы, все потоки — все тычут пальцем в один и тот же объект. Идеально для кэшей, конфигураций, каких-то статических менеджеров. Но вот тут-то и начинается самая интересная пиздобратия.

Главная засада (она же captive dependency): Если твой жирный, вечный Singleton возьмёт и внедрит в себя зависимость, которая по жизни Scoped (например, тот же DbContext), то контейнер, создавая Singleton один раз, один раз же и выдаст ему этот DbContext. И этот бедный контекст будет жить вечно, как пленник, таская за собой все подключения и отслеженные сущности, пока память не кончится. Это и есть утечка, ёпта. Или ещё хуже — данные от разных пользователей в одном контексте перемешаются, будет полный пиздец.

Как это выглядит на практике и как не наступить на грабли:

// ПРОБЛЕМА: Синглтон-кэш, который схватил скопленный DbContext и не отпускает.
public class CacheService
{
    private readonly AppDbContext _dbContext; // А этот ублюдок по жизни Scoped!
    public CacheService(AppDbContext dbContext) => _dbContext = dbContext;
    // Контейнер выдал DbContext при создании CacheService, и теперь они умрут вместе.
    // DbContext превратился в неявный Singleton. Пиздец, утечка.
}

// РЕШЕНИЕ: Не быть жадным, создавать свою область видимости когда надо.
public class CacheService
{
    private readonly IServiceScopeFactory _scopeFactory; // Эта штука всегда Singleton
    public CacheService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public async Task<string> GetCachedDataAsync()
    {
        // Создаём свою, новую, чистенькую область видимости.
        using (var scope = _scopeFactory.CreateScope())
        {
            // Достаём из ЭТОЙ области свой собственный, свежий DbContext.
            var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            // Поработали с ним...
            return await dbContext.Data.FindAsync(1);
            // ...а здесь using закроется, scope умрёт, и DbContext благополучно будет уничтожен.
            // Никаких пленников, никаких утечек. Красота.
        }
    }
}

Итоговые правила, которые надо выжечь на подкорке:

  1. Не суй Scoped-сервисы напрямую в конструктор Singleton'ов. Это как пригласить гостя на одну ночь, а он поселится у тебя навсегда и будет жрать твой холодильник.
  2. Transient-сервисы, внедрённые в Singleton, тоже становятся его пленниками. Они создаются один раз и живут столько же, сколько и их хозяин. Убедись, что они для этого предназначены.
  3. Всегда смотри на граф зависимостей. Кто от кого зависит? Не захватывает ли долгоживущий сервис какую-нибудь короткоживущую зависимость? Это и есть проверка на captive dependencies — главную причину ебанины в продакшене.

Короче, думай головой, когда регистрируешь сервисы, а не тыкай AddSingleton на всё подряд. Иначе будет тебе и хиросима, и нагасаки в одном флаконе.