1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WinFormsが「応答なし」になる時に最初に疑う5か所|.Wait / .Result / Invoke の詰まりどころを画面で比べる【救急E04】

1
Last updated at Posted at 2026-01-12

連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index

WinForms でボタンを押した直後に画面が白くなり、タスクマネージャでは「応答なし」になる。
この時に広く追い始めると、Click.Wait ではなく別の場所まで見に行ってしまい、時間を使いやすいです。
先に見るのは、UI イベントの中で画面を止めていないか、UI 側で同期的に待っていないか、背景処理から Invoke 完了待ちしていないかの 3 点です。

この記事では、WinForms で起きやすい詰まり方を 1 画面で比べます。
UI スレッドを占有して止まる例、UI 側の .Wait.Result で止まる例、Invoke 完了待ちが重なって止まる例、Task.Run で CPU 処理を外へ出した例、await で待機中も画面が動く例、BeginInvoke で UI 更新だけ渡す例を並べて、どこで詰まるかを切り分けます。

このページで確認したいのは 3 つです。
固まった時に最初に疑うコード、await を書いていても止まる場面、毎回は出ない引っ掛かりをログへ残す方法です。
サンプルは .NET 8 の WinForms を前提にしていますが、ここで比べる止まり方は Framework 4.8.1 の画面でもほぼ同じように見分けられます。

WinForms が固まった時、症状ごとに当たるコードを絞る

最初から全部追うと、時間だけ減っていきます。
まずは、症状ごとに開く場所と直す方向を絞った方が早いです。

症状 最初に開く場所 最初に grep する文字列 まず直す方向
ボタンを押した直後に白くなる Click Shown TimerTick Thread.Sleep for while 重い同期処理 UI イベントの外へ出す
非同期にしたのに固まる UI 層のイベントハンドラ .Wait .Result GetAwaiter().GetResult() UI 側の同期待ちをやめる
背景処理へ出したのに止まる 背景処理からの UI 更新 Invoke worker.Wait() Invoke 完了待ちを減らす
たまに一瞬だけ引っ掛かる ログ出力、画像処理、同期 I/O 周辺 File. Image Save Load GC. 遅れた ms と操作名を残す
表示更新だけ多い 進捗通知や UI 更新関数 BeginInvoke IProgress<T> UI 更新経路を 1 本へ寄せる

保存用チェックリスト

  • Click Shown TimerTick の中に重い処理が残っていないか
  • .Wait .Result GetAwaiter().GetResult() が UI 側に残っていないか
  • 背景処理から Invoke 完了待ちしていないか
  • ログ出力、画像処理、同期 I/O が UI スレッドへ残っていないか
  • 「何 ms 遅れたか」と「どの操作の直後か」をログへ残せているか

手元で動かしながら確認する場合は、先にサンプルを開いておくと本文と対応づけやすくなります。
GitHubで開く


止まり方ごとに、どこで詰まるかを比べる

同じ「止まったように見える」でも、詰まる場所は同じではありません。
このサンプルは、どこで詰まるかを 1 画面で比べられる形にしています。

ボタン どこで詰まる例か 心拍表示 最初に確認するコード 見つけたら直す方向
UIで重い処理 UI スレッド占有 止まる Click の中で重い処理を直接呼んでいないか CPU 処理を UI の外へ出す
.Waitで待機 UI 側の同期待ち 一時的に止まる .Wait .Result GetAwaiter().GetResult() UI 側を await へ戻す
Invoke + Wait UI 側と背景側の待ちの重なり 一時的に止まりやすい InvokeWait が同時に並んでいないか UI 完了待ちを減らす
Task.RunでCPU処理 実行スレッドの分離 止まりにくい CPU 処理を UI 外へ出しているか CPU と待機を分けて考える
async I/O待機 待機中に UI を空ける 止まりにくい 非同期 API を await しているか 待機は非同期 API へ寄せる
BeginInvokeで更新 UI 更新だけを非同期で渡す 止まりにくい BeginInvoke IProgress<T> 戻り値不要の更新は待たない

画像1_全体画面


心拍表示・状態表示・進捗・ログで止まり方を見分ける

