WPF и WinForms: async/await и поток UI

· Обновлено: · · C#, async/await, .NET, WPF, WinForms, UI, Потоки

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619658)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). WPF и WinForms: async/await и поток UI. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619658 https://comcomponent.com/ru/blog/2026/03/12/000-wpf-winforms-ui-thread-async-await-one-sheet/

DOI (последняя версия)
10.5281/zenodo.21619658
DOI (эта версия)
10.5281/zenodo.21619659

В WPF и WinForms с async / await чаще всего путаница в двух местах: на какой поток после await вернётся выполнение и когда можно обращаться к UI. Когда рядом оказываются Dispatcher, BeginInvoke, ConfigureAwait(false) и .Result / .Wait(), причины зависания экрана и исключений «не из того потока» становятся плохо видны.

Здесь разбирается только связь потока UI в WPF / WinForms с async / await. Общая таблица решений по async / await — в статье «Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait».

На практике обычно болит вот здесь:

  • непонятно, где после await пойдёт продолжение;
  • непонятно, можно ли трогать UI после Task.Run;
  • непонятно, куда ставить ConfigureAwait(false);
  • экран зависает на .Result / .Wait() / .GetAwaiter().GetResult();
  • Dispatcher в WPF и Invoke / BeginInvoke / InvokeAsync в WinForms смешиваются в одну кучу.

И WPF, и WinForms построены вокруг потока UI. Поэтому в async / await полезнее не рассуждения «что такое асинхронность», а ясность, что именно вы делаете с потоком UI и циклом сообщений.

Статья рассчитана в основном на приложения WPF / WinForms на .NET 6 и новее. Дальше — в практическом порядке: куда возвращается выполнение после await, что такое Dispatcher, ConfigureAwait(false) и почему застревают .Result / .Wait().

Control.InvokeAsync в WinForms есть начиная с .NET 9. В более раннем WinForms по умолчанию берут BeginInvoke / Invoke.

Код из статьи опубликован на GitHub как полный набор, который можно собрать и запустить (библиотека без зависимости от UI, примеры WPF / WinForms, модульные тесты, которые воспроизводят точку возврата после await и дедлок).

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

Содержание

  1. Сначала вывод (в двух словах)
  2. Краткая сводка
    • 2.1. Общая картина
    • 2.2. Таблица для первого выбора
  3. Термины, которые используются в статье
    • 3.1. Поток UI и цикл сообщений
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. Типичные сценарии
    • 4.1. Обычный await в обработчике события UI
    • 4.2. Task.Run только для тяжёлых вычислений CPU
    • 4.3. ConfigureAwait(false) — это не «гарантия, что не вернётся», а «не заставлять возвращаться»
    • 4.4. Почему зависают .Result / .Wait() / .GetAwaiter().GetResult()
  5. Когда использовать Dispatcher / Invoke
  6. Частые антипаттерны
  7. Чек-лист на ревью
  8. Как выбирать на практике
  9. Итог
  10. Источники

Карта знаний этой статьи

Основа: поток UI в WPF/WinForms продолжает крутить цикл обработки сообщений. Обычный await захватывает SynchronizationContext на этот момент и возвращает продолжение на поток UI, поэтому UI можно обновлять напрямую; ConfigureAwait(false) этот возврат не навязывает и поэтому уместен в универсальном библиотечном коде. Task.Run снимает с потока UI только вычисления CPU; чтобы вернуться в UI из другого места, используют Dispatcher в WPF, Control.BeginInvoke в WinForms или Control.InvokeAsync начиная с .NET 9. Синхронная блокировка потока UI через .Result, .Wait() или .GetAwaiter().GetResult() не даёт продолжению вернуться в UI и может привести к взаимной блокировке или фризу. В обработчике события async void, если исключение не перехватить, оно доходит до DispatcherUnhandledException в WPF или ThreadException в WinForms, и приложение падает.

Карта знаний: поток UI в WPF/WinForms и async/awaitСхема связей: поток UI продолжает крутить цикл обработки сообщений; обычный await возвращает продолжение в захваченный SynchronizationContext; ConfigureAwait(false) этот возврат не навязывает; Task.Run снимает вычисления CPU с потока UI; Dispatcher, Control.BeginInvoke и InvokeAsync — явные средства вернуться в UI; .Result, .Wait() и GetAwaiter().GetResult() могут блокировать поток UI и привести к взаимной блокировке или фризу; исключение из async void доходит до глобального обработчика UI.требуетиспользуетиспользуетиспользуетиспользуетиспользуетпреемниктребуеттребуетиспользуетпредотвращаетиспользуетрекомендуется длятребуетне рекомендуетсярекомендуется длярекомендуется дляне рекомендуетсяпредотвращаетможет вызватьможет вызватьне рекомендуетсяможет вызватьрекомендуется дляможет вызватьтребуетконтекст потока UIWPFWindows Formsцикл сообщений (message loop)SynchronizationContextDispatcher (WPF)Control.Invoke / Control.BeginInvokeControl.InvokeAsyncTaskCompletionSourceCancellationToken (.NET)защита от повторного входа (Interlocked.Exchange)гонка cancel и executeобычный awaitConfigureAwait(false)универсальный библиотечный кодTask.Runоперация I/O-boundблокировка UI-потокаsync-over-asyncвзаимная блокировка (deadlock).Result / .Wait() / .GetAwaiter().GetResult()async voidметод обработчика событияобщий обработчик UI (DispatcherUnhandledException / ThreadException)

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 26, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

