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?

G11:【外伝】メッセージループ入門: Application.RunとUIが固まる本当の理由

Last updated at Posted at 2026-01-12

G11:【外伝】メッセージループ入門: Application.RunとUIが固まる本当の理由

「ボタンを押したら固まった」「応答なしになった」
WinFormsを触っていると、一度は必ず遭遇します。

多くのケースは"重い処理"が原因です。
ただし現場では、それだけでは説明し切れないフリーズも混ざります。たとえば「たまに固まる」「一瞬だけ固まる」「再現しない」などです。

この回は、WinFormsが生きている仕組みである"メッセージループ"を軸に、UIが固まる理由を腹落ちさせます。
仕組みが繋がると、次の3つがまとめて避けられるようになります。

  • なぜ"応答なし"になるのか(描画/入力が止まる理由)
  • なぜDoEventsが嫌われるのか(再入で壊れる理由)
  • なぜ.Result/.Waitが地雷なのか(待ち合わせで詰む理由)

先に止血したい場合は E04 をどうぞ。
非同期のレビュー基準を固めたい場合は R06 が先に効きます。


掟: 今回守るルール

  • UIスレッドはメッセージを捌くのが本業
  • UIスレッドで同期ブロックをしない
  • DoEventsは原則禁止。使うなら副作用を説明できる場合のみ
  • 固まりは"原因箇所の特定"が勝ち筋。証拠を残して詰める

試練: 要件

必須

  • UIが固まる理由をメッセージループで説明できる
  • DoEventsを使わずに進捗表示や中断を設計できる

発展

  • "固まる瞬間"の計測ログを入れられる
  • Invoke/BeginInvokeの使い分けができる

地獄

  • デッドロック典型をメッセージループ観点で説明できる

0. まずここから: Application.Runがやっていること

起動コードはだいたいこうです。

[STAThread]
static void Main()
{
    ApplicationConfiguration.Initialize();
    Application.Run(new MainForm());
}

Application.Runが始めるのが"メッセージループ"です。雑に言うとこうです。

  • OSから届くメッセージを取り出す
  • 適切なウィンドウやコントロールに振り分ける
  • それをフォームが閉じるまで繰り返す

イメージ(擬似コード)

while (formIsAlive)
{
    var msg = GetMessage();
    DispatchMessage(msg); // 入力/描画/タイマー/クリック等
}

重要なのはここです。
UIスレッドがこのループを回せない時間が増えるほど、入力も描画も止まり、OSは"応答なし"と判断します。


1. 図解代わり: 固まりの代表パターン

WinFormsの固まりは、ほぼ次のどれかです。

事象 起きていること(メッセージループ視点) 代表原因 正攻法
応答なし UIスレッドがメッセージを処理できない UIで重い処理/Thread.Sleep/同期I/O CPUはTask.Run/I/Oは非同期API
UI更新されない 描画メッセージが詰まって反映されない 同期待ち/長時間イベント処理 awaitへ統一/処理を分割
たまに固まる 待ち合わせや競合が絡む .Result/.Wait/Invoke待ち/lock競合 同期ブロック根絶/設計見直し
一瞬だけ固まる 数百msだけポンプが止まる GC/同期ログ/画像処理/再入 計測で捕まえる

E04 は「止血手順(最短)」に寄せています。
このG11は「なぜそうなるか(構造)」に寄せています。


2. 最小再現: UIスレッド占有で固める

これは最短で固まります。理由は単純で、UIイベント内でUIスレッドを占有しているからです。

private void btnFreeze_Click(object sender, EventArgs e)
{
    Thread.Sleep(3000); // UIスレッド占有
    label1.Text = "done";
}

説明(メッセージループ視点)

  • Clickイベントが終わるまで、UIスレッドは他のメッセージを捌けない
  • 結果として入力も描画も止まる

3. 代表メッセージを知る: 何が詰まると何が止まるか

メッセージループに届くものの例です。暗記は不要ですが、現象と結び付けると調査が速くなります。

代表的なメッセージ(概念) 何が起きる 詰まるとどう見える
入力(マウス/キー) クリック/移動/キー入力 反応しない
描画(再描画) 画面更新/塗り直し 白化/更新されない
タイマー 定期処理/点滅/進捗 止まる/間延びする

結論は変わりません。
どのメッセージもUIスレッドが捌きます。だからUIスレッドを塞いだら全部止まります。