このサンプルは、ボタンを押した直後に何が止まり、何が動き続けるかを画面で見分ける形にしています。
画像を見る時は、次の表示だけ押さえれば十分です。

心拍表示

一定間隔でラベルやプログレスを更新しています。
ここが止まる時は、UI スレッドがメッセージを回せていません。

状態表示

今どの処理を動かしたかを表示します。
どの操作の直後に止まったかを対応づける欄です。

作業進捗

背景処理や待機処理の進み具合を出します。
UI が空いていれば、途中経過も動きます。

ログ

操作名、処理開始、処理終了、例外、UI ラグを残します。
毎回は出ない引っ掛かりでも、どの操作の直後だったかを追えます。

画像2_観測エリア拡大

補助関数の前提も先に書いておきます。
サンプル内の ReportProgressAppendLog は UI 更新用の共通関数です。
各ボタンで違いを見やすくするため、補助処理はそろえています。


1. UI スレッドで重い処理を回すと、Click の中でそのまま止まる

最初に見るのは、もっとも典型的な止まり方です。
ボタン押下のイベント内で重い CPU 処理をそのまま回すと、入力も描画も止まります。

private void btnHeavyOnUi_Click(object sender, EventArgs e)
{
    MarkAction("UIで重い処理");
    ResetProgress();
    SetStatus("UIスレッドを3秒占有中");

    HeavyCpuWork(3000);
    ReportProgress(100);
    SetStatus("完了");
}

このケースでは、次の変化が出ます。

  • 心拍表示が止まる
  • フォームの移動や再描画が止まる
  • ログは出ても、途中の画面更新は進まない

ここで確認したいのは、処理時間そのものではなく、Click の中で 3 秒占有している点です。
「重い処理」より先に、「UI スレッドの中で回していないか」を切り分けた方が早いです。

見つけたら、CPU 処理を Task.Run へ出すか、待機処理なら非同期 API へ寄せます。


2. .Wait は UI 側の待ちを消さない

非同期メソッドを呼んでいても、UI 側で同期的に待つと止まります。
このコードは再現用で、差だけ追えるようにタイムアウト復帰付きにしています。

private void btnWait_Click(object sender, EventArgs e)
{
    MarkAction(".Waitで待機");
    ResetProgress();
    SetStatus("危険なWaitを3.5秒だけ再現中");

    AppendLog(".Waitはデッドロック化しやすいため、このサンプルでは3.5秒で復帰させる");

    var completed = SimulatedAsyncOperation(3000).Wait(3500);
    if (!completed)
    {
        AppendLog(".Waitが継続待ちになったためタイムアウト復帰");
        SetStatus("タイムアウト復帰");
        return;
    }

    SetStatus("完了");
}

このケースでは、次の出方になります。

  • 心拍表示が止まる
  • 非同期メソッドを呼んでいても UI は空かない
  • await を書いているのに固まる」に見えやすい

この場面で最初に grep する文字列は次のとおりです。

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

先に確認するのは SimulatedAsyncOperation の中身ではなく、呼び出し側が .Wait() へ戻していないかです。
WinForms では、非同期化した処理より先に UI 側の待ち方を見た方が早いです。

見つけたら、UI 層では .Wait.Result を外し、呼び出し側を await へ戻します。


3. Invoke + Wait は UI 側と背景側の待ちがぶつかる

背景処理から UI を触るために Invoke を使うこと自体は普通にあります。
ただし、背景処理が Invoke 完了を待ち、UI 側も Wait で止まる形になると、待ちが重なって詰まりやすくなります。

このサンプルでは永久停止にはしていません。
その代わり、どこで詰まるかだけを短時間で確認できる形にしています。

private void btnInvokeWait_Click(object sender, EventArgs e)
{
    MarkAction("Invoke + Wait");
    ResetProgress();
    SetStatus("危険な待ちの鎖を3秒だけ再現中");

    var worker = Task.Run(() =>
    {
        Thread.Sleep(300);
        Invoke(new Action(() =>
        {
            AppendLog("InvokeでUI更新");
            ReportProgress(50);
        }));
    });

    var completed = worker.Wait(3000);
    if (!completed)
    {
        AppendLog("Invoke完了待ちがUI側Waitと重なりタイムアウト復帰");
        SetStatus("タイムアウト復帰");
        return;
    }

    ReportProgress(100);
    SetStatus("完了");
}