1. Сначала вывод (в двух словах)

  • Если в обработчике события UI WPF / WinForms сделать обычный await, продолжение после await в типичном случае возвращается на поток UI
  • Task.Run нужен чтобы снять вычисления CPU с потока UI, а не чтобы оборачивать ожидание I/O
  • Даже await Task.Run(...) внутри обработчика UI при обычном await обычно возвращает продолжение на поток UI
  • ConfigureAwait(false) значит: этот await не заставляет возвращаться в захваченный UI-контекст. После него трогать UI напрямую опасно
  • .Result / .Wait() / .GetAwaiter().GetResult() блокируют поток UI. Если продолжению await нужно вернуться в UI, зависание — обычный исход
  • Чтобы явно вернуться в UI в WPF, используйте Dispatcher.InvokeAsync
  • Чтобы явно вернуться в UI в WinForms, раньше брали BeginInvoke; с .NET 9 с цепочкой async/await лучше сочетается InvokeAsync
  • Первая стратегия: на самом внешнем слое UI — обычный await, в универсальных библиотеках стоит рассмотреть ConfigureAwait(false), а возврат в UI обозначать явно только там, где он действительно нужен

Иначе говоря, в WPF / WinForms держите в поле зрения три вещи:

  1. на каком потоке вы сейчас выполняетесь;
  2. куда возвращается продолжение await;
  3. кто отвечает за возврат в UI.

Когда эти три пункта ясны, картина заметно проще.

Три вопроса, которые проясняют картинуЕсли держать в поле зрения, на каком потоке вы сейчас выполняетесь, куда возвращается продолжение await и кто отвечает за возврат в UI, асинхронный UI-код в WPF и WinForms становится заметно понятнее.На каком потоке вы сейчас выполняетесьПонятный асинхронный UI-кодКуда возвращается продолжение awaitКто отвечает за возврат в UI

Рис. 1: Три вопроса, к которым стоит возвращаться. Разделяйте поток, точку возврата и ответственность за возврат в UI.

2. Краткая сводка

2.1. Общая картина

Быстрее всего охватить картину этой схемой.

Обработчик события UI(WPF / WinForms)обычный awaitI/O APIЗахватывает UI SynchronizationContextПосле await возобновляется на потоке UIОбновление UI можно писать сразуawait Task.Run(...)тяжёлая CPU-работаСамо вычисление идёт в ThreadPoolПосле await возобновляется на потоке UIawait SomeAsync().ConfigureAwait(false)Не заставляет возвращаться в UIПродолжение на произвольном потокеПрямое обновление UI опаснонужны Dispatcher / InvokeSomeAsync().Result / Wait()GetAwaiter().GetResult()Блокирует поток UIПродолжение не может вернуться в UIЗависание / дедлок / как минимум фриз

Рис. 2: Четыре типичных сценария глазами обработчика UI. Обычный await и Task.Run возвращаются в UI, ConfigureAwait(false) возврат не навязывает, .Result / .Wait() блокируют поток UI.

На практике встречаются в основном эти четыре сценария.

  1. обычный await в обработчике события UI
  2. Task.Run в обработчике события UI, чтобы снять CPU
  3. ConfigureAwait(false) снимает привязку точки возврата
  4. .Result / .Wait() блокируют поток UI

2.2. Таблица для первого выбора

Ситуация Что работает во время ожидания Продолжение после await Можно ли трогать UI напрямую Первый выбор
await SomeIoAsync() в обработчике UI Ожидание завершения I/O. Сам поток UI может вернуться в цикл сообщений Обычно поток UI Да обычный await
await Task.Run(...) в обработчике UI Тяжёлая CPU-работа в ThreadPool Обычно поток UI Да Task.Run только для CPU
await x.ConfigureAwait(false) в обработчике UI Точка возврата не закреплена за UI Произвольный поток Нет В UI-коде в целом избегать
x.Result / x.Wait() на потоке UI Поток UI блокируется ожиданием Продолжению вообще трудно выполниться Нет Не использовать
Нужно обновить UI после фонового потока или ConfigureAwait(false) Выполняется не на потоке UI Само по себе это не UI Нет Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
Пишете универсальную библиотеку без зависимости от UI Не зависит от обстоятельств вызывающего кода Не заставляет возвращаться в UI Проектируйте так, чтобы UI не трогать Рассмотреть ConfigureAwait(false)
Хотите вызвать async из конструктора или синхронного свойства Поток UI легко уходит в ожидание Путь запуска легко застревает Нет Унести в Loaded / Shown / InitializeAsync

