У попередній темі ви навчились писати async/await код і розумієте що await звільняє потік під час очікування I/O операції. Але є критично важливе питання, яке ми поки що оминули: на якому потоці продовжується виконання після await?
Розгляньмо простий приклад:
async Task ExampleAsync()
{
Console.WriteLine($"До await: потік {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(1000);
Console.WriteLine($"Після await: потік {Thread.CurrentThread.ManagedThreadId}");
}
Запустіть цей код у консольному додатку:
До await: потік 1
Після await: потік 4
Продовження виконалось на іншому потоці (ThreadPool). Це нормально для консольних додатків.
Тепер запустіть той самий код у WPF/WinForms додатку:
До await: потік 1 (UI thread)
Після await: потік 1 (UI thread)
Продовження виконалось на тому самому потоці (UI thread)! Чому?
Відповідь: SynchronizationContext. Це механізм який контролює де виконується continuation після await. Розуміння цього механізму критично важливе для:
SynchronizationContext — це абстракція яка представляє контекст виконання коду. Це клас який дозволяє планувати виконання делегатів у специфічному контексті (наприклад, на UI потоці).
public class SynchronizationContext
{
// Отримати поточний контекст
public static SynchronizationContext? Current { get; }
// Виконати делегат у цьому контексті (асинхронно)
public virtual void Post(SendOrPostCallback d, object? state);
// Виконати делегат у цьому контексті (синхронно)
public virtual void Send(SendOrPostCallback d, object? state);
}
Ключові методи:
Current — повертає SynchronizationContext поточного потоку (або null)Post() — виконати делегат асинхронно у цьому контекстіSend() — виконати делегат синхронно у цьому контексті (блокує поточний потік)Проблема яку він вирішує: UI frameworks мають thread affinity — UI елементи можна змінювати тільки з UI потоку.
// WPF додаток
private async void Button_Click(object sender, RoutedEventArgs e)
{
// Цей код виконується на UI потоці
StatusLabel.Text = "Завантаження...";
// Завантажуємо дані (I/O-bound)
string data = await DownloadDataAsync();
// ❓ На якому потоці ми тепер? Чи можемо змінити UI?
StatusLabel.Text = $"Завантажено: {data.Length} символів";
}
Без SynchronizationContext: продовження після await виконається на ThreadPool потоці, і спроба змінити StatusLabel.Text кине InvalidOperationException (cross-thread operation).
З SynchronizationContext: продовження автоматично повертається на UI потік, і зміна StatusLabel.Text працює коректно.
Коли ви викликаєте await, компілятор генерує код який:
SynchronizationContext.Current// Спрощена концептуальна модель (не реальний код)
var capturedContext = SynchronizationContext.Current;
awaiter.OnCompleted(() =>
{
if (capturedContext != null)
{
// Виконати continuation у захопленому контексті
capturedContext.Post(_ => ContinuationCode(), null);
}
else
{
// Немає контексту — виконати на ThreadPool
ThreadPool.QueueUserWorkItem(_ => ContinuationCode());
}
});
Різні frameworks надають різні реалізації SynchronizationContext:
| Framework | SynchronizationContext | Поведінка |
|---|---|---|
| Console App | null | Continuation на ThreadPool |
| WPF | DispatcherSynchronizationContext | Continuation на UI thread (Dispatcher) |
| WinForms | WindowsFormsSynchronizationContext | Continuation на UI thread (message loop) |
| MAUI | MauiSynchronizationContext | Continuation на UI thread |
| ASP.NET Framework | AspNetSynchronizationContext | Continuation на request thread |
| ASP.NET Core | null | Continuation на ThreadPool (немає контексту!) |
| xUnit/NUnit | Custom або null | Залежить від test runner |
SynchronizationContext — це одна з причин його високої продуктивності. Continuation виконується на будь-якому ThreadPool потоці.// Захопити поточний контекст
SynchronizationContext? context = SynchronizationContext.Current;
// Виконати роботу на ThreadPool
ThreadPool.QueueUserWorkItem(_ =>
{
// Виконуємо CPU-bound роботу
string result = PerformHeavyComputation();
// Повернутись у захоплений контекст (наприклад, UI thread)
if (context != null)
{
context.Post(_ =>
{
// Цей код виконається на UI потоці
UpdateUI(result);
}, null);
}
});
Це те, що async/await робить автоматично!
Консольний додаток (немає SynchronizationContext):
Thread 1 (Main):
↓
await Task.Delay(1000)
↓
SynchronizationContext.Current = null
↓
Continuation планується на ThreadPool
↓
Thread 4 (ThreadPool):
↓
Continuation виконується
WPF додаток (є DispatcherSynchronizationContext):
Thread 1 (UI):
↓
await Task.Delay(1000)
↓
SynchronizationContext.Current = DispatcherSynchronizationContext
↓
Continuation планується через Dispatcher
↓
Thread 1 (UI):
↓
Continuation виконується на UI потоці
void PrintCurrentContext()
{
var context = SynchronizationContext.Current;
if (context == null)
{
Console.WriteLine("Немає SynchronizationContext (консольний додаток або ASP.NET Core)");
}
else
{
Console.WriteLine($"SynchronizationContext: {context.GetType().Name}");
}
Console.WriteLine($"Потік: {Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine($"ThreadPool потік: {Thread.CurrentThread.IsThreadPoolThread}");
}
// У консольному додатку
PrintCurrentContext();
// Вивід: Немає SynchronizationContext, Потік: 1, ThreadPool: False
// У WPF додатку (на UI потоці)
PrintCurrentContext();
// Вивід: SynchronizationContext: DispatcherSynchronizationContext, Потік: 1, ThreadPool: False
Розуміння SynchronizationContext критично важливе для:
1. UI Додатків — продовження повинно виконуватись на UI потоці для зміни UI елементів
2. Deadlock-ів — неправильне використання .Result або .Wait() у UI коді призводить до deadlock (детально в розділі 4)
3. Performance — захоплення та відновлення контексту має overhead. У бібліотечному коді використовуйте ConfigureAwait(false) (розділ 3)
4. ASP.NET Core — немає контексту, тому немає overhead та deadlock-ів
Тепер детально розглянемо як працює SynchronizationContext у UI додатках. Це найпоширеніший use case для розуміння цього механізму.
У WPF всі UI операції повинні виконуватись на UI потоці через Dispatcher. DispatcherSynchronizationContext — це обгортка над Dispatcher яка інтегрується з async/await.
// WPF Window
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private async void DownloadButton_Click(object sender, RoutedEventArgs e)
{
// [1] Виконується на UI потоці
Console.WriteLine($"[1] Потік: {Thread.CurrentThread.ManagedThreadId}, UI: {!Thread.CurrentThread.IsThreadPoolThread}");
StatusLabel.Content = "Завантаження...";
DownloadButton.IsEnabled = false;
// [2] await — захоплює DispatcherSynchronizationContext
string data = await DownloadDataAsync("https://example.com");
// [3] Continuation автоматично повертається на UI потік!
Console.WriteLine($"[3] Потік: {Thread.CurrentThread.ManagedThreadId}, UI: {!Thread.CurrentThread.IsThreadPoolThread}");
// Можемо безпечно змінювати UI
StatusLabel.Content = $"Завантажено: {data.Length} символів";
DownloadButton.IsEnabled = true;
}
async Task<string> DownloadDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
}
Що відбувається під капотом:
await захоплює DispatcherSynchronizationContext.CurrentGetStringAsync завершується — continuation планується через Dispatcher.BeginInvoke()Dispatcher виконує continuation на UI потоціУ WinForms механізм аналогічний, але використовує Control.Invoke() замість Dispatcher:
// WinForms Form
public partial class MainForm : Form
{
public MainForm()
{
InitializeComponent();
}
private async void downloadButton_Click(object sender, EventArgs e)
{
// [1] Виконується на UI потоці
statusLabel.Text = "Завантаження...";
downloadButton.Enabled = false;
// [2] await — захоплює WindowsFormsSynchronizationContext
string data = await DownloadDataAsync("https://example.com");
// [3] Continuation автоматично повертається на UI потік
statusLabel.Text = $"Завантажено: {data.Length} символів";
downloadButton.Enabled = true;
}
async Task<string> DownloadDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url);
}
}
Без SynchronizationContext спроба змінити UI з іншого потоку кине exception:
// ❌ Неправильно — зміна UI з ThreadPool потоку
private void BadButton_Click(object sender, EventArgs e)
{
Task.Run(() =>
{
Thread.Sleep(1000);
// InvalidOperationException: Cross-thread operation not valid
statusLabel.Text = "Готово"; // CRASH!
});
}
// ✅ Правильно — async/await автоматично повертає на UI потік
private async void GoodButton_Click(object sender, EventArgs e)
{
await Task.Delay(1000);
// Це працює — ми на UI потоці
statusLabel.Text = "Готово";
}
Якщо з якоїсь причини ви не можете використовувати async/await, можна вручну повернутись на UI потік:
// WPF
private void Button_Click(object sender, RoutedEventArgs e)
{
Task.Run(() =>
{
// Виконуємо роботу на ThreadPool
string result = PerformHeavyComputation();
// Повертаємось на UI потік
Dispatcher.Invoke(() =>
{
StatusLabel.Content = result;
});
});
}
// WinForms
private void button_Click(object sender, EventArgs e)
{
Task.Run(() =>
{
// Виконуємо роботу на ThreadPool
string result = PerformHeavyComputation();
// Повертаємось на UI потік
this.Invoke(new Action(() =>
{
statusLabel.Text = result;
}));
});
}
Але async/await робить це автоматично і елегантніше!
Тепер розглянемо найважливіший метод для написання ефективного бібліотечного коду: ConfigureAwait(false).
Захоплення та відновлення SynchronizationContext має overhead:
SynchronizationContext.Current (heap allocation якщо не null)context.Post()Для бібліотечного коду який не потребує UI потоку — це марний overhead.
ConfigureAwait(false) каже компілятору: не захоплюй SynchronizationContext, виконай continuation на ThreadPool.
async Task<string> LibraryMethodAsync(string url)
{
using var client = new HttpClient();
// ConfigureAwait(false) — не захоплювати контекст
string content = await client.GetStringAsync(url).ConfigureAwait(false);
// Continuation виконається на ThreadPool (не на UI потоці)
return content.ToUpper();
}
Що відбувається:
await не захоплює SynchronizationContext.Current✅ Завжди у бібліотечному коді:
// Бібліотечний метод — не потребує UI потоку
public async Task<User> GetUserAsync(int userId)
{
var response = await httpClient.GetAsync($"/users/{userId}").ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
return JsonSerializer.Deserialize<User>(json);
}
❌ Ніколи у UI коді:
// UI код — потребує UI потік для зміни UI
private async void Button_Click(object sender, EventArgs e)
{
// ❌ НЕ використовуйте ConfigureAwait(false) тут!
string data = await DownloadDataAsync().ConfigureAwait(false);
// CRASH! Ми на ThreadPool потоці, не можемо змінити UI
StatusLabel.Text = data;
}
У бібліотечному коді додавайте ConfigureAwait(false) до кожного await:
public async Task<ProcessedData> ProcessAsync(string input)
{
// Крок 1: завантажити дані
var rawData = await DownloadAsync(input).ConfigureAwait(false);
// Крок 2: трансформувати
var transformed = await TransformAsync(rawData).ConfigureAwait(false);
// Крок 3: валідувати
await ValidateAsync(transformed).ConfigureAwait(false);
// Крок 4: зберегти
await SaveAsync(transformed).ConfigureAwait(false);
return transformed;
}
ConfigureAwait(false) ви вже на ThreadPool потоці, але наступні await можуть захопити контекст якщо хтось встановить його вручну. Безпечніше додавати ConfigureAwait(false) всюди.ConfigureAwait(true) — це поведінка за замовчуванням (захоплювати контекст). Явно писати не потрібно:
// Ці два рядки еквівалентні
await SomeMethodAsync();
await SomeMethodAsync().ConfigureAwait(true);
// Benchmark у WPF додатку
async Task<int> WithContextAsync()
{
int sum = 0;
for (int i = 0; i < 1000; i++)
{
await Task.Yield(); // захоплює контекст
sum += i;
}
return sum;
}
async Task<int> WithoutContextAsync()
{
int sum = 0;
for (int i = 0; i < 1000; i++)
{
await Task.Yield().ConfigureAwait(false); // не захоплює
sum += i;
}
return sum;
}
Результати (у WPF додатку):
WithContextAsync: 847ms (1000 context switches на UI потік)
WithoutContextAsync: 12ms (виконується на ThreadPool)
Прискорення: 70x
У ASP.NET Core немає SynchronizationContext, тому ConfigureAwait(false) не дає переваг:
// ASP.NET Core Controller
public async Task<IActionResult> GetUser(int id)
{
// ConfigureAwait(false) тут марний — немає контексту
var user = await userService.GetUserAsync(id).ConfigureAwait(false);
return Ok(user);
}
Але додавати його все одно безпечно і рекомендовано для сумісності з іншими frameworks.
Тепер розглянемо найнебезпечнішу пастку async/await — deadlock через неправильне використання .Result або .Wait() у коді з SynchronizationContext.
// WPF/WinForms додаток
private void Button_Click(object sender, EventArgs e)
{
// ❌ DEADLOCK!
string result = DownloadDataAsync().Result;
StatusLabel.Text = result;
}
async Task<string> DownloadDataAsync()
{
using var client = new HttpClient();
// await захоплює UI SynchronizationContext
string content = await client.GetStringAsync("https://example.com");
return content;
}
Що відбувається (крок за кроком):
1. UI Thread викликає DownloadDataAsync().Result
↓
2. .Result блокує UI Thread і чекає завершення Task
↓
3. DownloadDataAsync() починає виконуватись на UI Thread
↓
4. await client.GetStringAsync() захоплює DispatcherSynchronizationContext
↓
5. HTTP запит відправлено, UI Thread звільняється... НІ! Він БЛОКОВАНИЙ на .Result!
↓
6. HTTP запит завершується, continuation планується через Dispatcher
↓
7. Dispatcher намагається виконати continuation на UI Thread
↓
8. Але UI Thread БЛОКОВАНИЙ на .Result і чекає завершення Task
↓
9. Task чекає UI Thread для continuation
↓
10. UI Thread чекає Task для .Result
↓
DEADLOCK! 🔒
Візуалізація deadlock:
UI Thread: Task:
| |
| .Result (блокує) |
|------------------------→ чекає завершення
| |
| БЛОКОВАНИЙ | await (захопив UI context)
| |
| ← чекає UI Thread | continuation готовий
| для continuation |
| |
🔒 DEADLOCK 🔒
Ключові умови для deadlock:
.Result або .Wait()SynchronizationContext (UI context)Результат: циклічна залежність → deadlock.
// ✅ Правильно — async all the way
private async void Button_Click(object sender, EventArgs e)
{
string result = await DownloadDataAsync(); // не блокує UI Thread
StatusLabel.Text = result;
}
async Task<string> DownloadDataAsync()
{
using var client = new HttpClient();
// ConfigureAwait(false) — не захоплювати контекст
string content = await client.GetStringAsync("https://example.com").ConfigureAwait(false);
return content;
}
// Тепер .Result не призведе до deadlock (але все одно не рекомендовано!)
private void Button_Click(object sender, EventArgs e)
{
string result = DownloadDataAsync().Result; // працює, але блокує UI
StatusLabel.Text = result;
}
Той самий deadlock може статись у ASP.NET Framework (не Core!):
// ASP.NET Framework Controller (старий ASP.NET)
public ActionResult Index()
{
// ❌ DEADLOCK!
var data = GetDataAsync().Result;
return View(data);
}
async Task<Data> GetDataAsync()
{
// await захоплює AspNetSynchronizationContext
var response = await httpClient.GetAsync("https://api.example.com");
return await response.Content.ReadAsAsync<Data>();
}
Чому у ASP.NET Core немає цієї проблеми?
ASP.NET Core не має SynchronizationContext, тому continuation виконується на будь-якому ThreadPool потоці. Deadlock неможливий!
// WPF Window
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
// ❌ Deadlock button
private void DeadlockButton_Click(object sender, RoutedEventArgs e)
{
StatusLabel.Content = "Починаємо deadlock...";
// Це зависне назавжди
string result = SlowOperationAsync().Result;
StatusLabel.Content = result; // ніколи не виконається
}
// ✅ Правильний button
private async void CorrectButton_Click(object sender, RoutedEventArgs e)
{
StatusLabel.Content = "Починаємо правильну операцію...";
string result = await SlowOperationAsync();
StatusLabel.Content = result; // виконається коректно
}
async Task<string> SlowOperationAsync()
{
await Task.Delay(2000); // захоплює UI context
return "Готово!";
}
}
Симптоми:
Діагностика у Visual Studio:
.Result або .Wait()✅ DO:
async/await всюди (async all the way)ConfigureAwait(false) у бібліотечному коді.Result (але не рекомендовано)❌ DON'T:
.Result або .Wait() у UI коді.Result або .Wait() у ASP.NET Framework кодіОкрім SynchronizationContext, є ще один важливий контекст який передається через await — ExecutionContext.
ExecutionContext — це контейнер для ambient data (контекстних даних) які автоматично передаються через async операції:
AsyncLocal<T> значення// ExecutionContext автоматично передається через await
async Task Method1Async()
{
// Встановлюємо значення
MyAsyncLocal.Value = "Hello";
await Method2Async();
}
async Task Method2Async()
{
// Значення доступне тут!
Console.WriteLine(MyAsyncLocal.Value); // "Hello"
}
static AsyncLocal<string> MyAsyncLocal = new();
AsyncLocal<T> — це аналог ThreadStatic але для async коду. Значення передається через await:
static AsyncLocal<string> RequestId = new AsyncLocal<string>();
async Task ProcessRequestAsync(string requestId)
{
// Встановлюємо RequestId для цього async flow
RequestId.Value = requestId;
Console.WriteLine($"[ProcessRequest] RequestId: {RequestId.Value}");
await Step1Async();
await Step2Async();
}
async Task Step1Async()
{
await Task.Delay(100);
// RequestId доступний тут!
Console.WriteLine($"[Step1] RequestId: {RequestId.Value}");
}
async Task Step2Async()
{
await Task.Delay(100);
// І тут теж!
Console.WriteLine($"[Step2] RequestId: {RequestId.Value}");
}
// Використання
await ProcessRequestAsync("REQ-001");
public class CorrelationContext
{
private static AsyncLocal<string?> _correlationId = new AsyncLocal<string?>();
public static string? CorrelationId
{
get => _correlationId.Value;
set => _correlationId.Value = value;
}
}
// ASP.NET Core Middleware
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Встановлюємо correlation ID для цього request
CorrelationContext.CorrelationId = Guid.NewGuid().ToString();
await _next(context);
}
}
// Тепер у будь-якому місці коду можна отримати correlation ID
public class UserService
{
private readonly ILogger<UserService> _logger;
public async Task<User> GetUserAsync(int id)
{
// Correlation ID доступний автоматично!
_logger.LogInformation(
"Getting user {UserId}, CorrelationId: {CorrelationId}",
id,
CorrelationContext.CorrelationId
);
return await database.GetUserAsync(id);
}
}
| Характеристика | [ThreadStatic] | AsyncLocal<T> |
|---|---|---|
| Scope | Один потік | Async flow (через await) |
| Передається через await | ❌ Ні | ✅ Так |
| Передається через Task.Run | ❌ Ні | ✅ Так (за замовчуванням) |
| Use case | Thread-specific data | Request context, correlation ID |
Створіть WPF/WinForms додаток який демонструє deadlock та його виправлення.
Вимоги:
.Result і зависаєawait і працюєConfigureAwait(false) у бібліотечному методіСтворіть систему для передачі request context через async операції.
Вимоги:
RequestContext з AsyncLocal<T>Створіть власний SynchronizationContext який логує всі Post/Send виклики.
Вимоги:
SynchronizationContextPost() та Send()SynchronizationContext.CurrentЦе завершує матеріал про SynchronizationContext та ConfigureAwait. Ви навчились розуміти контекст виконання, UI thread affinity, ConfigureAwait(false), deadlock scenarios та ExecutionContext з AsyncLocal.
Async/Await — Фундамент Асинхронного Програмування
Глибокий академічний розбір async/await у C# — від проблеми блокуючого I/O до state machine під капотом. Історія асинхронності (APM, EAP, TAP), синтаксис async/await, return types, exception handling та best practices.
Async — Просунуті Паттерни
Глибокий розбір просунутих асинхронних паттернів у C# — TaskCompletionSource для перетворення callback-based API, IAsyncEnumerable для streaming даних, ValueTask для оптимізації, та best practices для production-ready коду.