1. Task.WhenAll: дочекатися всіх одразу
У повсякденному житті рідко буває, що ви робите щось одне. Ви встаєте з ліжка й дивитеся на годинник, приймаєте душ і співаєте, одягаєтеся та слухаєте музику, паралельно готуючи собі каву. А якщо працюєте за компʼютером, то одночасно завантажуєте кілька файлів, надсилаєте низку мережевих запитів, обчислюєте багато даних у різних напрямах. Усі ці дії можуть (і повинні!) виконуватися паралельно, щоб не марнувати час користувача. Ми не хочемо чекати, поки кожне завдання виконається одне за одним! Як організувати такий «оркестр»? Як зрозуміти, коли УСЕ завершилося? Або, навпаки, дізнатися, хто першим упорався?
Task.WhenAll і Task.WhenAny — інструменти, які й потрібні для цих сценаріїв.
Метод WhenAll
Task.WhenAll — це статичний метод класу Task, який приймає колекцію завдань і повертає нове завдання, що завершується лише тоді, коли завершилися всі передані завдання. Це наче ви на іспиті чекаєте, доки всі однокурсники здадуть свої білети, і лише тоді виходите з аудиторії.
Сигнатура методу
Task Task.WhenAll(params Task[] tasks)
Task<TResult[]> Task.WhenAll<TResult>(params Task<TResult>[] tasks)
Є версії для завдань, що повертають результати (Task<TResult>), і «порожніх» (Task).
Найпростіший приклад: чекати завантаження всіх файлів
Спробуймо завантажити три файли асинхронно. Для простоти — імітуємо завантаження затримкою (Task.Delay). Приклади реальні, код робочий.
using System;
using System.Threading.Tasks;
class Program
{
// Симулюємо завантаження файлу із затримкою, повертаємо імʼя
static async Task<string> DownloadFileAsync(string fileName)
{
Console.WriteLine($"► Починаємо завантаження: {fileName}");
await Task.Delay(2000); // Очікуємо 2 секунди
Console.WriteLine($"✓ Завантаження завершено: {fileName}");
return fileName;
}
static async Task Main()
{
var files = new[] { "fileA.txt", "fileB.txt", "fileC.txt" };
// Запускаємо всі завантаження одночасно
var downloadTasks = new Task<string>[files.Length];
for (int i = 0; i < files.Length; i++)
{
downloadTasks[i] = DownloadFileAsync(files[i]);
}
// Очікуємо завершення всіх завантажень
string[] results = await Task.WhenAll(downloadTasks);
Console.WriteLine($"Усі завантаження завершені! Список: {string.Join(", ", results)}");
}
}
Що тут відбувається?
- Ми не чекаємо кожне завдання по черзі. Усі три запускаються паралельно (асинхронно).
- Task.WhenAll(downloadTasks) повертає завдання, яке завершиться лише тоді, коли завершаться всі завдання.
- Після цього можемо працювати з результатами всіх завдань — використовувати їх, виводити на екран, передавати далі.
Аналогія
Це схоже на те, якби ви доручили трьом курʼєрам доставити три посилки й хотіли повідомити керівника лише тоді, коли всі успішно дістануться до клієнтів.
Схема роботи Task.WhenAll
sequenceDiagram
participant Program
participant Завдання1
participant Завдання2
participant Завдання3
Program->>Завдання1: Запуск
Program->>Завдання2: Запуск
Program->>Завдання3: Запуск
Завдання1-->>Program: Завершено (може бути раніше за інших)
Завдання2-->>Program: Завершено
Завдання3-->>Program: Завершено
Program->>Program: Task.WhenAll завершено, усі результати доступні
Що, якщо одне із завдань завершується з помилкою?
Task.WhenAll не переривається, коли якесь із завдань завершилося з помилкою. Він чекає завершення всіх завдань. Якщо хоча б в одному стався виняток — підсумкове завдання буде у стані Faulted, і в ньому буде колекція всіх винятків, що сталися.
Приклад
static async Task<string> MayThrowAsync(string fileName)
{
await Task.Delay(500);
if (fileName == "fileB.txt")
throw new Exception("Помилка завантаження fileB.txt");
return fileName;
}
static async Task Main()
{
var files = new[] { "fileA.txt", "fileB.txt", "fileC.txt" };
var downloadTasks = files.Select(MayThrowAsync).ToArray();
try
{
string[] results = await Task.WhenAll(downloadTasks);
Console.WriteLine($"Усе добре: {string.Join(", ", results)}");
}
catch (Exception ex)
{
Console.WriteLine($"Принаймні одне завдання завершилося з помилкою: {ex.Message}");
}
}
У разі винятку щоб переглянути всі внутрішні помилки, користуйтеся AggregateException.InnerExceptions.
Офіційна документація: Task.WhenAll docs
2. Task.WhenAny: дочекатися першого завершеного завдання
Task.WhenAny — теж статичний метод, але він завершить свою роботу, щойно будь-яке з переданих завдань перейде у стан «Завершено» (неважливо, з помилкою чи успішно). Повертає посилання на перше завершене завдання.
Сигнатура
Task<Task> Task.WhenAny(params Task[] tasks)
Task<Task<TResult>> Task.WhenAny<TResult>(params Task<TResult>[] tasks)
Аналогія
Хто першим спече пиріг — той і молодець, а решту можна попросити зупинитися. Іноді достатньо дізнатися, який із кількох серверів швидше дав відповідь, і використати саме його результат.
Приклад: хто швидше?
using System;
using System.Threading.Tasks;
class Program
{
// Симулюємо запит із різною швидкістю
static async Task<string> RequestAsync(string name, int delay)
{
await Task.Delay(delay);
return $"{name} завершився за {delay} мс";
}
static async Task Main()
{
var taskA = RequestAsync("A", 1000);
var taskB = RequestAsync("B", 700); // Найшвидший
var taskC = RequestAsync("C", 1500);
// Чекаємо перше завершене завдання
Task<string> finished = await Task.WhenAny(taskA, taskB, taskC);
Console.WriteLine($"Першим завершився: {finished.Result}");
}
}
За допомогою WhenAny ми дізнаємося, яке із завдань першим прийшло до фінішу. Після цього можна скасувати решту завдань, наприклад, за допомогою CancellationToken (тема наступних лекцій).
Чому WhenAny повертає саме завдання, а не результат?
Тому що метод не знає, який тип результату у завдання: він не може заздалегідь знати, яке з завдань завершиться першим. Тому він повертає саме завдання — далі нам потрібно самостійно витягти результат: через await або властивість Result у цього завдання.
WhenAll vs WhenAny: порівняльна ілюстрація
| Метод | Коли спрацьовує | Що повертає | Типовий сценарій |
|---|---|---|---|
|
Коли всі завершилися | Завдання (Task/Task<T[]>) | Чекаємо всі відповіді або всі файли, агрегуємо результати |
|
Коли якесь із завдань завершилося | Завдання, що завершилося першим | Використовуємо перший результат, скасовуємо решту |
3. Комбінуємо WhenAll і WhenAny — життєві сценарії
Іноді потрібно спочатку дочекатися будь-якого завдання, а потім — усіх. Або навпаки.
Приклад: «Пінг-понг» між двома серверами
Уявіть, що ви надсилаєте запити одразу двом серверам-клонам на випадок, якщо один гальмує. Але використовуєте перший результат.
static async Task Main()
{
var fastServer = RequestAsync("Швидкий сервер", 400); // 400 мс
var slowServer = RequestAsync("Повільний сервер", 2000); // 2000 мс
var completed = await Task.WhenAny(fastServer, slowServer);
Console.WriteLine(await completed);
// *За потреби*: тут можна скасувати ще не завершені завдання через CancellationToken
}
Приклад: запустити обробку лише після завантаження всіх даних
У вашому застосунку є три джерела даних. Лише коли всі три завантажилися, варто переходити до обробки:
static async Task Main()
{
var task1 = DownloadFileAsync("a.txt");
var task2 = DownloadFileAsync("b.txt");
var task3 = DownloadFileAsync("c.txt");
var all = await Task.WhenAll(task1, task2, task3);
ProcessFiles(all[0], all[1], all[2]);
}
4. Типові помилки під час роботи з Task.WhenAll і Task.WhenAny
Помилка №1: очікування завершення першого завдання в WhenAll.
Новачки думають, що Task.WhenAll завершиться, щойно виконається перше завдання. Насправді він чекає завершення всіх завдань.
Помилка №2: ігнорування винятків у WhenAll.
Якщо одне із завдань кине виняток, підсумкове завдання буде у стані Faulted. Не обробивши AggregateException.InnerExceptions, можна пропустити важливі помилки.
Помилка №3: доступ до Result без перевірки в WhenAny.
Якщо перше завершене завдання впало з помилкою, виклик Result кине виняток. Перевірте Task.IsFaulted перед доступом.
Помилка №4: запуск завдань послідовно замість паралельно.
Наприклад, очікування кожного завдання через await у циклі замість використання Task.WhenAll суттєво знижує продуктивність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