System Programming Windows

Async/Await — Фундамент Асинхронного Програмування

Глибокий академічний розбір async/await у C# — від проблеми блокуючого I/O до state machine під капотом. Історія асинхронності (APM, EAP, TAP), синтаксис async/await, return types, exception handling та best practices.

Async/Await: Фундамент Асинхронного Програмування

Вступ: Дві Категорії Операцій

Перш ніж занурюватись у async/await, критично важливо зрозуміти фундаментальну різницю між двома типами операцій, які виконує ваша програма. Без цього розуміння async/await залишиться магічним синтаксисом без логічного обґрунтування.

CPU-bound vs I/O-bound: Ключова Різниця

Кожна операція у вашій програмі належить до однієї з двох категорій:

CPU-bound операції — це операції, які активно використовують процесор для обчислень. Процесор постійно зайнятий виконанням інструкцій. Приклади:

  • Обчислення простих чисел
  • Шифрування/дешифрування даних
  • Компресія/декомпресія файлів
  • Рендеринг зображень
  • Сортування великих масивів
  • Обчислення математичних моделей

Для CPU-bound операцій єдиний спосіб не блокувати головний потік — це перенести роботу на інший потік (Thread або ThreadPool). Це справжній паралелізм: декілька CPU cores одночасно виконують різні обчислення.

I/O-bound операції — це операції, які чекають на зовнішню подію. Процесор не задіяний — він просто чекає поки операційна система, мережа, диск або інший пристрій завершить роботу. Приклади:

  • HTTP запити до веб-сервера
  • Читання/запис файлів на диск
  • Запити до бази даних
  • Очікування вводу користувача
  • Таймери та затримки
  • Мережеві socket операції

Для I/O-bound операцій не потрібні додаткові потоки. Операційна система має механізми (IOCP на Windows, epoll на Linux) для ефективного очікування завершення I/O без блокування потоків. Це те, що робить async/await — звільняє потік поки чекаємо на I/O.

Візуалізація Різниці

Уявіть собі ресторан:

CPU-bound — це кухар, який готує страву. Він активно працює: ріже овочі, смажить м'ясо, варить соус. Щоб приготувати більше страв одночасно — потрібно більше кухарів (більше CPU cores або потоків).

I/O-bound — це офіціант, який приніс замовлення на кухню і чекає поки кухар приготує страву. Офіціант не робить нічого корисного — він просто стоїть і чекає. Замість цього він міг би обслуговувати інших клієнтів, приймати нові замовлення, приносити напої. Це те, що робить async/await — офіціант (потік) не стоїть без діла, а обслуговує інші запити поки чекає на кухню (I/O).


1. Проблема: Чому Async?

Блокуюче I/O — Катастрофа для Масштабування

Щоб зрозуміти навіщо потрібен async/await, розгляньмо конкретну проблему. Уявіть веб-сервер, який обробляє HTTP запити:

BlockingWebServer.cs
// ❌ Синхронний (блокуючий) підхід
public string GetUserData(int userId)
{
    // 1. Запит до бази даних (I/O) — потік БЛОКУЄТЬСЯ на ~50ms
    var user = database.GetUser(userId);  // блокуюче I/O
    
    // 2. HTTP запит до зовнішнього API (I/O) — потік БЛОКУЄТЬСЯ на ~200ms
    var profile = httpClient.Get($"https://api.example.com/users/{userId}");  // блокуюче I/O
    
    // 3. Читання файлу з диску (I/O) — потік БЛОКУЄТЬСЯ на ~30ms
    var settings = File.ReadAllText($"users/{userId}/settings.json");  // блокуюче I/O
    
    // 4. Об'єднання результатів (CPU) — потік АКТИВНО ПРАЦЮЄ ~1ms
    return CombineData(user, profile, settings);
}

Що відбувається з потоком під час виконання цього методу?

Час (ms):  0    50   100   150   200   250   280  281
           |-----|-----|-----|-----|-----|-----|---|
Потік:     [DB] [────────── HTTP ──────────] [File] [CPU]
           ⏸️    ⏸️    ⏸️    ⏸️    ⏸️    ⏸️    ⏸️   ⚙️

⏸️ = потік БЛОКУЄТЬСЯ (чекає I/O, не робить корисної роботи)
⚙️ = потік АКТИВНО ПРАЦЮЄ (виконує CPU-bound код)

Проблема: з 281ms загального часу, потік активно працював лише 1ms (0.35% часу). Решту 280ms він простоював, чекаючи на I/O. Це катастрофічно неефективно!

Thread Starvation — Вичерпання Потоків

Тепер уявіть що на ваш веб-сервер приходить 1000 одночасних запитів. Кожен запит займає потік з ThreadPool на ~280ms. ThreadPool має обмежену кількість потоків (зазвичай ~100-200 на сервері). Що станеться?

ThreadPool: [████████████████████████] ← всі потоки зайняті
Нові запити: ⏳⏳⏳⏳⏳⏳⏳⏳⏳⏳ ← чекають у черзі

Результат: сервер "завис", response time зростає до десятків секунд

Це називається thread starvation (голодування потоків) — всі потоки ThreadPool зайняті блокуючими I/O операціями, і нові запити не можуть бути оброблені.

C10K Problem — Проблема 10,000 З'єднань

У 1999 році інженер Dan Kegel сформулював C10K problem: як створити веб-сервер, який може обробляти 10,000 одночасних з'єднань?

