JavaRush /Курси /C# SELF /Взаємодія асинхронного й синхронного коду

Взаємодія асинхронного й синхронного коду

C# SELF
Рівень 62 , Лекція 4
Відкрита

1. Вступ

Асинхронність у C# — потужний інструмент. Але інколи доводиться мати справу з ситуаціями, коли асинхронний код треба викликати із синхронного (або навпаки). Здається, усе має «магічно» працювати, але на практиці можливі дивні зависання (deadlock), втрата продуктивності й навіть несподівано ламається користувацький інтерфейс. До того ж помилка часто проявляється лише на реальних даних: у продакшені або в користувача. Причина зазвичай — у некоректній взаємодії синхронного й асинхронного коду та в неочевидних деталях роботи планувальника завдань .NET.

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

Коротке нагадування: що відбувається під час await

Коли ви пишете:

await SomeAsyncMethod();

Код «розривається» на дві частини: до await і після. Перша частина виконується до першої очікувальної асинхронної дії (наприклад, до запиту в мережу), а продовження — після її завершення. Де виконуватиметься «продовження»? На тому самому потоці чи на іншому? А якщо ви створюєте настільний застосунок (наприклад, WPF або WinForms), а якщо — консольний? Відповідь: залежить. І тут у гру вступає, зокрема, ConfigureAwait.

Що таке SynchronizationContext?

SynchronizationContext — спеціальний механізм .NET, який дозволяє коду «запамʼятовувати», де й як має бути викликане продовження асинхронної операції.

  • У класичних WinForms/WPF‑застосунках SynchronizationContext гарантує, що після await решта методу продовжить виконуватися на тому самому потоці, що й UI, щоб не виникали помилки «звернення до елемента керування не з того потоку».
  • У класичному ASP.NET (не Core) SynchronizationContext дозволяє відновлювати HttpContext і продовжувати роботу з HTTP‑запитом.
  • У консольних і ASP.NET Core‑застосунках SynchronizationContext, як правило, відсутній (дорівнює null), і продовження виконуються на пулі потоків.

Що таке TaskScheduler?

TaskScheduler — нижчорівневий механізм. У більшості випадків ви працюєте з TaskScheduler.Default, який використовує пул потоків .NET. Він відповідає за те, коли й де виконуються завдання.

2. Deadlock під час змішування await і Result/Wait()

Одна з найвідоміших пасток у C#:

// Десь у UI-коді
var result = SomeAsyncMethod().Result;

або

SomeAsyncMethod().Wait();

І все — застосунок «завис». Чому?

Як це відбувається?

  1. Ви викликаєте асинхронний метод і відразу запитуєте .Result або .Wait() — тобто блокуєте поточний потік і чекаєте завершення асинхронного завдання.
  2. SomeAsyncMethod усередині себе робить await і планує продовження на той самий потік через SynchronizationContext, але цей потік уже заблокований очікуванням Result/Wait().
  3. Оскільки потік уже чекає завершення Result/Wait(), він не може виконати continuation (продовження).
  4. Ось і deadlock: потік чекає сам на себе.

Це особливо легко відтворити в UI‑застосунках, де весь код виконується на потоці інтерфейсу, і всі продовження чекають саме на цей потік. У консольних застосунках і в ASP.NET Core (без синхронізаційного контексту) такі deadlock-и трапляються рідко.

Жарт із життя: якщо вам вдалося зловити deadlock із .Result — вітаємо, ви на кілька кроків ближче до звання Senior :D

3. Як правильно вбудовувати асинхронний код у синхронний?

Рекомендація № 1: асинхронність — зверху донизу

Якщо з’явився асинхронний метод, протягніть async/await угору по стеку викликів до самого UI або точки входу. Не «зупиняйте асинхронність» на півдорозі.

Погано (блокує потік):

// Синхронний метод викликає асинхронний через .Result
public void DoStuff()
{
    var data = GetDataAsync().Result;
    // ...
}