Главное в этой таблице: обычный await в UI-коде — скорее союзник. Враг не сам await, а синхронная блокировка потока UI.

Сам выбор сведён в эту таблицу. Дальше по ролям: почему таблица такая (главы 3 и 4), как выбирать средство возврата в UI (глава 5), как это ловить на ревью (главы 6 и 7).

Враг не await, а синхронная блокировкаОбычный await в UI-коде скорее союзник; настоящий враг — синхронная блокировка потока UI, из которой берутся фризы и дедлоки.обычный awaitВ UI-коде скорее союзникСинхронно блокировать поток UIИсточник фризов и дедлоков

Рис. 3: Ядро таблицы решений. Враг не сам await, а синхронная блокировка потока UI.

3. Термины, которые используются в статье

3.1. Поток UI и цикл сообщений

UI в WPF / WinForms устроен так: есть один поток UI, и он обрабатывает ввод, отрисовку и события.

Роль этого потока примерно такая.

  • обрабатывать сообщения вроде нажатия кнопки, ввода с клавиатуры, перерисовки;
  • быть единственным потоком, который безопасно может трогать элементы управления и объекты UI;
  • если перегрузить его работой, обновление экрана и отклик на ввод останавливаются.

Суть в том, что работа потока UI — быстро крутить цикл сообщений. Если заблокировать его надолго, мышь, клавиатура и перерисовка встают — с точки зрения пользователя это «зависло».

Эту картину лучше держать перед глазами схемой.

Ввод пользователя / запрос перерисовкиЦикл сообщений потока UIВыполнение обработчика событияОбновление экранаДолгая синхронная работаЦикл сообщений не крутитсяЭкран выглядит зависшим

Рис. 4: Работа потока UI — быстро крутить цикл сообщений. Долгая синхронная работа останавливает цикл, и экран выглядит зависшим.

3.2. SynchronizationContext / Dispatcher / Invoke

Частые здесь термины удобно разложить так.

Термин Значение здесь
Поток UI Поток, создавший объекты UI. Обычно только он может безопасно трогать UI
Цикл сообщений Механизм, которым поток UI по очереди обрабатывает сообщения
SynchronizationContext Абстракция «вернуть работу в то же место выполнения»
Dispatcher Очередь WPF для потока UI
Invoke / BeginInvoke / InvokeAsync API, чтобы передать работу на поток UI

Чуть точнее про точку продолжения. await (по умолчанию это эквивалент ConfigureAwait(true)) в первую очередь захватывает SynchronizationContext.Current. Только если он null, смотрит TaskScheduler.Current и, если это не TaskScheduler.Default, возвращает продолжение в этот TaskScheduler. Если нет ни того ни другого — то есть SynchronizationContext.Current равен null, а TaskScheduler.Current — значение по умолчанию, — продолжение идёт в ThreadPool. На потоке UI WPF / WinForms действует первый случай: установлен UI SynchronizationContext. На практике достаточно считать, что работает UI SynchronizationContext.

Как выбирается точка продолжения awaitОбычный await сначала захватывает SynchronizationContext.Current; если он null, смотрит TaskScheduler.Current и при нестандартном планировщике возвращает продолжение туда, иначе выполняет его в ThreadPool.естьnullне по умолчаниюпо умолчаниюОбычный awaitЕсть SynchronizationContext?Вернуть в этот контекстTaskScheduler по умолчанию?Вернуть в этот TaskSchedulerВыполнить продолжение в ThreadPoolНа потоке UI это UI-контекст

Рис. 5: Как выбирается точка продолжения. На потоке UI установлен UI SynchronizationContext, поэтому продолжение возвращается в UI.

Соответствие по фреймворкам удобнее таблицей.

Фреймворк Контекст со стороны UI Основные API явного возврата в UI
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

В WPF в центре Dispatcher. В WinForms в центре дескриптор элемента управления и цикл сообщений, а на первый план выходят BeginInvoke / Invoke.

На практике достаточно запомнить связь абстракции и конкретных API примерно на таком уровне.

Текущий кодSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

Рис. 6: За абстракцией SynchronizationContext в WPF стоят API Dispatcher, в WinForms — Invoke у Control.

4. Типичные сценарии

4.1. Обычный await в обработчике события UI

Это самый прямой вариант.

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "Загрузка...";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "Готово";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

В этом коде LoadButton_Click стартует на потоке UI. А await File.ReadAllTextAsync(...) — обычный await, поэтому обычно захватывает UI-контекст на этот момент.

В результате:

  • во время ожидания файлового I/O поток UI не занят;
  • продолжение после чтения, как правило, возвращается на поток UI;
  • PreviewTextBox.Text = text; можно писать как есть.

Лишний Dispatcher здесь не нужен. Если внутри обработчика UI сделан просто обычный await, UI обычно можно трогать напрямую.

