WPF/WinForms: async и UI-поток — сводка на одном листе

· · C#, async/await, .NET, WPF, WinForms, UI, Потоки

Самое сложное при использовании async / await в WPF / WinForms — это на какой поток возвращается выполнение после 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 и deadlock).

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. plain 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. Справочные материалы

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

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

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

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

эти три момента, картина становится заметно понятнее.

2. Сводка на одном листе

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

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

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

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

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

2.2. Таблица решений для первого приближения

Ситуация Что выполняется во время ожидания Продолжение после await Можно ли напрямую трогать UI Первый выбор
await SomeIoAsync() в UI-обработчике Ожидание завершения I/O. Сам UI-поток может вернуться в цикл обработки сообщений В основном UI-поток Да plain 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

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

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

3.1. UI-поток и цикл обработки сообщений

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

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

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

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

Держать этот образ перед глазами в виде диаграммы помогает избежать путаницы.

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

3.2. SynchronizationContext / Dispatcher / Invoke

Часто встречающиеся здесь термины удобнее разложить так, применительно к практике.

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

Если говорить точнее, await, определяя, куда направить продолжение, в первую очередь смотрит на текущий SynchronizationContext, а при его отсутствии — ещё и на нестандартный TaskScheduler. Но на практике WPF / WinForms достаточно считать, что прежде всего действует UI-контекст SynchronizationContext.

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

Фреймворк Контекст со стороны 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.

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

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

4. Типичные паттерны

4.1. plain 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(...) — это plain await, поэтому обычно захватывает UI-контекст на этот момент.

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

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

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

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

В виде диаграммы поток выглядит так.

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

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(...) — это plain await, поэтому оно возвращается на UI-поток;
  5. ResultText.Text = hash; можно писать как есть.

То есть отдельным потоком является только содержимое Task.Run. Мы не переходим навсегда в «место, которое больше не является UI» после await.

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

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

Здесь стоит учесть два момента.

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

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

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 может использовать plain 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-обработчик делает plain await этого вызова, продолжение вызывающей стороны возвращается в UI.

И наоборот, писать так прямо внутри 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)?Продолжение в основном на UI-потокеЛегко обновлять UI как естьПродолжение не закреплено за UIМожет возобновиться на произвольном потокеДля обновления UI нужны Dispatcher / Invoke

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/OUI-потокUI SynchronizationContextАсинхронный I/OUI-потокно UI заблокирован на .Resultпродолжение не может выполниться, поэтому завершение невозможностарт LoadButton_Clickвызов LoadTextAsync()возвращает незавершённый Taskблокируется в ожидании на .ResultI/O завершён, хотим вернуть продолжение в UIхотим выполнить продолжение

Если выразить происходящее словами, получится так.

  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» — и они ждут друг друга. Крайне неприятная ситуация.

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

Поэтому в UI безопаснее относиться ко всем трём как к одному и тому же запаху:

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

Отметим, что вызов Task.Wait() для Task, который возвращает Dispatcher.InvokeAsync(...) в WPF, тоже опасен. Документация WPF также указывает, что вызов Task.Wait для Task, возвращаемого DispatcherOperation, приводит к deadlock. В контексте UI застревает как раз само направление «синхронно ждать то, что отправил».

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

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

С учётом всего сказанного, в UI-обработчике с plain 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 естественно сочетается с асинхронным потоком.

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 отправляет и сразу возвращает управление. В асинхронном потоке в целом лучше сочетается неблокирующая сторона.

Для различения достаточно примерно такого уровня разделения.

Что хотим сделать 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-обработчике просто выполняется plain await;
  • используйте, когда хотите трогать UI из места, которое не является UI;
  • не разрастайте синхронный Invoke внутри асинхронного потока.

Это заметно сокращает число происшествий.

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

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

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

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

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

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

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

7. Чек-лист для ревью

При ревью async / await в WPF / WinForms стоит по порядку проверять следующее.

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

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

8. Как выбирать (кратко)

Что хотим сделать Первый выбор
Ждать HTTP / БД / файловый I/O в UI-обработчике plain await
Тяжёлое CPU-вычисление, которое не должно останавливать UI await над Task.Run
Обновить UI после ConfigureAwait(false) или из фонового потока WPF: Dispatcher.InvokeAsync / WinForms: BeginInvoke или .NET 9+ InvokeAsync
Написать универсальную библиотеку Рассмотреть ConfigureAwait(false)
Синхронизировать async в UI В принципе не делать. Растянуть async до самого вызывающего кода
Выполнить инициализацию при запуске Loaded / Shown / явный InitializeAsync
Трогать UI сразу после await Держать plain await на самом внешнем слое UI

9. Итог

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

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

В качестве первых правил достаточно придерживаться только этого, чтобы уверенно действовать:

  1. на самом внешнем слое UI — plain 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-потока.

10. Справочные материалы

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

Значки в области уведомлений и всплывающие (toast) уведомления в Windows-приложениях — подводные камни NotifyIcon и выбор правильного AppNotification

Практическое руководство о том, как удерживать бизнес-приложение Windows в области уведомлений (system tray) и оповещать пользователя с п...

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

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

Технические консультации и ревью дизайна

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

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

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

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

Об авторе

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

Го Комура

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

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

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

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