Task и TPL

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

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
}

Вопросы с ответами

Связанные темы