Обработчик здесь async void, потому что сигнатура обработчика события UI требует void; это как раз тот случай, где async void допустим. Отсюда же понятно, зачем внутри стоит try / catch. У async Task исключение попадает в возвращаемый Task, и вызывающий код получает его в момент await. У async void такого Task нет, поэтому исключение, которое ушло наружу, бросают обратно в SynchronizationContext на момент старта обработчика, то есть на поток UI. Необработанное исключение потока UI в WPF попадает в Application.DispatcherUnhandledException, в WinForms — в Application.ThreadException; если там его не обработать, приложение падает.

То есть в обработчике async void норма — ловить исключение внутри. Как в примере выше: показать сбой в статусе и в finally вернуть кнопку. Глобальная страховка (DispatcherUnhandledException и аналоги) — последняя сеть, не основной путь.

Куда уходит исключение из async voidИсключение async Task попадает в возвращаемый Task и его забирает вызывающий код через await; у async void исключения, ушедшие наружу, бросают обратно в SynchronizationContext старта, то есть на поток UI, и без глобального обработчика приложение падает.async Taskasync voidИсключение, ушедшее из обработчикаasync Task или async void?Попадает в возвращаемый TaskЗабирает вызывающий код на awaitБросают обратно на поток UIПопадает в глобальный обработчикБез обработки приложение падает

Рис. 7: У async void нет Task, куда положить исключение, поэтому норма — ловить его внутри обработчика.

В WinForms картина та же. Пока внутри обработчика Click идёт обычный await, продолжение, как правило, возвращается на сторону UI.

Поток выглядит так.

UI SynchronizationContextАсинхронный I/OПоток UIUI SynchronizationContextАсинхронный I/OПоток UIво время ожидания возвращается в цикл сообщенийстарт обработчика Clickawait ReadAllTextAsyncзапланировать возврат продолжения в UII/O завершёнвозобновить продолжение на потоке UIобновить TextBox / Label

Рис. 8: Пока обычный await ждёт, поток UI возвращается в цикл сообщений; после I/O продолжение возобновляется на потоке UI.

4.2. Task.Run только для тяжёлых вычислений CPU

Task.Run оправдан когда нужно снять тяжёлые вычисления CPU с потока UI.

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "Вычисление...";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

В этом коде происходит примерно следующее.

  1. обработчик события стартует на потоке UI;
  2. ожидание I/O у File.ReadAllBytesAsync идёт асинхронно;
  3. только тяжёлое вычисление хеша уходит в ThreadPool через Task.Run;
  4. продолжение await Task.Run(...) — обычный await, поэтому оно возвращается на поток UI;
  5. ResultText.Text = hash; можно писать как есть.

То есть отдельным потоком является только тело Task.Run. После await вы не остаётесь навсегда «уже не в UI».

На одной схеме это труднее перепутать.

ThreadPoolАсинхронный I/OПоток UIThreadPoolАсинхронный I/OПоток UIпродолжение await Task.Run(...) возобновляется на UIawait ReadAllBytesAsyncобычный await, поэтому возобновляется на UIтяжёлую CPU-работу отдаём через Task.Runвозвращаем результат вычислениявыводим результат на экран

Рис. 9: В ThreadPool работает только тело Task.Run; продолжение await возвращается на поток UI, поэтому результат можно сразу вывести на экран.

Здесь два ограничения.

  • не оборачивайте ожидание I/O в Task.Run;
  • думайте о Task.Run не как о «превращении в асинхронное», а как о «месте, куда сбросить CPU».

Запись вида Task.Run(async () => await File.ReadAllTextAsync(...)) лишь перекладывает ожидание I/O на ThreadPool и почти ничего не даёт.

Где уместен Task.RunРоль Task.Run — унести тяжёлые вычисления CPU в ThreadPool; оборачивать ожидание I/O значит просто переложить ожидание на ThreadPool, и выигрыша нет.тяжёлые вычисления CPUожидание I/OЧто вы хотите унестиУнести через Task.RunНе оборачивать в Task.RunТолько перекладываете ожидание, выигрыша нет

Рис. 10: Task.Run — не инструмент «сделать асинхронным», а место, куда сбрасывают CPU.

4.3. ConfigureAwait(false) — это не «гарантия, что не вернётся», а «не заставлять возвращаться»

Это место чаще всего понимают неправильно.

ConfigureAwait(false) уместен в универсальном библиотечном коде, который не зависит от UI и конкретной модели приложения.

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

Этот метод UI не трогает. Его можно вызывать из WPF, WinForms, ASP.NET Core и worker. Для такого кода ConfigureAwait(false) естественен.

Вызов со стороны UI может остаться обычным await.

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "Загрузка...";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "Готово";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

Важно: ConfigureAwait(false) внутри библиотеки не заставляет await вызывающего кода тоже стать false.

