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)

Порівняльна Таблиця: 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}");

TaskStatus enum (спрощена діаграма):

Created → WaitingToRun → Running → RanToCompletion
                                 ↘ Faulted (exception)
                                 ↘ Canceled (CancellationToken)
СтанОпис
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, який завершується коли всі передані задачі завершаться. Це паралельне виконання з очікуванням всіх результатів.

TaskWhenAll.cs
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}");
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>)
await Task.WhenAll(task1, task2, task3);

Console.WriteLine("Всі задачі завершені");

Task.WhenAny() — Очікування Першої Задачі

Task.WhenAny() завершується як тільки будь-яка з переданих задач завершиться. Це корисно для timeout, race conditions, або fallback сценаріїв.

TaskWhenAny.cs
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");

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

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

TimeoutPattern.cs
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}");
}

Task.WhenEach() — Обробка Задач у Порядку Завершення (.NET 9)

У .NET 9 додано Task.WhenEach() — він повертає IAsyncEnumerable<Task<T>>, який дозволяє обробляти задачі у порядку їх завершення.

TaskWhenEach.cs
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}");
}
Task.WhenEach Output
[12:45:31] Item 2 processed ← завершився першим (1s)
[12:45:32] Item 3 processed ← завершився другим (2s)
[12:45:33] Item 1 processed ← завершився останнім (3s)

Порівняння WhenAll vs WhenAny vs WhenEach:

МетодКоли завершуєтьсяРезультатUse Case
WhenAllКоли всі задачі завершатьсяT[] — масив результатівПаралельна обробка з очікуванням всіх
WhenAnyКоли перша задача завершитьсяTask<T> — перша завершенаTimeout, race, fallback
WhenEachПовертає задачі у порядку завершенняIAsyncEnumerable<Task<T>>Прогресивна обробка результатів

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 — НЕ блокує потік, звільняє його для іншої роботи
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) — потік звільняється, може обслуговувати інші задачі
Коли використовувати Task.Delay?
  • ✅ В async методах — завжди await Task.Delay()
  • ✅ Для 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 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;
}

Коли використовувати:

  • ✅ Кешування — результат вже є в пам'яті
  • ✅ Валідація — синхронна перевірка перед 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

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

CancellationBasics.cs
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("Операція була скасована");
}
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
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);
//              ↑ перевірка всередині      ↑ перевірка перед запуском
  • Перший токен (у lambda) — для перевірки скасування під час виконання
  • Другий токен (у Task.Run) — для перевірки скасування перед запуском (якщо вже скасовано — Task не запуститься)

Timeout через CancellationTokenSource

CancellationTokenSource має вбудовану підтримку timeout:

CancellationTimeout.cs
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");
}
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(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();
}

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(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("Операція скасована");
}

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
{
    await task;  // тут exception буде кинуто
}
catch (Exception ex)
{
    Console.WriteLine($"Перехоплено: {ex.Message}");
}

AggregateException — Обгортка для Множинних Exceptions

Коли ви використовуєте .Result або .Wait(), exception обгортається в AggregateException — спеціальний тип який може містити декілька exceptions (наприклад, якщо декілька паралельних задач кинули exceptions).

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: Щось пішло не так

await Автоматично Unwrap Exception

Коли ви використовуєте await, компілятор автоматично розгортає AggregateException і кидає оригінальний exception:

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

Коли декілька задач виконуються паралельно через 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);

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}");
    }
}
WhenAll Exceptions Output
Перехоплено через await: Task 1 failed
Всього exceptions: 3
- Task 1 failed
- Task 2 failed
- Task 3 failed

Правильний спосіб обробити всі exceptions:

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

await Task.Delay(1000);
Console.WriteLine("Програма завершилась");
Best Practice: завжди обробляйте exceptions у Task!
// ✅ Правильно
Task task = DoWorkAsync();
try
{
    await task;
}
catch (Exception ex)
{
    // Обробка
}

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

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

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