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の即効薬

Last updated at Posted at 2026-01-12

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

WinFormsのフリーズは"バグ"というより"構造"で起きます。
UIスレッド(メインスレッド)は1本、メッセージループも1本。ここが詰まると、入力も描画も止まります。

この回は以下を狙います。

  • まず切り分けで迷わない(アプリ起因かOS起因か)
  • "一瞬だけ固まる"も逃さない(計測で捕まえる)
  • 典型原因をパターン化して最短で止血する(処方箋)
  • チームで再発させない(運用と規約に落とす)

0. まずここから: メインスレッド(UIスレッド)とサブスレッド(背景スレッド)

新人が最初に混乱する点なので、先に言葉を揃えます。

呼び方 WinFormsでの意味 何をしている 触って良いもの
メインスレッド UIスレッド(=メッセージループを回すスレッド) 入力、描画、イベント処理 UIコントロール(原則ここだけ)
サブスレッド 背景スレッド(=UI以外の作業用) 重い処理、I/O、計算 UIは直接触らない(Invoke/BeginInvoke等で戻す)

重要: "UIはメインスレッド専用"

WinFormsのコントロールはスレッドセーフではありません。
背景スレッドから触ると、例外・壊れた表示・再現しない不具合の原因になります。

重要: async=サブスレッドではない

async/awaitは"スレッドを増やす魔法"ではありません。

  • asyncは「待てる形にする」だけ
  • CPUの重い処理を別スレッドで回したいならTask.Runなどで明示する
  • WinFormsではawait後に通常UIスレッドへ戻る(戻り先が重要)

ここを押さえると、フリーズの原因が見えるようになります。


断末魔: 症状(アプリとOSの切り分けに使う)

1) アプリ側の症状(WinFormsでよく見る)

症状 見え方 よくある正体
クリック後に固まる 画面が反応しない UIイベント内で重い処理、同期I/O、lock待ち
"応答なし"になる タイトルバーに出る メッセージが捌けない(UIスレッド詰まり)
スクロール/リサイズで白化 真っ白やガタつき 描画中に重い処理、レイアウト地獄、Invalidate連打
awaitしたのにUIが更新されない 反映されない/遅れる awaitの戻り先がUIではない、例外が消えている
たまに固まる 再現しない .Result/.Wait、Invoke待ち、ロック競合、タイミング依存
一瞬だけ固まる 100ms〜数秒の引っ掛かり GC、同期I/O、ログ出力、画像処理、再入、CPUスパイク

2) OS側に見える症状(でも根本は同じことが多い)

フリーズが"OSっぽい"見え方でも、原因がアプリの資源独占であることは珍しくありません。
切り分け用に症状を整理します。

症状 まず見る 可能性
PC全体が重い タスクマネージャ(CPU/メモリ/ディスク) アプリがCPU/ディスクを占有、メモリ逼迫、更新
マウスは動くがアプリだけ固まる アプリの"応答なし"表示 アプリのUIスレッド詰まり(本命)
エクスプローラも固まる ディスク100%やメモリ不足 ディスクI/O詰まり、スワップ
画面が一瞬ブラック/点滅 イベントログ、GPU関連 GPUドライバ、描画系負荷(アプリ描画が引き金になることも)

真犯人: 原因(結論)

WinFormsのフリーズは、ほぼこの4つのどれかです。

真犯人 何が起きているか 代表例
UIスレッド占有 メイン(UI)が処理で塞がる イベントで重い処理、Thread.Sleep、同期I/O
デッドロック メイン(UI)とサブが相互待ち .Result/.Wait、Invoke待ち+Wait/Join
再入(DoEvents等) 処理途中に別イベントが割り込む DoEvents、二重クリック、状態不整合
例外不可視 例外が表に出ず止まる async void乱用、投げっぱなしTask、握り潰し

処方箋: 解決手順(最短)

ステップ0: "固まった瞬間"の証拠を取る(最短で勝つ)

まずは原因を当てに行きます。再現が取れるならこれが最速です。

  • Debug -> Break All(中断)
  • UIスレッドのCall Stackを見る
  • Wait/lock/Invoke/.Resultが見えたら当たり

補足(あると強い)

  • Threadsウィンドウで「どのスレッドがどこで待っているか」を見る
  • 可能なら"応答なし"中に1回取る(犯人の場所が出やすい)

ステップ1: UIスレッドを止めない(重い処理を外へ出す)

断罪例(固まる)

private void btnRun_Click(object sender, EventArgs e)
{
    HeavyWork(); // UIスレッドで重い処理
    label1.Text = "done";
}

模範例(止血)

private async void btnRun_Click(object sender, EventArgs e)
{
    btnRun.Enabled = false;
    try
    {
        await Task.Run(() => HeavyWork()); // CPU負荷などを外へ
        label1.Text = "done"; // await後は通常UIスレッドに戻る
    }
    finally
    {
        btnRun.Enabled = true;
    }
}

注意点

やりがち なぜダメか 正解
I/OをTask.Runで包む スレッドを無駄に塞ぐ 非同期I/O(API)を使う
背景からUIを直接触る スレッド違反/再現しない不具合 UI更新はUIへ戻す
例外を握り潰す "固まった"に見える ログに必ず出す

ステップ2: .Result/.WaitをUIから根絶する(デッドロック典型)

断罪例

var x = GetAsync().Result; // UIが待つ -> 典型地雷

模範例

var x = await GetAsync();

実務のコツ

  • UI層に.Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult()が居たら赤信号
  • "一箇所だけならOK"が地雷。混ざると調査不能になるので0にする

ステップ3: UI更新の入口を作る(Invokeを散らさない)

UIをどこからでも触れる状態は事故の温床です。入口を1つにして安全に戻します。

private void SetTextSafe(Label label, string text)
{
    if (label.IsDisposed) return;

    if (label.InvokeRequired)
    {
        label.BeginInvoke(new Action(() => label.Text = text));
        return;
    }

    label.Text = text;
}

推奨: 進捗はIProgressで集約(散らない/安全)

private async void btn_Click(object sender, EventArgs e)
{
    var progress = new Progress<int>(v => progressBar1.Value = v);
    await Task.Run(() => DoWork(progress));
}

ステップ4: "一瞬だけ固まる"を捕まえる(計測)

ここが本題です。"一瞬フリーズ"は例外ログでは見えません。
測る対象は2つだけです。

  • "どの操作の直後に"
  • "UIがどれだけ詰まったか(何ms遅れたか)"

4-1) まずは区間計測(Stopwatch)を入れる

最小コストで効きます。イベントの入口に入れてください。

private void btnSearch_Click(object sender, EventArgs e)
{
    var sw = System.Diagnostics.Stopwatch.StartNew();
    try
    {
        SearchCore(); // まずは同期でも良い。測ってから手を打つ
    }
    finally
    {
        sw.Stop();
        LogInfo($"elapsed={sw.ElapsedMilliseconds}ms op=Search");
    }
}

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

目安(現場感)

  • 100ms: 体感で引っ掛かり始める
  • 300ms: 多くの人が"重い"と感じる
  • 500ms: フリーズ扱いで良い(改善対象)

4-2) UI遅延モニタ(おすすめ: 一瞬フリーズ専用)

UIスレッドが詰まると、UIへBeginInvokeした処理が戻ってきません。
これを利用して"UIの心拍"を測ります(デバッグ時だけ有効でも価値が高い)。

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;

    public UiLagMonitor(Control ui, Func<string> getLastAction, int intervalMs = 200, int thresholdMs = 200)
    {
        _ui = ui;
        _getLastAction = getLastAction;
        _thresholdMs = thresholdMs;

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

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

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

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

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

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

フォーム側の組み込み例

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

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());
}

運用のコツ

  • thresholdは200ms前後が実務で効く(一瞬の引っ掛かりを拾える)
  • actionを一緒に出すと「何の直後か」が追える(調査が速い)
  • 本番常時は重い/うるさい場合があるので、Debugのみでも十分価値がある

ステップ5: 例外が消える形を潰す(固まりに見える事故の正体)

フリーズに見えても、実は「例外が握り潰されて処理が進まず待っている」だけのことがあります。

最低限これを守る

  • async voidはイベント以外禁止
  • 投げっぱなしTaskは例外を観測する
  • catchして何もしないは禁止(最低でもログ)

投げっぱなしTaskの最低限(例外観測)

private static void FireAndForget(Task task, Action<Exception> onError)
{
    task.ContinueWith(t =>
    {
        var ex = t.Exception?.GetBaseException() ?? new Exception("Unknown background task error");
        onError(ex);
    }, TaskContinuationOptions.OnlyOnFaulted);
}

追い討ち: ハマりポイント(具体策付き)

1) "一瞬だけ固まる"はログで見えない

具体策

  • Stopwatchで区間計測(elapsed ms)を入れる
  • UiLagMonitorでUI遅延(lag ms)を拾う
  • 直前操作(action)を残す(MarkAction)

2) デッドロックは再現性が低い。だから根絶が最短

具体策

  • UI層の.Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult()を0にする
  • Invokeで待つ設計をやめる(BeginInvoke/IProgress/await後UI更新へ)
  • lock内でawaitしない(排他が必要なら設計を見直す)

3) ConfigureAwait(false)をUI層に入れて事故る

具体策

  • UI層は原則ConfigureAwait(false)を使わない(戻ってきてUI更新する前提が崩れる)
  • 使うのはライブラリ層(戻り先を固定しないため)に限定する

4) DoEventsで一瞬直った気がして地獄が始まる

具体策

  • DoEventsは撤去が基本
  • 代替は「処理の非同期化」「進捗UI」「キャンセル導線」

5) フォームを閉じた後のBeginInvoke/更新で落ちる

具体策

  • IsDisposed/IsHandleCreatedを確認してからBeginInvoke
  • FormClosingでCancellationTokenをCancel
  • 例外は握り潰さずログへ

再発防止の掟: ルール化(運用・規約)(具体案)

言うだけだと守られません。チームで回る形に落とします。

ルール 強制方法(具体案) 代替/補足
UIイベントで重い処理をしない レビュー観点に固定、200ms超は計測ログ必須 CPUはTask.Run、I/Oは非同期API
UI層で.Result/.Wait禁止 PRで全文検索、静的解析の導入を検討 同期が必要なら設計レビュー必須
UI更新は集約する SetTextSafe/IProgressなど共通の"入口"へ 例外は最小限で統一
DoEventsは使わない 発見したら撤去を前提 進捗/キャンセルへ置換
一瞬フリーズは計測で捕まえる UiLagMonitorを標準搭載(デバッグ時でも可) 200ms/500msを閾値に
例外不可視を作らない async voidはイベントのみ、投げっぱなしは禁止 投げっぱなしは観測付きに限定

レビュー用チェックリスト(コピペ可)

  • UIイベント内にThread.Sleep、同期I/O、重いループがない
  • UI層に.Result/.Waitが存在しない
  • Invokeで待っていない(Invoke+Wait/Joinの相互待ちがない)
  • DoEventsが入っていない
  • 一瞬フリーズ計測(Stopwatch/UiLagMonitor)が入っている(少なくともデバッグ時)
  • async voidがイベント以外に存在しない
  • 投げっぱなしTaskに例外観測が付いている

関連する掟・外伝・鍛錬(回遊導線)

  • R06: 非同期の掟(UIスレッド/awaitの帰還/デッドロック典型)
  • R05: ログ設計(例外不可視と計測ログの運用)
  • G11: メッセージループ入門(Application.Runと固まる理由)
  • G13: UIスレッドと非同期(SynchronizationContextの正体)
  • K24: テスト容易性(非同期と設計の相互作用)

付録: 現場の即効チェック(最短で刺さる順)

優先 チェック 具体手段
1 UIで.Result/.Waitがないか 全文検索して0へ
2 主要イベントで区間計測しているか Stopwatchでelapsedログ
3 一瞬フリーズ(UI詰まり)を拾えるか UiLagMonitor導入
4 UI更新が散っていないか IProgressや共通関数へ集約
5 DoEventsが入っていないか 撤去して非同期化へ

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

"固まった"だけだと調査不能です。最低限これを埋めると再現率が上がります。

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

次は「理屈を腹落ち」させるならG11、「事故を減らす掟」ならR06です。


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?