Добре (асинхронність наскрізна):

public async Task DoStuffAsync()
{
    var data = await GetDataAsync();
    // ...
}

Якщо можете — завжди використовуйте await, а не .Result/.Wait().

4. Але бувають ситуації: треба викликати async із sync

Перепишіть усе верхнє дерево на async (найкращий варіант — якщо маєте вибір).

Скористайтеся спеціальними патернами: наприклад, запустіть задачу на окремому потоці через Task.Run, і вже всередині неї викликайте async-метод.

public void DoStuff()
{
    var result = Task.Run(() => SomeAsyncMethod()).Result;
}

Але: втім, і тут є нюанси з синхронізацією в UI‑застосунках, тож краще так не робити без великої потреби.

5. Що робить ConfigureAwait(false)?

Іноді вам не потрібно, щоб після await код продовжував виконуватися на тому ж потоці (наприклад, у серверних застосунках або в бібліотеці, де не принциповий SynchronizationContext). Навпаки, краще, щоб .NET не прив’язувався до одного потоку — це пришвидшує роботу!

Синтаксис і принцип роботи

await SomeAsyncMethod().ConfigureAwait(false);
  • ConfigureAwait(false) каже: «мені не потрібен рідний SynchronizationContext; продовжуй виконання де завгодно, хоч на іншому потоці».
  • ConfigureAwait(true) (за замовчуванням) — «продовжуй виконання там, де був викликаний await, бажано в тому самому SynchronizationContext».

Візуальна схема


        ┌─────────────────────────────────────────────────────┐
        │                     SynchronizationContext          │
        └─────────────────────────────────────────────────────┘
                      ↑                             ↑
       (UI-потік)   await SomeAsyncMethod()        Continuation (після await)
                  ──────────────────────────────>  (той самий потік — якщо ConfigureAwait(true))
                      ↓
                    (будь-який потік — якщо ConfigureAwait(false))

Приклад використання ConfigureAwait(false) — «бібліотечний» код

Уявіть, що ви пишете бібліотеку, яку може використовувати хто завгодно: WinForms, WPF, ASP.NET, консольні застосунки…

Ви не маєте залежати від їхньої потокової моделі. Тому завжди використовуйте ConfigureAwait(false) в асинхронних методах своєї бібліотеки:

public async Task<string> LoadDataFromUrlAsync(string url)
{
    using var client = new HttpClient();
    string content = await client.GetStringAsync(url).ConfigureAwait(false);
    return content;
}

Тепер ваш метод не «вимагатиме» виконання в якомусь конкретному SynchronizationContext. Це безпечніше й продуктивніше (менше перемикань потоків).

Приклад: що відбувається з await без ConfigureAwait

Розглянемо WPF‑застосунок:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Button1.Content = "Завантаження...";
    await Task.Delay(2000);  // Імітація довгої операції
    Button1.Content = "Зроблено!";
}

Task.Delay усередині себе виконує «await». За замовчуванням після await керування повертається на потік UI, щоб можна було далі оновлювати елементи керування.

Якщо всередині вашої довгої операції ви використаєте ConfigureAwait(false):

await Task.Delay(2000).ConfigureAwait(false);
Button1.Content = "Зроблено!";  // Помилка!

Виникне виняток: InvalidOperationException: "The calling thread cannot access this object because a different thread owns it."
Адже тепер «продовження» виконується на чужому потоці, і не можна звертатися до UI.

Висновок: використовуйте ConfigureAwait(false) лише там, де не потрібен доступ до UI/контексту.

6. Корисні нюанси

Де й коли використовувати ConfigureAwait

Сценарій Чи потрібно використовувати .ConfigureAwait(false)? Чому?
Бібліотечний код Так Викликається в будь‑якому контексті, доступ до UI не потрібен
Всередині ASP.NET Core‑коду Так (контекст майже відсутній) Підвищує продуктивність
У WinForms/WPF, під час звернення до UI Ні Потрібно повернути керування на UI‑потік
Синхронні методи Немає сенсу Синхронізаційного контексту немає
У консольних застосунках Можна, але помітного ефекту не буде Контекст відсутній

