DbContext не Является Потокобезопасным. Распараллеливаем Запросы EF Core Правильно. Начало
Все мы создавали конечные точки вроде «Панель управления для мониторинга» или «Сводка по пользователю». Это конечная точка, которой необходимо получить несколько совершенно не связанных между собой наборов данных, чтобы составить полную картину для пользователя. Например, последние 50 заказов, текущие логи состояния системы, настройки профиля пользователя и, возможно, количество уведомлений.
Итак, вы пишете код стандартным способом:
var orders = await GetRecentOrdersAsync(id);
var logs = await GetSystemLogsAsync();
var stats = await GetUserStatsAsync(id);
return new DashboardDto(orders, logs, stats);
Это чисто, читаемо, работает. Но есть проблема. Если GetRecentOrdersAsync занимает 300мс, GetSystemLogsAsync — 400мс, а GetUserStatsAsync — 300мс, данные будут загружаться целую секунду (300+400+300).
В распределённой системе задержка ухудшает пользовательский опыт. Поскольку эти наборы данных не связаны между собой, мы должны иметь возможность выполнять их параллельно. Тогда общее время составило бы только длительность самого медленного запроса (400 мс). Это 60% улучшение производительности просто за счёт изменения способа выполнения кода. Но наивный подход с EF Core не сработает.
Проблема с Task.WhenAll
Самая распространённая ошибка – обернуть эти вызовы репозитория в задачи и ожидать их выполнения одновременно:
// ❌ НЕ ДЕЛАЙТЕ ТАК
public async Task<DashboardData>
GetDashboardData(int id)
{
// Методы используют один _dbContext
var orders = _repo.GetOrdersAsync(id);
var logs = _repo.GetLogsAsync();
var stats = _repo.GetStatsAsync(id);
await Task.WhenAll(orders, logs, stats);
return new DashboardData(
orders.Result,
logs.Result,
stats.Result);
}
Если вы выполните это, вы столкнётесь с таким исключением: «A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext, however instance members are not guaranteed to be thread safe.» («Вторая операция в этом контексте началась до завершения предыдущей. Обычно это происходит из-за того, что разные потоки используют один и тот же экземпляр DbContext, однако потокобезопасность членов экземпляра не гарантируется.»)
Почему это происходит?
DbContext в EF Core не является потокобезопасным. Это объект с состоянием, предназначенный для управления одной единицей работы. Он поддерживает «отслеживание изменений» для контроля за загруженными сущностями и инкапсулирует единственное базовое соединение с БД.
Протоколы баз данных (например, TCP-поток для PostgreSQL или SQL Server) обычно синхронны на уровне соединения. Вы не можете отправить два разных SQL-запроса по одному и тому же каналу в одну и ту же миллисекунду. При использовании Task.WhenAll несколько потоков пытаются одновременно захватить это единственное соединение, и EF Core вмешивается, чтобы сгенерировать исключение и предотвратить повреждение данных.
Таким образом, у нас возникает дилемма: нам нужна скорость параллелизма, но DbContext вынуждает нас использовать последовательное выполнение.
Окончание следует…
Источник: https://www.milanjovanovic.tech/blog/dbcontext-is-not-thread-safe-parallelizing-ef-core-queries-the-right-way