この例で詰まりが出るのは次の 3 点です。

  • 背景側は Invoke 完了を待つ
  • UI 側は Wait で止まる
  • どちらも相手が動くまで先へ進めない

実務ではここへ

  • lock
  • Join
  • 別の Wait
  • 同期 I/O

が重なると、呼び出し順の把握が一気に難しくなります。

この例で危ないのは、背景処理が Invoke 完了を待ち、UI 側も Wait で止まっている点です。
戻り値が不要な表示更新なら、BeginInvoke へ寄せた方が待ちの鎖を増やしにくくなります。


4. Task.Run は CPU 処理を UI スレッドの外へ出す

同じ重い処理でも、CPU を回している場所が変わると見え方は変わります。
このサンプルでは、別スレッドへ出した上で、10 回に分けて進捗も更新しています。

private async void btnTaskRunCpu_Click(object sender, EventArgs e)
{
    MarkAction("Task.RunでCPU処理");
    ResetProgress();
    SetStatus("別スレッドでCPU処理中");

    await Task.Run(() =>
    {
        for (var i = 1; i <= 10; i++)
        {
            HeavyCpuWork(250);
            ReportProgress(i * 10);
        }
    });

    SetStatus("完了");
}

このケースでは、次の差が出ます。

  • 心拍表示が止まりにくい
  • フォームの再描画や移動が残る
  • 作業進捗が途中でも動く

ここで分ける基準は単純です。

  • CPU を回しているなら Task.Run
  • 通信やファイル待ちなら非同期 API

待機時間まで Task.Run へ押し込むと、どこで詰まるかの判断が崩れます。
CPU 処理か待機処理かを分けて見た方が早いです。

見つけたら、CPU を使う部分だけを Task.Run へ出し、待機は非同期 API へ残します。


5. await は待機中に UI スレッドを空ける

次は I/O 待機相当のケースです。
サンプルでは Task.Delay を使った疑似 I/O を呼び、待機中も進捗が動く形にしています。

private async void btnAsyncIo_Click(object sender, EventArgs e)
{
    MarkAction("async I/O待機");
    ResetProgress();
    SetStatus("Task.Delayで待機中");

    await SimulatedAsyncOperation(3000);
    SetStatus("完了");
}

このケースでは、待機中でも UI は止まりません。
心拍表示も動きます。

ここで押さえたいのは、await は「別スレッド化」そのものではなく、待機中に UI スレッドを空ける書き方だという点です。
通信待ち、ファイル待ち、タイマー待ちのように CPU は遊んでいて完了待ちだけある処理なら、Task.Run より非同期 API の方が自然です。

見つけたら、待機処理は非同期 API へ寄せ、UI 層で同期的に待たない形へ戻します。


6. BeginInvoke は UI 更新だけを渡したい時に待ちを増やしにくい

最後は、背景側から UI 更新だけを投げる場面です。
このサンプルでは、1 回だけ更新するのではなく、進捗を少しずつ送っても待ちが増えにくい形を確認します。

private async void btnBeginInvoke_Click(object sender, EventArgs e)
{
    MarkAction("BeginInvokeで更新");
    ResetProgress();
    SetStatus("待たないUI更新を実行中");

    await Task.Run(() =>
    {
        for (var i = 1; i <= 10; i++)
        {
            var progress = i * 10;
            BeginInvoke(new Action(() =>
            {
                AppendLog($"BeginInvoke更新: {progress}%");
                ReportProgress(progress);
            }));
            Thread.Sleep(150);
        }
    });

    SetStatus("完了");
}

この形では、次の違いが出ます。

  • 背景側が UI 完了を待たない
  • 連続更新でも待ちが連ならない
  • UI 更新だけなら Invoke より軽く通る

この例で効いているのは、背景処理が UI 完了を待たない点です。
進捗表示のように戻り値が不要な更新なら、Invoke より BeginInvoke の方が待ちの鎖を増やしにくくなります。