З блокуючим I/O це неможливо:

  • 10,000 з'єднань × 1 потік на з'єднання = 10,000 потоків
  • 10,000 потоків × 1 MB стеку = 10 GB пам'яті тільки на стеки!
  • Context switching між 10,000 потоками = катастрофічний overhead

Рішення: non-blocking I/O. Замість блокування потоку, операційна система повідомляє коли I/O завершено, і потік звільняється для обробки інших запитів.

Async/Await — Рішення

async/await у C# — це синтаксичний цукор над non-blocking I/O. Коли ви пишете:

var data = await httpClient.GetStringAsync(url);

Компілятор генерує код, який:

  1. Ініціює I/O операцію (HTTP запит)
  2. Звільняє поточний потік (потік повертається в ThreadPool)
  3. Коли I/O завершується — продовжує виконання (можливо на іншому потоці)
AsyncWebServer.cs
// ✅ Асинхронний (non-blocking) підхід
public async Task<string> GetUserDataAsync(int userId)
{
    // 1. Запит до бази даних — потік ЗВІЛЬНЯЄТЬСЯ
    var user = await database.GetUserAsync(userId);
    
    // 2. HTTP запит — потік ЗВІЛЬНЯЄТЬСЯ
    var profile = await httpClient.GetStringAsync($"https://api.example.com/users/{userId}");
    
    // 3. Читання файлу — потік ЗВІЛЬНЯЄТЬСЯ
    var settings = await File.ReadAllTextAsync($"users/{userId}/settings.json");
    
    // 4. Об'єднання результатів — потік АКТИВНО ПРАЦЮЄ
    return CombineData(user, profile, settings);
}

Що відбувається з потоком?

Час (ms):  0    50   100   150   200   250   280  281
           |-----|-----|-----|-----|-----|-----|---|
Потік A:   [DB]                                  [CPU]
           ⚙️ await → потік звільнено              ⚙️

Потік A тепер може обробляти інші запити:
           [────── обробка запиту #2 ──────]
           [─── обробка запиту #3 ───]

Коли I/O завершується — продовжує виконання (можливо на іншому потоці)

Результат: один потік може обробляти сотні або тисячі одночасних запитів, тому що він не блокується на I/O.

Benchmark: Sync vs Async

SyncVsAsyncBenchmark.cs
using System.Diagnostics;
using System.Net.Http;

static string DownloadSync(string url)
{
    using var client = new HttpClient();
    return client.GetStringAsync(url).Result;  // ❌ блокує потік
}

static async Task<string> DownloadAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);  // ✅ звільняє потік
}

string[] urls = Enumerable.Range(1, 100)
    .Select(i => $"https://jsonplaceholder.typicode.com/posts/{i}")
    .ToArray();

// ❌ Синхронний підхід
var sw1 = Stopwatch.StartNew();
foreach (var url in urls)
{
    string content = DownloadSync(url);
}
sw1.Stop();
Console.WriteLine($"Sync: {sw1.ElapsedMilliseconds}ms");

// ✅ Асинхронний підхід
var sw2 = Stopwatch.StartNew();
var tasks = urls.Select(url => DownloadAsync(url));
await Task.WhenAll(tasks);
sw2.Stop();
Console.WriteLine($"Async: {sw2.ElapsedMilliseconds}ms");
Sync vs Async Benchmark
Sync: 47,823ms (47 секунд)
Async: 1,247ms (1.2 секунди)
Прискорення: 38.3x
Async не швидший — він ефективніший! Всі запити виконуються паралельно.
Важливо: async/awaitНЕ робить код швидшим. Він робить код ефективнішим — один потік може обробляти багато операцій одночасно, не блокуючись на I/O.Для CPU-bound операцій async/await не дає переваг — використовуйте Task.Run() або Parallel для справжнього паралелізму.

2. Історія: APM → EAP → TAP

Перш ніж async/await з'явився у C# 5.0 (2012 рік), .NET мав інші підходи до асинхронності. Розуміння цієї еволюції допоможе оцінити елегантність сучасного синтаксису.

APM (Asynchronous Programming Model) — .NET 1.0 (2002)

APM — це перший асинхронний паттерн у .NET, заснований на парі методів BeginXxx / EndXxx:

APM_Example.cs
// ❌ APM — legacy підхід (.NET 1.0-3.5)
using System.IO;

FileStream file = File.OpenRead("data.txt");

// Починаємо асинхронне читання
IAsyncResult asyncResult = file.BeginRead(
    buffer: new byte[1024],
    offset: 0,
    count: 1024,
    callback: ar =>  // callback викликається коли операція завершується
    {
        int bytesRead = file.EndRead(ar);  // завершуємо операцію
        Console.WriteLine($"Прочитано {bytesRead} байт");
        file.Close();
    },
    state: null
);

// Головний потік продовжує виконання
Console.WriteLine("Читання розпочато...");

Проблеми APM:

  • Callback hell — вкладені callbacks для послідовних операцій
  • ❌ Складна обробка помилок — exceptions у callback не можна перехопити ззовні
  • ❌ Немає стандартного способу скасування
  • ❌ Важко читати та підтримувати код

Приклад callback hell:

// ❌ Три послідовні асинхронні операції = три рівні вкладеності
file1.BeginRead(buffer1, 0, 1024, ar1 =>
{
    int bytes1 = file1.EndRead(ar1);
    
    file2.BeginRead(buffer2, 0, 1024, ar2 =>
    {
        int bytes2 = file2.EndRead(ar2);
        
        file3.BeginRead(buffer3, 0, 1024, ar3 =>
        {
            int bytes3 = file3.EndRead(ar3);
            // Нарешті можемо обробити результати
        }, null);
    }, null);
}, null);

