История изменений (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)
Содержание
- Сначала вывод (в двух словах)
- Краткая сводка
- 2.1. Общая картина
- 2.2. Таблица для первого выбора
- Термины, которые используются в статье
- 3.1. Поток UI и цикл сообщений
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- Типичные сценарии
- 4.1. Обычный
awaitв обработчике события UI - 4.2.
Task.Runтолько для тяжёлых вычислений CPU - 4.3.
ConfigureAwait(false)— это не «гарантия, что не вернётся», а «не заставлять возвращаться» - 4.4. Почему зависают
.Result/.Wait()/.GetAwaiter().GetResult()
- 4.1. Обычный
- Когда использовать
Dispatcher/Invoke - Частые антипаттерны
- Чек-лист на ревью
- Как выбирать на практике
- Итог
- Источники
Карта знаний этой статьи
Основа: поток 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, и приложение падает.
flowchart LR
accTitle: Карта знаний: поток UI в WPF/WinForms и async/await
accDescr: Схема связей: поток UI продолжает крутить цикл обработки сообщений; обычный await возвращает продолжение в захваченный SynchronizationContext; ConfigureAwait(false) этот возврат не навязывает; Task.Run снимает вычисления CPU с потока UI; Dispatcher, Control.BeginInvoke и InvokeAsync — явные средства вернуться в UI; .Result, .Wait() и GetAwaiter().GetResult() могут блокировать поток UI и привести к взаимной блокировке или фризу; исключение из async void доходит до глобального обработчика UI.
ui_thread_context["контекст потока UI"]
wpf["WPF"]
windows_forms["Windows Forms"]
message_loop["цикл сообщений (message loop)"]
synchronizationcontext["SynchronizationContext"]
wpf_dispatcher["Dispatcher (WPF)"]
winforms_begininvoke["Control.Invoke / Control.BeginInvoke"]
winforms_invokeasync["Control.InvokeAsync"]
taskcompletionsource["TaskCompletionSource"]
cancellationtoken_dotnet["CancellationToken (.NET)"]
reentrancy_guard["защита от повторного входа (Interlocked.Exchange)"]
cancel_execute_race["гонка cancel и execute"]
plain_await["обычный await"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["универсальный библиотечный код"]
taskrun_dotnet["Task.Run"]
io_bound_operation["операция I/O-bound"]
ui_thread_blocking["блокировка UI-потока"]
sync_over_async["sync-over-async"]
deadlock["взаимная блокировка (deadlock)"]
task_result_wait[".Result / .Wait() / .GetAwaiter().GetResult()"]
async_void["async void"]
event_handler_method["метод обработчика события"]
ui_unhandled_exception_handler["общий обработчик UI (DispatcherUnhandledException / ThreadException)"]
ui_thread_context -->|"требует"| message_loop
wpf -->|"использует"| synchronizationcontext
windows_forms -->|"использует"| synchronizationcontext
wpf -->|"использует"| wpf_dispatcher
windows_forms -->|"использует"| winforms_begininvoke
windows_forms -.->|"использует"| winforms_invokeasync
winforms_invokeasync -->|"преемник"| winforms_begininvoke
winforms_begininvoke -.->|"требует"| taskcompletionsource
taskcompletionsource -.->|"требует"| cancellationtoken_dotnet
taskcompletionsource -.->|"использует"| reentrancy_guard
reentrancy_guard -->|"предотвращает"| cancel_execute_race
plain_await -->|"использует"| synchronizationcontext
plain_await -->|"рекомендуется для"| ui_thread_context
configureawait_false -.->|"требует"| synchronizationcontext
configureawait_false -->|"не рекомендуется"| ui_thread_context
configureawait_false -->|"рекомендуется для"| generic_library_code
taskrun_dotnet -->|"рекомендуется для"| ui_thread_context
taskrun_dotnet -->|"не рекомендуется"| io_bound_operation
taskrun_dotnet -->|"предотвращает"| ui_thread_blocking
sync_over_async -->|"может вызвать"| deadlock
task_result_wait -.->|"может вызвать"| deadlock
task_result_wait -->|"не рекомендуется"| ui_thread_context
wpf_dispatcher -->|"может вызвать"| deadlock
async_void -->|"рекомендуется для"| event_handler_method
async_void -->|"может вызвать"| ui_unhandled_exception_handler
event_handler_method -.->|"требует"| ui_thread_context
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 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 держите в поле зрения три вещи:
- на каком потоке вы сейчас выполняетесь;
- куда возвращается продолжение
await; - кто отвечает за возврат в UI.
Когда эти три пункта ясны, картина заметно проще.
flowchart TB
accTitle: Три вопроса, которые проясняют картину
accDescr: Если держать в поле зрения, на каком потоке вы сейчас выполняетесь, куда возвращается продолжение await и кто отвечает за возврат в UI, асинхронный UI-код в WPF и WinForms становится заметно понятнее.
q1["На каком потоке вы сейчас выполняетесь"] --> goal["Понятный асинхронный UI-код"]
q2["Куда возвращается продолжение await"] --> goal
q3["Кто отвечает за возврат в UI"] --> goal
Рис. 1: Три вопроса, к которым стоит возвращаться. Разделяйте поток, точку возврата и ответственность за возврат в UI.
2. Краткая сводка
2.1. Общая картина
Быстрее всего охватить картину этой схемой.
flowchart LR
A["Обработчик события UI<br/>(WPF / WinForms)"] --> B["обычный await<br/>I/O API"]
B --> C["Захватывает UI SynchronizationContext"]
C --> D["После await возобновляется на потоке UI"]
D --> E["Обновление UI можно писать сразу"]
A --> F["await Task.Run(...)<br/>тяжёлая CPU-работа"]
F --> G["Само вычисление идёт в ThreadPool"]
G --> H["После await возобновляется на потоке UI"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["Не заставляет возвращаться в UI"]
J --> K["Продолжение на произвольном потоке"]
K --> L["Прямое обновление UI опасно<br/>нужны Dispatcher / Invoke"]
A --> M["SomeAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
M --> N["Блокирует поток UI"]
N --> O["Продолжение не может вернуться в UI"]
O --> P["Зависание / дедлок / как минимум фриз"]
Рис. 2: Четыре типичных сценария глазами обработчика UI. Обычный await и Task.Run возвращаются в UI, ConfigureAwait(false) возврат не навязывает, .Result / .Wait() блокируют поток UI.
На практике встречаются в основном эти четыре сценария.
- обычный
awaitв обработчике события UI Task.Runв обработчике события UI, чтобы снять CPUConfigureAwait(false)снимает привязку точки возврата.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).
flowchart TB
accTitle: Враг не await, а синхронная блокировка
accDescr: Обычный await в UI-коде скорее союзник; настоящий враг — синхронная блокировка потока UI, из которой берутся фризы и дедлоки.
pa["обычный await"] --> friend["В UI-коде скорее союзник"]
blk["Синхронно блокировать поток UI"] --> enemy["Источник фризов и дедлоков"]
Рис. 3: Ядро таблицы решений. Враг не сам await, а синхронная блокировка потока UI.
3. Термины, которые используются в статье
3.1. Поток UI и цикл сообщений
UI в WPF / WinForms устроен так: есть один поток UI, и он обрабатывает ввод, отрисовку и события.
Роль этого потока примерно такая.
- обрабатывать сообщения вроде нажатия кнопки, ввода с клавиатуры, перерисовки;
- быть единственным потоком, который безопасно может трогать элементы управления и объекты UI;
- если перегрузить его работой, обновление экрана и отклик на ввод останавливаются.
Суть в том, что работа потока UI — быстро крутить цикл сообщений. Если заблокировать его надолго, мышь, клавиатура и перерисовка встают — с точки зрения пользователя это «зависло».
Эту картину лучше держать перед глазами схемой.
flowchart LR
A["Ввод пользователя / запрос перерисовки"] --> B["Цикл сообщений потока UI"]
B --> C["Выполнение обработчика события"]
C --> D["Обновление экрана"]
D --> B
C --> E["Долгая синхронная работа"]
E --> F["Цикл сообщений не крутится"]
F --> G["Экран выглядит зависшим"]
Рис. 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.
flowchart TB
accTitle: Как выбирается точка продолжения await
accDescr: Обычный await сначала захватывает SynchronizationContext.Current; если он null, смотрит TaskScheduler.Current и при нестандартном планировщике возвращает продолжение туда, иначе выполняет его в ThreadPool.
a["Обычный await"] --> sc{"Есть SynchronizationContext?"}
sc -->|"есть"| toSc["Вернуть в этот контекст"]
sc -->|"null"| ts{"TaskScheduler по умолчанию?"}
ts -->|"не по умолчанию"| toTs["Вернуть в этот TaskScheduler"]
ts -->|"по умолчанию"| pool["Выполнить продолжение в ThreadPool"]
toSc -.-> ui["На потоке 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 примерно на таком уровне.
flowchart TD
A["Текущий код"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.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 и аналоги) — последняя сеть, не основной путь.
flowchart TB
accTitle: Куда уходит исключение из async void
accDescr: Исключение async Task попадает в возвращаемый Task и его забирает вызывающий код через await; у async void исключения, ушедшие наружу, бросают обратно в SynchronizationContext старта, то есть на поток UI, и без глобального обработчика приложение падает.
ex["Исключение, ушедшее из обработчика"] --> kind{"async Task или async void?"}
kind -->|"async Task"| task["Попадает в возвращаемый Task"]
task --> caller["Забирает вызывающий код на await"]
kind -->|"async void"| ctx["Бросают обратно на поток UI"]
ctx --> global["Попадает в глобальный обработчик"]
global --> crash["Без обработки приложение падает"]
Рис. 7: У async void нет Task, куда положить исключение, поэтому норма — ловить его внутри обработчика.
В WinForms картина та же.
Пока внутри обработчика Click идёт обычный await, продолжение, как правило, возвращается на сторону UI.
Поток выглядит так.
sequenceDiagram
participant UI as Поток UI
participant IO as Асинхронный I/O
participant Ctx as UI SynchronizationContext
UI->>UI: старт обработчика Click
UI->>IO: await ReadAllTextAsync
UI-->>Ctx: запланировать возврат продолжения в UI
Note over UI: во время ожидания возвращается в цикл сообщений
IO-->>Ctx: I/O завершён
Ctx-->>UI: возобновить продолжение на потоке UI
UI->>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;
}
}
В этом коде происходит примерно следующее.
- обработчик события стартует на потоке UI;
- ожидание I/O у
File.ReadAllBytesAsyncидёт асинхронно; - только тяжёлое вычисление хеша уходит в ThreadPool через
Task.Run; - продолжение
await Task.Run(...)— обычныйawait, поэтому оно возвращается на поток UI; ResultText.Text = hash;можно писать как есть.
То есть отдельным потоком является только тело Task.Run.
После await вы не остаётесь навсегда «уже не в UI».
На одной схеме это труднее перепутать.
sequenceDiagram
participant UI as Поток UI
participant IO as Асинхронный I/O
participant Pool as ThreadPool
UI->>IO: await ReadAllBytesAsync
IO-->>UI: обычный await, поэтому возобновляется на UI
UI->>Pool: тяжёлую CPU-работу отдаём через Task.Run
Pool-->>UI: возвращаем результат вычисления
Note over UI: продолжение await Task.Run(...) возобновляется на UI
UI->>UI: выводим результат на экран
Рис. 9: В ThreadPool работает только тело Task.Run; продолжение await возвращается на поток UI, поэтому результат можно сразу вывести на экран.
Здесь два ограничения.
- не оборачивайте ожидание I/O в
Task.Run; - думайте о
Task.Runне как о «превращении в асинхронное», а как о «месте, куда сбросить CPU».
Запись вида Task.Run(async () => await File.ReadAllTextAsync(...)) лишь перекладывает ожидание I/O на ThreadPool и почти ничего не даёт.
flowchart TB
accTitle: Где уместен Task.Run
accDescr: Роль Task.Run — унести тяжёлые вычисления CPU в ThreadPool; оборачивать ожидание I/O значит просто переложить ожидание на ThreadPool, и выигрыша нет.
q{"Что вы хотите унести"}
q -->|"тяжёлые вычисления CPU"| ok["Унести через Task.Run"]
q -->|"ожидание I/O"| ng["Не оборачивать в Task.Run"]
ng --> why["Только перекладываете ожидание, выигрыша нет"]
Рис. 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.
flowchart TB
accTitle: Разделение библиотеки и UI
accDescr: Внутри универсальной библиотеки ConfigureAwait(false) не требует возврата в UI-контекст; если обработчик UI делает обычный await, продолжение вызывающего кода возвращается в UI.
lib["await внутри библиотеки"] --> nof["ConfigureAwait〔false〕"]
nof --> stay["Не требует возврата в UI"]
uih["Обычный await в обработчике UI"] --> back["Продолжение вызывающего кода возвращается в UI"]
nof -.-> note["Внутреннее указание на внешнее не распространяется"]
Рис. 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-контекст.
На схеме это выглядит так.
flowchart LR
A["await в обработчике UI"] --> B{"Добавляем ConfigureAwait(false)?"}
B -- Нет --> C["Продолжение обычно на потоке UI"]
C --> D["UI легко обновлять как есть"]
B -- Да --> E["Продолжение не закреплено за UI"]
E --> F["Может возобновиться на произвольном потоке"]
F --> G["Для обновления 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 так делать опасно.
Поток событий выглядит так.
sequenceDiagram
participant UI as Поток UI
participant IO as Асинхронный I/O
participant Ctx as UI SynchronizationContext
UI->>UI: старт LoadButton_Click
UI->>IO: вызов LoadTextAsync()
IO-->>UI: возвращает незавершённый Task
UI->>UI: блокируется в ожидании на .Result
IO-->>Ctx: I/O завершён, хотим вернуть продолжение в UI
Ctx-->>UI: хотим выполнить продолжение
Note over UI: но UI заблокирован на .Result
Note over UI, Ctx: продолжение не может выполниться, поэтому завершение невозможно
Рис. 13: Продолжение не может вернуться на поток UI, занятый .Result, и Task уже не завершается.
Словами это так.
- Поток UI вызывает
LoadTextAsync(). awaitвнутриLoadTextAsync()захватывает UI-контекст.- Поток UI зависает в ожидании на
.Result. - I/O завершается.
- Продолжение
LoadTextAsync()хочет вернуться на поток UI. - Но поток UI заблокирован на
.Result. - Продолжение не может выполниться, поэтому
LoadTextAsync()не завершается. .Resultникогда не заканчивается.
То есть UI говорит «подожду, пока ты не закончишь», а асинхронная сторона — «закончу, когда вернусь в UI», и они ждут друг друга. Крайне неприятная схема.
flowchart TB
accTitle: Взаимное ожидание
accDescr: Поток UI ждёт завершения асинхронной работы через .Result, а продолжение асинхронной стороны ждёт свободный поток UI — стороны ждут друг друга и не продвигаются.
ui["Поток UI ждёт на .Result"] --> need["Нужно завершение асинхронной стороны"]
cont["Продолжению нужно вернуться в UI"] --> free["Нужен свободный поток UI"]
need --> cycle["Ждут друг друга и не продвигаются"]
free --> cycle
Рис. 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!.
flowchart TB
accTitle: Опасность синхронного ожидания InvokeAsync
accDescr: Dispatcher.InvokeAsync только кладёт делегат в очередь; выполняется он, когда поток UI разбирает эту очередь. Если поток UI стоит на Wait, очередь не разбирается и Task никогда не завершается.
post["InvokeAsync кладёт работу в очередь"] --> run["Выполняется, когда поток UI разбирает очередь"]
wait["Поток UI стоит на Wait"] --> norun["Очередь не разбирается"]
norun --> never["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 лучше сочетается сторона, которая не блокирует.
flowchart TB
accTitle: Разница Invoke и BeginInvoke
accDescr: Invoke — синхронная отправка и заставляет вызывающую сторону ждать; BeginInvoke ставит работу в очередь и сразу возвращает управление, поэтому с цепочкой async/await лучше сочетается сторона, которая не блокирует.
inv["Invoke〔синхронная отправка〕"] --> waitc["Заставляет вызывающую сторону ждать"]
bi["BeginInvoke〔постановка в очередь〕"] --> ret["Сразу возвращает управление"]
ret --> fit["Сочетается с цепочкой 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или аналог.
flowchart TB
accTitle: Спор за право между отменой и выполнением
accDescr: Сторона отмены и сторона выполнения через Interlocked.Exchange оспаривают одноразовое право сработать; дальше идёт только тот, кто взял его первым, а второй ничего не делает и выходит — так старый делегат не переписывает экран.
race["Одноразовое право сработать"] --> c["Сторона отмены берёт первой"]
race --> e["Сторона выполнения берёт первой"]
c --> c2["Завершает Task как отменённый"]
c --> c3["Старый делегат ничего не делает и выходит"]
e --> e2["Выполняет 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.
Сбоев становится заметно меньше.
Если сомневаетесь, достаточно такой схемы.
flowchart TD
A["Место, где пишется это продолжение, — поток UI?"] --> B{"Да?"}
B -- Да --> C["Оставляем обычный await и обновляем UI"]
B -- Нет --> D{"Хотим трогать UI?"}
D -- Нет --> E["Продолжаем обработку как есть"]
D -- Да --> F["WPF: Dispatcher.InvokeAsync"]
D -- Да --> G["WinForms: 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 |
Среди них особенно часто встречаются три.
.Result/.Wait()на потоке UI- механически добавлять
ConfigureAwait(false)в UI-код - смешение ответственности библиотеки и UI, из-за которого
Dispatcherпроникает вглубь
Если убрать именно эти три, с кодом становится заметно спокойнее.
flowchart TB
accTitle: Три самых частых антипаттерна
accDescr: С кодом становится заметно спокойнее, если убрать .Result и .Wait на потоке UI, механический ConfigureAwait(false) в UI-коде и проникновение Dispatcher вглубь библиотеки из-за смешения ответственности.
a1[".Result или .Wait на потоке UI"] --> fix["Убрать эти три"]
a2["Механический ConfigureAwait"] --> fix
a3["Dispatcher проникает вглубь"] --> fix
fix --> calm["С кодом становится заметно спокойнее"]
Рис. 19: Среди антипаттернов эти три встречаются чаще всего; убрать их уже даёт большой эффект.
7. Чек-лист на ревью
Содержание то же, что у таблицы в 2.2 и антипаттернов в главе 6, но здесь это вопросы, которые задают по порядку, открыв код.
- не остались ли
.Result/.Wait()/.GetAwaiter().GetResult()в обработчиках событий UI или на пути инициализации UI; - используется ли
Task.Runтолько для вычислений CPU? Не оборачивает ли он I/O; - не проник ли
ConfigureAwait(false)механически в UI-код; - и наоборот, не тащит ли универсальная библиотека зависимость от UI-контекста;
- можно ли действительно утверждать, что место, где после
awaitUI трогают напрямую, находится в UI-контексте; - используются ли
Dispatcher.InvokeAsync/BeginInvoke/InvokeAsyncтам, где действительно нужен явный возврат в UI; - не разрастаются ли без необходимости синхронные вызовы маршалинга вроде
Dispatcher.Invoke/Control.Invoke; - не синхронизируется ли async насильно из конструктора, синхронного свойства или синхронного события;
- не обращается ли слой библиотеки напрямую к
Window/Control/Dispatcher.
Этим чек-листом удобно и команде сверить, «что относится к зоне ответственности UI».
8. Как выбирать на практике
Сам выбор по ситуациям сведён в таблицу 2.2; здесь только то, что стоит унести с собой.
- На самом внешнем слое UI — обычный
await. ПослеawaitUI можно трогать сразу именно потому, что это правило соблюдают. 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.
Как первые правила достаточно этого:
- на самом внешнем слое UI — обычный
await; Task.Runтолько для тяжёлого CPU;- в универсальных библиотеках рассмотреть
ConfigureAwait(false); Dispatcher/BeginInvoke/InvokeAsyncтолько когда действительно нужно вернуться в UI;- на потоке UI не использовать
.Result/.Wait()/.GetAwaiter().GetResult().
Сам по себе async / await — не такой уж капризный механизм.
Но если пользоваться им, не держа в центре поток UI, он внезапно превращается в трясину.
Иначе говоря,
- разделяйте внешнюю и внутреннюю стороны UI;
- держите в уме, куда возвращается продолжение;
- не тащите блокировку.
Достаточно этих трёх правил, и асинхронный код в WPF / WinForms становится заметно спокойнее. Код, из-за которого замирает экран, обычно объясняется не тем, что «асинхронность плоха», а лишь неаккуратным способом занимать поток UI.
flowchart TB
accTitle: Три правила спокойного асинхронного UI-кода
accDescr: Если разделять внешнюю и внутреннюю стороны UI, помнить точку возврата await и не тащить блокировку, асинхронный код в WPF и WinForms становится спокойнее.
r1["Разделять внешнюю и внутреннюю стороны UI"] --> calm["Асинхронный код становится спокойнее"]
r2["Помнить точку возврата"] --> calm
r3["Не тащить блокировку"] --> calm
Рис. 20: Три правила итога. Разделяйте, помните точку возврата, не блокируйте — и экран перестаёт зависать.
10. Источники
- Полный набор примеров кода к этой статье (библиотека без зависимости от UI, примеры WPF / WinForms, модульные тесты) - komurasoft-blog-samples (GitHub)
- Связанная статья: Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
- Threading Model - WPF
- DispatcherSynchronizationContext Class
- How to handle cross-thread operations with controls - Windows Forms
- WindowsFormsSynchronizationContext Class
- Events Overview - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Method
- ConfigureAwait FAQ
- How Async/Await Really Works in C#
- Await, and UI, and deadlocks! Oh my!
- Threading model for WebView2 apps
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
CI/CD для WinForms / WPF: сборка, подпись и распространение в GitHub Actions
Практическое руководство по CI/CD для WinForms / WPF в GitHub Actions. Минимальный YAML сборки и тестов на windows-latest, нумерация верс...
Значок в области уведомлений и toast в Windows-приложении: ошибки NotifyIcon и выбор AppNotification
Разбираем, как держать бизнес-приложение Windows в области уведомлений и показывать toast. Правильная работа с NotifyIcon, повторная реги...
Локализация WinForms и WPF: resx, спутниковые сборки, переключение культуры
Разбираем локализацию десктопных приложений Windows: чем CurrentCulture отличается от CurrentUICulture, как устроены resx и спутниковые с...
Аутентификация Entra ID в WinForms и WPF: MSAL.NET и брокер WAM
Как добавить вход через Entra ID в десктопное приложение WinForms или WPF: общедоступный клиент, регистрация приложения, AcquireTokenSile...
UI-автотесты десктопных приложений Windows — UI Automation и устойчивые тесты на FlaUI
Разбираем UI-автотесты WinForms и WPF с устройства Windows UI Automation. Минимальная реализация на FlaUI, AutomationId и ожидание по усл...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Поток UI и таймеры
Поток UI WPF / WinForms, асинхронные операции, Dispatcher и проектирование таймеров.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Поток UI и async/await в WPF и WinForms — одно из мест, где при разработке Windows-приложений чаще всего застревают.
Технические консультации и ревью дизайна
Если нужно развести ответственность UI и фоновой работы и понять, когда уместен Dispatcher, это можно проработать на технической консультации и ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- На какой поток возвращается выполнение после 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.