Перш ніж занурюватись у async/await, критично важливо зрозуміти фундаментальну різницю між двома типами операцій, які виконує ваша програма. Без цього розуміння async/await залишиться магічним синтаксисом без логічного обґрунтування.
Кожна операція у вашій програмі належить до однієї з двох категорій:
CPU-bound операції — це операції, які активно використовують процесор для обчислень. Процесор постійно зайнятий виконанням інструкцій. Приклади:
Для CPU-bound операцій єдиний спосіб не блокувати головний потік — це перенести роботу на інший потік (Thread або ThreadPool). Це справжній паралелізм: декілька CPU cores одночасно виконують різні обчислення.
I/O-bound операції — це операції, які чекають на зовнішню подію. Процесор не задіяний — він просто чекає поки операційна система, мережа, диск або інший пристрій завершить роботу. Приклади:
Для I/O-bound операцій не потрібні додаткові потоки. Операційна система має механізми (IOCP на Windows, epoll на Linux) для ефективного очікування завершення I/O без блокування потоків. Це те, що робить async/await — звільняє потік поки чекаємо на I/O.
Уявіть собі ресторан:
CPU-bound — це кухар, який готує страву. Він активно працює: ріже овочі, смажить м'ясо, варить соус. Щоб приготувати більше страв одночасно — потрібно більше кухарів (більше CPU cores або потоків).
I/O-bound — це офіціант, який приніс замовлення на кухню і чекає поки кухар приготує страву. Офіціант не робить нічого корисного — він просто стоїть і чекає. Замість цього він міг би обслуговувати інших клієнтів, приймати нові замовлення, приносити напої. Це те, що робить async/await — офіціант (потік) не стоїть без діла, а обслуговує інші запити поки чекає на кухню (I/O).
Щоб зрозуміти навіщо потрібен async/await, розгляньмо конкретну проблему. Уявіть веб-сервер, який обробляє HTTP запити:
// ❌ Синхронний (блокуючий) підхід
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. Це катастрофічно неефективно!
Тепер уявіть що на ваш веб-сервер приходить 1000 одночасних запитів. Кожен запит займає потік з ThreadPool на ~280ms. ThreadPool має обмежену кількість потоків (зазвичай ~100-200 на сервері). Що станеться?
ThreadPool: [████████████████████████] ← всі потоки зайняті
Нові запити: ⏳⏳⏳⏳⏳⏳⏳⏳⏳⏳ ← чекають у черзі
Результат: сервер "завис", response time зростає до десятків секунд
Це називається thread starvation (голодування потоків) — всі потоки ThreadPool зайняті блокуючими I/O операціями, і нові запити не можуть бути оброблені.
У 1999 році інженер Dan Kegel сформулював C10K problem: як створити веб-сервер, який може обробляти 10,000 одночасних з'єднань?
З блокуючим I/O це неможливо:
Рішення: non-blocking I/O. Замість блокування потоку, операційна система повідомляє коли I/O завершено, і потік звільняється для обробки інших запитів.
async/await у C# — це синтаксичний цукор над non-blocking I/O. Коли ви пишете:
var data = await httpClient.GetStringAsync(url);
Компілятор генерує код, який:
// ✅ Асинхронний (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.
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");
async/awaitНЕ робить код швидшим. Він робить код ефективнішим — один потік може обробляти багато операцій одночасно, не блокуючись на I/O.Для CPU-bound операцій async/await не дає переваг — використовуйте Task.Run() або Parallel для справжнього паралелізму.Перш ніж async/await з'явився у C# 5.0 (2012 рік), .NET мав інші підходи до асинхронності. Розуміння цієї еволюції допоможе оцінити елегантність сучасного синтаксису.
APM — це перший асинхронний паттерн у .NET, заснований на парі методів BeginXxx / EndXxx:
// ❌ 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:
// ❌ Три послідовні асинхронні операції = три рівні вкладеності
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 — це паттерн заснований на подіях (events), популярний у UI-фреймворках:
// ❌ 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 та Task<T>. У .NET 4.0 з'явився TAP, але без синтаксичного цукру:
// ⚠️ 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 — це синтаксичний цукор над TAP, який робить асинхронний код схожим на синхронний:
// ✅ Сучасний підхід (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/catchCancellationTokenSynchronizationContext| Паттерн | .NET Version | Синтаксис | Проблеми |
|---|---|---|---|
| APM | 1.0 (2002) | BeginXxx / EndXxx | Callback hell, складна обробка помилок |
| EAP | 2.0 (2005) | Events (XxxCompleted) | Складно ланцюжити, немає return value |
| TAP | 4.0 (2010) | Task + ContinueWith | Все ще callback-подібний |
| Async/Await | 4.5 (2012) | async / await | ✅ Ідеальний підхід |
TaskCompletionSource<T> (детально в темі 14).Багато 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 систем.
Тепер, коли ви розумієте навіщо потрібен async/await та як він еволюціонував, розгляньмо детально синтаксис та семантику.
async — Що Воно Робить?Багато розробників помилково думають що async робить метод асинхронним або паралельним. Це не так!
async — це маркер для компілятора, який означає: "у цьому методі можуть бути await вирази, згенеруй state machine для них".
// ❌ Поширена помилка — 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:
await всередині методуvoid → Task, T → Task<T>Що НЕ робить async:
await — Точка Призупиненняawait — це точка призупинення виконання методу. Коли виконання досягає await:
Task викликачуasync Task ExampleAsync()
{
Console.WriteLine($"[1] Потік: {Thread.CurrentThread.ManagedThreadId}");
// await — точка призупинення
await Task.Delay(1000);
// Продовження може бути на іншому потоці!
Console.WriteLine($"[2] Потік: {Thread.CurrentThread.ManagedThreadId}");
}
Async метод може повертати один з наступних типів:
1. Task — async метод без результату (аналог void):
async Task ProcessDataAsync()
{
await Task.Delay(1000);
Console.WriteLine("Обробка завершена");
// Немає return — компілятор автоматично повертає Task
}
// Виклик
await ProcessDataAsync(); // чекаємо завершення
2. Task<T> — async метод з результатом типу T:
async Task<int> CalculateSumAsync()
{
await Task.Delay(1000);
return 42; // компілятор обгортає в Task<int>
}
// Виклик
int result = await CalculateSumAsync();
Console.WriteLine($"Результат: {result}");
3. void — тільки для event handlers (❌ не рекомендовано):
// ⚠️ async void — тільки для event handlers!
private async void Button_Click(object sender, EventArgs e)
{
await Task.Delay(1000);
MessageBox.Show("Готово!");
}
async void крім event handlers!Проблеми:await не працює)// ❌ Погано
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+):
// Оптимізація: якщо результат часто доступний синхронно
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.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 метод — ваш метод теж повинен бути async. Це називається "async all the way":
// ❌ Погано — блокуємо 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);
}
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("Файл оброблено");
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} сторінок");
Lambda вирази теж можуть бути async:
// 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);
Local functions (C# 7.0+) теж можуть бути async:
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}");
}
Тепер, коли ви розумієте синтаксис async/await, настав час зазирнути під капот і побачити що насправді генерує компілятор. Це критично важливо для розуміння performance implications та debugging складних сценаріїв.
Коли ви пишете async метод, компілятор C# трансформує його у складну state machine. Розгляньмо простий приклад:
// Ваш код
async Task<int> CalculateAsync()
{
Console.WriteLine("Початок");
await Task.Delay(1000);
Console.WriteLine("Після delay");
return 42;
}
Компілятор генерує приблизно такий код (спрощено):
// Згенерований компілятором код (спрощено)
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;
}
1. State Field — поточний стан виконання:
state = 0 → початковий стан (до першого await)
state = 1 → після першого await
state = 2 → після другого await
...
state = -2 → завершено (RanToCompletion)
2. AsyncTaskMethodBuilder — створює та управляє Task:
builder.Start() — запускає state machinebuilder.SetResult() — встановлює результат у Taskbuilder.SetException() — встановлює exception у Taskbuilder.AwaitUnsafeOnCompleted() — реєструє continuation3. Awaiter — об'єкт який чекає завершення операції:
awaiter.IsCompleted — чи вже завершено?awaiter.GetResult() — отримати результат (або кинути exception)awaiter.OnCompleted() — зареєструвати continuation4. MoveNext() — метод який виконує state machine:
Виклик 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 додає новий стан:
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
Локальні змінні які використовуються після await переміщуються (hoisted) у поля state machine:
// Ваш код
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 не призупиняється:
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;
}
Що відбувається:
Це називається fast path optimization — компілятор генерує код який уникає overhead state machine для синхронних шляхів.
Щоб побачити реальний згенерований код, використовуйте SharpLab.io:
Приклад для аналізу:
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().
Розуміння 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 — читабельність важливіша за мікрооптимізації.
Обробка помилок у async коді має свої особливості. Розуміння цих особливостей критично важливе для написання надійних додатків.
Коли async метод кидає exception, він зберігається всередині Task. При await exception автоматично "розгортається":
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#.
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 — це дає чистішу обробку помилок.
Exceptions у async void методах не можна перехопити ззовні:
// ❌ 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 методах:
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 операції:
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 працює коректно з async:
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() викликається тут
Коли декілька задач кидають exceptions, Task.WhenAll зберігає всі exceptions у AggregateException:
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}");
}
}
}
Exception filters (when) працюють з async:
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;
}
}
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}");
}
Створіть утиліту для асинхронного копіювання файлів з відображенням прогресу.
Вимоги:
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("Копіювання скасовано");
}
Створіть систему для паралельного виклику декількох API endpoints з різними стратегіями.
Вимоги:
Task.WhenAllTask.WhenAny// Приклад використання:
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));
Створіть async pipeline для обробки даних з автоматичним відновленням після помилок.
Вимоги:
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 коді.
Parallel Class та PLINQ — Data Parallelism
Глибокий академічний розбір Parallel.For/ForEach/Invoke, ParallelOptions, thread-local state, PLINQ (Parallel LINQ) з AsParallel, partitioning strategies та performance optimization. Теорія і практика паралельної обробки даних.
SynchronizationContext та ConfigureAwait — Контекст Виконання
Глибокий академічний розбір SynchronizationContext у .NET — UI thread affinity, ConfigureAwait(false), deadlock scenarios, ExecutionContext, AsyncLocal<T> та best practices для бібліотечного та UI коду.