4. DoEventsが嫌われる理由: 再入が起きるから

DoEventsは「溜まっているメッセージを一旦捌く」ので、見た目だけは動きます。
しかし副作用として、処理途中に別イベントが割り込む"再入"が起きます。ここから状態不整合が始まります。

4-1. 再入の副作用例1: 二重実行が刺さる

断罪例(DoEventsで二重クリックが刺さる)

private bool _running;

private void btnRun_Click(object sender, EventArgs e)
{
    if (_running) return;
    _running = true;

    btnRun.Enabled = false;

    // NG: ここでDoEventsすると、同じClickが割り込める
    Application.DoEvents();

    HeavyWork(); // 途中状態のまま別イベントが動く可能性
    btnRun.Enabled = true;
    _running = false;
}

何が起きるか

  • DoEventsでメッセージを捌く
  • その瞬間に同じボタンのClickが再び入る
  • "1回しか走らない"前提が崩れて二重実行、状態破壊、再現性低下

正攻法(DoEventsを使わず、awaitでUIに戻す)

private async void btnRun_Click(object sender, EventArgs e)
{
    btnRun.Enabled = false;
    try
    {
        await Task.Run(() => HeavyWork());
    }
    finally
    {
        btnRun.Enabled = true;
    }
}

4-2. 再入の副作用例2: 途中状態を別イベントが踏む

断罪例(途中でコントロールやコレクションを作り替えている最中に割り込みが入る)

private readonly List<int> _items = new();

private void btnBuild_Click(object sender, EventArgs e)
{
    _items.Clear();

    for (int i = 0; i < 1000; i++)
    {
        _items.Add(i);

        // NG: 途中状態で別イベントが割り込む
        Application.DoEvents();
    }

    label1.Text = _items.Count.ToString();
}

何が怖いか

  • 途中状態で別ボタンが_itemsを参照して例外
  • Closeが割り込みDisposeされ、その後のUIアクセスで落ちる
  • ロックや非同期が絡むと調査不能になりやすい

結論

  • DoEventsは"動く"代わりに"デバッグ不能"へ寄ります
  • 使うなら「再入しても壊れない不変条件」を説明できる必要があります
  • 説明できないなら使わないのが正解です

5. DoEventsの代替: 非同期 + 進捗 + 中断

DoEventsの代替は、だいたいこの3点セットです。

5-1. CPU負荷はTask.Runで逃がす

private async void btnRun_Click(object sender, EventArgs e)
{
    btnRun.Enabled = false;
    try
    {
        progressBar.Style = ProgressBarStyle.Marquee;
        await Task.Run(() => HeavyWork());
    }
    finally
    {
        progressBar.Style = ProgressBarStyle.Blocks;
        btnRun.Enabled = true;
    }
}

注意

  • I/OはTask.Runで包まない。非同期APIを使う
  • 詳細は R06 の掟に寄せています

5-2. 進捗はIProgressでUIへ戻す

private async void btnRun_Click(object sender, EventArgs e)
{
    var progress = new Progress<int>(p =>
    {
        progressBar.Value = p;
        label1.Text = $"{p}%";
    });

    await Task.Run(() => DoWork(progress));
}

private static void DoWork(IProgress<int> progress)
{
    for (int i = 0; i <= 100; i++)
    {
        Thread.Sleep(20);
        progress.Report(i);
    }
}

5-3. 中断はCancellationTokenで通す

private CancellationTokenSource? _cts;

private async void btnStart_Click(object sender, EventArgs e)
{
    _cts?.Dispose();
    _cts = new CancellationTokenSource();

    btnStart.Enabled = false;
    btnCancel.Enabled = true;

    try
    {
        await Task.Run(() => DoWorkCancelable(_cts.Token));
    }
    catch (OperationCanceledException)
    {
        MessageBox.Show("キャンセルしました");
    }
    finally
    {
        btnStart.Enabled = true;
        btnCancel.Enabled = false;
    }
}

private void btnCancel_Click(object sender, EventArgs e) => _cts?.Cancel();

private static void DoWorkCancelable(CancellationToken ct)
{
    for (int i = 0; i < 200; i++)
    {
        ct.ThrowIfCancellationRequested();
        Thread.Sleep(20);
    }
}

6. Invokeで待つ設計になっていないか: 典型デッドロック