Получается такое разделение:

  • внутри библиотеки возврата в UI нет;
  • когда обработчик UI делает обычный await этого вызова, продолжение вызывающего кода возвращается в UI.
Разделение библиотеки и UIВнутри универсальной библиотеки ConfigureAwait(false) не требует возврата в UI-контекст; если обработчик UI делает обычный await, продолжение вызывающего кода возвращается в UI.await внутри библиотекиConfigureAwait〔false〕Не требует возврата в UIОбычный await в обработчике UIПродолжение вызывающего кода возвращается в UIВнутреннее указание на внешнее не распространяется

Рис. 11: ConfigureAwait(false) внутри библиотеки не заставляет await вызывающего кода тоже стать false.

А вот так писать прямо в обработчике UI опасно.

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

Тогда продолжение именно этого await в OpenButton_Click не заставляют возвращаться в UI. Поэтому PreviewTextBox.Text = text; может стать обращением не из того потока.

Есть ещё один неприметный, но важный момент. ConfigureAwait(false) не гарантирует переход в ThreadPool: если этот await завершается сразу, без реального ожидания, продолжение может просто пойти дальше на текущем потоке. Читать это как «обязательно уходит на другой поток» или «дальше это уже не UI» — прямой путь к сбоям. Смысл только в том, что продолжение этого await не заставляют вернуть в исходный UI-контекст.

На схеме это выглядит так.

НетДаawait в обработчике UIДобавляем ConfigureAwait(false)?Продолжение обычно на потоке UIUI легко обновлять как естьПродолжение не закреплено за UIМожет возобновиться на произвольном потокеДля обновления UI нужны Dispatcher / Invoke

Рис. 12: ConfigureAwait(false) меняет только одно: заставлять ли продолжение возвращаться в UI.

4.4. Почему зависают .Result / .Wait() / .GetAwaiter().GetResult()

Это самый частый сбой.

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

На вид это просто синхронное получение результата, но на потоке UI так делать опасно.

Поток событий выглядит так.

UI SynchronizationContextАсинхронный I/OПоток UIUI SynchronizationContextАсинхронный I/OПоток UIно UI заблокирован на .Resultпродолжение не может выполниться, поэтому завершение невозможностарт LoadButton_Clickвызов LoadTextAsync()возвращает незавершённый Taskблокируется в ожидании на .ResultI/O завершён, хотим вернуть продолжение в UIхотим выполнить продолжение

Рис. 13: Продолжение не может вернуться на поток UI, занятый .Result, и Task уже не завершается.

Словами это так.

  1. Поток UI вызывает LoadTextAsync().
  2. await внутри LoadTextAsync() захватывает UI-контекст.
  3. Поток UI зависает в ожидании на .Result.
  4. I/O завершается.
  5. Продолжение LoadTextAsync() хочет вернуться на поток UI.
  6. Но поток UI заблокирован на .Result.
  7. Продолжение не может выполниться, поэтому LoadTextAsync() не завершается.
  8. .Result никогда не заканчивается.

То есть UI говорит «подожду, пока ты не закончишь», а асинхронная сторона — «закончу, когда вернусь в UI», и они ждут друг друга. Крайне неприятная схема.

Взаимное ожиданиеПоток UI ждёт завершения асинхронной работы через .Result, а продолжение асинхронной стороны ждёт свободный поток UI — стороны ждут друг друга и не продвигаются.Поток UI ждёт на .ResultНужно завершение асинхронной стороныПродолжению нужно вернуться в UIНужен свободный поток UIЖдут друг друга и не продвигаются

Рис. 14: «Подожду, пока закончишь» сталкивается с «закончу, когда вернусь в UI», и стороны ждут друг друга.

Часто думают, что GetAwaiter().GetResult() безопаснее. Но суть — блокировка потока UI — та же. Отличается в основном упаковка исключения.

Поэтому в UI все три лучше считать одним запахом:

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

Вызов Task.Wait() с потока UI для Task, который возвращает Dispatcher.InvokeAsync(...) в WPF, опасен по той же причине. InvokeAsync только кладёт переданный делегат в очередь Dispatcher; реально он выполняется, когда поток UI разбирает эту очередь. Если поток UI стоит на Wait(), очередь не разбирается, и этот Task никогда не завершится. То же на стороне DispatcherOperation: DispatcherOperation.Wait() явно бросает InvalidOperationException, если ждать операцию, которая уже выполняется на том же потоке. Сам путь «встать и подождать» здесь не предусмотрен. В контексте UI застревает как раз направление «синхронно ждать то, что сам отправил». Подробный разбор — в Await, and UI, and deadlocks! Oh my!.

Опасность синхронного ожидания InvokeAsyncDispatcher.InvokeAsync только кладёт делегат в очередь; выполняется он, когда поток UI разбирает эту очередь. Если поток UI стоит на Wait, очередь не разбирается и Task никогда не завершается.InvokeAsync кладёт работу в очередьВыполняется, когда поток UI разбирает очередьПоток UI стоит на WaitОчередь не разбираетсяTask никогда не завершается

