Task и TPL
↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая
Зачем это на собесе
Task— основа асинхронности в .NET. Вопросы: чем Task отличается от Thread, что такое Task.Run и когда он нужен, как работают WhenAll и WhenAny, как обрабатывать исключения нескольких задач.
Объяснение
Task — обещание результата, а не поток
Task / Task<T> — объект, представляющий асинхронную операцию и её будущий результат. Он не равен потоку:
- I/O-задача (запрос к БД, HTTP) во время ожидания не занимает ни одного потока: ОС уведомит о завершении.
- CPU-задача через
Task.Runвыполняется на потоке пула. Состояния:Created,WaitingForActivation,Running,RanToCompletion,Faulted,Canceled.
Task.Run
Выполнить CPU-bound работу на пуле потоков, чтобы не блокировать текущий поток.
var hash = await Task.Run(() => ComputeHeavyHash(bytes), ct);- В ASP.NET Core не оборачивайте I/O в
Task.Run(Task.Run(() => db.Query())) — вы просто занимаете ещё один поток. Task.Runв обработчике запроса для CPU-работы тоже почти бесполезен: запрос и так на потоке пула. Смысл есть, если нужно параллельно выполнить несколько CPU-задач или разгрузить UI-поток (в десктопе).
Параллельное ожидание: WhenAll
// последовательно: время = сумма
var patient = await patientClient.GetAsync(id, ct);
var visits = await visitClient.GetAsync(id, ct);
var labs = await labClient.GetAsync(id, ct);
// параллельно: время = максимум
var patientTask = patientClient.GetAsync(id, ct);
var visitsTask = visitClient.GetAsync(id, ct);
var labsTask = labClient.GetAsync(id, ct);
await Task.WhenAll(patientTask, visitsTask, labsTask);
var summary = new PatientSummary(patientTask.Result, visitsTask.Result, labsTask.Result); // .Result безопасен после await WhenAllТак собирает данные сборщик для печатных форм из разных storage-сервисов: независимые запросы параллельно.
Warning
Один
DbContextнельзя использовать в параллельных запросах (A second operation was started on this context). Для параллели — отдельные контексты (IDbContextFactory) или последовательные запросы.
Исключения в WhenAll
var all = Task.WhenAll(t1, t2, t3);
try { await all; }
catch (Exception first) // await пробрасывает ПЕРВОЕ исключение
{
var allErrors = all.Exception!.InnerExceptions; // все исключения — в AggregateException у задачи
}WhenAny и таймауты
var winner = await Task.WhenAny(primary.GetAsync(ct), fallback.GetAsync(ct)); // первая завершившаяся
var result = await winner; // пробросит её исключение, если было
var data = await client.GetAsync(ct).WaitAsync(TimeSpan.FromSeconds(5), ct); // .NET 6: таймаут на ожидание
await foreach (var done in Task.WhenEach(tasks)) { Handle(await done); } // .NET 9: по мере завершенияСоздание задач
Task.FromResult(value),Task.CompletedTask— уже завершённые (для синхронной реализации асинхронного интерфейса).Task.FromException,Task.FromCanceled.TaskCompletionSource<T>— вручную управляемая задача (обёртка над колбэками и событиями):
var tcs = new TaskCompletionSource<Report>(TaskCreationOptions.RunContinuationsAsynchronously);
job.Completed += (_, r) => tcs.TrySetResult(r);
job.Failed += (_, e) => tcs.TrySetException(e);
return await tcs.Task;RunContinuationsAsynchronously — чтобы продолжения не выполнялись синхронно в потоке, вызвавшем SetResult (защита от дедлоков и неожиданного реентранса).
Нюансы и подводные камни
- «Fire-and-forget» (
_ = DoAsync();) в веб-запросе: исключение потеряется, а scoped-сервисы будут освобождены раньше завершения задачи. Для фоновой работы — очередь иBackgroundService. Task.WhenAllна тысячах задач одновременно → перегрузка внешнего сервиса или БД. Ограничивайте параллелизм (SemaphoreSlim,Parallel.ForEachAsync).Taskможно await-ить многократно, результат кэшируется.ValueTask— только один раз.task.Resultиtask.Wait()до завершения блокируют поток (и оборачивают исключение в AggregateException).ContinueWith— низкоуровневый и опасный API (планировщик, исключения). Используйтеawait.
Тестирование
[Fact]
public async Task Collects_data_in_parallel()
{
var patients = Substitute.For<IPatientClient>();
var visits = Substitute.For<IVisitClient>();
patients.GetAsync(default, default).ReturnsForAnyArgs(Task.Delay(100).ContinueWith(_ => new PatientDto()));
visits.GetAsync(default, default).ReturnsForAnyArgs(Task.Delay(100).ContinueWith(_ => (IReadOnlyList<VisitDto>)[]));
var sw = Stopwatch.StartNew();
await new SummaryCollector(patients, visits).CollectAsync(Guid.NewGuid(), default);
sw.ElapsedMilliseconds.Should().BeLessThan(180); // ~100 мс, а не 200
}Вопросы с ответами
Чем Task отличается от Thread?
Thread — поток ОС. Task — абстракция асинхронной операции и её результата. I/O-задача вообще не занимает поток во время ожидания, CPU-задача выполняется на потоке пула. Task поддерживает продолжения, результат, исключения и отмену.
Когда использовать Task.Run?
Для CPU-bound работы, которую нужно вынести с текущего потока (UI-поток) или распараллелить. Не для I/O и не как обёртку над синхронными вызовами в ASP.NET Core.
Как выполнить несколько независимых запросов параллельно?
Запустить все задачи, не дожидаясь каждой, и затем await Task.WhenAll. Время будет равно самому долгому запросу, а не сумме.
Что будет с исключениями, если упали несколько задач в WhenAll?
await пробросит первое исключение. Все исключения доступны в Exception.InnerExceptions задачи, которую вернул WhenAll.
Что такое TaskCompletionSource?
Способ создать Task, которой вы управляете вручную (SetResult, SetException, SetCanceled). Используется для оборачивания колбэков и событий в async-API.
Связанные темы
- Предыдущая: Thread и ThreadPool · Следующая: async∕await изнутри: state machine, SynchronizationContext, ConfigureAwait
- Parallel и ограничение параллелизма: Parallel, PLINQ, Task.WhenAll и ограничение параллелизма
- Promise.all в JS (аналог): Асинхронность: callbacks, Promise, async∕await
- Фабрика отчётов и сбор данных: Фабрика отчётов и сбор данных из разных источников
- DbContext и параллельность: DbContext: жизненный цикл, pooling, Unit of Work