Как работают несколько параллельных запросов при использовании Scoped контекста в Entity Framework?

Ответ

В ASP.NET Core при регистрации DbContext как Scoped (по умолчанию) для каждого HTTP-запроса создается один общий экземпляр контекста. Если в рамках этого запроса вы запускаете несколько асинхронных операций с БД параллельно (например, через Task.WhenAll), они будут обращаться к одному экземпляру DbContext. Это приводит к критическим проблемам, потому что DbContext не является потокобезопасным.

Основные риски:

  • Состояние гонки (Race Condition): Параллельное изменение внутренних коллекций отслеживаемых сущностей (DbSet).
  • Исключения: Чаще всего возникает InvalidOperationException с сообщением "A second operation was started on this context before a previous operation completed".
  • Порча данных: Непредсказуемые результаты при конкурентной записи.

Пример проблемного кода:

public class DataService
{
    private readonly AppDbContext _context; // Один Scoped контекст на запрос
    public DataService(AppDbContext context) => _context = context;

    public async Task ProcessUsersAsync(List<int> userIds)
    {
        var tasks = userIds.Select(id => _context.Users.FindAsync(id)); // ПАРАЛЛЕЛЬНЫЙ вызов FindAsync
        var users = await Task.WhenAll(tasks); // ВЫБРОСИТ ИСКЛЮЧЕНИЕ!
    }
}

Правильные стратегии решения:

  1. Последовательное выполнение (Async/Await): Избегайте параллелизма на одном контексте. Выполняйте операции последовательно.
  2. Создание новых независимых экземпляров: Для истинно параллельной работы создавайте новые экземпляры DbContext внутри каждой задачи. В ASP.NET Core для этого нужно использовать IServiceScopeFactory.

    public class ParallelDataService
    {
        private readonly IServiceScopeFactory _scopeFactory;
        public ParallelDataService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
    
        public async Task<List<User>> GetUsersInParallelAsync(List<int> userIds)
        {
            var tasks = userIds.Select(async id =>
            {
                // Создаем новый scope и свой экземпляр контекста для каждой задачи
                using var scope = _scopeFactory.CreateScope();
                var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                return await context.Users.FindAsync(id);
            });
            return (await Task.WhenAll(tasks)).ToList();
        }
    }
  3. Использование Transient: В очень специфичных сценариях можно зарегистрировать контекст как Transient, но это обычно антипаттерн для веб-приложений, так как теряются преимущества единой области видимости на запрос (отслеживание изменений, автоматическое управление соединением).

Вывод: Scoped DbContext предназначен для последовательной работы в рамках одного логического запроса. Для параллельных операций необходимо явно создавать изолированные экземпляры.

Ответ 18+ 🔞

А, блядь, вот это тема! Смотри, сейчас объясню на пальцах, как эта хуйня работает, а то многие влетают, как слепые котята.

В общем, представляешь: у тебя в ASP.NET Core есть этот самый DbContext, который по умолчанию зарегистрирован как Scoped. Это значит, что на каждый пришедший HTTP-запрос создаётся один, блядь, единственный экземпляр контекста на весь запрос. И все твои сервисы в рамках этого запроса делят этого одного несчастного контекста, как последнюю палку колбасы.

И вроде бы всё норм, пока ты не начинаешь выёбываться и пытаться в параллельные запросы к базе из одного контекста. Типа, "ой, а давайте я тут Task.WhenAll запущу, чтобы быстрее было!". И тут начинается пиздец, потому что DbContext — он, сука, не потокобезопасный. Вообще. Ни капли.

Что происходит, если начать его насиловать параллельными вызовами? Да хуйня полная:

  • Состояние гонки (Race Condition): Внутри контекста есть коллекции сущностей, которые он отслеживает. Представь, что два потока одновременно пытаются туда что-то пихнуть или изменить. Это как два мужика в одной сортирной кабинке — места на всех не хватит, и всё кончится скандалом и говном на полу.
  • Исключения: Чаще всего тебе прилетит InvalidOperationException с текстом вроде "A second operation was started on this context before a previous operation completed". Это он тебе вежливо намекает: "Мудила, дождись, пока первый запрос дохуярится, а потом уже второй запускай!"
  • Порча данных: Самый страшный сценарий. Можешь получить в базу такую ахинею, что потом будешь неделю откатывать и плакать.

Вот, смотри, как НЕ НАДО делать. Типичный пример долбоёба, который ещё не обжёгся:

public class DataService
{
    private readonly AppDbContext _context; // Один контекст на всех
    public DataService(AppDbContext context) => _context = context;

    public async Task ProcessUsersAsync(List<int> userIds)
    {
        // Опа-на! Запускаем кучу FindAsync параллельно на одном контексте!
        var tasks = userIds.Select(id => _context.Users.FindAsync(id));
        var users = await Task.WhenAll(tasks); // ЖДИ ИСКЛЮЧЕНИЯ, ДУРИЛА!
    }
}

Этот код упадёт с треском, и будет прав. Потому что нельзя.

Так как же делать-то, если овердохуища данных и хочется параллельно?

Вариант первый, самый простой и правильный в 90% случаев: не выёбывайся, делай последовательно. Просто await один запрос, потом другой. Да, может быть чуть дольше, зато голова не болит, и данные целы.

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

Смотри, как умные люди делают:

public class ParallelDataService
{
    private readonly IServiceScopeFactory _scopeFactory; // Вот эта хуйня — твой ключ к параллелизму
    public ParallelDataService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public async Task<List<User>> GetUsersInParallelAsync(List<int> userIds)
    {
        var tasks = userIds.Select(async id =>
        {
            // Магия начинается тут! Создаём НОВЫЙ изолированный scope.
            using var scope = _scopeFactory.CreateScope();
            // Из этого scope достаём СВОЙ, личный, ни с кем не делимый экземпляр контекста.
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            // И вот теперь в этой задаче можно спокойно работать. Она не знает о других задачах.
            return await context.Users.FindAsync(id);
        });
        // Теперь Task.WhenAll не сломает всё к хуям, потому что у каждой задачи свой контекст.
        return (await Task.WhenAll(tasks)).ToList();
    }
}

Видишь разницу? Каждая задача живёт в своей песочнице. Одна упала — остальным похуй. Одна долго работает — остальные не ждут. Красота!

Кто-то может сказать: "А давайте зарегистрируем контекст как Transient, и не надо будет с этими scope'ами ебаться!" Ну, типа, чтобы каждый раз новый инстанс создавался. Так-то оно так, но это обычно антипаттерн для веб-приложений. Потому что теряется вся магия работы с контекстом в рамках одного запроса: отслеживание изменений сущностей, автоматическое открытие и закрытие соединения с базой. Получишь другие грабли по лицу.

Короче, вывод, блядь, простой: Scoped DbContext — он для последовательной работы в рамках одного запроса. Это как рабочий инструмент: одна отвертка на бригаду — можно передавать по очереди, но пытаться закручивать два винта одновременно одной отвёрткой — только себе навредишь. Хочешь реального параллелизма — будь добр, создавай каждому потоку (задаче) свой собственный, новый инструмент, через IServiceScopeFactory. И тогда всё будет пучком.