Як дізнатися, чи є SynchronizationContext?

Можна перевірити прямо з коду:

Console.WriteLine(SynchronizationContext.Current == null
    ? "Немає контексту"
    : "Контекст існує");
  • У консольних і ASP.NET Core‑застосунках буде «Немає контексту».
  • У WinForms/WPF — «Контекст існує».

Візуалізація переходу потоків

sequenceDiagram
    participant MainThread as Головний потік (UI/Console)
    participant ThreadPool as Потік із пулу

    MainThread->>SomeAsyncMethod: Виклик методу
    SomeAsyncMethod->>MainThread: await без ConfigureAwait
    Note right of MainThread: Після await\nповертаємося на той самий потік
    SomeAsyncMethod->>ThreadPool: await з ConfigureAwait(false)
    Note right of ThreadPool: Після await\nможна працювати на будь-якому потоці

Короткі правила використання

  • Не використовуйте .Result і .Wait() в UI‑коді з асинхронними методами.
  • У бібліотечному коді послідовно використовуйте ConfigureAwait(false) для всіх «awaitʼів».
  • В UI‑застосунках застосовуйте ConfigureAwait(false) лише всередині методів, що не звертаються до елементів інтерфейсу.
  • Краще не змішувати синхронний і асинхронний код без крайньої необхідності.
  • Якщо потрібно викликати async із sync — подумайте двічі: чи не можна зробити весь ланцюжок async?

7. Типові помилки новачків під час роботи з асинхронністю

Помилка № 1: повсюдне використання .Result або .Wait().
Дуже часто розробники, особливо після кількох статей про асинхронність, починають додавати .Result або .Wait() усюди, щоб «синхронізувати» виклики асинхронних методів. На перший погляд зручно, але на практиці це гарантований шлях до deadlock у UI‑застосунках. Особливо це небезпечно, якщо всередині асинхронних методів не використовується ConfigureAwait(false): потік UI блокується й не може виконати продовження завдання.

Помилка № 2: неправильне або надмірне використання ConfigureAwait(false).
Дехто застосовує ConfigureAwait(false) усюди, навіть у коді, що працює з елементами інтерфейсу. В UI‑застосунках це призводить до помилок доступу до елементів керування (InvalidOperationException), адже продовження тепер виконується на потоці, відмінному від UI‑потоку.

Помилка № 3: забувають, що ConfigureAwait працює лише з awaitable‑об’єктами.
Часто вважають, що можна застосовувати ConfigureAwait(false) для будь‑яких методів, але насправді воно працює тільки з об’єктами, що повертають Task, Task<T>, ValueTask та інші awaitable‑типи. Для синхронних методів ConfigureAwait не має жодного ефекту, і очікування виконання таких методів не стає асинхронним.

Помилка № 4: змішування синхронного й асинхронного коду без потреби.
Спроби викликати асинхронний метод із синхронного коду без продуманої стратегії (наприклад, через .Result, .Wait() або Task.Run) часто призводять до підступних багів, втрати продуктивності та складних для діагностики deadlock-ів. Навіть якщо метод здається коротким і безпечним, такі виклики в ланцюжку можуть зламати роботу всього застосунку.

Помилка № 5: недооцінка впливу SynchronizationContext.
Новачки часто забувають, що продовження після await може виконуватися на тому ж потоці (UI), якщо не використовувати ConfigureAwait(false). Це призводить до непередбачуваної поведінки, особливо коли поєднуються UI та бібліотечний код, де очікування й продовження переплітаються.

1
Опитування
Асинхронні потоки даних, рівень 62, лекція 4
Недоступний
Асинхронні потоки даних
Глибоке занурення в асинхронність
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