EAP (Event-based Asynchronous Pattern) — .NET 2.0 (2005)

EAP — це паттерн заснований на подіях (events), популярний у UI-фреймворках:

EAP_Example.cs
// ❌ EAP — legacy підхід (.NET 2.0-3.5)
using System.ComponentModel;
using System.Net;

var client = new WebClient();

// Підписуємось на подію завершення
client.DownloadStringCompleted += (sender, e) =>
{
    if (e.Error != null)
    {
        Console.WriteLine($"Помилка: {e.Error.Message}");
    }
    else if (e.Cancelled)
    {
        Console.WriteLine("Операція скасована");
    }
    else
    {
        Console.WriteLine($"Завантажено: {e.Result.Length} символів");
    }
};

// Починаємо асинхронне завантаження
client.DownloadStringAsync(new Uri("https://example.com"));

Console.WriteLine("Завантаження розпочато...");

Проблеми EAP:

  • ❌ Складно ланцюжити операції — треба підписуватись на події
  • ❌ Немає стандартного способу повернути результат
  • ❌ Обробка помилок через e.Error — не природно для C#
  • ✅ Підтримка скасування через CancelAsync()

TAP (Task-based Asynchronous Pattern) — .NET 4.0 (2010)

TAP — це сучасний паттерн, заснований на Task та Task<T>. У .NET 4.0 з'явився TAP, але без синтаксичного цукру:

TAP_Without_Await.cs
// ⚠️ TAP без async/await (.NET 4.0-4.5)
using System.Net.Http;
using System.Threading.Tasks;

var client = new HttpClient();

Task<string> downloadTask = client.GetStringAsync("https://example.com");

// Продовження через ContinueWith
downloadTask.ContinueWith(task =>
{
    if (task.IsFaulted)
    {
        Console.WriteLine($"Помилка: {task.Exception.Message}");
    }
    else if (task.IsCanceled)
    {
        Console.WriteLine("Операція скасована");
    }
    else
    {
        Console.WriteLine($"Завантажено: {task.Result.Length} символів");
    }
});

Console.WriteLine("Завантаження розпочато...");

Проблеми TAP без async/await:

  • ContinueWith — все ще callback-подібний підхід
  • ❌ Складно ланцюжити операції
  • ❌ Проблеми з SynchronizationContext (детально в темі 13)

Async/Await — C# 5.0 (2012)

Async/Await — це синтаксичний цукор над TAP, який робить асинхронний код схожим на синхронний:

Modern_Async_Await.cs
// ✅ Сучасний підхід (C# 5.0+)
using System.Net.Http;
using System.Threading.Tasks;

async Task DownloadAndProcessAsync()
{
    var client = new HttpClient();
    
    try
    {
        // Виглядає як синхронний код, але не блокує потік!
        string content = await client.GetStringAsync("https://example.com");
        Console.WriteLine($"Завантажено: {content.Length} символів");
        
        // Можна легко ланцюжити операції
        await File.WriteAllTextAsync("output.txt", content);
        Console.WriteLine("Збережено у файл");
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"Помилка: {ex.Message}");
    }
}

await DownloadAndProcessAsync();

Переваги async/await:

  • ✅ Лінійний код — виглядає як синхронний
  • ✅ Природна обробка помилок через try/catch
  • ✅ Підтримка CancellationToken
  • ✅ Автоматичне управління SynchronizationContext
  • ✅ Легко ланцюжити операції

Порівняльна Таблиця

Паттерн.NET VersionСинтаксисПроблеми
APM1.0 (2002)BeginXxx / EndXxxCallback hell, складна обробка помилок
EAP2.0 (2005)Events (XxxCompleted)Складно ланцюжити, немає return value
TAP4.0 (2010)Task + ContinueWithВсе ще callback-подібний
Async/Await4.5 (2012)async / await✅ Ідеальний підхід
Legacy код: якщо ви працюєте зі старим кодом, який використовує APM або EAP, можна обгорнути його в TAP через TaskCompletionSource<T> (детально в темі 14).

Еволюція API у .NET

Багато API у .NET мають всі три версії для зворотної сумісності:

// FileStream має всі три паттерни:

// APM (.NET 1.0)
IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state);
int EndRead(IAsyncResult asyncResult);

// Синхронний
int Read(byte[] buffer, int offset, int count);

// TAP (.NET 4.5+) — рекомендовано
Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken);

Правило: завжди використовуйте XxxAsync методи (TAP) у новому коді. APM та EAP — тільки для підтримки legacy систем.


3. Async та Await: Синтаксис

Тепер, коли ви розумієте навіщо потрібен async/await та як він еволюціонував, розгляньмо детально синтаксис та семантику.

Ключове Слово async — Що Воно Робить?

Багато розробників помилково думають що async робить метод асинхронним або паралельним. Це не так!

async — це маркер для компілятора, який означає: "у цьому методі можуть бути await вирази, згенеруй state machine для них".

AsyncKeyword.cs
// ❌ Поширена помилка — async НЕ робить метод асинхронним
async Task DoWorkAsync()
{
    // Цей метод виконується СИНХРОННО від початку до першого await
    Console.WriteLine("Початок методу");
    Thread.Sleep(1000);  // блокує потік!
    Console.WriteLine("Кінець методу");
    // Немає await — метод виконався повністю синхронно
}

// ✅ Правильно — async дозволяє використовувати await
async Task DoWorkAsync()
{
    Console.WriteLine("Початок методу");
    await Task.Delay(1000);  // НЕ блокує потік
    Console.WriteLine("Кінець методу");
}

