System Programming Windows

TPL, Task та Композиція — Від Thread до Task

Глибокий академічний розбір Task Parallel Library — еволюція від Thread до Task, Task<T>, композиція через WhenAll/WhenAny, CancellationToken, exception handling та AggregateException. Теорія і практика сучасного паралелізму.

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.

ThreadExample.cs
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 (за замовчуванням), на окремому потоці, або навіть синхронно.

TaskExample.cs
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 можуть перемикатися та виконуватися на невеликій групі вже існуючих потоків, економлячи пам'ять та час процесора.

Loading diagram...
@startuml
skinparam style plain
skinparam componentStyle rectangle

package "Старий підхід: Thread" {
  component "Thread 1 (1MB Stack + OS Context)" as T1 #FFCCCC
  component "Thread 2 (1MB Stack + OS Context)" as T2 #FFCCCC
  component "Thread N (1MB Stack + OS Context)" as T3 #FFCCCC
  
  card "Work 1" as W1
  card "Work 2" as W2
  card "Work N" as W3
  
  W1 --> T1 : 1:1 Mapping
  W2 --> T2 : 1:1 Mapping
  W3 --> T3 : 1:1 Mapping
}

package "Сучасний підхід: Task Parallel Library" {
  component "ThreadPool" as TP #CCFFCC {
    component "Worker Thread A" as WA
    component "Worker Thread B" as WB
  }
  
  card "Task 1" as TK1
  card "Task 2" as TK2
  card "Task 3" as TK3
  card "Task N" as TKN
  
  TK1 ..> TP : N:M Mapping (Multiplexing)
  TK2 ..> TP
  TK3 ..> TP
  TKN ..> TP
}

@enduml

Порівняльна Таблиця: Thread vs Task

ХарактеристикаThreadTask / 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 flagCancellationToken
Pooling❌ Кожен Thread — окремий✅ Використовує ThreadPool
Use caseДовгі фонові операції (IsBackground = true)99% сценаріїв паралелізму
Коли використовувати Thread замість Task?Лише у виняткових випадках:
  • Потрібен довгий фоновий потік з власним життєвим циклом (наприклад, listener socket)
  • Потрібен контроль над ThreadPriority або ApartmentState (COM interop)
  • Потрібен потік з нестандартним розміром стеку
У 99% випадків використовуйте Task.

2. Task та Task<T>: Повний API

Task.Run() — Рекомендований Спосіб

Task.Run() — це найпростіший та найбезпечніший спосіб запустити CPU-bound роботу на ThreadPool. Він був доданий у .NET 4.5 як спрощення над Task.Factory.StartNew().

TaskRunBasics.cs
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. Використовуйте його коли потрібні спеціальні налаштування.

TaskFactoryStartNew.cs
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 (вже виконується).

ColdVsHotTasks.cs
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();
Завжди використовуйте Hot Tasks через Task.Run() або Task.Factory.StartNew().new Task() + Start() — це legacy підхід з .NET 4.0, який може призвести до помилок (забули викликати Start()). Сучасний код повинен використовувати Task.Run().

TaskStatus — Життєвий Цикл Task

Task проходить через декілька станів під час виконання:

TaskStatus.cs
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):

Loading diagram...
@startuml
skinparam style plain
hide empty description

state "Created" as Created
state "WaitingToRun" as Waiting
state "Running" as Running
state "RanToCompletion" as Completed #CCFFCC
state "Faulted" as Faulted #FFCCCC
state "Canceled" as Canceled #FFE5CC

[*] --> Created : new Task()
Created --> Waiting : Start()
[*] --> Waiting : Task.Run()

Waiting --> Running : ThreadPool призначає потік
Running --> Completed : Успішне завершення
Running --> Faulted : Необроблений Exception
Running --> Canceled : Скасовано через CancellationToken

Completed --> [*]
Faulted --> [*]
Canceled --> [*]
@enduml
СтанОпис
CreatedTask створений через new Task() але не запущений
WaitingToRunTask у черзі ThreadPool, чекає вільного потоку
RunningTask виконується на потоці
RanToCompletionTask успішно завершився
FaultedTask завершився з exception
CanceledTask був скасований через 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:

