更新履歴(7件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- `InvokeOnUiAsync`で、キャンセルと実行の判定が競合していたのを直しました。`BeginInvoke`したデリゲートの中で「キャンセル済みか」を確認してから`action()`を呼ぶ形は不可分ではなく、確認した直後にキャンセルが走ると、`tcs`はキャンセル済みになって呼び出し側は次の操作を始めるのに、キューに残った古いデリゲートがあとから画面を書き換えます。新しい表示が古い表示で上書きされる、再現しにくい壊れ方です。`Interlocked.Exchange`で「1回ぶんの権利」をキャンセル側と実行側に奪い合わせ、取れなかった側は何もせずに帰る形にしました。`BeginInvoke`自身が例外を投げた経路も同じ権利を取ってから畳みます。
- `BeginInvoke`に投稿したデリゲートが、キャンセル後も無条件に`action()`を実行していました。呼び出し側は既にキャンセルを受け取っているのに、あとから画面だけが書き換わり、新しい操作の結果を古い結果で上書きします。実行する前にトークンと完了状態を確認するようにしました。
- `InvokeOnUiAsync`に`CancellationToken`を必須で受け取らせました。`BeginInvoke`が受け付けたあとにコントロールが破棄されると、投稿したデリゲートは実行されないまま捨てられ、`TaskCompletionSource`には結果も例外も入らないため、`await`している側が永久に待ちます。記事の後半でこの危険には触れていましたが、サンプルには打ち切る口が無く、そのまま写すとハングする形でした。`BeginInvoke`自体が投げる場合も畳むようにし、呼び出し側にはフォームの終了へ結び付けたトークンを渡す例を追加しています。
- 重複していた判断表を1か所に集約し、他は覚え方と導線に圧縮しました。.NET 9未満向けに`Control.BeginInvoke`を待てるようにする拡張メソッドを追加し、継続先がどう決まるかの説明を厳密にしました。あわせて、出典を確認できなかった「WPFのドキュメントでもそうされている」という帰属を外し、仕組みの説明と実在するドキュメントへのリンクに置き換えました。
- 本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589613)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「WPF/WinFormsのasyncとUIスレッドを一枚で整理」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589613 https://staging.comcomponent.com/blog/2026/03/12/000-wpf-winforms-ui-thread-async-await-one-sheet/
- DOI(最新版)
- 10.5281/zenodo.21589613
- DOI(この版)
- 10.5281/zenodo.21732636
WPF / WinForms で async / await を使うときに一番迷いやすいのは、await のあとにどのスレッドへ戻るのか、そして いつ UI を触ってよいのか です。
特に Dispatcher、BeginInvoke、ConfigureAwait(false)、.Result / .Wait() が混ざると、画面フリーズやクロススレッド例外の原因が見えにくくなります。
この記事では、WPF / WinForms の UI スレッドと async / await の関係だけを取り上げます。
async / await の全体的な判断軸は、C# async/await実務判断表 - Task.RunとConfigureAwait とつながる形です。
実務で本当に血の匂いがするのは、だいたいこのへんです。
awaitのあと、どこで続きが動くのか分からないTask.Runを挟んだあとに UI を触ってよいのか分からないConfigureAwait(false)をどこに付けるべきか迷う.Result/.Wait()/.GetAwaiter().GetResult()で画面が固まる- WPF の
Dispatcherと WinForms のInvoke/BeginInvoke/InvokeAsyncが頭の中で混ざる
WPF / WinForms は、どちらも UI スレッド中心のモデル です。
なので、async / await の整理でいちばん効くのは「非同期とは何か」という哲学っぽい話より、UI スレッドとメッセージループに対して何をしているのか をはっきりさせることです。
この記事では、主に .NET 6 以降の WPF / WinForms アプリ を前提に、
await 後の戻り先、Dispatcher、ConfigureAwait(false)、.Result / .Wait() で詰まる理由を、実務で使いやすい順番で追っていきます。
なお、WinForms の Control.InvokeAsync は .NET 9 以降 です。
それより前の WinForms では、基本は BeginInvoke / Invoke を使います。
また、この記事に登場するコードは、ビルド・実行できるサンプル一式(UI 非依存ライブラリ、WPF / WinForms サンプル、await の戻り先とデッドロックを再現するユニットテスト)として GitHub で公開しています。
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. UI イベントハンドラで plain
await - 4.2. 重い CPU 計算だけ
Task.Run - 4.3.
ConfigureAwait(false)は「戻らない保証」ではなく「戻りを強制しない」 - 4.4.
.Result/.Wait()/.GetAwaiter().GetResult()で詰まる理由
- 4.1. UI イベントハンドラで plain
Dispatcher/Invokeをいつ使うか- よくあるアンチパターン
- レビュー時のチェックリスト
- ざっくり使い分け
- まとめ
- 参考資料
この記事の知識マップ
この記事は、WPF/WinFormsのUIスレッドがメッセージループを回し続けることを土台に、plain awaitはその時点のSynchronizationContextを捕まえて継続をUIスレッドへ戻すため素のままUIを更新できる一方、ConfigureAwait(false)はその戻りを強制しないので汎用ライブラリコードに向くと整理します。Task.RunはCPU計算だけをUIスレッドから外す道具であり、UI以外の場所からUIへ戻すにはWPFのDispatcher、WinFormsのControl.BeginInvokeや.NET 9以降のControl.InvokeAsyncを使うべきだとします。逆に.Result・.Wait()・.GetAwaiter().GetResult()でUIスレッドを同期的に塞ぐと継続がUIへ戻れずデッドロックやフリーズを招きかねず、async voidのイベントハンドラで例外を握らなければWPFのDispatcherUnhandledExceptionやWinFormsのThreadExceptionまで抜けてアプリが落ちるとしています。
flowchart LR
accTitle: WPF/WinFormsのUIスレッドとasync/awaitの知識マップ
accDescr: UIスレッドがメッセージループを回し続けること、plain 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["メッセージループ"]
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["キャンセルと実行が競合するレース"]
plain_await["plain 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. まず結論(ひとことで)
- WPF / WinForms の UI イベントハンドラ で plain
awaitした場合、await後の続きは 基本的に UI スレッドへ戻る と考えてよい Task.Runは CPU 計算を UI スレッドから外すためのもの であって、I/O 待ちを包む道具ではない- UI ハンドラの中で
await Task.Run(...)しても、そのawaitが plainawaitなら、続きは通常 UI スレッドへ戻る ConfigureAwait(false)は、そのawaitで キャプチャした UI コンテキストへ戻ることを強制しない という意味。付けたあとの続きで UI を直接触るのは危ない.Result/.Wait()/.GetAwaiter().GetResult()は UI スレッドを塞ぐ。awaitの継続が UI に戻る必要があると、かなり普通に詰まる- WPF で明示的に UI に戻すなら
Dispatcher.InvokeAsync - WinForms で明示的に UI に戻すなら、旧来は
BeginInvoke、.NET 9 以降ならInvokeAsyncが async フローと相性がよい - まずの方針は、UI の一番外側は plain
await、汎用ライブラリはConfigureAwait(false)を検討、UI への戻しは必要な場所でだけ明示、です
要するに、WPF / WinForms では
- 今どのスレッドで走っているか
awaitの続きがどこに戻るか- UI へ戻す責任をどこが持つか
この 3 つを押さえると、見通しがぐっと良くなります。
2. まず一枚で整理
2.1. 全体像
まずはこの図で全体像を掴むのが早いです。
flowchart LR
A["UIイベントハンドラ<br/>(WPF / WinForms)"] --> B["plain 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["ハング / デッドロック / 少なくともフリーズ"]
実務で見かけるのは、だいたいこの 4 パターンです。
- UI イベントハンドラで plain
await - UI イベントハンドラで
Task.Runを使って CPU を逃がす ConfigureAwait(false)で戻り先を外す.Result/.Wait()で UI スレッドを塞ぐ
2.2. まずの判断表
| 状況 | 待ち中にどこが動くか | await 後の続き |
UI を直接触ってよいか | まずの選択 |
|---|---|---|---|---|
UI ハンドラで await SomeIoAsync() |
I/O の完了待ち。UI スレッド自体はメッセージループへ戻れる | 基本は UI スレッド | よい | plain await |
UI ハンドラで await Task.Run(...) |
重い CPU は ThreadPool | 基本は UI スレッド | よい | CPU だけ Task.Run |
UI ハンドラで await x.ConfigureAwait(false) |
戻り先を UI に固定しない | 任意のスレッド | よくない | UI コードでは基本避ける |
UI スレッドで x.Result / x.Wait() |
UI スレッドが待ちで塞がる | そもそも継続が回りにくい | よくない | 使わない |
背景スレッドや ConfigureAwait(false) の後で UI 更新したい |
UI とは別スレッドで動いている | そのままでは UI ではない | よくない | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync |
| UI に依存しない汎用ライブラリを書く | 呼び出し元の事情に依存しない | UI へ戻すことを強制しない | 触らない設計にする | ConfigureAwait(false) を検討 |
| コンストラクタや同期プロパティから async を呼びたい | UI スレッドが待ちに入りやすい | 起動経路が詰まりやすい | よくない | Loaded / Shown / InitializeAsync へ逃がす |
この表で大事なのは、plain await は UI コードではむしろ味方 だという点です。
敵なのは await そのものではなく、UI スレッドを同期的に塞ぐこと です。
選択そのものはこの表に集約してあります。以降の章は、この表がなぜそうなるのか(3 章と 4 章)、UI へ戻す道具の選び方(5 章)、レビューでの見つけ方(6 章と 7 章)という分担です。
3. この記事で使う言葉
3.1. UI スレッドとメッセージループ
WPF / WinForms の UI は、基本的に UI スレッドが 1 本あって、そこが入力・描画・イベント処理を回す という形です。
この UI スレッドの役割は、だいたいこうです。
- ボタン押下、キー入力、再描画などのメッセージを処理する
- コントロールや UI オブジェクトを安全に触れる唯一のスレッドになる
- そこに処理を詰め込みすぎると、画面更新や入力応答が止まる
ここでのキモは、UI スレッドは「速く回ること」が仕事 だということです。 ここを長くブロックすると、マウスもキーボードも再描画も詰まり、ユーザーから見ると「固まった」に見えます。
このイメージは、図にして頭に置いておくと混乱しにくくなります。
flowchart LR
A["ユーザー入力 / 再描画要求"] --> B["UIスレッドのメッセージループ"]
B --> C["イベントハンドラ実行"]
C --> D["画面更新"]
D --> B
C --> E["長い同期処理"]
E --> F["メッセージループが回らない"]
F --> G["画面が固まって見える"]
3.2. SynchronizationContext / Dispatcher / Invoke
ここでよく出る言葉を、実務向けに分けるとこうです。
| 言葉 | ここでの意味 |
|---|---|
| UI スレッド | UI オブジェクトを作ったスレッド。基本はここだけが UI を安全に触れる |
| メッセージループ | UI スレッドがメッセージを順に処理する仕組み |
SynchronizationContext |
「その実行場所へ処理を戻す」ための抽象化 |
Dispatcher |
WPF の UI スレッド用キュー |
Invoke / BeginInvoke / InvokeAsync |
UI スレッドへ処理を投げるための API |
継続先の決まり方をもう少し正確に書くと、こうです。await(既定の ConfigureAwait(true) 相当)が捕まえるのは、まず SynchronizationContext.Current です。それが null のときにだけ TaskScheduler.Current を見て、それが TaskScheduler.Default でなければ その TaskScheduler へ継続を戻します。どちらでもない、つまり SynchronizationContext.Current が null で TaskScheduler.Current が既定なら、継続は ThreadPool 上で走ります。
WPF / WinForms の UI スレッドでは前者、つまり UI の SynchronizationContext が立っているので、実務では UI の SynchronizationContext が効いている と考えて差し支えありません。
フレームワークごとの対応は、表にしてしまうのが分かりやすいです。
| フレームワーク | UI 側のコンテキスト | 明示的に UI へ戻す代表 API |
|---|---|---|
| WPF | DispatcherSynchronizationContext |
Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke |
| WinForms | WindowsFormsSynchronizationContext |
Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync |
WPF は Dispatcher が中心です。
WinForms はコントロールのハンドルとメッセージループが中心で、BeginInvoke / Invoke が表に出てきます。
実務では、抽象化と実体の関係をこのくらいで覚えると混ざりにくいです。
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+)"]
4. 典型パターン
4.1. UI イベントハンドラで plain await
いちばん素直な形です。
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 コンテキストを捕まえます。
そのため、
- ファイル I/O の待ち中は UI スレッドを占有しない
- 読み込み完了後の続きは、基本的に UI スレッドへ戻る
PreviewTextBox.Text = text;をそのまま書ける
という形になります。
ここで余計な Dispatcher は要りません。
UI ハンドラの中で plain 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 でボタンを戻す」形にしておくと、UI として自然に閉じられます。アプリ全体の受け皿(DispatcherUnhandledException など)は、あくまで最後の網として置くものです。
WinForms でも見方は同じです。
Click ハンドラの中で plain await している限り、続きは基本的に UI 側へ戻ります。
図にすると、こういう流れです。
sequenceDiagram
participant UI as UIスレッド
participant IO as 非同期I/O
participant Ctx as UI SynchronizationContext
UI->>UI: Click ハンドラ開始
UI->>IO: ReadAllTextAsync を await
UI-->>Ctx: 続きを UI に戻す予約
Note over UI: 待ち中はメッセージループへ戻る
IO-->>Ctx: I/O 完了
Ctx-->>UI: 続きを UI スレッドで再開
UI->>UI: TextBox / Label を更新
4.2. 重い CPU 計算だけ Task.Run
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 スレッドでイベントハンドラが始まる
File.ReadAllBytesAsyncの I/O 待ちは非同期で流す- 重いハッシュ計算だけ
Task.Runで ThreadPool に出す await Task.Run(...)の続きは plainawaitなので UI スレッドへ戻るResultText.Text = hash;をそのまま書ける
つまり、Task.Run の中だけが別スレッド です。
await 後まで永続的に「もう UI ではない場所」へ行くわけではありません。
ここを 1 枚で見ると、誤解しにくいです。
sequenceDiagram
participant UI as UIスレッド
participant IO as 非同期I/O
participant Pool as ThreadPool
UI->>IO: ReadAllBytesAsync を await
IO-->>UI: plain await なので UI で再開
UI->>Pool: Task.Run で重いCPU処理を投げる
Pool-->>UI: 計算結果を返す
Note over UI: await Task.Run(...) の続きは UI で再開
UI->>UI: 画面へ結果反映
ここでの注意は 2 つです。
- 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;
}
この場合、OpenButton_Click の その await の続き は UI に戻ることを強制しません。
そのため PreviewTextBox.Text = text; は クロススレッドアクセス になりえます。
もう 1 つ、地味に大事な点があります。
ConfigureAwait(false) を付けても必ず ThreadPool に移るとは限らず、その await が待たずに即完了した場合、続きはそのまま今のスレッドで流れることがあります。
「必ず別スレッドへ行く」「ここから先はずっと UI ではない」と読んでしまうと事故のもとで、意味はあくまで その await の継続を、元の UI コンテキストへ戻すことを強制しない、これだけです。
図で見るならこうです。
flowchart LR
A["UIハンドラで await"] --> B{"ConfigureAwait(false) を付ける?"}
B -- いいえ --> C["続きは基本 UI スレッド"]
C --> D["そのまま UI 更新しやすい"]
B -- はい --> E["続きは UI に固定しない"]
E --> F["任意のスレッドで再開しうる"]
F --> G["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 スレッドでやると危険です。
流れを図にするとこうです。
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: 継続が回らないので完了できない
何が起きているかを言葉にすると、こうです。
- UI スレッドが
LoadTextAsync()を呼ぶ LoadTextAsync()の中のawaitは UI コンテキストを捕まえる- UI スレッドは
.Resultで待ってしまう - I/O が終わる
LoadTextAsync()の続きは UI スレッドへ戻りたい- でも UI スレッドは
.Resultで塞がっている - 続きが走れないので
LoadTextAsync()が完了しない .Resultは終わらない
つまり、UI が「お前が終わるまで待つ」と言い、非同期側が「UI に戻れたら終われる」と言って、互いに待ち合う わけです。 実に嫌な感じです。
ここでよくある勘違いは、GetAwaiter().GetResult() にすると安全だと思うことです。
ですが、UI スレッドを塞ぐ という本質は同じです。違うのは主に例外の包まれ方です。
なので、UI ではこの 3 つを同じ匂いとして扱ったほうが安全です。
.Result.Wait().GetAwaiter().GetResult()
なお、WPF の Dispatcher.InvokeAsync(...) が返す DispatcherOperation の Task を、UI スレッドから Task.Wait() するのも同じ理由で危険です。InvokeAsync は渡したデリゲートを Dispatcher のキューへ積むだけで、実際に走るのは UI スレッドがそのキューを回したときです。UI スレッドが Wait() で止まっていればキューは回らないので、その Task は永遠に完了しません。
同じ話は DispatcherOperation 側にもあり、DispatcherOperation.Wait() は、同じスレッドで実行中の操作を待つと InvalidOperationException になると明記されています。ブロックして待つ経路そのものが想定されていない、ということです。
UI の文脈では、「投げたものを同期で待つ」方向そのもの が詰まりやすいわけです。詰まり方の詳しい解説は Await, and UI, and deadlocks! Oh my! が読みやすいです。
「絶対にデッドロックするのか」というと、必ずしもそうではありません。 たまたま継続が UI に戻らないコードなら、デッドロックせずに単に UI をフリーズさせるだけ のこともあります。 しかし、それも十分につらいので、UI では基本的にやらない方がよいです。
5. Dispatcher / Invoke をいつ使うか
ここまでをふまえると、plain await の UI ハンドラ では、普段は明示的な Dispatcher / Invoke は要りません。
必要になるのは、たとえばこういうときです。
ConfigureAwait(false)の続きで UI を触りたいTask.Runの中や、その外側でも UI に戻らない構成にしている- ソケット受信、タイマー、イベントコールバックなど、最初から UI スレッドでない場所で通知が来る
- 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 フローと素直に噛み合います。
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 フローでは、基本的に ブロックしない側 のほうが噛み合わせがよいです。
ただし、Control.BeginInvoke が返すのは IAsyncResult なので、そのままでは await できません。Control.InvokeAsync が無い環境(.NET Framework 4.8、.NET 6 / 8 など)で async フローに載せたいなら、TaskCompletionSource で包んで Task にするのが素直です。
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);
// キャンセルと実行が、同じ「1回ぶんの権利」を奪い合う形にします。
// 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 自体が投げることもあります(既にハンドルが無いなど)。
// ここで畳んでおかないと、やはり待ちっぱなしになります。
// デリゲートは走らないので、ここでも権利を取ってから畳みます
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 側で起きた例外も await した場所の try / catch で受け取れます。押さえておく点は 4 つです。
- キャンセルと実行は「フラグを見てから動く」では足りません。「キャンセル済みか?」を確認した直後、
action()を呼ぶ前にキャンセルが走ることがあります。この一瞬でtcsはキャンセル済みになり、awaitしていた呼び出し側は先へ進んで次の操作を始めます。そのあとで、まだキューに残っていた古いデリゲートが動いて画面を書き換える ── 新しい表示が古い表示で上書きされる、という再現しにくい壊れ方です。上のコードがInterlocked.Exchangeで「1回ぶんの権利」を奪い合わせているのはこのためで、取れなかった側は何もせずに帰ります - ハンドル作成前(
Loadより前)や、フォームを閉じたあとのBeginInvokeは例外になります。呼ぶ側の寿命を意識してください - 投稿したあとにコントロールが破棄されると、デリゲートは実行されずに捨てられることがあります。その場合
TaskCompletionSourceには結果も例外も入らないので、awaitしている側は永久に待ちます。上の例のようにフォームの終了へ結び付けたトークンを必ず渡してください。畳んだ結果はOperationCanceledExceptionとして上がります File.ReadAllTextAsyncは .NET Core 2.0 以降の API です。.NET Framework 4.8 で同じ形にするなら、StreamReader.ReadToEndAsyncなどに置き換えてください
見分け方としては、この程度の区別で十分です。
| やりたいこと | 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 を触りたくなったら使う
- async フローの中で同期
Invokeを増やしすぎない
これでだいぶ事故が減ります。
迷ったら、この程度の判断図で十分です。
flowchart TD
A["この続きを書く場所は UI スレッド?"] --> B{"はい?"}
B -- はい --> C["plain await のまま UI 更新してよい"]
B -- いいえ --> D{"UI を触りたい?"}
D -- いいえ --> E["そのまま処理継続"]
D -- はい --> F["WPF: Dispatcher.InvokeAsync"]
D -- はい --> G["WinForms: BeginInvoke / InvokeAsync"]
6. よくあるアンチパターン
| アンチパターン | 何がつらいか | まずの置き換え |
|---|---|---|
UI ハンドラで LoadAsync().Result |
UI スレッドを塞ぐ。デッドロックしやすい | await LoadAsync() |
UI ハンドラで LoadAsync().Wait() |
同上。メッセージループが止まる | await LoadAsync() |
UI ハンドラで LoadAsync().GetAwaiter().GetResult() |
例外の見え方が違うだけで、ブロックは同じ | await LoadAsync() |
UI コードへ機械的に ConfigureAwait(false) |
await 後の UI 更新が壊れやすい |
UI の一番外側は plain await |
Task.Run(async () => await IoAsync()) |
I/O を無駄に投げ直している | await IoAsync() |
ライブラリコードが Dispatcher や Control を直接握る |
UI 依存が深くなる。再利用しにくい | ライブラリはデータだけ返し、UI 側で marshal する |
Dispatcher.Invoke / Control.Invoke を async フローに多用する |
ブロックの輪ができやすい | Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync を検討 |
| コンストラクタやプロパティ getter で async を同期化する | 起動時ハングの温床になる | Loaded / Shown / InitializeAsync へ逃がす |
この中でも特に遭遇率が高いのは 3 つあります。
- UI スレッドで
.Result/.Wait() - UI コードに
ConfigureAwait(false)を機械的に付ける - ライブラリと UI の責務が混ざって
Dispatcherが奥まで侵入する
この 3 つを外すだけでも、コードはずいぶん落ち着きます。
7. レビュー時のチェックリスト
中身は 2.2 の判断表と 6 章のアンチパターンと同じですが、こちらは コードを開いたときに順番に確認する質問 の形にしてあります。
- UI イベントハンドラや UI 初期化経路に
.Result/.Wait()/.GetAwaiter().GetResult()が残っていないか Task.Runは CPU 計算 にだけ使われているか。I/O を包んでいないかConfigureAwait(false)が UI コードに機械的に入っていないか- 逆に、汎用ライブラリで UI コンテキストへの依存を引きずっていないか
await後に UI を直接触っている箇所は、そこが本当に UI コンテキスト上だと言えるか- UI に明示的に戻す必要がある箇所で、
Dispatcher.InvokeAsync/BeginInvoke/InvokeAsyncが使われているか Dispatcher.Invoke/Control.Invokeのような同期 marshal が、不要に増えていないか- コンストラクタ、同期プロパティ、同期イベントから async を無理やり同期化していないか
- ライブラリ層が
Window/Control/Dispatcherを直接参照していないか
このチェックリストは、チームで「どこが UI の責務か」を揃えるのにも使いやすいです。
8. ざっくり使い分け
状況ごとの選択は 2.2 の判断表に集約したので、ここでは持ち帰り用の覚え方だけ置いておきます。
- UI の一番外側は plain
await。await後にそのまま UI を触れるのは、これを保っているからです Task.Runは CPU の逃がし先。I/O 待ちを包む道具ではありませんConfigureAwait(false)は汎用ライブラリの道具。UI コードに機械的に付けませんDispatcher/BeginInvoke/InvokeAsyncは、UI 以外の場所から UI を触るときだけ- UI スレッドで待つ 3 つ(
.Result/.Wait()/.GetAwaiter().GetResult())は使わない。同期化したくなったら、呼び出し元ごと async に伸ばします
理由の部分は 4 章、Dispatcher / Invoke の選び方は 5 章、実際のコードで見つけるための観点は 6 章と 7 章にあります。
9. まとめ
WPF / WinForms の async / await で本当に大事なのは、
「非同期は難しい」という雰囲気ではなく、
- 今どこで始まったか
awaitの続きがどこへ戻るか- UI へ戻す責任を誰が持つか
を分けて考えることです。
まずのルールとしては、これだけ守れば十分戦えます。
- UI の一番外側では plain
await - 重い CPU だけ
Task.Run - 汎用ライブラリでは
ConfigureAwait(false)を検討 - UI へ戻す必要があるときだけ
Dispatcher/BeginInvoke/InvokeAsync - UI スレッドでは
.Result/.Wait()/.GetAwaiter().GetResult()を使わない
async / await 自体は、そこまで気難しい仕組みではありません。
ただ、UI スレッドを中心に見ないまま使うと、急にぬかるみになります。
逆に言うと、
- UI の外側と内側を分ける
- 戻り先を意識する
- ブロックを持ち込まない
この 3 つを守るだけで、WPF / WinForms の非同期コードはだいぶ静かになります。 画面が固まるコードは、だいたい「非同期が悪い」のではなく、UI スレッドへの借金の仕方が雑 なだけです。
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
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
WinForms / WPFアプリのCI/CD実践 ── GitHub Actionsでビルドから署名・配布まで自動化する
WinForms / WPFアプリのCI/CDをGitHub Actionsで組む実務ガイド。windows-latestでのビルド+テストの最小YAML、タグ駆動のバージョン採番、signtoolによる署名の組み込み、MSI/MSIX/ClickOnce/xcopy別のC...
Windowsアプリのタスクトレイ常駐とトースト通知 ── NotifyIconの落とし穴とAppNotificationの選び方
業務Windowsアプリのタスクトレイ常駐とトースト通知の実装を整理します。NotifyIconの正しい使い方、Explorer再起動時の再登録、トーストAPI3種の選定判断表、通知が届かないケースまで解説します。
WinForms/WPFアプリの多言語化 ── resx・サテライトアセンブリ・カルチャ切り替えの実務
Windowsデスクトップアプリの多言語化を整理します。CurrentCultureとCurrentUICultureの違い、resxとサテライトアセンブリの仕組み、WPFでの現実的な方式選択、実行時の言語切り替え、書式・RTLまで解説します。
WinForms/WPFアプリにEntra ID認証を組み込む ── MSAL.NETとWAMブローカーの実務構成
WinForms/WPFデスクトップアプリにEntra ID認証を組み込む手順を整理します。パブリッククライアントの考え方、アプリ登録、MSAL.NETのAcquireTokenSilent、WAMブローカー、トークンキャッシュの永続化まで解説します。
WindowsデスクトップアプリのUI自動テスト ── UI Automationの仕組みとFlaUIで作る壊れにくいテスト
WinForms/WPFアプリのUI自動テストを、Windows UI Automationの仕組みから整理します。FlaUIによる最小実装、AutomationId設計と条件待機で壊れにくくする方法、CI無人実行の罠まで実務目線で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
UI スレッド / タイマーテーマ
WPF / WinForms、UI スレッド、async/await、タイマー設計を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
WPF / WinForms の UI スレッドと async/await は、Windowsアプリ開発 の実装で最も詰まりやすい論点の一つです。
技術相談・設計レビュー
UI とバックグラウンド処理の責務や Dispatcher の使い分けを整理したい段階なら、技術相談・設計レビューとして見直せます。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- awaitの後はどのスレッドに戻りますか?
- WPF / WinFormsのUIイベントハンドラでplain await(ConfigureAwaitなし)した場合、await後の続きは基本的にUIスレッドへ戻ります。awaitはその時点のUI SynchronizationContextを捕まえて継続を戻すためで、await後にTextBoxやLabelの更新をそのまま書けます。await Task.Run(...)の場合も、計算本体はThreadPoolで動きますが、plain awaitなら続きはUIスレッドで再開します。
- UIスレッドで.Resultや.Wait()を使うとなぜ固まるのですか?
- UIスレッドが.Resultで待つ間、非同期処理の継続はキャプチャしたUIコンテキストへ戻ろうとしますが、UIスレッドは.Resultで塞がっているため継続が実行できず、互いに待ち合ってデッドロックになるからです。GetAwaiter().GetResult()も例外の包まれ方が違うだけでUIスレッドを塞ぐ本質は同じです。UIでは.Result、.Wait()、GetAwaiter().GetResult()の3つとも避け、awaitを使います。
- ConfigureAwait(false)はUIコードに付けるべきですか?
- 付けないほうがよいです。ConfigureAwait(false)は「キャプチャしたUIコンテキストへ戻ることを強制しない」という意味なので、続きが任意のスレッドで再開しえて、直後のUI更新はクロススレッドアクセスになりえます。向いているのはUIに依存しない汎用ライブラリコードで、UIの一番外側はplain awaitに保つのが方針です。
- Task.Runはいつ使うべきですか?
- 重いCPU計算をUIスレッドから外したいときだけです。I/O待ちをTask.Runで包むのは、待ちを無駄にThreadPoolへ投げ直しているだけで得がありません。Task.Runの中だけが別スレッドで、await Task.Run(...)の続きはplain awaitなら通常UIスレッドへ戻るため、結果の画面反映はそのまま書けます。