Рис. 15: Если поток UI сам синхронно ждёт отправленную работу, очередь — место выполнения — не разбирается, и ожидание не заканчивается.

Означает ли это «обязательный дедлок»? Не обязательно. Если продолжение случайно не возвращается в UI, бывает вариант, когда дедлока нет, а UI просто зависает. Но и этого достаточно, чтобы было плохо, поэтому в UI так в принципе делать не стоит.

5. Когда использовать Dispatcher / Invoke

С учётом сказанного, в обработчике UI с обычным await явный Dispatcher / Invoke обычно не нужен.

Он нужен, например, в таких случаях:

  • хотите трогать UI в продолжении после ConfigureAwait(false);
  • находитесь внутри Task.Run или в схеме, где даже внешний код не возвращается в UI;
  • уведомление изначально приходит не с потока UI — приём по сокету, таймер, callback события;
  • в слое, где UI и не-UI сознательно разделены, хотите явно обозначить только финальное обновление UI.

В WPF основной вариант — Dispatcher.InvokeAsync.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "Готово";
    });
}

В WinForms начиная с .NET 9 InvokeAsync естественно ложится на цепочку async/await.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Готово";
    });
}

В традиционном WinForms используют BeginInvoke. Invoke — синхронная отправка: вызывающая сторона ждёт. BeginInvoke ставит работу в очередь и сразу возвращает управление. С цепочкой async/await лучше сочетается сторона, которая не блокирует.

Разница Invoke и BeginInvokeInvoke — синхронная отправка и заставляет вызывающую сторону ждать; BeginInvoke ставит работу в очередь и сразу возвращает управление, поэтому с цепочкой async/await лучше сочетается сторона, которая не блокирует.Invoke〔синхронная отправка〕Заставляет вызывающую сторону ждатьBeginInvoke〔постановка в очередь〕Сразу возвращает управлениеСочетается с цепочкой async/await

Рис. 16: Оба «отправляют в UI», но ждущий Invoke и сразу возвращающий BeginInvoke устроены по-разному.

Но Control.BeginInvoke возвращает IAsyncResult, и его нельзя await как есть. Если Control.InvokeAsync нет (например .NET Framework 4.8, .NET 6 / 8), а работу нужно встроить в цепочку async/await, естественно обернуть её в Task через TaskCompletionSource.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // Чтобы тот же код собирался и на .NET Framework 4.8,
    // используем обобщённый TaskCompletionSource.
    // На .NET 5 и новее можно писать и необобщённый вариант.
    //
    // cancellationToken намеренно обязателен. Если после того, как BeginInvoke
    // принял работу, элемент управления уничтожат, поставленный в очередь
    // делегат выбросят невыполненным, и в TaskCompletionSource не попадут
    // ни результат, ни исключение. Без точки отмены сторона на await
    // будет ждать бесконечно.
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("Дескриптор окна ещё не создан.");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // Чтобы продолжение после await не пошло синхронно на потоке UI,
        // явно просим выполнять продолжения асинхронно.
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // Отмена и выполнение оспаривают одно и то же одноразовое право
        // сработать. Interlocked.Exchange пропускает дальше только того,
        // кто первым записал 1. Если сначала проверить флаг, а потом
        // вызвать action(), между проверкой и вызовом может прийти отмена:
        // вызывающая сторона уже получила отмену и начала следующую
        // операцию, а старый делегат позже перепишет экран.
        int claimed = 0;   // 0 = ещё не занято / 1 = кто-то уже взял

        // Если работу отменят, Task всё равно завершится, даже если делегат
        // так и не выполнится. Регистрацию обязательно снимаем по завершении
        // Task (иначе tcs удерживается, пока жив токен).
        // CancellationTokenRegistration.Dispose потокобезопасен,
        // вызывать его можно с любого потока.
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // Между постановкой в очередь и работой потока UI отмена
                // уже могла произойти. Если право здесь не взять,
                // его уже взяла сторона отмены — уходим, не трогая экран.
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // BeginInvoke и сам может бросить (например, дескриптора уже нет).
            // Если здесь не завершить Task, ожидание снова повиснет.
            // Делегат не побежит, поэтому и здесь сначала берём право.
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

Вызывающий код почти такой же, как в примере с InvokeAsync. Свяжите токен с временем жизни формы.

// Поле формы. Отменяем при закрытии.
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // Даже если уже поставленный в очередь делегат выбросят невыполненным,
    // здесь можно завершить сторону await.
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Готово";
    }, linked.Token);
}