BlockingWait.cs
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Рекомендовано для синхронного коду
Deadlock Warning!Ніколи не використовуйте .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, який завершується коли всі передані задачі завершаться. Це паралельне виконання з очікуванням всіх результатів.

Loading diagram...
@startuml
skinparam style plain

participant "Main Thread" as Main
participant "Task 1 (1s)" as T1
participant "Task 2 (2s)" as T2
participant "Task 3 (1.5s)" as T3

Main -> T1 : Task.Run()
Main -> T2 : Task.Run()
Main -> T3 : Task.Run()

activate T1 #CCFFCC
activate T2 #CCFFCC
activate T3 #CCFFCC

Main -> Main : Task.WhenAll(T1, T2, T3).Result
activate Main #FFCCCC

T1 --> Main : Завершено (1s)
deactivate T1

T3 --> Main : Завершено (1.5s)
deactivate T3

T2 --> Main : Завершено (2s)
deactivate T2

Main -> Main : Всі задачі завершені!
deactivate Main
@enduml
TaskWhenAll.cs
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}");
Task.WhenAll Output
[12:45:30] Починаємо завантаження https://site1.com
[12:45:30] Починаємо завантаження https://site2.com
[12:45:30] Починаємо завантаження https://site3.com
[12:45:31] Завершили завантаження https://site1.com
[12:45:31] Завершили завантаження https://site3.com
[12:45:32] Завершили завантаження https://site2.com
Всі завантаження завершені за 2003ms
- Content from https://site1.com
- Content from https://site2.com
- Content from https://site3.com
Паралельне виконання: 2 секунди замість 4.5 секунд послідовно!

Ключові моменти:

  • ✅ Всі задачі запускаються одночасно (паралельно)
  • WhenAll чекає поки найповільніша задача завершиться
  • ✅ Повертає масив результатів у тому ж порядку що й задачі
  • ✅ Якщо хоча б одна задача кине exception — WhenAll кине AggregateException

Без Task (void tasks):

WhenAllVoid.cs
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 сценаріїв.

Loading diagram...
@startuml
skinparam style plain

participant "Main Thread" as Main
participant "Server EU (1s)" as T1
participant "Server US (2s)" as T2
participant "Server ASIA (3s)" as T3

Main -> T1 : Task.Run()
Main -> T2 : Task.Run()
Main -> T3 : Task.Run()

activate T1 #CCFFCC
activate T2 #CCFFCC
activate T3 #CCFFCC

Main -> Main : Task.WhenAny().Result
activate Main #FFCCCC

T1 --> Main : Завершено (1s) \nПерший результат!
deactivate T1

Main -> Main : Продовжує роботу
deactivate Main

note right of T2 : Продовжує виконуватись у фоні\n(якщо не скасовано)
note right of T3 : Продовжує виконуватись у фоні\n(якщо не скасовано)

T2 -->x Main : Ігнорується (2s)
deactivate T2
T3 -->x Main : Ігнорується (3s)
deactivate T3
@enduml
TaskWhenAny.cs
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");

// Інші задачі продовжують виконуватись у фоні (якщо не скасувати)
Task.WhenAny Output
Перший відповів: Data from Server-EU за 1002ms
Server-US та Server-ASIA продовжують виконуватись у фоні

Практичний приклад: Timeout Pattern:

TimeoutPattern.cs
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(), але не блокує потік.

TaskDelay.cs
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 потік) використовуйте:

TaskFromResult.cs
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 (той, хто слухає сигнал скасування). Це дозволяє безпечно передавати токен у будь-які глибини викликів.

Loading diagram...
@startuml
skinparam style plain

participant "UI Thread / Main" as UI
participant "CancellationToken\nSource (CTS)" as CTS
participant "Task 1" as T1
participant "Task 2" as T2

UI -> CTS : new CancellationTokenSource()
activate CTS

UI -> T1 : Task.Run(..., CTS.Token)
activate T1

UI -> T2 : Task.Run(..., CTS.Token)
activate T2

... Через деякий час ...

UI -> CTS : CTS.Cancel()
note over CTS : Переводить стан Token\nу IsCancellationRequested = true

CTS --> T1 : Сигнал скасування
CTS --> T2 : Сигнал скасування