Що робить async:

  1. Дозволяє використовувати await всередині методу
  2. Змінює return type: voidTask, TTask<T>
  3. Компілятор генерує state machine (детально в розділі 4)

Що НЕ робить async:

  • ❌ Не робить метод паралельним
  • ❌ Не створює новий потік
  • ❌ Не робить метод швидшим

Ключове Слово await — Точка Призупинення

await — це точка призупинення виконання методу. Коли виконання досягає await:

  1. Якщо операція вже завершена — продовжити синхронно (без призупинення)
  2. Якщо операція ще виконується — призупинити метод, звільнити потік, повернути Task викликачу
  3. Коли операція завершується — продовжити виконання (можливо на іншому потоці)
AwaitKeyword.cs
async Task ExampleAsync()
{
    Console.WriteLine($"[1] Потік: {Thread.CurrentThread.ManagedThreadId}");
    
    // await — точка призупинення
    await Task.Delay(1000);
    
    // Продовження може бути на іншому потоці!
    Console.WriteLine($"[2] Потік: {Thread.CurrentThread.ManagedThreadId}");
}
Await Output
[1] Потік: 4
[2] Потік: 7
Продовження виконалось на іншому потоці ThreadPool!

Return Types — Що Може Повертати Async Метод?

Async метод може повертати один з наступних типів:

1. Task — async метод без результату (аналог void):

TaskReturnType.cs
async Task ProcessDataAsync()
{
    await Task.Delay(1000);
    Console.WriteLine("Обробка завершена");
    // Немає return — компілятор автоматично повертає Task
}

// Виклик
await ProcessDataAsync();  // чекаємо завершення

2. Task<T> — async метод з результатом типу T:

TaskTReturnType.cs
async Task<int> CalculateSumAsync()
{
    await Task.Delay(1000);
    return 42;  // компілятор обгортає в Task<int>
}

// Виклик
int result = await CalculateSumAsync();
Console.WriteLine($"Результат: {result}");

3. void — тільки для event handlers (❌ не рекомендовано):

AsyncVoid.cs
// ⚠️ async void — тільки для event handlers!
private async void Button_Click(object sender, EventArgs e)
{
    await Task.Delay(1000);
    MessageBox.Show("Готово!");
}
Ніколи не використовуйте async void крім event handlers!Проблеми:
  • ❌ Неможливо дочекатись завершення (await не працює)
  • ❌ Exceptions не можна перехопити ззовні (crash процесу)
  • ❌ Немає способу дізнатись про помилки
// ❌ Погано
async void DoWorkAsync()
{
    throw new Exception("Boom!");  // crash процесу!
}

DoWorkAsync();  // не можна await, exception втрачено

// ✅ Добре
async Task DoWorkAsync()
{
    throw new Exception("Boom!");
}

try
{
    await DoWorkAsync();  // exception перехоплено
}
catch (Exception ex)
{
    Console.WriteLine($"Помилка: {ex.Message}");
}

4. ValueTask та ValueTask<T> — оптимізація для hot paths (.NET Core 2.1+):

ValueTaskReturnType.cs
// Оптимізація: якщо результат часто доступний синхронно
async ValueTask<int> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out int value))
    {
        return value;  // синхронне повернення — no heap allocation
    }
    
    // Якщо немає в кеші — завантажуємо асинхронно
    value = await LoadFromDatabaseAsync(key);
    _cache[key] = value;
    return value;
}

Різниця Task vs ValueTask:

ХарактеристикаTask<T>ValueTask<T>
ТипReference type (class)Value type (struct)
Heap allocationЗавждиТільки якщо async path
Можна await двічі✅ Так❌ Ні (undefined behavior)
Можна зберігати✅ Так❌ Ні (тільки await одразу)
Use caseЗагальний випадокHot paths з частим sync completion
Правило: використовуйте Task<T> за замовчуванням. ValueTask<T> — тільки для hot paths після benchmarking.

Naming Convention — Суфікс Async

За конвенцією, всі async методи повинні мати суфікс Async:

// ✅ Правильно
async Task<string> DownloadDataAsync(string url) { ... }
async Task ProcessFileAsync(string path) { ... }
async Task<int> CalculateSumAsync(int[] numbers) { ... }

// ❌ Неправильно
async Task<string> DownloadData(string url) { ... }  // немає Async
async Task ProcessFile(string path) { ... }

Виняток: event handlers не потребують суфікса:

// ✅ Правильно для event handlers
private async void Button_Click(object sender, EventArgs e) { ... }

Async All The Way — Правило Асинхронності

Якщо ви викликаєте async метод — ваш метод теж повинен бути async. Це називається "async all the way":

AsyncAllTheWay.cs
// ❌ Погано — блокуємо async метод
public string GetData()
{
    return DownloadDataAsync().Result;  // блокує потік!
}

// ✅ Добре — async all the way
public async Task<string> GetDataAsync()
{
    return await DownloadDataAsync();  // не блокує потік
}

Ланцюжок async методів:

// Кожен рівень додає async
async Task<string> Level1Async()
{
    return await Level2Async();
}

async Task<string> Level2Async()
{
    return await Level3Async();
}

async Task<string> Level3Async()
{
    using var client = new HttpClient();
    return await client.GetStringAsync("https://example.com");
}

// Виклик з Main (C# 7.1+)
static async Task Main(string[] args)
{
    string result = await Level1Async();
    Console.WriteLine(result);
}

Приклад: Async File Operations

