TPL, Task та Композиція — Від Thread до Task
TPL, Task та Композиція — Від Thread до Task
Вступ: Еволюція Паралелізму в .NET
Ви вже знаєте як працювати з потоками через клас Thread, як синхронізувати доступ до спільного стану через lock та Monitor, як використовувати ThreadPool для ефективного виконання коротких задач. Але є фундаментальна проблема з усіма цими інструментами: вони занадто низькорівневі.
Проблеми Низькорівневих Примітивів
Коли ви працюєте з Thread або ThreadPool.QueueUserWorkItem(), ви стикаєтесь з наступними обмеженнями:
1. Немає стандартного способу отримати результат:
// ❌ Thread не повертає значення
int result = 0;
var thread = new Thread(() => result = ComputeSum(1000));
thread.Start();
thread.Join(); // чекаємо завершення
Console.WriteLine(result); // тільки тепер можемо прочитати
// ❌ ThreadPool теж не повертає значення
ThreadPool.QueueUserWorkItem(_ =>
{
int sum = ComputeSum(1000);
// Як передати sum назад? Shared variable? Event? Callback?
});
2. Немає композиції — не можна легко виразити "виконай A, потім B, потім C":
// ❌ Складна координація через callbacks
ThreadPool.QueueUserWorkItem(_ =>
{
var dataA = DownloadData("url1");
ThreadPool.QueueUserWorkItem(_ =>
{
var dataB = ProcessData(dataA);
ThreadPool.QueueUserWorkItem(_ =>
{
SaveData(dataB);
// Callback hell!
});
});
});
3. Немає стандартного механізму скасування:
// ❌ Кожен розробник винаходить свій велосипед
bool shouldStop = false;
var thread = new Thread(() =>
{
while (!shouldStop) // ручна перевірка
{
DoWork();
}
});
thread.Start();
// ...
shouldStop = true; // примітивне скасування
4. Обробка помилок — катастрофа:
// ❌ Exception у потоці = crash всього процесу (якщо не обгорнути в try/catch)
var thread = new Thread(() =>
{
throw new Exception("Boom!"); // AppDomain.UnhandledException → crash
});
thread.Start();
5. Немає уніфікованого API для CPU-bound та I/O-bound операцій.
Task Parallel Library (TPL) — Рішення
У .NET 4.0 (2010 рік) Microsoft представила Task Parallel Library (TPL) — високорівневу бібліотеку для паралелізму, яка вирішує всі ці проблеми. Центральний елемент TPL — клас Task.
Task — це абстракція одиниці роботи, яка може виконуватись асинхронно. Це не потік (Thread), а обіцянка результату (promise/future у термінології інших мов). Task може виконуватись на ThreadPool, на окремому потоці, або навіть синхронно — це деталь реалізації, прихована від розробника.
1. Від Thread до Task: Еволюція
Thread — Низькорівневий Примітив
Клас Thread — це пряма обгортка над системним потоком ОС. Один об'єкт Thread = один kernel thread з власним стеком (~1 MB), власними регістрами, власним instruction pointer.
using System.Threading;
static int ComputeSum(int n)
{
int sum = 0;
for (int i = 1; i <= n; i++)
sum += i;
return sum;
}
// ❌ Старий підхід: Thread
int result = 0;
var thread = new Thread(() =>
{
result = ComputeSum(1_000_000);
});
thread.Start();
thread.Join(); // блокуємо поточний потік поки не завершиться
Console.WriteLine($"Sum = {result}");
Проблеми:
- Створення потоку — дорого (~20-50 μs + 1 MB стеку)
- Немає повернення значення — треба використовувати shared variable
- Немає обробки помилок — exception у потоці = crash
- Немає композиції — не можна легко ланцюжити операції
Task — Високорівнева Абстракція
Task — це обіцянка результату, яка може виконуватись на ThreadPool (за замовчуванням), на окремому потоці, або навіть синхронно.
using System.Threading.Tasks;
static int ComputeSum(int n)
{
int sum = 0;
for (int i = 1; i <= n; i++)
sum += i;
return sum;
}
// ✅ Сучасний підхід: Task<T>
Task<int> task = Task.Run(() => ComputeSum(1_000_000));
// Можемо робити іншу роботу поки task виконується...
Console.WriteLine("Task запущено, виконуємо іншу роботу...");
// Отримуємо результат (блокуємо поточний потік якщо task ще не завершився)
int result = task.Result;
Console.WriteLine($"Sum = {result}");
Переваги:
- ✅ Використовує ThreadPool — немає overhead створення потоку
- ✅ Повертає значення через
Task<T> - ✅ Обробка помилок через
AggregateException - ✅ Композиція через
ContinueWith,WhenAll,WhenAny - ✅ Скасування через
CancellationToken - ✅ Уніфікований API для CPU-bound (
Task.Run) та I/O-bound (async/await)
Архітектурна Різниця
Основна перевага Task полягає в ефективному використанні пулу потоків (ThreadPool). Замість того, щоб кожна операція створювала "важкий" системний потік, тисячі Task можуть перемикатися та виконуватися на невеликій групі вже існуючих потоків, економлячи пам'ять та час процесора.
Порівняльна Таблиця: Thread vs Task
| Характеристика | Thread | Task / Task<T> |
|---|---|---|
| Рівень абстракції | Низький (wrapper над OS thread) | Високий (одиниця роботи) |
| Створення | Дорого (~20-50 μs + 1 MB стеку) | Дешево (використовує ThreadPool) |
| Повернення результату | ❌ Немає (треба shared variable) | ✅ Task<T>.Result |
| Обробка помилок | ❌ Exception → crash (якщо не try/catch) | ✅ AggregateException |
| Композиція | ❌ Складно (callbacks, events) | ✅ WhenAll, WhenAny, ContinueWith |
| Скасування | ❌ Ручна перевірка boolean flag | ✅ CancellationToken |
| Pooling | ❌ Кожен Thread — окремий | ✅ Використовує ThreadPool |
| Use case | Довгі фонові операції (IsBackground = true) | 99% сценаріїв паралелізму |
- Потрібен довгий фоновий потік з власним життєвим циклом (наприклад, listener socket)
- Потрібен контроль над
ThreadPriorityабоApartmentState(COM interop) - Потрібен потік з нестандартним розміром стеку
Task.2. Task та Task<T>: Повний API
Task.Run() — Рекомендований Спосіб
Task.Run() — це найпростіший та найбезпечніший спосіб запустити CPU-bound роботу на ThreadPool. Він був доданий у .NET 4.5 як спрощення над Task.Factory.StartNew().
using System.Threading.Tasks;
// Task без результату (аналог void)
Task task1 = Task.Run(() =>
{
Console.WriteLine($"Виконується на потоці {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(1000);
Console.WriteLine("Робота завершена");
});
// Task<T> з результатом
Task<int> task2 = Task.Run(() =>
{
int sum = 0;
for (int i = 1; i <= 1000; i++)
sum += i;
return sum; // результат автоматично обгортається в Task<int>
});
// Очікування завершення
task1.Wait(); // блокує поточний потік
int result = task2.Result; // блокує поточний потік + повертає результат
Console.WriteLine($"Сума: {result}");
Task.Run() завжди виконується на ThreadPool. Це означає:- ✅ Немає overhead створення нового потоку
- ✅ Автоматичне управління кількістю потоків (Hill Climbing Algorithm)
- ❌ Не підходить для довгих операцій (>1 секунди) — може викликати thread starvation
Task.Factory.StartNew() з TaskCreationOptions.LongRunning.Task.Factory.StartNew() — Повний Контроль
Task.Factory.StartNew() — це низькорівневий API, який дає повний контроль над створенням Task. Використовуйте його коли потрібні спеціальні налаштування.
using System.Threading.Tasks;
// Довга операція — створити окремий потік замість використання ThreadPool
Task longRunningTask = Task.Factory.StartNew(
() =>
{
Console.WriteLine($"Довга операція на потоці {Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(10_000); // 10 секунд
},
TaskCreationOptions.LongRunning // створить окремий Thread
);
// Дочірня задача (attached child)
Task parentTask = Task.Factory.StartNew(() =>
{
Console.WriteLine("Parent task почався");
// Дочірня задача — parent не завершиться поки child не завершиться
Task childTask = Task.Factory.StartNew(
() =>
{
Thread.Sleep(2000);
Console.WriteLine("Child task завершився");
},
TaskCreationOptions.AttachedToParent
);
Console.WriteLine("Parent task завершує свою роботу");
});
parentTask.Wait(); // чекає завершення parent + child
Console.WriteLine("Обидві задачі завершені");
TaskCreationOptions (flags enum):
| Опція | Опис |
|---|---|
None | За замовчуванням — використовує ThreadPool |
LongRunning | Створює окремий Thread (не використовує ThreadPool) |
AttachedToParent | Дочірня задача — parent чекає її завершення |
DenyChildAttach | Заборонити дочірнім задачам прикріплюватись (рекомендовано) |
PreferFairness | Виконати задачу в порядку додавання (FIFO замість LIFO) |
Task.Factory.StartNew() без необхідності!Task.Run() автоматично встановлює DenyChildAttach та інші безпечні налаштування. Task.Factory.StartNew() має складну семантику з дочірніми задачами, яка може призвести до неочікуваної поведінки.Правило: використовуйте Task.Run() для 99% випадків. Task.Factory.StartNew() — тільки для LongRunning операцій.Cold vs Hot Tasks
Важлива концепція: Task може бути cold (не запущений) або hot (вже виконується).
using System.Threading.Tasks;
// ❌ Cold Task — створений але НЕ запущений (DEPRECATED підхід)
Task coldTask = new Task(() =>
{
Console.WriteLine("Cold task виконується");
});
Console.WriteLine($"Cold task status: {coldTask.Status}"); // Created (не запущений)
coldTask.Start(); // тепер запускаємо
coldTask.Wait();
// ✅ Hot Task — одразу запущений (РЕКОМЕНДОВАНО)
Task hotTask = Task.Run(() =>
{
Console.WriteLine("Hot task виконується");
});
Console.WriteLine($"Hot task status: {hotTask.Status}"); // Running або RanToCompletion
hotTask.Wait();
Task.Run() або Task.Factory.StartNew().new Task() + Start() — це legacy підхід з .NET 4.0, який може призвести до помилок (забули викликати Start()). Сучасний код повинен використовувати Task.Run().TaskStatus — Життєвий Цикл Task
Task проходить через декілька станів під час виконання:
using System.Threading.Tasks;
Task task = Task.Run(() =>
{
Thread.Sleep(2000);
return 42;
});
// Моніторинг стану
while (!task.IsCompleted)
{
Console.WriteLine($"Status: {task.Status}");
Thread.Sleep(100);
}
Console.WriteLine($"Final status: {task.Status}");
Візуалізація Життєвого Циклу Task (TaskStatus):
| Стан | Опис |
|---|---|
Created | Task створений через new Task() але не запущений |
WaitingToRun | Task у черзі ThreadPool, чекає вільного потоку |
Running | Task виконується на потоці |
RanToCompletion | Task успішно завершився |
Faulted | Task завершився з exception |
Canceled | Task був скасований через CancellationToken |
Корисні властивості:
Task<int> task = Task.Run(() => 42);
// Перевірка стану
bool isCompleted = task.IsCompleted; // true якщо RanToCompletion, Faulted або Canceled
bool isSuccess = task.IsCompletedSuccessfully; // true тільки якщо RanToCompletion
bool isFaulted = task.IsFaulted; // true якщо exception
bool isCanceled = task.IsCanceled; // true якщо скасовано
// Отримання результату
int result = task.Result; // блокує поточний потік якщо task ще не завершився
Блокуюче Очікування: .Result vs .Wait() vs .GetAwaiter().GetResult()
Є три способи синхронно дочекатись завершення Task:
using System.Threading.Tasks;
Task<int> task = Task.Run(() =>
{
Thread.Sleep(1000);
return 42;
});
// Спосіб 1: .Result (тільки для Task<T>)
int result1 = task.Result; // блокує + повертає результат
// Спосіб 2: .Wait() (для Task та Task<T>)
task.Wait(); // блокує, не повертає результат
int result2 = task.Result; // тепер можна прочитати
// Спосіб 3: .GetAwaiter().GetResult() (рекомендовано для синхронного коду)
int result3 = task.GetAwaiter().GetResult(); // блокує + повертає результат
Різниця між ними:
| Метод | Exception Wrapping | Рекомендація |
|---|---|---|
.Result | ✅ Обгортає в AggregateException | Використовуйте для Task |
.Wait() | ✅ Обгортає в AggregateException | Використовуйте для Task (void) |
.GetAwaiter().GetResult() | ❌ Повертає оригінальний exception | Рекомендовано для синхронного коду |
.Result або .Wait() в UI-коді (WinForms, WPF, MAUI) або в ASP.NET Core коді з SynchronizationContext!Це призведе до deadlock. Детально розглянемо в темі 13 (SynchronizationContext та ConfigureAwait).3. Composing Tasks: Координація Паралельних Операцій
Одна з найпотужніших можливостей Task — це композиція: можливість комбінувати декілька асинхронних операцій у складні сценарії. Замість callback hell (вкладені callbacks) ми отримуємо чистий, лінійний код.
Task.WhenAll() — Очікування Всіх Задач
Task.WhenAll() створює Task, який завершується коли всі передані задачі завершаться. Це паралельне виконання з очікуванням всіх результатів.
using System.Threading.Tasks;
using System.Diagnostics;
static Task<string> DownloadPageAsync(string url, int delayMs)
{
return Task.Run(() =>
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Починаємо завантаження {url}");
Thread.Sleep(delayMs); // імітація мережевого запиту
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Завершили завантаження {url}");
return $"Content from {url}";
});
}
var sw = Stopwatch.StartNew();
// Запускаємо три завантаження ПАРАЛЕЛЬНО
Task<string> task1 = DownloadPageAsync("https://site1.com", 1000);
Task<string> task2 = DownloadPageAsync("https://site2.com", 2000);
Task<string> task3 = DownloadPageAsync("https://site3.com", 1500);
// Чекаємо завершення ВСІХ задач
Task<string[]> allTasks = Task.WhenAll(task1, task2, task3);
string[] results = allTasks.Result; // блокує поточний потік
sw.Stop();
Console.WriteLine($"\nВсі завантаження завершені за {sw.ElapsedMilliseconds}ms");
foreach (var result in results)
Console.WriteLine($" - {result}");
Ключові моменти:
- ✅ Всі задачі запускаються одночасно (паралельно)
- ✅
WhenAllчекає поки найповільніша задача завершиться - ✅ Повертає масив результатів у тому ж порядку що й задачі
- ✅ Якщо хоча б одна задача кине exception —
WhenAllкинеAggregateException
Без Task
Task task1 = Task.Run(() => Console.WriteLine("Task 1"));
Task task2 = Task.Run(() => Console.WriteLine("Task 2"));
Task task3 = Task.Run(() => Console.WriteLine("Task 3"));
// WhenAll для void tasks повертає Task (не Task<T>)
Task.WhenAll(task1, task2, task3).Wait();
Console.WriteLine("Всі задачі завершені");
Task.WhenAny() — Очікування Першої Задачі
Task.WhenAny() завершується як тільки будь-яка з переданих задач завершиться. Це корисно для timeout, race conditions, або fallback сценаріїв.
using System.Threading.Tasks;
using System.Diagnostics;
static Task<string> DownloadFromServerAsync(string server, int delayMs)
{
return Task.Run(() =>
{
Thread.Sleep(delayMs);
return $"Data from {server}";
});
}
var sw = Stopwatch.StartNew();
// Запускаємо завантаження з трьох серверів паралельно
Task<string> server1 = DownloadFromServerAsync("Server-US", 2000);
Task<string> server2 = DownloadFromServerAsync("Server-EU", 1000);
Task<string> server3 = DownloadFromServerAsync("Server-ASIA", 3000);
// Чекаємо ПЕРШУ завершену задачу
Task<string> firstCompleted = Task.WhenAny(server1, server2, server3).Result;
sw.Stop();
string result = firstCompleted.Result; // отримуємо результат
Console.WriteLine($"Перший відповів: {result} за {sw.ElapsedMilliseconds}ms");
// Інші задачі продовжують виконуватись у фоні (якщо не скасувати)
Практичний приклад: Timeout Pattern:
using System.Threading.Tasks;
static string DownloadWithTimeout(string url, int timeoutMs)
{
Task<string> downloadTask = DownloadPageAsync(url, 10000);
Task timeoutTask = Task.Delay(timeoutMs);
Task completedTask = Task.WhenAny(downloadTask, timeoutTask).Result;
if (completedTask == timeoutTask)
{
throw new TimeoutException($"Завантаження {url} перевищило {timeoutMs}ms");
}
return downloadTask.Result; // повертаємо результат
}
try
{
string content = DownloadWithTimeout("https://slow-site.com", 5000);
Console.WriteLine($"Завантажено: {content}");
}
catch (TimeoutException ex)
{
Console.WriteLine($"Помилка: {ex.Message}");
}
Task.Delay() — Асинхронна Затримка
Task.Delay() — це асинхронний аналог Thread.Sleep(), але не блокує потік.
using System.Threading.Tasks;
using System.Diagnostics;
var sw = Stopwatch.StartNew();
// ❌ Thread.Sleep — блокує потік на 2 секунди
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Thread.Sleep(2000)...");
Thread.Sleep(2000);
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Thread.Sleep завершився");
// ✅ Task.Delay — НЕ блокує потік, звільняє його для іншої роботи (у реальному async коді)
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Task.Delay(2000)...");
Task.Delay(2000).Wait(); // Для прикладу чекаємо синхронно
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Task.Delay завершився");
Різниця:
Thread.Sleep(2000)— потік блокується на 2 секунди, не може виконувати іншу роботуTask.Delay(2000)— потік звільняється, може обслуговувати інші задачі
Коли використовувати Task.Delay?
- ✅ В async методах (які ми розглянемо пізніше)
- ✅ Для throttling, retry logic, polling
- ❌ Не використовуйте
Thread.Sleep()в async коді — це блокує потік! ::
Task.FromResult() та Task.CompletedTask — Синхронні Результати
Іноді потрібно повернути Task, але результат вже відомий синхронно. Замість Task.Run() (який займе ThreadPool потік) використовуйте:
using System.Threading.Tasks;
// ❌ Неефективно — займає ThreadPool потік для нічого
Task<int> GetCachedValueBad()
{
return Task.Run(() => 42); // overhead ThreadPool
}
// ✅ Ефективно — одразу повертає завершений Task
Task<int> GetCachedValueGood()
{
return Task.FromResult(42); // no overhead
}
// Для void tasks
Task DoNothing()
{
return Task.CompletedTask; // завершений Task без результату
}
// Приклад: кешування
Dictionary<string, string> cache = new();
// Приклад: кешування
Dictionary<string, string> cache = new();
Task<string> GetData(string key)
{
if (cache.TryGetValue(key, out string? value))
{
return Task.FromResult(value); // повертаємо вже завершений Task без overhead
}
// Якщо немає в кеші — завантажуємо (імітація)
return Task.Run(() =>
{
string data = DownloadData(key); // синхронне завантаження
cache[key] = data;
return data;
});
}
Коли використовувати:
- ✅ Кешування — результат вже є в пам'яті
- ✅ Валідація — синхронна перевірка перед async операцією
- ✅ Mock/stub для тестування
4. CancellationToken: Скасування Операцій
Одна з найважливіших можливостей TPL — це кооперативне скасування через CancellationToken. Це стандартний механізм для зупинки довгих операцій без використання примітивних boolean flags або Thread.Abort() (deprecated).
Проблема: Як Зупинити Довгу Операцію?
Уявіть що ви завантажуєте великий файл, і користувач натискає кнопку "Скасувати". Як зупинити завантаження?
// ❌ Старий підхід: boolean flag
bool shouldStop = false;
Task downloadTask = Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
if (shouldStop) // ручна перевірка на кожній ітерації
return;
DownloadChunk(i);
}
});
// Користувач натиснув "Скасувати"
shouldStop = true;
Проблеми:
- ❌ Немає стандартного API — кожен розробник винаходить свій велосипед
- ❌ Немає способу передати причину скасування
- ❌ Немає callback-ів на скасування
- ❌ Немає timeout-ів
- ❌ Складно координувати скасування декількох операцій
CancellationTokenSource та CancellationToken
Цей патерн розділяє відповідальність на дві частини: Source (той, хто ініціює скасування) та Token (той, хто слухає сигнал скасування). Це дозволяє безпечно передавати токен у будь-які глибини викликів.
CancellationTokenSource — це об'єкт, який ініціює скасування. CancellationToken — це токен (структура), який передається в методи для перевірки скасування.
using System.Threading;
using System.Threading.Tasks;
static void DoWork(CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
// Перевірка: чи було скасування?
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Крок {i + 1}/10");
Task.Delay(500, cancellationToken).Wait(); // передаємо токен у Delay
}
Console.WriteLine("Робота завершена успішно");
}
// Створюємо джерело скасування
using var cts = new CancellationTokenSource();
// Запускаємо роботу з токеном
Task workTask = Task.Run(() => DoWork(cts.Token));
// Через 2 секунди скасовуємо
Task.Delay(2000).Wait();
cts.Cancel(); // ініціюємо скасування
try
{
workTask.Wait();
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
Console.WriteLine("Операція була скасована");
}
Ключові моменти:
- ✅
CancellationTokenSource— створює токен та ініціює скасування через.Cancel() - ✅
CancellationToken— передається в методи для перевірки скасування - ✅
ThrowIfCancellationRequested()— кидаєOperationCanceledExceptionякщо скасовано - ✅
IsCancellationRequested— boolean перевірка без exception
Два Способи Перевірки Скасування
using System.Threading;
static void ProcessData(CancellationToken cancellationToken)
{
for (int i = 0; i < 1000; i++)
{
// Спосіб 1: ThrowIfCancellationRequested() — кидає exception
cancellationToken.ThrowIfCancellationRequested();
// Спосіб 2: IsCancellationRequested — boolean перевірка
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("Скасування виявлено, виконуємо cleanup...");
// Cleanup код
return; // або throw new OperationCanceledException();
}
ProcessItem(i);
}
}
Коли що використовувати:
| Метод | Коли використовувати |
|---|---|
ThrowIfCancellationRequested() | Коли потрібно одразу зупинити виконання (стандартний підхід) |
IsCancellationRequested | Коли потрібен cleanup перед зупинкою (закрити файли, з'єднання) |
Передача CancellationToken у Task.Run()
using System.Threading;
using System.Threading.Tasks;
static int ComputeSum(int n, CancellationToken cancellationToken)
{
int sum = 0;
for (int i = 1; i <= n; i++)
{
cancellationToken.ThrowIfCancellationRequested();
sum += i;
}
return sum;
}
using var cts = new CancellationTokenSource();
// Передаємо токен у Task.Run
Task<int> task = Task.Run(() => ComputeSum(10_000_000, cts.Token), cts.Token);
// Скасовуємо через 100ms
Task.Delay(100).Wait();
cts.Cancel();
try
{
int result = task.Result;
Console.WriteLine($"Результат: {result}");
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
Console.WriteLine("Обчислення скасовано");
}
Task.Run(() => ComputeSum(n, cts.Token), cts.Token);
// ↑ перевірка всередині ↑ перевірка перед запуском
- Перший токен (у lambda) — для перевірки скасування під час виконання
- Другий токен (у Task.Run) — для перевірки скасування перед запуском (якщо вже скасовано — Task не запуститься)
Timeout через CancellationTokenSource
CancellationTokenSource має вбудовану підтримку timeout:
using System.Threading;
using System.Threading.Tasks;
static void DownloadFile(string url, CancellationToken cancellationToken)
{
Console.WriteLine($"Починаємо завантаження {url}...");
// Імітація довгого завантаження
for (int i = 0; i < 20; i++)
{
cancellationToken.ThrowIfCancellationRequested();
Task.Delay(500, cancellationToken).Wait();
Console.WriteLine($"Прогрес: {(i + 1) * 5}%");
}
Console.WriteLine("Завантаження завершено");
}
// Спосіб 1: Timeout у конструкторі
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
// Спосіб 2: Timeout через метод
// using var cts = new CancellationTokenSource();
// cts.CancelAfter(TimeSpan.FromSeconds(3));
try
{
Task.Run(() => DownloadFile("https://example.com/bigfile.zip", cts.Token)).Wait();
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
Console.WriteLine("Завантаження перервано через timeout");
}
Linked Tokens — Ієрархія Скасування
Іноді потрібно скасувати операцію якщо будь-який з декількох токенів скасовано. Для цього використовується CreateLinkedTokenSource().
using System.Threading;
using System.Threading.Tasks;
// Глобальний токен скасування (наприклад, shutdown застосунку)
using var globalCts = new CancellationTokenSource();
// Локальний токен для конкретної операції
using var operationCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Створюємо linked token — скасується якщо будь-який з батьківських скасовано
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
globalCts.Token,
operationCts.Token
);
Task workTask = Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
linkedCts.Token.ThrowIfCancellationRequested();
Task.Delay(100).Wait();
Console.WriteLine($"Крок {i + 1}");
}
}, linkedCts.Token);
// Через 2 секунди скасовуємо глобально
Task.Delay(2000).Wait();
globalCts.Cancel(); // це скасує і linkedCts
try
{
workTask.Wait();
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
Console.WriteLine("Операція скасована (глобальне скасування або timeout)");
}
Use case: ASP.NET Core автоматично створює linked token для кожного HTTP запиту:
// ASP.NET Core Controller
public Task<IActionResult> ProcessData(CancellationToken cancellationToken)
{
// cancellationToken скасується якщо:
// 1. Клієнт закрив з'єднання (disconnect)
// 2. Сервер зупиняється (shutdown)
// 3. Request timeout
LongRunningOperation(cancellationToken).Wait();
return Task.FromResult<IActionResult>(Ok());
}
Callback на Скасування: token.Register()
Іноді потрібно виконати cleanup код коли токен скасовано:
using System.Threading;
using System.Threading.Tasks;
using var cts = new CancellationTokenSource();
// Реєструємо callback який виконається при скасуванні
cts.Token.Register(() =>
{
Console.WriteLine("Токен скасовано! Виконуємо cleanup...");
// Закрити файли, з'єднання, звільнити ресурси
});
Task workTask = Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
cts.Token.ThrowIfCancellationRequested();
Task.Delay(500).Wait();
Console.WriteLine($"Робота: крок {i + 1}");
}
}, cts.Token);
Task.Delay(2000).Wait();
cts.Cancel(); // callback виконається одразу після Cancel()
try
{
workTask.Wait();
}
catch (AggregateException ex) when (ex.InnerException is OperationCanceledException)
{
Console.WriteLine("Операція скасована");
}
Best Practices для CancellationToken
✅ DO:
- Завжди приймайте
CancellationTokenу методах які виконують довгі операції - Передавайте токен у всі async методи (
Task.Delay,HttpClient.GetAsync, тощо) - Використовуйте
ThrowIfCancellationRequested()для швидкої зупинки - Використовуйте
IsCancellationRequestedякщо потрібен cleanup - Dispose
CancellationTokenSourceпісля використання
❌ DON'T:
- Не ігноруйте
CancellationToken— якщо метод приймає токен, перевіряйте його! - Не ловіть
OperationCanceledExceptionбез необхідності — це нормальний flow - Не створюйте
CancellationTokenSourceбез dispose (memory leak) - Не викликайте
.Cancel()двічі (хоча це безпечно, але марно)
5. Exception Handling: Обробка Помилок у Task
Коли Task виконується асинхронно, exceptions не можуть бути оброблені звичайним try/catch у місці створення Task. Вони зберігаються всередині Task і "спливають" коли ви очікуєте результат через .Result, .Wait() або await.
Проблема: Exception у Thread vs Task
// ❌ Exception у Thread — crash всього процесу!
var thread = new Thread(() =>
{
throw new Exception("Boom!"); // UnhandledException → crash
});
thread.Start();
// Немає способу перехопити цей exception ззовні
// ✅ Exception у Task — зберігається всередині Task
Task task = Task.Run(() =>
{
throw new Exception("Boom!"); // exception зберігається в task
});
// Exception "спливе" коли ми очікуємо результат
try
{
task.Wait(); // тут exception буде кинуто (загорнутий у AggregateException)
}
catch (AggregateException ex)
{
Console.WriteLine($"Перехоплено: {ex.InnerException?.Message}");
}
AggregateException — Обгортка для Множинних Exceptions
Коли ви використовуєте .Result або .Wait(), exception обгортається в AggregateException — спеціальний тип який може містити декілька exceptions (наприклад, якщо декілька паралельних задач кинули exceptions).
using System.Threading.Tasks;
Task task = Task.Run(() =>
{
throw new InvalidOperationException("Щось пішло не так");
});
try
{
task.Wait(); // блокуюче очікування
}
catch (AggregateException ex)
{
Console.WriteLine($"AggregateException з {ex.InnerExceptions.Count} exceptions:");
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($" - {innerEx.GetType().Name}: {innerEx.Message}");
}
// Або отримати перший exception
Exception firstException = ex.InnerException;
Console.WriteLine($"\nПерший exception: {firstException.Message}");
}
.GetAwaiter().GetResult() Автоматично Unwrap Exception
Коли ви використовуєте .GetAwaiter().GetResult(), він автоматично розгортає AggregateException і кидає оригінальний exception:
using System.Threading.Tasks;
Task task = Task.Run(() =>
{
throw new InvalidOperationException("Помилка");
});
// ✅ .GetAwaiter().GetResult() — повертає оригінальний exception
try
{
task.GetAwaiter().GetResult();
}
catch (InvalidOperationException ex) // НЕ AggregateException!
{
Console.WriteLine($"Перехоплено: {ex.Message}");
}
// ❌ .Wait() — обгортає в AggregateException
try
{
task.Wait();
}
catch (AggregateException ex) // треба обробляти AggregateException
{
Console.WriteLine($"Перехоплено: {ex.InnerException?.Message}");
}
AggregateException у синхронному коді використовуйте .GetAwaiter().GetResult().Task.WhenAll() та Множинні Exceptions
Коли декілька задач виконуються паралельно через Task.WhenAll(), і декілька з них кидають exceptions — всі exceptions зберігаються в AggregateException:
using System.Threading.Tasks;
static Task FailingTask(int id)
{
return Task.Run(() =>
{
Thread.Sleep(100 * id);
throw new Exception($"Task {id} failed");
});
}
Task task1 = FailingTask(1);
Task task2 = FailingTask(2);
Task task3 = FailingTask(3);
Task allTasks = Task.WhenAll(task1, task2, task3);
try
{
allTasks.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine($"\nВсього exceptions: {ex.InnerExceptions.Count}");
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($" - {innerEx.Message}");
}
}
AggregateException.Flatten() — Розгортання Вкладених Exceptions
Іноді AggregateException може містити інші AggregateException (вкладені). Метод Flatten() розгортає всю ієрархію:
using System.Threading.Tasks;
Task outerTask = Task.Run(() =>
{
Task innerTask = Task.Run(() =>
{
throw new Exception("Inner exception");
});
try
{
innerTask.Wait();
}
catch (AggregateException ex)
{
throw new AggregateException("Outer exception", ex);
}
});
try
{
outerTask.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine($"Оригінальна структура: {ex.InnerExceptions.Count} exceptions");
// Flatten розгортає всю ієрархію
AggregateException flattened = ex.Flatten();
Console.WriteLine($"Після Flatten: {flattened.InnerExceptions.Count} exceptions");
foreach (var innerEx in flattened.InnerExceptions)
{
Console.WriteLine($" - {innerEx.Message}");
}
}
AggregateException.Handle() — Вибіркова Обробка
Метод Handle() дозволяє обробити тільки певні типи exceptions, а інші — пропустити далі:
using System.Threading.Tasks;
Task[] tasks =
[
Task.Run(() => throw new InvalidOperationException("Invalid op")),
Task.Run(() => throw new ArgumentException("Bad argument")),
Task.Run(() => throw new TimeoutException("Timeout"))
];
try
{
Task.WaitAll(tasks);
}
catch (AggregateException ex)
{
ex.Handle(innerEx =>
{
if (innerEx is TimeoutException)
{
Console.WriteLine($"Timeout обробляємо: {innerEx.Message}");
return true; // exception оброблено
}
if (innerEx is ArgumentException)
{
Console.WriteLine($"ArgumentException обробляємо: {innerEx.Message}");
return true; // exception оброблено
}
return false; // інші exceptions пропускаємо далі
});
}
catch (AggregateException ex)
{
// Сюди потраплять тільки необроблені exceptions
Console.WriteLine($"Необроблені exceptions: {ex.InnerExceptions.Count}");
foreach (var innerEx in ex.InnerExceptions)
{
Console.WriteLine($" - {innerEx.GetType().Name}: {innerEx.Message}");
}
}
Unobserved Task Exceptions — "Забуті" Exceptions
Якщо Task кинув exception, але ви ніколи не перевірили результат (не викликали .Result, .Wait(), або await) — exception стає unobserved (незамічений).
using System.Threading.Tasks;
// ❌ Task кидає exception, але ми його ігноруємо
Task.Run(() =>
{
throw new Exception("Unobserved exception!");
});
// Task завершився з exception, але ми ніколи не перевірили результат
// Exception "загубився"
// Через деякий час GC збере Task, і exception буде "unobserved"
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("Програма продовжує працювати...");
У .NET 4.0 unobserved exceptions призводили до crash процесу. У .NET 4.5+ вони логуються але не крашать процес.
Підписка на UnobservedTaskException:
using System.Threading.Tasks;
// Глобальний handler для unobserved exceptions
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
Console.WriteLine($"Unobserved exception: {e.Exception.Message}");
// Позначити як "observed" щоб не логувати далі
e.SetObserved();
};
// Task з exception який ми ігноруємо
Task.Run(() => throw new Exception("Забутий exception"));
// Форсуємо GC щоб Task був зібраний
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Task.Delay(1000).Wait();
Console.WriteLine("Програма завершилась");
// ✅ Правильно
Task task = Task.Run(() => DoWork());
try
{
task.Wait();
}
catch (AggregateException ex)
{
// Обробка
}
// ❌ Неправильно — fire-and-forget без обробки
_ = Task.Run(() => DoWork()); // exception може бути втрачений
Практичний Приклад: Retry Pattern з Exception Handling
using System.Threading.Tasks;
static string DownloadWithRetry(string url, int maxRetries = 3)
{
int attempt = 0;
while (true)
{
attempt++;
try
{
Console.WriteLine($"Спроба {attempt}/{maxRetries}...");
return DownloadData(url); // синхронна імітація
}
catch (HttpRequestException ex) when (attempt < maxRetries)
{
Console.WriteLine($"Помилка: {ex.Message}. Повторюємо...");
Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))).Wait(); // exponential backoff
}
catch (Exception ex)
{
Console.WriteLine($"Критична помилка після {attempt} спроб: {ex.Message}");
throw;
}
}
}
try
{
string content = DownloadWithRetry("https://example.com/data");
Console.WriteLine($"Завантажено: {content.Length} байт");
}
catch (Exception ex)
{
Console.WriteLine($"Не вдалося завантажити: {ex.Message}");
}
Це завершує перший файл 11.tpl-parallel-plinq.md. Тепер переходимо до другого файлу 11a.tpl-parallel-plinq-advanced.md з розділами про Parallel Class та PLINQ.
Concurrent та Immutable Collections
Thread-safe колекції в .NET — ConcurrentDictionary з striped locking, lock-free ConcurrentQueue/Stack/Bag, BlockingCollection для producer-consumer, Immutable Collections та persistent data structures. Детальний розбір архітектури, benchmarks та практичні сценарії.
Parallel Class та PLINQ — Data Parallelism
Глибокий академічний розбір Parallel.For/ForEach/Invoke, ParallelOptions, thread-local state, PLINQ (Parallel LINQ) з AsParallel, partitioning strategies та performance optimization. Теорія і практика паралельної обробки даних.