Ви вже знаєте як працювати з потоками через клас 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 операцій.
У .NET 4.0 (2010 рік) Microsoft представила Task Parallel Library (TPL) — високорівневу бібліотеку для паралелізму, яка вирішує всі ці проблеми. Центральний елемент TPL — клас Task.
Task — це абстракція одиниці роботи, яка може виконуватись асинхронно. Це не потік (Thread), а обіцянка результату (promise/future у термінології інших мов). Task може виконуватись на ThreadPool, на окремому потоці, або навіть синхронно — це деталь реалізації, прихована від розробника.
Клас 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}");
Проблеми:
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}");
Переваги:
Task<T>AggregateExceptionContinueWith, WhenAll, WhenAnyCancellationTokenTask.Run) та I/O-bound (async/await)| Характеристика | 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% сценаріїв паралелізму |
ThreadPriority або ApartmentState (COM interop)Task.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. Це означає:Task.Factory.StartNew() з TaskCreationOptions.LongRunning.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 операцій.Важлива концепція: 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().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}");
TaskStatus enum (спрощена діаграма):
Created → WaitingToRun → Running → RanToCompletion
↘ Faulted (exception)
↘ Canceled (CancellationToken)
| Стан | Опис |
|---|---|
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 ще не завершився
Є три способи синхронно дочекатись завершення 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).Одна з найпотужніших можливостей Task — це композиція: можливість комбінувати декілька асинхронних операцій у складні сценарії. Замість callback hell (вкладені callbacks) ми отримуємо чистий, лінійний код.
Task.WhenAll() створює Task, який завершується коли всі передані задачі завершаться. Це паралельне виконання з очікуванням всіх результатів.
using System.Threading.Tasks;
using System.Diagnostics;
static async Task<string> DownloadPageAsync(string url, int delayMs)
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Починаємо завантаження {url}");
await Task.Delay(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);
// Чекаємо завершення ВСІХ задач
string[] results = await Task.WhenAll(task1, task2, task3);
sw.Stop();
Console.WriteLine($"\nВсі завантаження завершені за {sw.ElapsedMilliseconds}ms");
foreach (var result in results)
Console.WriteLine($" - {result}");
Ключові моменти:
WhenAll чекає поки найповільніша задача завершиться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>)
await Task.WhenAll(task1, task2, task3);
Console.WriteLine("Всі задачі завершені");
Task.WhenAny() завершується як тільки будь-яка з переданих задач завершиться. Це корисно для timeout, race conditions, або fallback сценаріїв.
using System.Threading.Tasks;
using System.Diagnostics;
static async Task<string> DownloadFromServerAsync(string server, int delayMs)
{
await Task.Delay(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 = await Task.WhenAny(server1, server2, server3);
sw.Stop();
string result = await firstCompleted; // отримуємо результат
Console.WriteLine($"Перший відповів: {result} за {sw.ElapsedMilliseconds}ms");
// Інші задачі продовжують виконуватись у фоні (якщо не скасувати)
Практичний приклад: Timeout Pattern:
using System.Threading.Tasks;
static async Task<string> DownloadWithTimeoutAsync(string url, int timeoutMs)
{
Task<string> downloadTask = DownloadPageAsync(url);
Task timeoutTask = Task.Delay(timeoutMs);
Task completedTask = await Task.WhenAny(downloadTask, timeoutTask);
if (completedTask == timeoutTask)
{
throw new TimeoutException($"Завантаження {url} перевищило {timeoutMs}ms");
}
return await downloadTask; // повертаємо результат
}
try
{
string content = await DownloadWithTimeoutAsync("https://slow-site.com", 5000);
Console.WriteLine($"Завантажено: {content}");
}
catch (TimeoutException ex)
{
Console.WriteLine($"Помилка: {ex.Message}");
}
У .NET 9 додано Task.WhenEach() — він повертає IAsyncEnumerable<Task<T>>, який дозволяє обробляти задачі у порядку їх завершення.
using System.Threading.Tasks;
static async Task<string> ProcessItemAsync(int id, int delayMs)
{
await Task.Delay(delayMs);
return $"Item {id} processed";
}
// Запускаємо задачі з різним часом виконання
Task<string>[] tasks =
[
ProcessItemAsync(1, 3000),
ProcessItemAsync(2, 1000),
ProcessItemAsync(3, 2000)
];
// Обробляємо у порядку завершення (не у порядку запуску!)
await foreach (Task<string> completedTask in Task.WhenEach(tasks))
{
string result = await completedTask;
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {result}");
}
Порівняння WhenAll vs WhenAny vs WhenEach:
| Метод | Коли завершується | Результат | Use Case |
|---|---|---|---|
WhenAll | Коли всі задачі завершаться | T[] — масив результатів | Паралельна обробка з очікуванням всіх |
WhenAny | Коли перша задача завершиться | Task<T> — перша завершена | Timeout, race, fallback |
WhenEach | Повертає задачі у порядку завершення | IAsyncEnumerable<Task<T>> | Прогресивна обробка результатів |
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 — НЕ блокує потік, звільняє його для іншої роботи
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Task.Delay(2000)...");
await Task.Delay(2000);
Console.WriteLine($"[{sw.ElapsedMilliseconds}ms] Task.Delay завершився");
Різниця:
Thread.Sleep(2000) — потік блокується на 2 секунди, не може виконувати іншу роботуTask.Delay(2000) — потік звільняється, може обслуговувати інші задачіawait Task.Delay()Thread.Sleep() в async коді — це блокує потік!Іноді потрібно повернути 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 DoNothingAsync()
{
return Task.CompletedTask; // завершений Task без результату
}
// Приклад: кешування
Dictionary<string, string> cache = new();
async Task<string> GetDataAsync(string key)
{
if (cache.TryGetValue(key, out string? value))
{
return value; // компілятор автоматично обгорне в Task.FromResult
}
// Якщо немає в кеші — завантажуємо
string data = await DownloadDataAsync(key);
cache[key] = data;
return data;
}
Коли використовувати:
Одна з найважливіших можливостей 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;
Проблеми:
CancellationTokenSource — це об'єкт, який ініціює скасування. CancellationToken — це токен, який передається в методи для перевірки скасування.
using System.Threading;
using System.Threading.Tasks;
static async Task DoWorkAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
// Перевірка: чи було скасування?
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Крок {i + 1}/10");
await Task.Delay(500, cancellationToken); // передаємо токен у Delay
}
Console.WriteLine("Робота завершена успішно");
}
// Створюємо джерело скасування
using var cts = new CancellationTokenSource();
// Запускаємо роботу з токеном
Task workTask = DoWorkAsync(cts.Token);
// Через 2 секунди скасовуємо
await Task.Delay(2000);
cts.Cancel(); // ініціюємо скасування
try
{
await workTask;
}
catch (OperationCanceledException)
{
Console.WriteLine("Операція була скасована");
}
Ключові моменти:
CancellationTokenSource — створює токен та ініціює скасування через .Cancel()CancellationToken — передається в методи для перевірки скасуванняThrowIfCancellationRequested() — кидає OperationCanceledException якщо скасованоIsCancellationRequested — boolean перевірка без exceptionusing 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 перед зупинкою (закрити файли, з'єднання) |
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
await Task.Delay(100);
cts.Cancel();
try
{
int result = await task;
Console.WriteLine($"Результат: {result}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Обчислення скасовано");
}
Task.Run(() => ComputeSum(n, cts.Token), cts.Token);
// ↑ перевірка всередині ↑ перевірка перед запуском
CancellationTokenSource має вбудовану підтримку timeout:
using System.Threading;
using System.Threading.Tasks;
static async Task DownloadFileAsync(string url, CancellationToken cancellationToken)
{
Console.WriteLine($"Починаємо завантаження {url}...");
// Імітація довгого завантаження
for (int i = 0; i < 20; i++)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(500, cancellationToken);
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
{
await DownloadFileAsync("https://example.com/bigfile.zip", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Завантаження перервано через timeout");
}
Іноді потрібно скасувати операцію якщо будь-який з декількох токенів скасовано. Для цього використовується 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(async () =>
{
for (int i = 0; i < 100; i++)
{
linkedCts.Token.ThrowIfCancellationRequested();
await Task.Delay(100);
Console.WriteLine($"Крок {i + 1}");
}
}, linkedCts.Token);
// Через 2 секунди скасовуємо глобально
await Task.Delay(2000);
globalCts.Cancel(); // це скасує і linkedCts
try
{
await workTask;
}
catch (OperationCanceledException)
{
Console.WriteLine("Операція скасована (глобальне скасування або timeout)");
}
Use case: ASP.NET Core автоматично створює linked token для кожного HTTP запиту:
// ASP.NET Core Controller
public async Task<IActionResult> ProcessData(CancellationToken cancellationToken)
{
// cancellationToken скасується якщо:
// 1. Клієнт закрив з'єднання (disconnect)
// 2. Сервер зупиняється (shutdown)
// 3. Request timeout
await LongRunningOperationAsync(cancellationToken);
return Ok();
}
Іноді потрібно виконати cleanup код коли токен скасовано:
using System.Threading;
using System.Threading.Tasks;
using var cts = new CancellationTokenSource();
// Реєструємо callback який виконається при скасуванні
cts.Token.Register(() =>
{
Console.WriteLine("Токен скасовано! Виконуємо cleanup...");
// Закрити файли, з'єднання, звільнити ресурси
});
Task workTask = Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
{
cts.Token.ThrowIfCancellationRequested();
await Task.Delay(500);
Console.WriteLine($"Робота: крок {i + 1}");
}
}, cts.Token);
await Task.Delay(2000);
cts.Cancel(); // callback виконається одразу після Cancel()
try
{
await workTask;
}
catch (OperationCanceledException)
{
Console.WriteLine("Операція скасована");
}
✅ DO:
CancellationToken у методах які виконують довгі операціїTask.Delay, HttpClient.GetAsync, тощо)ThrowIfCancellationRequested() для швидкої зупинкиIsCancellationRequested якщо потрібен cleanupCancellationTokenSource після використання❌ DON'T:
CancellationToken — якщо метод приймає токен, перевіряйте його!OperationCanceledException без необхідності — це нормальний flowCancellationTokenSource без dispose (memory leak).Cancel() двічі (хоча це безпечно, але марно)Коли Task виконується асинхронно, exceptions не можуть бути оброблені звичайним try/catch у місці створення Task. Вони зберігаються всередині Task і "спливають" коли ви очікуєте результат через .Result, .Wait() або await.
// ❌ 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
{
await task; // тут exception буде кинуто
}
catch (Exception ex)
{
Console.WriteLine($"Перехоплено: {ex.Message}");
}
Коли ви використовуєте .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}");
}
Коли ви використовуєте await, компілятор автоматично розгортає AggregateException і кидає оригінальний exception:
using System.Threading.Tasks;
Task task = Task.Run(() =>
{
throw new InvalidOperationException("Помилка");
});
// ✅ await — повертає оригінальний exception (рекомендовано)
try
{
await task;
}
catch (InvalidOperationException ex) // НЕ AggregateException!
{
Console.WriteLine($"Перехоплено: {ex.Message}");
}
// ❌ .Wait() — обгортає в AggregateException
try
{
task.Wait();
}
catch (AggregateException ex) // треба обробляти AggregateException
{
Console.WriteLine($"Перехоплено: {ex.InnerException.Message}");
}
await замість .Result/.Wait() — це дає чистіший exception handling без AggregateException.Коли декілька задач виконуються паралельно через 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);
try
{
await Task.WhenAll(task1, task2, task3);
}
catch (Exception ex)
{
// await повертає ТІЛЬКИ ПЕРШИЙ exception!
Console.WriteLine($"Перехоплено через await: {ex.Message}");
// Щоб отримати ВСІ exceptions — треба читати task.Exception
var allExceptions = Task.WhenAll(task1, task2, task3).Exception;
Console.WriteLine($"\nВсього exceptions: {allExceptions?.InnerExceptions.Count}");
foreach (var innerEx in allExceptions?.InnerExceptions ?? [])
{
Console.WriteLine($" - {innerEx.Message}");
}
}
Правильний спосіб обробити всі exceptions:
Task[] tasks =
[
FailingTask(1),
FailingTask(2),
FailingTask(3)
];
Task allTasks = Task.WhenAll(tasks);
try
{
await allTasks;
}
catch
{
// Обробляємо всі exceptions
if (allTasks.Exception != null)
{
foreach (var ex in allTasks.Exception.InnerExceptions)
{
Console.WriteLine($"Помилка: {ex.Message}");
// Логування, retry, тощо
}
}
}
Іноді 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}");
}
}
Метод 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}");
}
}
Якщо 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();
await Task.Delay(1000);
Console.WriteLine("Програма завершилась");
// ✅ Правильно
Task task = DoWorkAsync();
try
{
await task;
}
catch (Exception ex)
{
// Обробка
}
// ❌ Неправильно — fire-and-forget без обробки
_ = DoWorkAsync(); // exception може бути втрачений
using System.Threading.Tasks;
static async Task<string> DownloadWithRetryAsync(string url, int maxRetries = 3)
{
int attempt = 0;
while (true)
{
attempt++;
try
{
Console.WriteLine($"Спроба {attempt}/{maxRetries}...");
return await DownloadAsync(url);
}
catch (HttpRequestException ex) when (attempt < maxRetries)
{
Console.WriteLine($"Помилка: {ex.Message}. Повторюємо...");
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); // exponential backoff
}
catch (Exception ex)
{
Console.WriteLine($"Критична помилка після {attempt} спроб: {ex.Message}");
throw;
}
}
}
try
{
string content = await DownloadWithRetryAsync("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. Теорія і практика паралельної обробки даних.