AsyncFileOperations.cs
using System.IO;
using System.Threading.Tasks;

// Асинхронне читання файлу
async Task<string> ReadFileAsync(string path)
{
    // File.ReadAllTextAsync — non-blocking I/O
    string content = await File.ReadAllTextAsync(path);
    return content;
}

// Асинхронний запис файлу
async Task WriteFileAsync(string path, string content)
{
    await File.WriteAllTextAsync(path, content);
}

// Асинхронне копіювання файлу
async Task CopyFileAsync(string sourcePath, string destPath)
{
    using var sourceStream = File.OpenRead(sourcePath);
    using var destStream = File.Create(destPath);
    
    // CopyToAsync — non-blocking I/O
    await sourceStream.CopyToAsync(destStream);
}

// Використання
string content = await ReadFileAsync("input.txt");
string processed = content.ToUpper();
await WriteFileAsync("output.txt", processed);

Console.WriteLine("Файл оброблено");

Приклад: Async HTTP Requests

AsyncHttpRequests.cs
using System.Net.Http;
using System.Threading.Tasks;

async Task<string> DownloadPageAsync(string url)
{
    using var client = new HttpClient();
    
    // GetStringAsync — non-blocking I/O
    string content = await client.GetStringAsync(url);
    
    return content;
}

async Task<byte[]> DownloadImageAsync(string url)
{
    using var client = new HttpClient();
    
    // GetByteArrayAsync — non-blocking I/O
    byte[] imageData = await client.GetByteArrayAsync(url);
    
    return imageData;
}

async Task<HttpResponseMessage> PostDataAsync(string url, string jsonData)
{
    using var client = new HttpClient();
    using var content = new StringContent(jsonData, Encoding.UTF8, "application/json");
    
    // PostAsync — non-blocking I/O
    HttpResponseMessage response = await client.PostAsync(url, content);
    
    return response;
}

// Паралельне завантаження декількох сторінок
string[] urls = ["https://site1.com", "https://site2.com", "https://site3.com"];

Task<string>[] tasks = urls.Select(url => DownloadPageAsync(url)).ToArray();
string[] results = await Task.WhenAll(tasks);

Console.WriteLine($"Завантажено {results.Length} сторінок");

Async Lambda Expressions

Lambda вирази теж можуть бути async:

AsyncLambda.cs
// Async lambda без параметрів
Func<Task<int>> asyncFunc = async () =>
{
    await Task.Delay(1000);
    return 42;
};

int result = await asyncFunc();

// Async lambda з параметрами
Func<string, Task<string>> downloadFunc = async url =>
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
};

string content = await downloadFunc("https://example.com");

// Використання в LINQ
string[] urls = GetUrls();

var tasks = urls.Select(async url =>
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
});

string[] results = await Task.WhenAll(tasks);

Async Local Functions

