WPF/WinFormsのasyncとUIスレッドを一枚で整理

· 更新日: · · C#, async/await, .NET, WPF, WinForms, UI, スレッド

更新履歴(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 を触ってよいのか です。 特に DispatcherBeginInvokeConfigureAwait(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 後の戻り先、DispatcherConfigureAwait(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)

目次

  1. まず結論(ひとことで)
  2. まず一枚で整理
    • 2.1. 全体像
    • 2.2. まずの判断表
  3. この記事で使う言葉
    • 3.1. UI スレッドとメッセージループ
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. 典型パターン
    • 4.1. UI イベントハンドラで plain await
    • 4.2. 重い CPU 計算だけ Task.Run
    • 4.3. ConfigureAwait(false) は「戻らない保証」ではなく「戻りを強制しない」
    • 4.4. .Result / .Wait() / .GetAwaiter().GetResult() で詰まる理由
  5. Dispatcher / Invoke をいつ使うか
  6. よくあるアンチパターン
  7. レビュー時のチェックリスト
  8. ざっくり使い分け
  9. まとめ
  10. 参考資料

この記事の知識マップ

この記事は、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まで抜けてアプリが落ちるとしています。

WPF/WinFormsのUIスレッドとasync/awaitの知識マップUIスレッドがメッセージループを回し続けること、plain awaitが捕まえたSynchronizationContextに継続を戻すこと、ConfigureAwait(false)がその戻りを強制しないこと、Task.RunがCPU計算をUIスレッドから外すこと、Dispatcher・Control.BeginInvoke・InvokeAsyncがUIへ明示的に戻す手段であること、.Result・.Wait()・GetAwaiter().GetResult()がUIスレッドを塞いでデッドロックやフリーズを招きうること、async voidの例外がUIの全体受け皿に届くことの関係を示す図前提とする利用する利用する利用する利用する利用するの後継前提とする前提とする利用する防止する利用する推奨される対応前提とする用いるのは非推奨推奨される対応推奨される対応用いるのは非推奨防止する原因になり得る原因になり得る用いるのは非推奨原因になり得る推奨される対応原因になり得る前提とするUIスレッドのコンテキストWPFWindows FormsメッセージループSynchronizationContextDispatcher(WPF)Control.Invoke / Control.BeginInvokeControl.InvokeAsyncTaskCompletionSourceCancellationToken(.NET)再入防止ガード(Interlocked.Exchange等)キャンセルと実行が競合するレースplain awaitConfigureAwait(false)汎用ライブラリコードTask.RunI/O-bound処理UIスレッドの詰まり同期待ちの混入(sync-over-async)デッドロック(deadlock).Result / .Wait() / .GetAwaiter().GetResult()async voidイベントハンドラメソッドUIの全体受け皿(DispatcherUnhandledException / ThreadException)

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全26件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

1. まず結論(ひとことで)

  • WPF / WinForms の UI イベントハンドラ で plain await した場合、await 後の続きは 基本的に UI スレッドへ戻る と考えてよい
  • Task.RunCPU 計算を UI スレッドから外すためのもの であって、I/O 待ちを包む道具ではない
  • UI ハンドラの中で await Task.Run(...) しても、その await が plain await なら、続きは通常 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 では

  1. 今どのスレッドで走っているか
  2. await の続きがどこに戻るか
  3. UI へ戻す責任をどこが持つか

この 3 つを押さえると、見通しがぐっと良くなります。

2. まず一枚で整理

2.1. 全体像

まずはこの図で全体像を掴むのが早いです。

UIイベントハンドラ(WPF / WinForms)plain awaitI/O APIUI SynchronizationContext を捕まえるawait後は UI スレッドで再開UI更新をそのまま書けるawait Task.Run(...)重いCPU処理計算本体は ThreadPoolawait後は UI スレッドで再開await SomeAsync().ConfigureAwait(false)UI へ戻ることを強制しない続きは任意のスレッド直接 UI 更新は危険Dispatcher / Invoke が必要SomeAsync().Result / Wait()GetAwaiter().GetResult()UIスレッドをブロック継続が UI に戻れないハング / デッドロック / 少なくともフリーズ

実務で見かけるのは、だいたいこの 4 パターンです。

  1. UI イベントハンドラで plain await
  2. UI イベントハンドラで Task.Run を使って CPU を逃がす
  3. ConfigureAwait(false) で戻り先を外す
  4. .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 スレッドは「速く回ること」が仕事 だということです。 ここを長くブロックすると、マウスもキーボードも再描画も詰まり、ユーザーから見ると「固まった」に見えます。

このイメージは、図にして頭に置いておくと混乱しにくくなります。

ユーザー入力 / 再描画要求UIスレッドのメッセージループイベントハンドラ実行画面更新長い同期処理メッセージループが回らない画面が固まって見える

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.CurrentnullTaskScheduler.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 が表に出てきます。

実務では、抽象化と実体の関係をこのくらいで覚えると混ざりにくいです。

現在のコードSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.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_ClickUI スレッド上で始まります。 そして 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 側へ戻ります。

図にすると、こういう流れです。

UI SynchronizationContext非同期I/OUIスレッドUI SynchronizationContext非同期I/OUIスレッド待ち中はメッセージループへ戻るClick ハンドラ開始ReadAllTextAsync を await続きを UI に戻す予約I/O 完了続きを 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;
    }
}