В таком виде исключение на стороне UI тоже попадает в try / catch вокруг await. Держать в голове стоит четыре пункта.

  • «Сначала проверить флаг, потом действовать» для отмены и выполнения недостаточно. Между проверкой «уже отменено?» и вызовом action() отмена ещё успевает произойти. В этот миг tcs уже отменён, сторона на await идёт дальше и начинает следующую операцию. Потом ещё лежавший в очереди старый делегат переписывает экран — новое изображение затирается старым. Это плохо воспроизводимая поломка. Поэтому код выше через Interlocked.Exchange заставляет стороны оспаривать одноразовое право сработать: кто не взял это право, ничего не делает и выходит.
  • BeginInvoke до создания дескриптора (раньше Load) и после закрытия формы бросает исключение. Учитывайте время жизни вызывающей стороны.
  • Если после постановки в очередь элемент управления уничтожат, делегат могут выбросить невыполненным. Тогда в TaskCompletionSource нет ни результата, ни исключения, и сторона на await ждёт бесконечно. Всегда передавайте токен, связанный с закрытием формы. Завершение приходит как OperationCanceledException.
  • File.ReadAllTextAsync — API начиная с .NET Core 2.0. На .NET Framework 4.8 ту же форму соберите через StreamReader.ReadToEndAsync или аналог.
Спор за право между отменой и выполнениемСторона отмены и сторона выполнения через Interlocked.Exchange оспаривают одноразовое право сработать; дальше идёт только тот, кто взял его первым, а второй ничего не делает и выходит — так старый делегат не переписывает экран.Одноразовое право сработатьСторона отмены берёт первойСторона выполнения берёт первойЗавершает Task как отменённыйСтарый делегат ничего не делает и выходитВыполняет action и записывает результат

Рис. 17: Не «проверить флаг и действовать», а оспорить одноразовое право сработать; кто не взял — выходит.

Для выбора достаточно такого разделения.

Что нужно сделать WPF WinForms
Синхронно попасть в UI Dispatcher.Invoke Control.Invoke
Асинхронно отправить в UI Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
Естественно стыковать с async / await Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync, до этого — BeginInvoke

Практическое чутьё такое:

  • не нужно, если в обработчике UI просто идёт обычный await;
  • используйте, когда хотите трогать UI из места, которое не является UI;
  • не разрастайте синхронный Invoke внутри цепочки async/await.

Сбоев становится заметно меньше.

Если сомневаетесь, достаточно такой схемы.

ДаНетНетДаДаМесто, где пишется это продолжение, — поток UI?Да?Оставляем обычный await и обновляем UIХотим трогать UI?Продолжаем обработку как естьWPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

Рис. 18: Если место продолжения — поток UI, достаточно обычного await; иначе для доступа к UI нужны Dispatcher / Invoke.

6. Частые антипаттерны

Антипаттерн В чём боль Первая замена
LoadAsync().Result в обработчике UI Блокирует поток UI. Легко приводит к дедлоку await LoadAsync()
LoadAsync().Wait() в обработчике UI То же. Останавливает цикл сообщений await LoadAsync()
LoadAsync().GetAwaiter().GetResult() в обработчике UI Исключение выглядит иначе, блокировка та же await LoadAsync()
Механически добавлять ConfigureAwait(false) в UI-код Обновление UI после await легко ломается На самом внешнем слое UI — обычный await
Task.Run(async () => await IoAsync()) Напрасно перекладывает I/O await IoAsync()
Библиотечный код напрямую держит Dispatcher или Control Углубляет зависимость от UI. Трудно переиспользовать Библиотека возвращает только данные, маршалинг — на стороне UI
Обильно использовать Dispatcher.Invoke / Control.Invoke в цепочке async/await Легко образуются кольца блокировок Рассмотреть Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
Синхронизировать async в конструкторе или геттере свойства Питательная среда для зависаний при запуске Перенести в Loaded / Shown / InitializeAsync

Среди них особенно часто встречаются три.

  1. .Result / .Wait() на потоке UI
  2. механически добавлять ConfigureAwait(false) в UI-код
  3. смешение ответственности библиотеки и UI, из-за которого Dispatcher проникает вглубь

Если убрать именно эти три, с кодом становится заметно спокойнее.

Три самых частых антипаттернаС кодом становится заметно спокойнее, если убрать .Result и .Wait на потоке UI, механический ConfigureAwait(false) в UI-коде и проникновение Dispatcher вглубь библиотеки из-за смешения ответственности..Result или .Wait на потоке UIУбрать эти триМеханический ConfigureAwaitDispatcher проникает вглубьС кодом становится заметно спокойнее

Рис. 19: Среди антипаттернов эти три встречаются чаще всего; убрать их уже даёт большой эффект.

7. Чек-лист на ревью

Содержание то же, что у таблицы в 2.2 и антипаттернов в главе 6, но здесь это вопросы, которые задают по порядку, открыв код.

  • не остались ли .Result / .Wait() / .GetAwaiter().GetResult() в обработчиках событий UI или на пути инициализации UI;
  • используется ли Task.Run только для вычислений CPU? Не оборачивает ли он I/O;
  • не проник ли ConfigureAwait(false) механически в UI-код;
  • и наоборот, не тащит ли универсальная библиотека зависимость от UI-контекста;
  • можно ли действительно утверждать, что место, где после await UI трогают напрямую, находится в UI-контексте;
  • используются ли Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync там, где действительно нужен явный возврат в UI;
  • не разрастаются ли без необходимости синхронные вызовы маршалинга вроде Dispatcher.Invoke / Control.Invoke;
  • не синхронизируется ли async насильно из конструктора, синхронного свойства или синхронного события;
  • не обращается ли слой библиотеки напрямую к Window / Control / Dispatcher.