Local functions (C# 7.0+) теж можуть бути async:

AsyncLocalFunction.cs
async Task ProcessDataAsync()
{
    // Async local function
    async Task<int> DownloadAndCountAsync(string url)
    {
        using var client = new HttpClient();
        string content = await client.GetStringAsync(url);
        return content.Length;
    }
    
    // Використання
    int count1 = await DownloadAndCountAsync("https://site1.com");
    int count2 = await DownloadAndCountAsync("https://site2.com");
    
    Console.WriteLine($"Загальна довжина: {count1 + count2}");
}
Best Practice: використовуйте async local functions для допоміжної логіки всередині async методів — це покращує читабельність та дозволяє уникнути дублювання коду.

4. State Machine Під Капотом

Тепер, коли ви розумієте синтаксис async/await, настав час зазирнути під капот і побачити що насправді генерує компілятор. Це критично важливо для розуміння performance implications та debugging складних сценаріїв.

Що Генерує Компілятор?

Коли ви пишете async метод, компілятор C# трансформує його у складну state machine. Розгляньмо простий приклад:

SimpleAsyncMethod.cs
// Ваш код
async Task<int> CalculateAsync()
{
    Console.WriteLine("Початок");
    await Task.Delay(1000);
    Console.WriteLine("Після delay");
    return 42;
}

Компілятор генерує приблизно такий код (спрощено):

GeneratedStateMachine.cs
// Згенерований компілятором код (спрощено)
struct CalculateAsyncStateMachine : IAsyncStateMachine
{
    public int state;  // поточний стан state machine
    public AsyncTaskMethodBuilder<int> builder;  // builder для Task<int>
    private TaskAwaiter awaiter;  // awaiter для Task.Delay
    
    public void MoveNext()
    {
        int result = 0;
        
        try
        {
            if (state == 0)  // перший запуск
            {
                Console.WriteLine("Початок");
                
                // Починаємо await Task.Delay(1000)
                awaiter = Task.Delay(1000).GetAwaiter();
                
                if (!awaiter.IsCompleted)  // якщо ще не завершено
                {
                    state = 1;  // переходимо у стан 1
                    
                    // Реєструємо continuation — викликати MoveNext коли завершиться
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                    return;  // звільняємо потік!
                }
                
                // Якщо вже завершено — продовжуємо синхронно
            }
            
            if (state == 1)  // продовження після await
            {
                awaiter.GetResult();  // отримуємо результат (або exception)
                Console.WriteLine("Після delay");
                result = 42;
                state = -2;  // завершено
            }
        }
        catch (Exception ex)
        {
            state = -2;
            builder.SetException(ex);  // встановлюємо exception у Task
            return;
        }
        
        builder.SetResult(result);  // встановлюємо результат у Task
    }
    
    public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}

// Сам async метод стає wrapper
Task<int> CalculateAsync()
{
    var stateMachine = new CalculateAsyncStateMachine();
    stateMachine.state = 0;
    stateMachine.builder = AsyncTaskMethodBuilder<int>.Create();
    stateMachine.builder.Start(ref stateMachine);
    return stateMachine.builder.Task;
}

Ключові Елементи State Machine

1. State Field — поточний стан виконання:

state = 0  → початковий стан (до першого await)
state = 1  → після першого await
state = 2  → після другого await
...
state = -2 → завершено (RanToCompletion)

2. AsyncTaskMethodBuilder — створює та управляє Task:

  • builder.Start() — запускає state machine
  • builder.SetResult() — встановлює результат у Task
  • builder.SetException() — встановлює exception у Task
  • builder.AwaitUnsafeOnCompleted() — реєструє continuation

3. Awaiter — об'єкт який чекає завершення операції:

  • awaiter.IsCompleted — чи вже завершено?
  • awaiter.GetResult() — отримати результат (або кинути exception)
  • awaiter.OnCompleted() — зареєструвати continuation

4. MoveNext() — метод який виконує state machine:

  • Викликається при старті
  • Викликається після кожного await (continuation)
  • Містить всю логіку методу розбиту на стани

Візуалізація Виконання

Виклик CalculateAsync():
  ↓
[State 0] "Початок" → await Task.Delay(1000)
  ↓
Task.Delay ще не завершено → state = 1, return (звільнити потік)
  ↓
... потік обслуговує інші запити ...
  ↓
Task.Delay завершився → викликати MoveNext() (continuation)
  ↓
[State 1] "Після delay" → return 42
  ↓
builder.SetResult(42) → Task<int> завершено

Декілька Await — Декілька Станів

Кожен await додає новий стан:

MultipleAwaits.cs
async Task<string> MultipleAwaitsAsync()
{
    Console.WriteLine("Стан 0");
    
    await Task.Delay(1000);  // state = 1
    Console.WriteLine("Стан 1");
    
    await Task.Delay(1000);  // state = 2
    Console.WriteLine("Стан 2");
    
    await Task.Delay(1000);  // state = 3
    Console.WriteLine("Стан 3");
    
    return "Готово";
}

State machine має 4 стани:

State 0: до першого await
State 1: між першим та другим await
State 2: між другим та третім await
State 3: після третього await

Локальні Змінні — Hoisting

Локальні змінні які використовуються після await переміщуються (hoisted) у поля state machine:

LocalVariableHoisting.cs
// Ваш код
async Task ProcessAsync()
{
    int x = 10;  // використовується після await
    await Task.Delay(1000);
    Console.WriteLine(x);  // x повинна "вижити" після await
}

// Згенерований код (спрощено)
struct ProcessAsyncStateMachine
{
    public int state;
    public int x;  // локальна змінна стала полем!
    
    public void MoveNext()
    {
        if (state == 0)
        {
            x = 10;  // ініціалізація поля
            // await Task.Delay(1000)
            state = 1;
            return;
        }
        
        if (state == 1)
        {
            Console.WriteLine(x);  // читаємо поле
        }
    }
}

Performance implication: локальні змінні які використовуються після await займають пам'ять у state machine (heap allocation для reference types).

Синхронне Завершення — Оптимізація

Якщо операція вже завершена коли ви викликаєте await, state machine не призупиняється:

SyncCompletion.cs
async Task<int> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out int value))
    {
        return value;  // синхронне повернення — no state machine overhead
    }
    
    // Якщо немає в кеші — async path
    value = await LoadFromDatabaseAsync(key);
    return value;
}

Що відбувається:

  • Якщо значення в кеші → метод виконується повністю синхронно, state machine не активується
  • Якщо немає в кеші → state machine активується тільки для async path

Це називається fast path optimization — компілятор генерує код який уникає overhead state machine для синхронних шляхів.

Розбір Згенерованого Коду — SharpLab

Щоб побачити реальний згенерований код, використовуйте SharpLab.io:

  1. Відкрийте https://sharplab.io
  2. Напишіть async метод
  3. Виберіть "C# → IL" або "C# → C#" (decompiled)
  4. Подивіться згенерований state machine

Приклад для аналізу:

using System;
using System.Threading.Tasks;

public class C
{
    public async Task<int> M()
    {
        Console.WriteLine("Before");
        await Task.Delay(1000);
        Console.WriteLine("After");
        return 42;
    }
}

Ви побачите згенерований <M>d__0 struct з полями <>1__state, <>t__builder, awaiter-ами та методом MoveNext().

Performance Implications

Розуміння state machine допомагає оптимізувати код:

1. Heap Allocation:

// ❌ Кожен виклик = heap allocation для state machine
async Task<int> CalculateAsync()
{
    await Task.Delay(1000);
    return 42;
}

// ✅ Якщо часто синхронне завершення — ValueTask
async ValueTask<int> CalculateAsync()
{
    if (_cached)
        return 42;  // no heap allocation
    
    await Task.Delay(1000);
    return 42;
}

2. Локальні Змінні:

// ❌ Великі локальні змінні стають полями state machine
async Task ProcessAsync()
{
    byte[] largeBuffer = new byte[1024 * 1024];  // 1 MB
    await Task.Delay(1000);
    // largeBuffer тепер поле state machine — займає пам'ять
}

// ✅ Обмежити scope локальних змінних
async Task ProcessAsync()
{
    {
        byte[] largeBuffer = new byte[1024 * 1024];
        ProcessBuffer(largeBuffer);
    }  // largeBuffer більше не потрібна
    
    await Task.Delay(1000);  // largeBuffer не стає полем
}

3. Кількість Await:

Кожен await додає стан у state machine. Але це не означає що треба уникати await — читабельність важливіша за мікрооптимізації.

Висновок: розуміння state machine допомагає писати ефективніший async код, але не варто передчасно оптимізувати. Пишіть читабельний код, профілюйте, оптимізуйте тільки hot paths.

5. Exception Handling в Async

Обробка помилок у async коді має свої особливості. Розуміння цих особливостей критично важливе для написання надійних додатків.

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

Коли async метод кидає exception, він зберігається всередині Task. При await exception автоматично "розгортається":

AsyncExceptionUnwrap.cs
async Task<string> DownloadAsync(string url)
{
    using var client = new HttpClient();
    
    // Якщо URL невалідний — кине HttpRequestException
    return await client.GetStringAsync(url);
}

// Обробка exception
try
{
    string content = await DownloadAsync("https://invalid-url.com");
}
catch (HttpRequestException ex)  // оригінальний exception (не AggregateException!)
{
    Console.WriteLine($"Помилка завантаження: {ex.Message}");
}

Ключовий момент: await повертає оригінальний exception, а не AggregateException. Це робить обробку помилок природною для C#.

Порівняння: Await vs .Result

AwaitVsResult.cs
async Task FailingMethodAsync()
{
    throw new InvalidOperationException("Щось пішло не так");
}

// ✅ await — повертає оригінальний exception
try
{
    await FailingMethodAsync();
}
catch (InvalidOperationException ex)  // можна ловити конкретний тип
{
    Console.WriteLine($"Перехоплено: {ex.Message}");
}

// ❌ .Result — обгортає в AggregateException
try
{
    FailingMethodAsync().Result;
}
catch (AggregateException ex)  // треба обробляти AggregateException
{
    Console.WriteLine($"Перехоплено: {ex.InnerException.Message}");
}

Правило: завжди використовуйте await замість .Result — це дає чистішу обробку помилок.

Exception у Async Void — Катастрофа

Exceptions у async void методах не можна перехопити ззовні:

AsyncVoidException.cs
// ❌ async void — exception crash процесу!
async void DangerousMethodAsync()
{
    await Task.Delay(100);
    throw new Exception("Boom!");  // crash!
}

try
{
    DangerousMethodAsync();  // не можна await
    await Task.Delay(200);
}
catch (Exception ex)
{
    // Цей catch НЕ спрацює — exception вже crash процесу!
    Console.WriteLine("Не буде виконано");
}

Що відбувається: exception у async void потрапляє у SynchronizationContext.Current або TaskScheduler.UnobservedTaskException, що зазвичай призводить до crash.

Рішення: ніколи не використовуйте async void крім event handlers:

// ✅ async Task — exception можна перехопити
async Task SafeMethodAsync()
{
    await Task.Delay(100);
    throw new Exception("Boom!");
}

try
{
    await SafeMethodAsync();
}
catch (Exception ex)
{
    Console.WriteLine($"Перехоплено: {ex.Message}");
}

Try/Catch у Async Методах

Try/catch працює природно у async методах:

AsyncTryCatch.cs
async Task<string> DownloadWithRetryAsync(string url, int maxRetries = 3)
{
    int attempt = 0;
    
    while (true)
    {
        attempt++;
        
        try
        {
            using var client = new HttpClient();
            return await client.GetStringAsync(url);
        }
        catch (HttpRequestException ex) when (attempt < maxRetries)
        {
            Console.WriteLine($"Спроба {attempt} не вдалась: {ex.Message}");
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));  // exponential backoff
        }
        catch (HttpRequestException ex)
        {
            Console.WriteLine($"Всі {maxRetries} спроб не вдались");
            throw;  // re-throw після останньої спроби
        }
    }
}

Finally у Async Методах

finally блок виконується після завершення async операції:

AsyncFinally.cs
async Task ProcessFileAsync(string path)
{
    FileStream? stream = null;
    
    try
    {
        stream = File.OpenRead(path);
        
        byte[] buffer = new byte[1024];
        int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
        
        Console.WriteLine($"Прочитано {bytesRead} байт");
    }
    catch (IOException ex)
    {
        Console.WriteLine($"Помилка читання: {ex.Message}");
    }
    finally
    {
        // finally виконається ПІСЛЯ await
        stream?.Dispose();
        Console.WriteLine("Файл закрито");
    }
}

Важливо: finally виконується після повернення з await, навіть якщо continuation виконується на іншому потоці.

Using Statement у Async

using працює коректно з async:

AsyncUsing.cs
async Task ProcessFileAsync(string path)
{
    // using автоматично викличе Dispose після await
    using (var stream = File.OpenRead(path))
    {
        byte[] buffer = new byte[1024];
        int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
        Console.WriteLine($"Прочитано {bytesRead} байт");
    }  // stream.Dispose() викликається тут
    
    Console.WriteLine("Файл закрито");
}

// Або з using declaration (C# 8.0+)
async Task ProcessFileAsync(string path)
{
    using var stream = File.OpenRead(path);
    
    byte[] buffer = new byte[1024];
    int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
    Console.WriteLine($"Прочитано {bytesRead} байт");
}  // stream.Dispose() викликається тут

Task.WhenAll та Multiple Exceptions

Коли декілька задач кидають exceptions, Task.WhenAll зберігає всі exceptions у AggregateException:

WhenAllExceptions.cs
async Task FailingTask(int id)
{
    await Task.Delay(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
{
    await allTasks;
}
catch (Exception ex)
{
    // await повертає ТІЛЬКИ ПЕРШИЙ exception
    Console.WriteLine($"Перехоплено через await: {ex.Message}");
    
    // Щоб отримати ВСІ exceptions — читаємо task.Exception
    if (allTasks.Exception != null)
    {
        Console.WriteLine($"\nВсього exceptions: {allTasks.Exception.InnerExceptions.Count}");
        
        foreach (var innerEx in allTasks.Exception.InnerExceptions)
        {
            Console.WriteLine($"  - {innerEx.Message}");
        }
    }
}
WhenAll Exceptions Output
Перехоплено через await: Task 1 failed
Всього exceptions: 3
- Task 1 failed
- Task 2 failed
- Task 3 failed

Exception Filters у Async

Exception filters (when) працюють з async:

AsyncExceptionFilters.cs
async Task<string> DownloadAsync(string url)
{
    try
    {
        using var client = new HttpClient();
        return await client.GetStringAsync(url);
    }
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
    {
        Console.WriteLine("Сторінка не знайдена");
        return string.Empty;
    }
    catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
    {
        Console.WriteLine("Потрібна авторизація");
        throw;
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"Інша помилка HTTP: {ex.StatusCode}");
        throw;
    }
}

Практичний Приклад: Resilient HTTP Client

ResilientHttpClient.cs
using System.Net.Http;
using System.Threading.Tasks;

async Task<string> DownloadWithResilienceAsync(string url)
{
    int maxRetries = 3;
    int attempt = 0;
    
    while (true)
    {
        attempt++;
        
        try
        {
            using var client = new HttpClient();
            client.Timeout = TimeSpan.FromSeconds(10);
            
            Console.WriteLine($"Спроба {attempt}/{maxRetries}...");
            string content = await client.GetStringAsync(url);
            
            Console.WriteLine($"Успішно завантажено {content.Length} символів");
            return content;
        }
        catch (HttpRequestException ex) when (attempt < maxRetries)
        {
            Console.WriteLine($"HTTP помилка: {ex.Message}");
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));  // exponential backoff
        }
        catch (TaskCanceledException ex) when (attempt < maxRetries)
        {
            Console.WriteLine($"Timeout: {ex.Message}");
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Критична помилка після {attempt} спроб: {ex.Message}");
            throw;
        }
    }
}

// Використання
try
{
    string content = await DownloadWithResilienceAsync("https://example.com");
    Console.WriteLine("Дані отримано успішно");
}
catch (Exception ex)
{
    Console.WriteLine($"Не вдалося завантажити: {ex.Message}");
}

6. Практичні Завдання

Рівень 1: Async File Copier з Progress Reporting

Створіть утиліту для асинхронного копіювання файлів з відображенням прогресу.

Вимоги:

  • Асинхронне читання та запис файлів
  • Progress reporting через IProgress<T>
  • Підтримка CancellationToken
  • Обробка помилок (файл не існує, недостатньо місця, тощо)
// Приклад використання:
var progress = new Progress<double>(p => 
    Console.WriteLine($"Прогрес: {p:P0}"));

using var cts = new CancellationTokenSource();

try
{
    await CopyFileAsync(
        sourcePath: "large-file.bin",
        destPath: "copy.bin",
        progress: progress,
        cancellationToken: cts.Token
    );
    
    Console.WriteLine("Копіювання завершено");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Копіювання скасовано");
}

Рівень 2: Concurrent API Caller з WhenAll та WhenAny

Створіть систему для паралельного виклику декількох API endpoints з різними стратегіями.

Вимоги:

  • Паралельний виклик декількох endpoints через Task.WhenAll
  • Race condition — перший відповів через Task.WhenAny
  • Timeout для кожного запиту
  • Retry logic з exponential backoff
  • Aggregate results
// Приклад використання:
string[] endpoints = 
[
    "https://api1.example.com/data",
    "https://api2.example.com/data",
    "https://api3.example.com/data"
];

// Стратегія 1: чекати всі відповіді
var allResults = await FetchAllAsync(endpoints, timeout: TimeSpan.FromSeconds(5));

// Стратегія 2: перший хто відповів
var firstResult = await FetchFirstAsync(endpoints, timeout: TimeSpan.FromSeconds(5));

Рівень 3: Async Pipeline з Error Recovery

Створіть async pipeline для обробки даних з автоматичним відновленням після помилок.

Вимоги:

  • Multi-stage pipeline: Download → Transform → Validate → Save
  • Кожен stage — async
  • Error recovery: retry failed items
  • Dead letter queue для items які не вдалося обробити
  • Progress reporting для всього pipeline
  • Graceful shutdown через CancellationToken
// Приклад використання:
var pipeline = new AsyncPipeline<string, ProcessedData>(
    stages: new[]
    {
        new DownloadStage(),
        new TransformStage(),
        new ValidateStage(),
        new SaveStage()
    },
    maxRetries: 3,
    deadLetterQueue: deadLetterQueue
);

var progress = new Progress<PipelineProgress>(p =>
    Console.WriteLine($"Оброблено: {p.Processed}/{p.Total}, Помилок: {p.Failed}"));

await pipeline.ProcessAsync(
    items: urls,
    progress: progress,
    cancellationToken: cts.Token
);

Це завершує матеріал про Async/Await фундаменти. Ви навчились розуміти проблему блокуючого I/O, історію асинхронності в .NET, синтаксис async/await, state machine під капотом, та обробку помилок у async коді.