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

«Как работают несколько параллельных запросов при использовании Scoped контекста в Entity Framework?» — вопрос из категории Entity Framework, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

В 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 предназначен для последовательной работы в рамках одного логического запроса. Для параллельных операций необходимо явно создавать изолированные экземпляры.