Этим чек-листом удобно и команде сверить, «что относится к зоне ответственности UI».

8. Как выбирать на практике

Сам выбор по ситуациям сведён в таблицу 2.2; здесь только то, что стоит унести с собой.

  • На самом внешнем слое UI — обычный await. После await UI можно трогать сразу именно потому, что это правило соблюдают.
  • Task.Run — место, куда сбрасывают CPU. Это не инструмент, чтобы оборачивать ожидание I/O.
  • ConfigureAwait(false) — инструмент универсальной библиотеки. В UI-код его механически не ставят.
  • Dispatcher / BeginInvoke / InvokeAsync — только когда UI трогают из места, которое не является UI
  • Три способа ждать на потоке UI (.Result / .Wait() / .GetAwaiter().GetResult()) не используют. Если захотелось синхронизировать, растяните async до самого вызывающего кода.

Почему так — в главе 4, как выбирать Dispatcher / Invoke — в главе 5, на что смотреть в реальном коде — в главах 6 и 7.

9. Итог

В async / await для WPF / WinForms важно не ощущение «асинхронность — это сложно», а раздельно держать в голове:

  • где всё началось;
  • куда возвращается продолжение await;
  • кто отвечает за возврат в UI.

Как первые правила достаточно этого:

  1. на самом внешнем слое UI — обычный await;
  2. Task.Run только для тяжёлого CPU;
  3. в универсальных библиотеках рассмотреть ConfigureAwait(false);
  4. Dispatcher / BeginInvoke / InvokeAsync только когда действительно нужно вернуться в UI;
  5. на потоке UI не использовать .Result / .Wait() / .GetAwaiter().GetResult().

Сам по себе async / await — не такой уж капризный механизм. Но если пользоваться им, не держа в центре поток UI, он внезапно превращается в трясину.

Иначе говоря,

  • разделяйте внешнюю и внутреннюю стороны UI;
  • держите в уме, куда возвращается продолжение;
  • не тащите блокировку.

Достаточно этих трёх правил, и асинхронный код в WPF / WinForms становится заметно спокойнее. Код, из-за которого замирает экран, обычно объясняется не тем, что «асинхронность плоха», а лишь неаккуратным способом занимать поток UI.

Три правила спокойного асинхронного UI-кодаЕсли разделять внешнюю и внутреннюю стороны UI, помнить точку возврата await и не тащить блокировку, асинхронный код в WPF и WinForms становится спокойнее.Разделять внешнюю и внутреннюю стороны UIАсинхронный код становится спокойнееПомнить точку возвратаНе тащить блокировку

Рис. 20: Три правила итога. Разделяйте, помните точку возврата, не блокируйте — и экран перестаёт зависать.

10. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

На какой поток возвращается выполнение после await?
Если в обработчике события UI в WPF или WinForms сделать обычный await (без ConfigureAwait), продолжение после await в типичном случае возвращается на поток UI. await захватывает текущий UI SynchronizationContext и ставит продолжение туда, поэтому после await можно сразу обновлять TextBox или Label. То же для await Task.Run(...): само вычисление идёт в ThreadPool, но при обычном await продолжение возобновляется на потоке UI.
Почему .Result или .Wait() на потоке UI зависают?
Пока поток UI ждёт на .Result, продолжение асинхронной работы пытается вернуться в захваченный UI-контекст, но поток UI занят ожиданием .Result и не может это продолжение выполнить — стороны ждут друг друга, получается дедлок. GetAwaiter().GetResult() отличается в основном упаковкой исключения; поток UI он блокирует так же. В UI избегайте всех трёх — .Result, .Wait() и GetAwaiter().GetResult() — и используйте await.
Нужно ли писать ConfigureAwait(false) в UI-коде?
Лучше не писать. ConfigureAwait(false) значит «не заставлять возвращаться в захваченный UI-контекст», поэтому продолжение может возобновиться на любом потоке, и следующее обновление UI легко превращается в обращение не из того потока. Это уместно в универсальном библиотечном коде без зависимости от UI. Самый внешний слой UI держите на обычном await.
Когда стоит использовать Task.Run?
Только когда нужно снять с потока UI тяжёлые вычисления CPU. Оборачивать ожидание I/O в Task.Run бессмысленно: вы просто перекладываете ожидание на ThreadPool. Отдельным потоком работает только тело Task.Run; продолжение await Task.Run(...) при обычном await обычно возвращается на поток UI, поэтому результат можно сразу вывести на экран.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог