0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

E04 【現場救急Tips】UIフリーズの正体: メッセージループ/Invoke/asyncの即効薬

0
Last updated at Posted at 2026-01-12

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

WinFormsの業務アプリでボタンを押した直後、画面が白くなって反応が返らない。
「入力できなくなった?」が先に出て、マウスを動かしてもクリックしても戻らない。

タスクマネージャを開き、CPUやディスクを見て、アプリだけが「応答なし」になっているのに気づく。
止まっているのは処理そのものというより、UIスレッドがメッセージを処理できていない状態だった。

このページでは、UIフリーズを短い手順で切り分け、最短で見当を付ける順番を整理しておく。


このページで手に入るもの

「固まった」が「ここが詰まっている」に変わるところまでをまとめる。
次に同じ現象が出ても、最初の数分で見当を付けて修正へ進める状態にする。

  • 症状 -> 原因 -> まずやることの逆引き表(応答なし/白化/一瞬遅延で分ける)
  • Break Allで見る場所(UIスレッドの待ち/ロック/同期待ち)
  • 一瞬フリーズ検出:UiLagMonitor(遅延ms+直前操作名を残す)
  • UIイベント最短テンプレ(UI更新/CPU処理/非同期I/O/例外観測)
  • 失敗パターン集(.Result/.Wait、Invoke待ち、DoEvents、ConfigureAwait(false)の置き場所)

先に逆引き(症状 -> 原因 -> 対策)

固まり方だけだと手が止まりやすいので、症状ごとに「疑う所」と「最初の一手」をまとめておく。
ひとまずこの表から始めると、切り分けの順番が見えやすくなる。

アプリ側(WinFormsで起きやすい)

症状(見え方) 疑う所(当たり) 最初の一手
クリック直後に固まる/反応しない UIイベント内で重い処理、同期I/O、lock待ち UIイベント直下へStopwatchを入れ、重い区間を確定する。CPU処理はUI外へ、I/Oは非同期APIへ
タイトルバーに「応答なし」 メッセージループ詰まり、UIスレッドが戻らない デバッグ中断(Break All)でUIスレッドのCall Stackを確認する
スクロール/リサイズで白化、チラつき 再描画/再レイアウト中に重い処理、Invalidate連打 描画経路から重い処理を外す。計測で場所を絞る
awaitしたのにUIが更新されない await後の戻り先がUIではない、例外が見えていない ConfigureAwait(false)の置き場所を点検。投げっぱなしTaskの例外観測を入れる
たまに固まる/再現しづらい .Result/.Wait、Invoke待ち、ロック競合 UI層の同期待ちを避ける。Invokeで完了待ちしない形へ
一瞬だけ固まる(100ms〜数秒) GC、同期I/O、ログ出力、画像処理、再入、CPUスパイク UiLagMonitorで遅延ms+直前操作名を残す。区間計測も足す
マウスは動くがアプリだけ固まる UIスレッド詰まり Break AllでUIスレッドの待ち(lock/Wait/Invoke/.Result)を探す
PC/環境側(アプリ外の重さが絡む)
症状(見え方) 疑う所(当たり) 最初の一手
PC全体が重い CPU/メモリ/ディスクの資源独占 タスクマネージャで支配資源を特定。I/Oやループを疑う
エクスプローラも固まる ディスク100%やメモリ不足 ディスクI/O詰まり、スワップを確認する
画面が一瞬ブラック/点滅 GPUドライバ、描画負荷(アプリ描画が引き金になる場合あり) イベントログ、GPU周り、描画経路の負荷を確認する

最短テンプレ(コピペ)

「応答なし」をその場で止める手と、後から原因へ近づくための計測をまとめる。
貼る場所と差し込み位置がコメントで分かるので、まずは差し込んで様子を見るところから始められる。

1) UIイベント用テンプレ(CPU負荷/非同期I/O/例外/進捗)

UI側に残す処理と、UI外へ逃がす処理が分かるように、最小の骨組みを用意しておく。

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

/// <summary>
/// UIイベントの基本形。
/// - UIの操作(Enabled切替/表示更新)はUI側
/// - CPU重い処理は Task.Run でUI外へ
/// - I/O は非同期API(例: HttpClient)へ寄せ、Task.Runで包まない
/// - 例外は掴んで、握りつぶさずログへ出す
/// </summary>
private async void btnRun_Click(object sender, EventArgs e)
{
    btnRun.Enabled = false;

    // 「何の直後か」を残す(UiLagMonitorとセットで効く)
    MarkAction("Run");

    var sw = Stopwatch.StartNew();
    try
    {
        // CPU負荷など(UI外へ)
        await Task.Run(() => HeavyCpuWork());

        // I/O は非同期APIへ(例として Task.Delay を置く)
        await Task.Delay(200);

        labelStatus.Text = "done";
    }
    catch (Exception ex)
    {
        LogError(ex, op: "Run");
        labelStatus.Text = "error";
    }
    finally
    {
        sw.Stop();
        LogInfo($"elapsed={sw.ElapsedMilliseconds}ms op=Run");
        btnRun.Enabled = true;
    }
}

private static void HeavyCpuWork()
{
    // 例: 画像処理、圧縮、集計など
    Thread.SpinWait(10000000);
}

private static void LogInfo(string msg) => Debug.WriteLine(msg);

private static void LogError(Exception ex, string op)
    => Debug.WriteLine($"op={op} ex={ex}");

2) 一瞬フリーズ専用:UI遅延モニタ(UiLagMonitor)

BeginInvokeが戻ってくるまでの遅延msで、UIスレッドの詰まりを測る。直前操作名とセットで残すと調査が速い。

using System;
using System.Diagnostics;
using System.Threading;
using System.Windows.Forms;

public sealed class UiLagMonitor : IDisposable
{
    private readonly Control _ui;
    private readonly System.Threading.Timer _timer;
    private readonly int _thresholdMs;
    private long _seq;
    private readonly Func<string> _getLastAction;

    /// <summary>
    /// UI遅延モニタ。
    /// - 貼る場所: FormのOnShownで開始、OnFormClosedでDispose
    /// - intervalMs: 心拍の間隔(200ms程度が扱いやすい)
    /// - thresholdMs: 何ms以上を「遅延」とみなすか(200ms前後が拾いやすい)
    /// </summary>
    public UiLagMonitor(Control ui, Func<string> getLastAction, int intervalMs = 200, int thresholdMs = 200)
    {
        _ui = ui;
        _getLastAction = getLastAction;
        _thresholdMs = thresholdMs;

        _timer = new System.Threading.Timer(
            callback: (_) =>
            {
                if (_ui.IsDisposed || !_ui.IsHandleCreated) return;

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

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

                    if (ms >= _thresholdMs)
                    {
                        var act = _getLastAction();
                        LogWarn($"ui_lag seq={id} lag={ms:F0}ms action={act}");
                    }
                }));
            },
            state: null,
            dueTime: 0,
            period: intervalMs);
    }

    public void Dispose() => _timer.Dispose();

    private static void LogWarn(string msg) => Debug.WriteLine(msg);
}

フォーム側の組み込み例(貼る場所が分かる形)

private string _lastAction = "none";
private UiLagMonitor? _lag;

/// <summary>
/// 直前操作名を残す。ボタン/検索/保存など、起点イベント名を入れる。
/// </summary>
private void MarkAction(string name)
{
    _lastAction = name;
    System.Diagnostics.Debug.WriteLine($"action={name}");
}

protected override void OnShown(EventArgs e)
{
    base.OnShown(e);
    _lag = new UiLagMonitor(this, () => _lastAction, intervalMs: 200, thresholdMs: 200);
}

protected override void OnFormClosed(FormClosedEventArgs e)
{
    _lag?.Dispose();
    _lag = null;
    base.OnFormClosed(e);
}

private async void btnSearch_Click(object sender, EventArgs e)
{
    MarkAction("Search");
    await Task.Run(() => SearchCore());
}

private static void SearchCore()
{
    Thread.Sleep(300); // 例: 重い処理のつもり
}

3) 投げっぱなしTaskの例外観測(FireAndForget)

例外が見えないと「固まった」に見える場面が出る。例外を観測する補助だけは入れておく。

using System;
using System.Threading.Tasks;

private static void FireAndForget(Task task, Action<Exception> onError)
{
    task.ContinueWith(
        continuationAction: (t) =>
        {
            var ex = t.Exception?.GetBaseException() ?? new Exception("Unknown background task error");
            onError(ex);
        },
        cancellationToken: System.Threading.CancellationToken.None,
        continuationOptions: TaskContinuationOptions.OnlyOnFaulted,
        scheduler: TaskScheduler.Default);
}

解説:メッセージループ/Invoke/asyncを同じ図で整理する

「何が詰まると固まるのか」「await後にどこへ戻るのか」「Invokeは何をしているのか」を同じ見方でまとめる。

用語の整理(最初に迷いを消す)

  • UIスレッド:WinFormsのメッセージループ(入力/描画/イベント)を回すスレッド
  • 背景スレッド:UI以外の作業(I/O/計算/待ち)を行うスレッド
  • メッセージループ:OSから届く入力や描画要求を取り出して処理するループ(止まるとUIが止まる)
  • Invoke/BeginInvoke:背景側からUIスレッドへ処理を投げる仕組み(Invokeは完了待ち、BeginInvokeは待たない)
  • async/await:待てる形にする仕組みで、スレッドを増やす仕掛けではない
  • SynchronizationContext:await後の戻り先を決める仕組み(WinFormsでは通常UIへ戻る)

まず見える形(スレッドの流れ)

UIスレッドが塞がると、入力も描画も止まる。BeginInvokeの帰還が遅れると「一瞬だけ固まる」も検出できる。

評価の目安(体感の切り分けに使う)

  • 100ms:引っ掛かりが体感に出始める
  • 300ms:「重い」が出やすい
  • 500ms:フリーズ扱いで改善対象に入れやすい

区間計測(Stopwatch)とUI遅延モニタ(UiLagMonitor)で「どの操作」「何ms」を残すと、再現が弱い場面でも前へ進む。


判例:原因別に潰す(よくある形 -> 直した形 -> ポイント)

よく出る順に並べ、「形」「直し方」「ポイント」を同じレベル感で書きそろえる。

1) UIイベント内で重い処理を回している

クリック直後に固まる時、まず疑うのがUIイベント内の重い処理。
CPU処理・同期I/O・長いループがUIスレッドに乗ると、入力も描画も止まりやすい。

ありがちな書き方(UIイベントで重い処理が走る)

private void btnRun_Click(object sender, EventArgs e)
{
    // ここが肝: UIスレッドで重い処理を回すと、メッセージループが止まりやすい
    HeavyWork();

    // ここが肝: UI更新はできても、途中の入力/描画が詰まる
    label1.Text = "done";
}

手直し例(重い処理はUI外へ、UI更新はawait後へ)

private async void btnRun_Click(object sender, EventArgs e)
{
    // ここが肝: 連打/二重実行を防ぐ(体感の安定)
    btnRun.Enabled = false;

    try
    {
        // ここが肝: CPU負荷はUI外へ(UIスレッドを塞がない)
        await Task.Run(() => HeavyWork());

        // ここが肝: await後にUI更新(通常はUIへ戻って続く)
        label1.Text = "done";
    }
    finally
    {
        // ここが肝: 失敗時も復帰させる(UIが戻らないを避ける)
        btnRun.Enabled = true;
    }
}

覚えておく所

  • CPU負荷は Task.Run、I/Oは非同期API(I/OをTask.Runで包むと見通しが悪くなりやすい)
  • await後にUI更新が続くなら、UIへ戻る前提が残る方が追いやすい
  • Stopwatchで区間計測を入れ、重い場所を先に確定させる

2) .Result/.Wait でUIスレッドが待っている

「たまに固まる」「再現しづらい」で目立つのがUI側の同期待ち。
awaitの戻り待ちとUIの戻り待ちが絡むと、相互待ちになりやすい。

ありがちな書き方(UI側で同期待ち)

// ここが肝: UIスレッドを止めて待つ(相互待ちになりやすい)
var x = GetAsync().Result;

手直し例(待ちはawaitへ)

// ここが肝: 待ちはawaitへ(UIスレッドを塞がず続きへ進む)
var x = await GetAsync();

覚えておく所

  • UI層の .Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult() は候補に上がりやすい
  • 一部だけ残ると、切り分けの道筋が見えにくくなる
  • 「待ちを作っている場所」から減らすと、調査が前へ進みやすい

3) Invokeで完了待ちしている(Invoke待ち+別の待ちが重なる)

背景側からUIへ投げた処理を「待つ」書き方が入ると、別の待ちと結び付きやすい。
待ちが連なった結果、UI側が詰まりやすくなる。

ありがちな書き方(Invokeで完了を待つ)

// 背景側
// ここが肝: InvokeはUIで実行し終わるまで待つ(待ちの鎖になりやすい)
this.Invoke(new Action(() => label1.Text = "update"));

// ここが肝: さらに別の待ちが乗ると、詰まりが見えにくくなる
SomeOtherWait(); // Join/Waitなど

手直し例1(待たない投げ方に変える)

// 背景側
// ここが肝: BeginInvokeは待たない(鎖を作りにくい)
this.BeginInvoke(new Action(() => label1.Text = "update"));

手直し例2(進捗をIProgressへまとめる)

private async void btn_Click(object sender, EventArgs e)
{
    // ここが肝: UI更新の窓口を1つに寄せる(散らかりにくい)
    var progress = new Progress<int>((v) => progressBar1.Value = v);

    // ここが肝: CPU負荷はUI外へ、UI更新はProgress経由へ
    await Task.Run(() => DoWork(progress));
}

覚えておく所

  • Invokeは完了待ち、BeginInvokeは待たない
  • UI更新が散ると追いづらいので、IProgressや共通関数へまとめると楽になる
  • 「待ち+待ち」が続かない流れに変えるのが近道になる

4) UI更新が散っている(背景側からUIへ直接触っている)

背景側からControlを直接更新すると、例外や表示崩れにつながりやすい。
UI更新の窓口を用意しておくと、動きが読みやすくなる。

ありがちな書き方(背景側から直接UIを触る)

private void StartWork()
{
    Task.Run(() =>
    {
        // ここが肝: 背景側から直接UIを触る(例外/不安定さの起点になりやすい)
        label1.Text = "working...";
    });
}

手直し例(UI更新の窓口を通す)

private void SetTextSafe(Label label, string text)
{
    // ここが肝: 終了後に飛んでくる更新を避ける
    if (label.IsDisposed) return;

    // ここが肝: UIスレッドでなければUIへ投げる
    if (label.InvokeRequired)
    {
        label.BeginInvoke(new Action(() => label.Text = text)); // 待たない
        return;
    }

    // ここが肝: UIスレッドならそのまま更新
    label.Text = text;
}

覚えておく所

  • UIコントロールはスレッドセーフではない前提
  • UI更新の通り道が増えるほど追いづらくなるので、窓口があると助かる
  • 終了時のBeginInvokeは IsDisposed/IsHandleCreated を先に見る

5) DoEventsで「一時的に動いた」に見えるが、状態が壊れやすい

DoEventsは途中で別イベントが割り込みやすく、状態の整合が崩れやすい。
その場では動いたように見えても、後から詰まりやすくなる。

ありがちな書き方(DoEventsで割り込みを許す)

// ここが肝: 途中で別イベントが走る(状態が読みづらくなる)
Application.DoEvents();

手直し例(非同期化+進捗+キャンセルへ置き換える)

private CancellationTokenSource? _cts;

private async void btnStart_Click(object sender, EventArgs e)
{
    // ここが肝: 前の実行を止められるようにする
    _cts?.Cancel();
    _cts = new CancellationTokenSource();

    // ここが肝: UI更新はProgressへ寄せる(散らかりにくい)
    var progress = new Progress<int>((v) => progressBar1.Value = v);

    try
    {
        // ここが肝: UIを塞がずに進める(DoEventsの代替)
        await RunLongWorkAsync(progress, _cts.Token);
        label1.Text = "done";
    }
    catch (OperationCanceledException)
    {
        // ここが肝: キャンセルは例外で流れるので、ここで整える
        label1.Text = "canceled";
    }
}

private void btnCancel_Click(object sender, EventArgs e)
{
    // ここが肝: いつでも止められるようにする
    _cts?.Cancel();
}

private static async Task RunLongWorkAsync(IProgress<int> progress, CancellationToken token)
{
    for (var i = 0; i <= 100; i++)
    {
        // ここが肝: 停止点をループ内に置く(止まらないを避ける)
        token.ThrowIfCancellationRequested();

        // ここが肝: UI更新はProgress経由
        progress.Report(i);

        // ここが肝: 分割してawait(UIスレッドを塞がない)
        await Task.Delay(20, token);
    }
}

覚えておく所

  • DoEventsは割り込みが起きやすく、状態管理が難しくなる
  • 代替は「非同期化」「進捗」「キャンセル」をセットで用意すると扱いやすい
  • UiLagMonitorやStopwatchと組み合わせると、遅延の場所が見えやすくなる

6) 例外が見えず、固まったように見える

裏側で例外が起きても表に出てこないと、「終わらない」「反応が返らない」に見えやすい。
UIが詰まっているのか、裏側で止まっているのかの区別も付きにくくなる。

ありがちな書き方(投げっぱなしで例外が見えない)

// ここが肝: 例外がどこにも出ず、消えることがある
Task.Run(() => DoWork());

手直し例(例外を拾ってログへ出す)

// ここが肝: 失敗の事実を残す(固まったように見えるを減らす)
FireAndForget(Task.Run(() => DoWork()), (ex) => LogError(ex, op: "DoWork"));

覚えておく所

  • イベント以外の async void は避け、Taskを返す形に合わせる
  • 例外を飲み込んで無視すると、手掛かりが消えて切り分けが止まりやすい
  • 投げっぱなしTaskには例外観測を付けて、起きた事実を残す

7) ConfigureAwait(false) がUI側に入り、await後のUI更新が止まる

UI側のawaitは、終わったあとに画面更新へ続くことが多い。
そこへ ConfigureAwait(false) が入ると、await後がUIではないスレッドで続き、画面更新が詰まりやすくなる。

  • UI側:画面更新が続く前提を残す(ConfigureAwait(false) は基本入れない)
  • ライブラリ側:UIに依存しないために ConfigureAwait(false) を検討する(戻り先を決めない)

手直し例(UI側は付けず、ライブラリ側で付ける)

// UI側:await後に画面更新が続くので、戻り先を崩さない
private async void btn_Click(object sender, EventArgs e)
{
    // ここが肝: UI側ではConfigureAwait(false)を付けない
    var data = await service.GetAsync();

    // ここが肝: await後にUI更新(UIスレッドで続く前提)
    label1.Text = data;
}
// ライブラリ側:UIに依存しないので、戻り先を決めない
public async Task<string> GetAsync()
{
    // ここが肝: ライブラリ側でConfigureAwait(false)(UIへ戻る前提を持たない)
    var s = await _client.GetStringAsync(_url).ConfigureAwait(false);

    // ここが肝: ここから先はUIを触らない前提で組む
    return s;
}

覚えておく所

  • UI側は await後に画面更新が続きやすいので、戻り先を崩さない方が扱いやすい
  • ConfigureAwait(false) はUI側とライブラリ側で扱いが変わる
  • 逆引きの「awaitしたのにUIが更新されない」につながりやすいので、付いている場所から確認する

チェックリスト:レビューで見返す所(表)

迷いが出やすい観点を表にまとめ、チームの判断がぶれにくくなる形にする。

運用ルール(回るための最小セット)

ルール 回し方(具体案) 補足
UIイベントで重い処理をしない レビュー観点に入れる。200ms超は計測ログを残す CPUはTask.Run、I/Oは非同期API
UI層で .Result/.Wait を置かない PRで全文検索。静的解析の導入も検討 同期待ちは設計レビュー対象
UI更新は窓口へまとめる SetTextSafe / IProgress など共通手段を用意 例外の扱いも統一しやすい
DoEventsを使わない 発見したら撤去を前提に扱う 進捗/キャンセルへ置換
一瞬フリーズは計測で拾う UiLagMonitorをデバッグ時に組み込む 閾値は200ms/500msを目安にする
例外が見えない形を避ける async void はイベントのみ、投げっぱなしは観測付きにする catchで無視は避ける

レビュー観点(表)

観点 探す場所(具体) 見つかった時の方向
UIイベントがUIを塞いでいないか Click/Changed/Shown/TimerTick Task.Runや非同期I/Oへ分解、Stopwatchで計測
同期待ちが残っていないか .Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult() awaitへ統一、設計の待ち構造を見直す
待ちが連鎖していないか Invokeの完了待ち、lock、Join BeginInvoke/IProgressへ、待ちの鎖を切る
UI更新が散っていないか 背景側からControl更新 SetTextSafeやIProgressへまとめる
一瞬フリーズが拾えるか 主要操作の直後 UiLagMonitor+MarkAction、閾値を設定
例外が見えるか 投げっぱなしTask、catch 例外観測とログを入れる
終了時の更新が残っていないか FormClosed後のBeginInvoke IsDisposed/IsHandleCreated、キャンセル導入

障害報告テンプレ(フリーズ編)

「固まった」だけだと調査が止まりやすい。最低限これが埋まると再現と切り分けが進む。

項目 意味
発生タイミング ボタンA押下直後 起点イベント
再現率 10回中3回 条件探索の指針
操作手順 1)起動 2)A 3)B 再現の核
固まり方 応答なし/白化/一瞬だけ 原因の当たり
OS影響 他アプリは動く/OSも重い 切り分け
CPU/メモリ/ディスク CPU 90%/Disk 100%など 資源独占の手掛かり
計測ログ ui_lag lag=450ms action=Search 一瞬フリーズの手掛かり
例外ログ 例外の有無/スタック 例外の可視化
回避策 再起動で復帰など 重要なヒント

ふり返り(5問)

読み終えたら、理解できた点と曖昧な点を軽く確認するための5問。
明日どこを手直しするかが見えやすくなる。

    1. UIイベント内の .Result / .Wait が危ない理由を、短く説明できるか
    1. 「応答なし」の時、Break All から UIスレッドの Call Stack を見て「どこで待っているか」を追えるか
    1. I/O を Task.Run で包むのが合いにくい理由を説明できるか
    1. UiLagMonitor が測っているのが「UIへ投げた処理が戻るまでの遅延」だと説明できるか
    1. DoEvents を避ける代替として「非同期化 / 進捗 / キャンセル」を挙げられるか
回答例(クリックで展開)
    1. UI側の同期待ちは相互待ちの温床になりやすく、awaitへ統一する方が見通しが良い。
    1. DebugのBreak Allで停止し、UIスレッドのCall StackにWait/lock/Invoke/.Resultが見えたら見当を付ける。
    1. I/Oは待ち時間が主でスレッドを塞ぎやすい。非同期API(HttpClient等)にまとめる方がスレッド消費を抑えやすい。
    1. BeginInvokeがUIスレッドで実行されるまでの時間=UIがメッセージを処理できていない時間、として遅延msを出す。
    1. DoEventsは割り込みで状態が壊れやすい。処理をawaitで分割し、進捗はIProgress、停止はCancellationTokenで扱う。

次なる試練(関連トピック)

次に読むと理解がつながりやすいリンクを並べる。


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

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?