このコードで起きていることは、だいたいこうです。

  1. UI スレッドでイベントハンドラが始まる
  2. File.ReadAllBytesAsync の I/O 待ちは非同期で流す
  3. 重いハッシュ計算だけ Task.Run で ThreadPool に出す
  4. await Task.Run(...) の続きは plain await なので UI スレッドへ戻る
  5. ResultText.Text = hash; をそのまま書ける

つまり、Task.Run の中だけが別スレッド です。 await 後まで永続的に「もう UI ではない場所」へ行くわけではありません。

ここを 1 枚で見ると、誤解しにくいです。

ThreadPool非同期I/OUIスレッドThreadPool非同期I/OUIスレッドawait Task.Run(...) の続きは UI で再開ReadAllBytesAsync を awaitplain await なので UI で再開Task.Run で重いCPU処理を投げる計算結果を返す画面へ結果反映

ここでの注意は 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 コンテキストへ戻すことを強制しない、これだけです。

図で見るならこうです。

いいえはいUIハンドラで awaitConfigureAwait(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 を返す.Result で待機してブロックI/O 完了、継続を UI に戻したい続きを実行したい

何が起きているかを言葉にすると、こうです。

  1. UI スレッドが LoadTextAsync() を呼ぶ
  2. LoadTextAsync() の中の await は UI コンテキストを捕まえる
  3. UI スレッドは .Result で待ってしまう
  4. I/O が終わる
  5. LoadTextAsync() の続きは UI スレッドへ戻りたい
  6. でも UI スレッドは .Result で塞がっている
  7. 続きが走れないので LoadTextAsync() が完了しない
  8. .Result は終わらない

つまり、UI が「お前が終わるまで待つ」と言い、非同期側が「UI に戻れたら終われる」と言って、互いに待ち合う わけです。 実に嫌な感じです。

ここでよくある勘違いは、GetAwaiter().GetResult() にすると安全だと思うことです。 ですが、UI スレッドを塞ぐ という本質は同じです。違うのは主に例外の包まれ方です。

なので、UI ではこの 3 つを同じ匂いとして扱ったほうが安全です。

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

なお、WPF の Dispatcher.InvokeAsync(...) が返す DispatcherOperationTask を、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 を増やしすぎない

これでだいぶ事故が減ります。

迷ったら、この程度の判断図で十分です。

はいいいえいいえはいはいこの続きを書く場所は UI スレッド?はい?plain await のまま UI 更新してよいUI を触りたい?そのまま処理継続WPF: Dispatcher.InvokeAsyncWinForms: 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()
ライブラリコードが DispatcherControl を直接握る UI 依存が深くなる。再利用しにくい ライブラリはデータだけ返し、UI 側で marshal する
Dispatcher.Invoke / Control.Invoke を async フローに多用する ブロックの輪ができやすい Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync を検討
コンストラクタやプロパティ getter で async を同期化する 起動時ハングの温床になる Loaded / Shown / InitializeAsync へ逃がす

この中でも特に遭遇率が高いのは 3 つあります。

  1. UI スレッドで .Result / .Wait()
  2. UI コードに ConfigureAwait(false) を機械的に付ける
  3. ライブラリと UI の責務が混ざって Dispatcher が奥まで侵入する

この 3 つを外すだけでも、コードはずいぶん落ち着きます。

7. レビュー時のチェックリスト

中身は 2.2 の判断表と 6 章のアンチパターンと同じですが、こちらは コードを開いたときに順番に確認する質問 の形にしてあります。

  • UI イベントハンドラや UI 初期化経路に .Result / .Wait() / .GetAwaiter().GetResult() が残っていないか
  • Task.RunCPU 計算 にだけ使われているか。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 awaitawait 後にそのまま 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 へ戻す責任を誰が持つか

を分けて考えることです。

まずのルールとしては、これだけ守れば十分戦えます。

  1. UI の一番外側では plain await
  2. 重い CPU だけ Task.Run
  3. 汎用ライブラリでは ConfigureAwait(false) を検討
  4. UI へ戻す必要があるときだけ Dispatcher / BeginInvoke / InvokeAsync
  5. UI スレッドでは .Result / .Wait() / .GetAwaiter().GetResult() を使わない

async / await 自体は、そこまで気難しい仕組みではありません。 ただ、UI スレッドを中心に見ないまま使うと、急にぬかるみになります。

逆に言うと、

  • UI の外側と内側を分ける
  • 戻り先を意識する
  • ブロックを持ち込まない

この 3 つを守るだけで、WPF / WinForms の非同期コードはだいぶ静かになります。 画面が固まるコードは、だいたい「非同期が悪い」のではなく、UI スレッドへの借金の仕方が雑 なだけです。

10. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

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スレッドへ戻るため、結果の画面反映はそのまま書けます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る