見つけたら、戻り値が不要な表示更新は BeginInvokeIProgress<T> へ寄せます。


UiLagMonitor で「何 ms 遅れたか」と「どの操作の直後か」を残す

今回のサンプルでは、心拍表示だけでなく、UI ラグをログへ残す処理も入れています。
考え方は単純で、一定間隔で UI へ処理を投げ、その戻りが遅れたらラグとして記録します。

private void OnTimer(object? state)
{
    if (uiControl.IsDisposed || !uiControl.IsHandleCreated)
    {
        return;
    }

    var id = Interlocked.Increment(ref sequence);
    var start = Stopwatch.GetTimestamp();

    try
    {
        uiControl.BeginInvoke(new Action(() =>
        {
            var end = Stopwatch.GetTimestamp();
            var elapsedMs = (end - start) * 1000.0 / Stopwatch.Frequency;

            if (elapsedMs >= thresholdMs)
            {
                logger($"ui_lag seq={id} lag={elapsedMs:F0}ms action={getLastAction()}");
            }
        }));
    }
    catch (ObjectDisposedException)
    {
    }
    catch (InvalidOperationException)
    {
    }
}

この記録で見たいのは次の 2 点です。

  • 何 ms 遅れたか
  • どの操作の直後か

目安はこのあたりです。

  • 100ms 前後: 引っ掛かりが出始める
  • 300ms 前後: 明確に重い
  • 500ms 以上: フリーズに近く、要確認

毎回は出ない引っ掛かりでも、lag=312ms action=.Waitで待機 のように残れば、ログを開いた瞬間に疑うボタンを絞れます。
目視で追いにくい場面ほど、この記録が効きます。

画像3_ログ拡大


業務コードで固まった時の調査順

業務コードで WinForms が固まった時は、次の順で開くと早いです。

順番 開く場所 確認するもの 次に当たる場所
1 Click Shown TimerTick UI イベント内の重い処理 Task.Run 化できるか
2 UI 層のイベントハンドラ .Wait .Result GetAwaiter().GetResult() 呼び出し側を await へ戻せるか
3 背景処理からの UI 更新 Invoke の完了待ち BeginInvoke IProgress<T> へ寄せられるか
4 ログ出力、画像処理、同期 I/O 100ms 以上の引っ掛かり 遅延 ms と操作名を残せるか
5 例外経路 投げっぱなし Task、握りつぶした catch ログへ残っているか

最初から全部追うより、
UI イベントの中身 → UI 側の待ち → Invoke 周り
の順で当たる方が早いです。


この比較で残る判断基準

今回の比較で残る判断基準は 3 つです。

UI は「重い処理」より「戻らない処理」で止まる

重い処理でも UI の外で動けば、画面は残ります。
逆に短い処理でも、UI イベントの中で待ちを増やすと体感は一気に悪くなります。

await が入っていても、UI 側で待てば止まる

await を書いているかどうかより、

  • UI 側で .Wait していないか
  • .Result に戻していないか
  • Invoke 完了待ちが重なっていないか

の方が、WinForms では先に効きます。

UI 更新経路を 1 本へ寄せると追いやすくなる

背景処理から直接 UI を触るより、

  • await 後に UI 更新
  • IProgress<T>
  • BeginInvoke
  • 共通の UI 更新関数

のどれかへ寄せた方が、どこで UI が止まったかを追いやすくなります。


サンプルを動かす時に確認する点

このサンプルは、正解コードを見る用途より、止まり方の差を見比べる用途の方が合っています。

動かす順番

  1. UIで重い処理
  2. .Waitで待機
  3. Invoke + Wait
  4. Task.RunでCPU処理
  5. async I/O待機
  6. BeginInvokeで更新

画面で確認する点

  • 心拍表示が止まるか
  • フォームをドラッグした時に止まるか
  • 作業進捗が途中でも動くか
  • ログの順番が崩れるか
  • 完了表示がいつ出るか

この 5 点だけ見れば、asyncInvoke、メッセージループの差が画面と対応づきます。


続けて読むなら

サンプルを手元でそのまま動かしたい場合は、コード一式を置いています。
GitHubで開く

連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?