Invokeは「UIで実行されるまで待つ」ので、待ち合わせと混ぜると詰みます。

6-1. 断罪例: UIがWaitで待つ + 背景がInvokeで待つ

private void btnRun_Click(object sender, EventArgs e)
{
    var t = Task.Run(() =>
    {
        // 背景からUIへ同期呼び出し -> UIが動かないと戻れない
        this.Invoke(new Action(() => label1.Text = "working..."));
        HeavyWork();
    });

    // NG: UIスレッドが待つ -> メッセージループが止まる
    t.Wait();
}

何が起きるか(メッセージループ視点)

  • UIスレッドはt.Waitでブロックしてメッセージを捌けない
  • 背景スレッドはInvokeでUI実行を待つ
  • お互い待つ -> デッドロック

6-2. 正攻法: await + BeginInvoke or IProgress

private async void btnRun_Click(object sender, EventArgs e)
{
    btnRun.Enabled = false;
    try
    {
        label1.Text = "working...";
        await Task.Run(() => HeavyWork());
        label1.Text = "done";
    }
    finally
    {
        btnRun.Enabled = true;
    }
}

使い分けの基準

API ブロックするか 基本方針
Invoke する 原則避ける。待ち合わせと混ぜると事故る
BeginInvoke しない UI更新の基本形。予約して戻る
IProgress しない 進捗通知の第一選択。UIへ安全に戻せる

7. 計測ログが入っているか: 一瞬フリーズの勝ち筋

"固まった"はログだけでは見えないことが多いです。測って捕まえます。

7-1. 区間計測: 操作単位のStopwatch

private async void btnSearch_Click(object sender, EventArgs e)
{
    var sw = System.Diagnostics.Stopwatch.StartNew();
    try
    {
        await SearchAsync();
    }
    finally
    {
        sw.Stop();
        _logger.LogInformation("elapsedMs={ElapsedMs} op=Search", sw.ElapsedMilliseconds);
    }
}

目安

  • 100ms: 引っ掛かり開始
  • 300ms: 重い
  • 500ms: フリーズ扱いで改善対象

7-2. UI遅延検知: UIが捌けていない時間を測る

BeginInvokeが返ってくるまでの遅延で、"UIポンプの詰まり"を検知します。

public sealed class UiLagMonitor : IDisposable
{
    private readonly Control _ui;
    private readonly System.Threading.Timer _timer;
    private readonly int _thresholdMs;

    public UiLagMonitor(Control ui, int intervalMs = 200, int thresholdMs = 200)
    {
        _ui = ui;
        _thresholdMs = thresholdMs;

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

            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)
                {
                    System.Diagnostics.Debug.WriteLine($"UI lag {ms:F0}ms");
                }
            }));
        }, null, 0, intervalMs);
    }

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

導入例(フォーム表示後に監視開始)

private UiLagMonitor? _lag;

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

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

容赦なき断罪: レビュー観点

観点 典型NG なぜダメか 代替
UIイベントで長時間処理 Thread.Sleep, 重いループ ポンプが止まり応答なし await + Task.Run or 非同期I/O
同期ブロックが残る .Result, .Wait デッドロック/フリーズの温床 asyncを最後まで流す
DoEventsが入る Application.DoEvents 再入で順序が壊れる 進捗はIProgress, 中断はToken
UI直叩き 背景からlabel.Text スレッド安全でない IProgress, BeginInvoke
Invokeで待つ設計 UIがWait + 背景がInvoke 相互待ちで詰む await, BeginInvoke
計測がない "たまに固まる" 調査不能 Stopwatch, UiLagMonitor

コピペ用チェックリスト

  • UI層に.Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult()が存在しない
  • UIイベント内にThread.Sleep, 同期I/O, 重い処理が存在しない
  • DoEventsが存在しない(再入しても壊れない説明ができないなら撤去)
  • Invokeで待っていない(待ち合わせと混ぜていない)
  • 主要操作にStopwatch計測が入っている
  • 一瞬フリーズがUiLagMonitor等で検知できる(少なくともDebug時)

次なる試練

  • G13: UIスレッドと非同期(SynchronizationContext/awaitの帰還/デッドロック典型)(リンク: TBD)

関連トピック

  • E04: UIフリーズの即効薬(計測で捕まえる/止血の手順)
  • R06: 非同期の掟(UIスレッド/awaitの帰還/デッドロック典型)
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?