1. Вступ
У світі асинхронного програмування ми живемо за принципом «делегування із зворотним зв’язком». Уявіть: вам потрібно завантажити величезний файл, проаналізувати гігабайти логів або надіслати запит на далекий сервер. Замість того, щоб завмерти в очікуванні, мов статуя, ми кажемо системі: «Займися цим, а я поки попрацюю над чимось іншим. Коли закінчиш — неодмінно дай знати!»
Саме тут на сцену виходить Task — елегантне втілення такого підходу. Це не просто технічна абстракція, а своєрідний «розумний посередник», який бере на себе виконання роботи й гарантує, що результат не загубиться в цифровій порожнечі.
Task працює як персональний помічник, якому ви доручаєте важливу справу. Він киває, занотовує завдання у свій блокнот і каже: «Ідіть спокійно займатися своїми справами, я обов’язково знайду вас, коли все буде готово». І справді знаходить — з результатом у руках або з чесним поясненням, чому щось пішло не так.
Якщо шукати ближчу до життя аналогію, то Task нагадує сучасну систему попереднього запису: ви реєструєтеся онлайн на прийом до лікаря, отримуєте підтвердження — й немає потреби годинами сидіти в черзі. Система сама нагадає про наближення часу прийому, а ви тим часом можете жити повноцінним життям.
Клас Task
Task — це базовий будівельний блок асинхронного програмування в .NET. Він представляє запущену або майбутню операцію, результат якої з’явиться згодом. Якщо метод нічого не має повертати, використовуємо просто Task.
public async Task BackupToCloudAsync()
{
// Робить магію резервного копіювання, нічого не повертає
}
Клас Task<TResult>
Якщо потрібно повернути результат (наприклад, рядок, число, об’єкт…), використовуємо Task<TResult>:
public async Task<string> DownloadHtmlAsync(string url)
{
// Завантажує сторінку й повертає HTML-код
return "<html>...</html>";
}
Чому Task, а не Thread?
Thread керує самим потоком (це складно й небезпечно), а Task — вищорівнева абстракція: він може виконуватися в пулі потоків, може працювати асинхронно без виділення нового потоку (наприклад, під час I/O‑операцій) і не змушує вас перейматися низькорівневими деталями.
Клас Task дає змогу описати: «Я хочу запустити цю дію», а вже як саме вона виконуватиметься — нехай вирішує .NET!
2. Влаштування об’єкта Task
Властивості та методи Task, які варто знати
| Властивість / Метод | Опис |
|---|---|
|
Поточний стан завдання |
|
Результат для Task<TResult> (блокує потік) |
|
Чи завершене завдання |
|
Чи сталася помилка (виняток) у завданні |
|
Чи було завдання скасовано |
|
Блокує поточний потік до завершення (небезпечно) |
|
Запустити ще одне завдання після завершення |
|
Доступ до винятку, якщо Task завершилося з помилкою |
|
Унікальний ідентифікатор завдання |
Як працює асинхронний метод із Task
sequenceDiagram
participant Main as Головний потік
participant Task as Task (Фонове завдання)
Main->>Task: Запуск Task.Run(() => ...)
Note right of Task: Виконання у фоні
(CPU або I/O)
alt Завдання завершене
Task->>Main: await завершився — рухаємося далі
else Помилка
Task->>Main: await викидає виняток
end
3. Створення й запуск завдань: як працює Task
Асинхронні методи з async
Найпоширеніший випадок — ви просто оголошуєте метод як async і повертаєте або Task, або Task<TResult> (як щойно бачили).
Task.Run: виконання в пулі потоків
Якщо потрібно виконати важку роботу у фоновому завданні (наприклад, порахувати великі числа або закодувати відео), можна використати Task.Run:
Task work = Task.Run(() =>
{
// Складні обчислення — не блокуємо основний потік!
Console.WriteLine("Розпочато фонові обчислення...");
Thread.Sleep(2000); // Емуляція тривалої роботи
Console.WriteLine("Фонові обчислення завершено!");
});
Якщо потрібно отримати результат:
Task<int> calculateTask = Task.Run(() =>
{
// Наприклад, підрахунок суми перших 100 чисел
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
});
Task.Factory.StartNew
Це більш низькорівневий і гнучкий спосіб, що дає змогу тонко налаштовувати запуск завдання (наприклад, вказувати планувальник, передавати параметри тощо). У сучасному коді зазвичай радять використовувати Task.Run, бо він простіший і краще убезпечує від помилок.
4. Застосунок дня: наш довідник книжок
Припустімо, у нас є застосунок‑довідник книжок і потрібно додати функцію завантаження книжок із «хмарного» джерела — це буде I/O‑bound операція (повільний HTTP‑запит або читання з файла).
Додамо метод, який асинхронно «завантажує» книжки (емуляція затримки):
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
}
public class BookCatalog
{
public List<Book> Books { get; set; } = new();
public async Task LoadBooksAsync()
{
Console.WriteLine("Завантаження книжок...");
await Task.Delay(2000); // Емуляція тривалого завантаження (наприклад, HTTP або файл)
Books = new List<Book>
{
new Book { Title = "CLR via C#", Author = "Jeffrey Richter" },
new Book { Title = "C# in Depth", Author = "Jon Skeet" }
};
Console.WriteLine("Книжки успішно завантажено.");
}
}
У Main викличемо асинхронне завантаження (через await):
var catalog = new BookCatalog();
await catalog.LoadBooksAsync();
Console.WriteLine($"У каталозі {catalog.Books.Count} книжок.");
Таблиця: Основні способи створення й запуску Task
| Спосіб створення | Як застосовується | Результат | Застосування |
|---|---|---|---|
| async-метод | |
Асинхронна операція | Переважно для I/O; зручно |
|
|
Фонове завдання | CPU-bound (обчислення) |
|
Вручну створюємо й завершуємо Task | Під повним контролем розробника | Рідко; для низькорівневих випадків |
5. Життєвий цикл Task
Task може перебувати в різних станах:
- Created — завдання створено, але не запущено (для Task з явним запуском).
- WaitingToRun — очікує в черзі пулу потоків.
- Running — виконується.
- WaitingForActivation — очікує запуску або зовнішньої активації.
- RanToCompletion — успішно завершилося.
- Faulted — завершилося з помилкою (винятком).
- Canceled — скасовано (якщо підтримується скасування).
Діаграма
flowchart LR
Start -->|Запуск завдання| Running
Running -->|Успішно| Completed
Running -->|Помилка| Faulted
Running -->|Скасування| Canceled
Перевіримо це на практиці
Task task = Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine(task.Status); // Зазвичай: Running або WaitingToRun
await task;
Console.WriteLine(task.Status); // RanToCompletion після завершення
6. Як отримати результат із Task<TResult>?
Task<TResult> — це обгортка над результатом, який з’явиться в майбутньому. Коли потрібно дочекатися результату, використовуйте await:
Task<int> sumTask = Task.Run(() =>
{
int sum = 0;
for (int i = 1; i <= 5; i++) sum += i;
return sum;
});
int result = await sumTask;
Console.WriteLine(result); // 15
Якщо ви забудете написати await, отримаєте Task (обіцянку), а не результат. Це типова «асинхронна пастка».
Альтернатива: Синхронне отримання результату (НЕ РОБІТЬ В UI!)
Іноді (наприклад, у тестах) потрібно отримати результат без await. Можна використати властивість .Result:
int result = sumTask.Result;
Але якщо Task ще не завершено, цей код блокує потік, і якщо це UI‑потік, то застосунок зависне! Тому краще завжди надавати перевагу await.
Типові помилки з Task і Task<TResult>
Забули повернути Task, метод став void. Якщо метод нічого не повертає — повертайте Task, тільки не void, інакше не вийде обробити помилку.
Ігнорування await. Просто викликали метод, не дочекавшись результату, — і завдання починає жити власним життям («fire and forget»). Ви вже не дізнаєтеся, коли воно завершилося або впало з помилкою.
Блокувальне очікування через .Result або .Wait(). Легко отримати deadlock, особливо в UI та ASP.NET. Користуйтеся лише await.
7. Розширені можливості Task
Ланцюжок завдань: ContinueWith
Можна «навісити» дії, які потрібно виконати після завершення завдання, через ContinueWith:
Task.Run(() => 10)
.ContinueWith(t =>
{
Console.WriteLine($"Виконано! Результат: {t.Result}");
});
Але в сучасному C# зазвичай роблять це за допомогою async/await — так читати легше.
Приклад: Паралельне й послідовне завантаження даних
Припустімо, вам потрібно завантажити дві книжки з різних джерел. Можна запустити обидва Task паралельно й дочекатися обох:
public async Task LoadBooksFromMultipleSourcesAsync()
{
Task<List<Book>> t1 = LoadFromCloudAsync();
Task<List<Book>> t2 = LoadFromLocalAsync();
// Очікуємо обидва завдання паралельно
await Task.WhenAll(t1, t2);
// Об'єднуємо результати
Books = t1.Result.Concat(t2.Result).ToList();
}
private async Task<List<Book>> LoadFromCloudAsync()
{
await Task.Delay(2000); // "Хмара"
return new List<Book> { new Book { Title = "Cloud Book", Author = "Cloud Author" } };
}
private async Task<List<Book>> LoadFromLocalAsync()
{
await Task.Delay(1000); // "Локальний диск"
return new List<Book> { new Book { Title = "Local Book", Author = "Local Author" } };
}
Зверніть увагу: завдяки await Task.WhenAll(...) обидва запити стартують одночасно та виконуються паралельно (якщо це можливо), а застосунок чекає, поки вони обидва завершаться.
8. Корисні нюанси
Task і fire-and-forget
Інколи хочеться запустити завдання й не чекати, коли воно завершиться (наприклад, надіслати логи в хмару або «підсмажити тост», доки користувач працює):
async void LogToCloudAsync(string message)
{
await Task.Run(() =>
{
// Тривале надсилання логу
Thread.Sleep(1000);
Console.WriteLine($"Лог надіслано: {message}");
});
}
Але пам’ятайте: якщо в такому завданні станеться помилка — дізнатися про це буде дуже складно. Тому, якщо є можливість, повертайте Task і принаймні логуйте винятки всередині!
Task і Task<TResult> у реальному житті
- У клієнтських UWP/WPF/WinForms‑застосунках не блокуйте UI — використовуйте Task для тривалих операцій (файли, мережа).
- У Web API/ASP.NET Task допомагає не витрачати потоки на очікування мережі/БД, підвищуючи продуктивність.
- Організуйте «паралельне» виконання: одночасно завантажити, обробити й зберегти.
- Майже всі тривалі методи мають Async‑варіант: File.ReadAllTextAsync, HttpClient.GetStringAsync тощо.
FAQ і неочікувані моменти
Питання: Чому Task інколи виконується синхронно?
Відповідь: Якщо операцію вже завершено (наприклад, результат кешовано), компілятор або планувальник може завершити метод у тому самому потоці синхронно. Це нормально і пришвидшує повторні виклики.
Питання: Чому не можна використовувати async void?
Відповідь: Такий метод не можна «почекати», не можна спіймати його помилки й відстежити завершення. Використовуйте Task, а async void — лише для EventHandler (наприклад, Button_Click).
Питання: Чи можна запустити кілька завдань і дочекатися лише однієї?
Відповідь: Так — використовуйте Task.WhenAny.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