note over T1 : Перевіряє token.\nThrowIfCancellationRequested()
T1 -->x UI : OperationCanceledException
deactivate T1

note over T2 : Перевіряє token.\nThrowIfCancellationRequested()
T2 -->x UI : OperationCanceledException
deactivate T2

deactivate CTS
@enduml

CancellationTokenSource — це об'єкт, який ініціює скасування. CancellationToken — це токен (структура), який передається в методи для перевірки скасування.

CancellationBasics.cs
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("Операція була скасована");
}
Cancellation Output
Крок 1/10
Крок 2/10
Крок 3/10
Крок 4/10
Операція була скасована

Ключові моменти:

  • CancellationTokenSource — створює токен та ініціює скасування через .Cancel()
  • CancellationToken — передається в методи для перевірки скасування
  • ThrowIfCancellationRequested() — кидає OperationCanceledException якщо скасовано
  • IsCancellationRequested — boolean перевірка без exception

Два Способи Перевірки Скасування

CancellationCheck.cs
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()

TaskRunCancellation.cs
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:

CancellationTimeout.cs
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");
}
Timeout Output
Починаємо завантаження https://example.com/bigfile.zip...
Прогрес: 5%
Прогрес: 10%
Прогрес: 15%
Прогрес: 20%
Прогрес: 25%
Прогрес: 30%
Завантаження перервано через timeout

Linked Tokens — Ієрархія Скасування

Іноді потрібно скасувати операцію якщо будь-який з декількох токенів скасовано. Для цього використовується CreateLinkedTokenSource().

LinkedTokens.cs
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 код коли токен скасовано:

CancellationCallback.cs
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

ExceptionInThread.cs
// ❌ 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).

Loading diagram...
@startuml
skinparam style plain

object "Task.WaitAll()" as WaitAll #FFCCCC

object "AggregateException" as AE #FFCCCC {
  Message = "One or more errors occurred."
}

object "InvalidOperationException" as Ex1 #FFE5CC {
  Message = "Task 1 failed"
}

object "TimeoutException" as Ex2 #FFE5CC {
  Message = "Task 2 failed"
}

object "ArgumentNullException" as Ex3 #FFE5CC {
  Message = "Task 3 failed"
}

WaitAll --> AE : Throws
AE *-- Ex1 : InnerExceptions[0]
AE *-- Ex2 : InnerExceptions[1]
AE *-- Ex3 : InnerExceptions[2]

@enduml
AggregateException.cs
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}");
}
AggregateException Output
AggregateException з 1 exceptions:
- InvalidOperationException: Щось пішло не так
Перший exception: Щось пішло не так

.GetAwaiter().GetResult() Автоматично Unwrap Exception

Коли ви використовуєте .GetAwaiter().GetResult(), він автоматично розгортає AggregateException і кидає оригінальний exception:

AwaitUnwrap.cs
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}");
}
Примітка: для отримання оригінального exception без AggregateException у синхронному коді використовуйте .GetAwaiter().GetResult().

Task.WhenAll() та Множинні Exceptions

Коли декілька задач виконуються паралельно через Task.WhenAll(), і декілька з них кидають exceptions — всі exceptions зберігаються в AggregateException:

WhenAllExceptions.cs
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}");
    }
}
WhenAll Exceptions Output
Всього exceptions: 3
- Task 1 failed
- Task 2 failed
- Task 3 failed

AggregateException.Flatten() — Розгортання Вкладених Exceptions

Іноді AggregateException може містити інші AggregateException (вкладені). Метод Flatten() розгортає всю ієрархію:

FlattenException.cs
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, а інші — пропустити далі:

HandleException.cs
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 (незамічений).

UnobservedException.cs
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:

UnobservedHandler.cs
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("Програма завершилась");
Best Practice: завжди обробляйте exceptions у Task!
// ✅ Правильно
Task task = Task.Run(() => DoWork());
try
{
    task.Wait();
}
catch (AggregateException ex)
{
    // Обробка
}

// ❌ Неправильно — fire-and-forget без обробки
_ = Task.Run(() => DoWork());  // exception може бути втрачений

Практичний Приклад: Retry Pattern з Exception Handling

RetryPattern.cs
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.

Copyright © 2026