1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

E03 【現場救急Tips】WinFormsが固まる(応答なし) ── .Result/.Wait/Invoke/Resize/NotifyIcon/多重起動の切り分け

Last updated at Posted at 2026-01-20

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

WinFormsで「画面が固まる」「クリックが効かない」「Resizeでカクつく」「×で消えたのにプロセスが残る」「起動連打で増える」が出たとき、原因当てより先に観測点を揃える。
Break All の Call Stack とイベント時系列ログで、.Result/.Wait の同期ブロック、Invoke の相互待ち、過剰イベント、レイアウト連鎖、NotifyIcon 寿命、多重起動、成果物差分を短時間で切り分け、止血から恒久へつなげる。

このページは、原因当てより先に、現場で最短に到達するための「観測点を揃える→止血→恒久」を整理する救急メモとする。


0. 30秒で結論(まず止血)

  • 「固まった」「クリックが効かない」= UIスレッドが止まっている前提で観測する
  • 「一瞬止まる」= 1回の重さより短い処理の積み重ねを疑う(開始/終了ログで可視化する)
  • 最初の1分はこれだけ:
    1. Visual Studio の Break All(全スレッドの Call Stack)
    2. UIスレッドがどこで止まっているかを確定する
    3. イベント時系列ログ(開始/終了+Stopwatch)
    4. スタック上で .Result/.Wait/Invoke/DoEvents/Resize/Layout/TextChanged を探す

1. 断末魔: 症状(見え方で切り分ける)

「固まった」に見えても原因は複数ある。初手で迷わないように観測点を表で揃える。

見え方 具体例 まず疑う切り口(最短)
UIが無反応 ボタンが押せない、ウィンドウが動かない 同期ブロック / Invoke待ち
一瞬だけ止まる スクロール時に引っかかる、入力でカクつく 過剰イベント+短い重処理(計測)
特定画面だけ止まる 設定画面だけ、通知画面だけ固まる イベント連鎖 / 再入 / Invoke待ち
ちらつく/ガタつく Resizeでガタガタ、リスト更新でチラチラ 描画/レイアウト粒度
終了できない ×で消えたがプロセス残る トレイ常駐の終了手順 / Dispose
二重起動する 起動連打で2つ立つ 単一起動制御(Mutex)
端末差が出る A端末OK、B端末だけNG 成果物差分 / 依存差分 / 設定差分

2. 真犯人: 原因(結論を先に置く)

WinFormsはイベント順とレイアウトが絡むと、期待どおりに動かない。固まりや二重起動も混ざると調査が難しくなるため、まず「よくある型」に当てて切り分ける。

パターン 典型例 兆候(観測できるサイン)
同期ブロック .Result / .Wait() / 同期I/O 画面が固まり、ログが止まる
Invoke待ち Invoke() が戻らない 背景側でUI待ち、UI側で別待ち(相互待ち)
再入 DoEvents() / 入力イベント連鎖 たまに落ちる、状態が壊れる
過剰イベント TextChanged / Resize の重処理 「一瞬止まり」が連打される
レイアウト地獄 Layout / Resize でControl変更 Resize中に再帰・ちらつき・CPU上昇
成果物差分/依存差分 古いdll残存、欠落、端末設定差 端末差、バージョン差
トレイ寿命 NotifyIconがDisposeされない 終了しても残骸、プロセスが残る
多重起動 排他なし 起動が重いほど再現しやすい

補足:async は別スレッドではない。await 後にUIへ戻るかは SynchronizationContext と呼び出し元で決まる。


3. 処方箋: 解決手順(最短ルート)

3-1) 証拠取り(最初の1分を揃える)

  • Visual Studio の Break All で止め、全スレッドの Call Stack を見る
  • UIスレッドがどこで止まっているかを確定する
  • 一瞬の固まりは Stopwatch で開始/終了を計測し、累積箇所を特定する

所要時間の計測(開始/終了の数字化)

// 証拠: “短い処理の積み重ね” を数値で捕まえる(開始/終了+ms)
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
    // 補足: 重い疑いのある処理(イベント内・描画前後・I/Oなど)
}
finally
{
    sw.Stop();
    // 証拠: どのハンドラが何msか、積み上げ可能な形で残す
    _logger.LogInformation("UI handler XXX took {Elapsed}ms", sw.ElapsedMilliseconds);
}

イベント時系列ログ(連鎖を揃える)

// 証拠: “どのイベントが連鎖しているか” を時系列で揃える(開始/終了+所要ms)
private void Trace(string name, Action body)
{
    _logger.LogInformation("[UI] enter {Name}", name);
    var sw = Stopwatch.StartNew();
    try
    {
        body();
    }
    finally
    {
        sw.Stop();
        _logger.LogInformation("[UI] exit  {Name} ({Ms}ms)", name, sw.ElapsedMilliseconds);
    }
}

// 補足: TextChanged/Resize/Layout/Shown/Click をこれで囲い、連鎖と回数を可視化する
private void textBox1_TextChanged(object sender, EventArgs e)
{
    Trace(nameof(textBox1_TextChanged), () =>
    {
        // 再発防止: ここに重処理を置かない(置くなら非同期化+スロットリングへ寄せる)
    });
}

3-2) 止血(被害を増やさない最小手当て)

  • 問題のイベントを時系列でログ化し、どのイベントが連鎖しているかを確定する
  • レイアウト/描画はまとめる(更新粒度を落とす)

レイアウトをバッチ化(ちらつき・再帰の抑制)

// 止血: 大量のUI更新を “1回のレイアウト” に畳み、チラつきと再帰を抑える
this.SuspendLayout();
try
{
    // 補足: まとめて変更する(追加/削除/位置/サイズ/Visibleなど)
    panel1.Controls.Clear();
    panel1.Controls.Add(CreateRow());
    panel1.Controls.Add(CreateRow());
}
finally
{
    this.ResumeLayout(performLayout: true);
}

Resize/Layout の止血(連打を1回に畳む)

// 止血: Resize連打を “後で1回” に畳む(イベント内で重処理しない)
private readonly System.Windows.Forms.Timer _resizeTimer = new() { Interval = 100 };

public Form1()
{
    InitializeComponent();

    _resizeTimer.Tick += (_, __) =>
    {
        _resizeTimer.Stop();
        // 止血: 連打が落ち着いてから重い再計算を1回だけ実行する
        RebuildLayoutOnce();
    };
}

private void Form1_Resize(object sender, EventArgs e)
{
    // 止血: 連打はタイマーで畳む(ここで重処理しない)
    _resizeTimer.Stop();
    _resizeTimer.Start();
}

private void RebuildLayoutOnce()
{
    // 再発防止: レイアウト計算・リスト再構築等の重い処理はここに隔離する
}

3-3) 恒久対策(設計で再発を止める)

  • 画面初期化は Load/Shown で責務分離し、Shown で重い処理を始める
  • イベント内で重処理しない(処理を分離し、呼び出し回数を制御する)
  • 多重起動制御は Mutex 等で止める
  • トレイ常駐は「終了手順」と「Dispose順」を揃える

Load と Shown を分ける(表示を先に成功させる)

// 再発防止: 画面を先に表示し、重い初期化は後段へ回す
protected override void OnLoad(EventArgs e)
{
    base.OnLoad(e);
    // 補足: ここでは軽い初期化だけ(見た目の準備・バインドなど)
}

protected override async void OnShown(EventArgs e)
{
    base.OnShown(e);

    // 再発防止: UIが表示された後に、重い処理を開始する(体感を守る)
    try
    {
        await InitializeHeavyAsync();
    }
    catch (Exception ex)
    {
        // 証拠: 起動時の失敗を握り潰さず残す(ログで追える形へ寄せる)
        _logger.LogError(ex, "InitializeHeavyAsync failed.");
    }
}

private async Task InitializeHeavyAsync()
{
    // 補足: I/Oや集計など重い処理は async で逃がす
    await Task.Delay(10);
}

補足:Thread.Sleep(0)Task.Yield() は応急処置にはなるが設計解ではない。恒久対策では「処理の責務分割」と「戻り先の揃え」が要る。


追い討ち: ハマりポイント

  • Loadで重い処理をすると初回表示が固まる
  • Resize/Layout内でプロパティを触り続けると再帰する
  • NotifyIconは終了手順が弱いとプロセスが残る

4. 失敗パターン別:最短での見つけ方と直し方

4章は、各パターンを次の順で揃える。

  • 見え方: 現場で見える症状
  • 見つけ方: 何を見ると特定が進むか(Break All / ログ / 画面の観測点)
  • 止血: まず被害を増やさない手
  • 恒久: 設計と配置を寄せる手

4-1. 同期ブロック(.Result/.Wait)— UIが固まる最頻出

見え方

  • クリックが効かない、フォーム移動が引っかかる
  • ログが途中で止まる(最後のログ位置が再現ごとに近い)

見つけ方(観測)

  • Break All で UI スレッドの Call Stack を見る
    • Task の待ち(GetResult / Wait / WaitOne)が見えるなら同期ブロック寄り
  • 直近の UI イベント(Click / Shown / TextChanged / Resize)から、同期I/Oや .Result/.Wait を探す
  • イベント時系列ログで「開始は出るが終了が出ない」ハンドラを特定する

原因(何が起きているか)

  • UI スレッドが待ちに入ると、メッセージループが進まず、画面更新やイベント処理が止まる
  • await の戻り先が UI コンテキストに戻る設計だと、待ちが相互に噛んで止まりやすい

止血(まずこれ)

  • UI イベント内の .Result/.Waitawait に置き換える
// 悪い例: UIイベント内で同期で待つ → UIスレッドが止まる
private void buttonRun_Click(object sender, EventArgs e)
{
    Trace(nameof(buttonRun_Click), () =>
    {
        // 伝えたいこと: “結果が欲しい” だけで同期待ちにすると、画面が止まりやすい
        var text = LoadTextAsync().Result;
        label1.Text = text;
    });
}

private async Task<string> LoadTextAsync()
{
    await Task.Delay(200);
    return "ok";
}
// 止血: async/await へ寄せ、UIスレッドの待ちを消す
private async void buttonRun_Click(object sender, EventArgs e)
{
    Trace(nameof(buttonRun_Click), async () =>
    {
        // 伝えたいこと: 待ちは await で表現し、メッセージループを塞がない
        var text = await LoadTextAsync();
        label1.Text = text;
    });
}

// Trace を async 対応にした版(必要な場面だけ)
private async void Trace(string name, Func<Task> body)
{
    _logger.LogInformation("[UI] enter {Name}", name);
    var sw = Stopwatch.StartNew();
    try
    {
        await body();
    }
    finally
    {
        sw.Stop();
        _logger.LogInformation("[UI] exit  {Name} ({Ms}ms)", name, sw.ElapsedMilliseconds);
    }
}

恒久(設計へ寄せる)

  • UI 層は async void(イベント)→ 内部は Task で統一し、待ちと例外を合成できる形へ寄せる
  • ライブラリ層は必要に応じ ConfigureAwait(false)(UI層では扱いを分ける)

4-2. Invoke待ち(相互待ち)— 「Invokeが戻らない」の正体

見え方

  • 背景処理中に UI 更新した瞬間から固まる
  • 「固まり」と「戻る」が環境やタイミングで変わる

見つけ方(観測)

  • Break All で次の並びを探す
    • 背景側: Control.Invoke / WindowsFormsSynchronizationContext.Send
    • UI側: Task.Wait / .Result / WaitHandle.WaitOne(別待ちに入っている)
  • Threads ウィンドウで UI スレッドがどこに居るかを見る
  • ログで UI 側の開始は出るが、背景側の終了が出ない箇所を探す

原因(何が起きているか)

  • Invoke は「UI スレッドで実行し、終わるまで待つ」
  • UI スレッドが別の待ちに入っていると、背景側が Invoke で待ち、UI側も別待ちで進まず、相互待ちになる

止血(まずこれ)

  • 待ち合わせ不要の UI 更新は BeginInvoke へ寄せる(メッセージキューへ積む)
// 悪い例: 背景側が Invoke で完了待ちを持つ
private void RunInBackground()
{
    Task.Run(() =>
    {
        // 伝えたいこと: Invoke は完了まで待つため、相互待ちの引き金になる
        this.Invoke((Action)(() => label1.Text = "working"));

        // 背景側の重い処理
        Thread.Sleep(300);

        this.Invoke((Action)(() => label1.Text = "done"));
    });
}
// 止血: BeginInvoke へ寄せ、背景側の待ちを消す
private void RunInBackground()
{
    Task.Run(() =>
    {
        // 伝えたいこと: 待たずにUI更新を積み、背景側は進める
        this.BeginInvoke((Action)(() => label1.Text = "working"));

        Thread.Sleep(300);

        this.BeginInvoke((Action)(() => label1.Text = "done"));
    });
}

恒久(設計へ寄せる)

  • 背景処理の完了は Task で表現し、UI側は await で受ける
  • UI 更新は「UI層に集約」し、背景層に Control 参照を渡さない

4-3. 再入(DoEvents/イベント連鎖)— たまに壊れるの温床

見え方

  • たまに二重実行する、状態が飛ぶ
  • ボタン連打や入力連打で再現しやすい

見つけ方(観測)

  • イベント時系列ログで enter が入れ子になる(同じイベントが重なって出る)
  • Call Stack 上に Application.DoEvents が見える
  • UI状態が「処理中」のはずなのに別イベントが入っている

原因(何が起きているか)

  • DoEvents() はメッセージ処理を回すため、処理中でも別イベントが割り込む
  • 状態が中途半端なタイミングで別ハンドラが動き、状態整合が壊れる

止血(まずこれ)

  • 再入をガードし、同時に UI を反応させたい場面は処理分割へ寄せる
private bool _busy;

private void buttonApply_Click(object sender, EventArgs e)
{
    Trace(nameof(buttonApply_Click), () =>
    {
        // 伝えたいこと: 再入ガードで状態破壊を止める
        if (_busy) return;
        _busy = true;
        try
        {
            // 重い処理をイベント内に置かない。置いた場合は後段へ逃がす。
            ApplySettings();
        }
        finally
        {
            _busy = false;
        }
    });
}

private void ApplySettings()
{
    Thread.Sleep(200);
}

恒久(設計へ寄せる)

  • 重い処理は Task 化し、UIは進捗表示だけを持つ
  • 入力イベントは「確定イベント」(Validated / Leave 等)へ寄せ、連打系イベントに重処理を置かない

4-4. 過剰イベント(TextChanged/Resize)— 「一瞬止まり」が連打される

見え方

  • 文字入力が遅れる
  • Resize でガクガクする
  • CPU が上がるが、1回の処理はそれほど重くない

見つけ方(観測)

  • イベント時系列ログで同じハンドラが短時間に大量に出る
  • Stopwatch の 1回が数msでも、回数が多く合計で詰まる

原因(何が起きているか)

  • TextChanged/Resize は入力やドラッグで大量に発火する
  • その中で DB/ファイル/再計算などを走らせると累積で詰まる

止血(まずこれ)

  • 「落ち着いたら1回」を Timer で作り、連打を畳む
// 止血: TextChanged 連打を “後で1回” に畳む
private readonly System.Windows.Forms.Timer _textTimer = new() { Interval = 250 };

public Form1()
{
    InitializeComponent();

    _textTimer.Tick += (_, __) =>
    {
        _textTimer.Stop();
        RequeryOnce(textBox1.Text);
    };
}

private void textBox1_TextChanged(object sender, EventArgs e)
{
    // 伝えたいこと: 連打イベントでは重処理を走らせず、後段へ畳む
    _textTimer.Stop();
    _textTimer.Start();
}

private void RequeryOnce(string keyword)
{
    // 補足: フィルタ・検索・再計算など重い処理をここへ隔離
}

恒久(設計へ寄せる)

  • 確定操作(検索ボタン/Enter/Validated)で実行する設計へ寄せる
  • 連打が避けられない場合はスロットリング/キャンセル(CancellationToken)を加える

4-5. 描画/ちらつき — 更新粒度とバッファを疑う

見え方

  • リスト更新でチラつく
  • Resize 中だけガタつく
  • 表示が一瞬白くなる

見つけ方(観測)

  • Resize 中に Layout / Paint が大量に走っている(ログ回数で見える)
  • Controls.Add/Remove を連続で叩いている箇所がある
  • Layout イベント内でさらに UI を変更している(再帰の匂い)

原因(何が起きているか)

  • 1件ずつ追加/削除すると、その都度レイアウトと描画が走る
  • バッファが弱いと描画の途中が見えてチラつく

止血(まずこれ)

  • バッチ更新(SuspendLayout/ResumeLayout、BeginUpdate/EndUpdate)へ寄せる
// 止血: リスト更新をバッチ化(対応コントロールなら BeginUpdate/EndUpdate)
listView1.BeginUpdate();
try
{
    // 伝えたいこと: “1件ごと” の再描画を避け、まとめて差し替える
    listView1.Items.Clear();
    listView1.Items.Add(new ListViewItem("A"));
    listView1.Items.Add(new ListViewItem("B"));
}
finally
{
    listView1.EndUpdate();
}

恒久(設計へ寄せる)

  • ちらつきが強い画面は DoubleBuffer を有効化し、描画コストを抑える
// 恒久: DoubleBuffer を有効化し、描画中の見え方を抑える
public sealed class DoubleBufferedPanel : Panel
{
    public DoubleBufferedPanel()
    {
        this.DoubleBuffered = true;
        this.ResizeRedraw = true;
    }
}

4-6. NotifyIcon(トレイ)— 「消えたつもり」でもプロセスが残る

見え方

  • ×で消えたが、タスクマネージャにプロセスが残る
  • トレイに残骸が残る(再起動後に増えることがある)

見つけ方(観測)

  • ×を押した後、プロセスが残っているか確認する
  • 終了操作が「隠す」になっていないか(FormClosing の処理を見る)
  • NotifyIcon が Dispose されているか、終了経路を追う

原因(何が起きているか)

  • トレイ常駐は「閉じる=終了」ではなく「閉じる=隠す」に寄りやすい
  • NotifyIcon を Dispose しないと、トレイ側に残骸が残りやすい

止血(まずこれ)

  • 終了専用の経路を用意し、そこでは Visible→Dispose→Exit の順で片付ける
private NotifyIcon _tray;

private void SetupTray()
{
    _tray = new NotifyIcon
    {
        Icon = this.Icon,
        Visible = true,
        Text = "MyApp"
    };

    var menu = new ContextMenuStrip();
    menu.Items.Add("開く", null, (_, __) => ShowMainWindow());
    menu.Items.Add("終了", null, (_, __) => ExitApp());
    _tray.ContextMenuStrip = menu;
}

private void ExitApp()
{
    // 伝えたいこと: トレイ残骸を残さないため、片付け順を揃える
    _tray.Visible = false;
    _tray.Dispose();
    Application.Exit();
}

恒久(設計へ寄せる)

  • 「×」は隠す、「終了」は確実に終了、の責務を分ける
  • 終了経路を1本に寄せ、例外時も Dispose が通るようにする

4-7. 多重起動 — 起動連打で増える

見え方

  • 起動が重いときに実行ファイルを連打すると2つ立つ
  • データファイルの排他で落ちたり、二重操作で状態が壊れる

見つけ方(観測)

  • タスクマネージャで同名プロセスが複数起動している
  • 起動処理が重い(起動ログの開始から表示までが長い)

原因(何が起きているか)

  • 単一起動の排他が無いと、起動要求をそのまま受けて増殖する

止血(まずこれ)

  • Mutex で単一起動を担保する
// 止血: 単一起動を保証し、起動連打による多重起動を止める
static class Program
{
    [STAThread]
    static void Main()
    {
        using var mutex = new Mutex(initiallyOwned: true, name: @"Global\\MyApp.SingleInstance", out var createdNew);
        if (!createdNew)
        {
            // 補足: 既に起動している場合はここで終える(前面化は別設計)
            return;
        }

        Application.EnableVisualStyles();
        Application.SetCompatibleTextRenderingDefault(false);
        Application.Run(new Form1());
    }
}

恒久(設計へ寄せる)

  • 既存インスタンスを前面化する場合、通知手段(NamedPipe 等)を足す
  • データ層は排他前提で設計し、二重起動が発生しても壊れにくい形へ寄せる

4-8. 成果物差分/依存差分 — 端末差の正体を言語化して潰す

まず用語を揃える。

  • 成果物: 配布するフォルダ一式(exe/dll/config/リソース)
  • 依存: 実行に必要な外部要素(.NET Runtime、VC++ Runtime、OS機能、ドライバ、フォント、権限、環境変数、レジストリ、外部サーバ接続設定など)
  • 差分: 端末ごとに「成果物」や「依存」が揃っていない状態

見え方

  • A端末は動くがB端末だけ落ちる/固まる
  • 更新後に一部端末だけ挙動が変わる

見つけ方(観測)

  • 成果物が同一かを確認する(最短)
    • 配布先フォルダを丸ごと比較する(ファイル数、サイズ、更新日時だけでも手掛かりになる)
    • exe/dll の FileVersion/AssemblyVersion を見る
    • 可能ならハッシュ(SHA256 等)で一致を取る
  • 依存が揃っているかを確認する
    • .NET の実行環境が一致しているか
    • x86/x64 の差が出ていないか(AnyCPU の設定や依存dllのbit数)
    • 設定ファイル(appsettings/app.config)の差が無いか
    • 権限差(管理者権限、ファイル/レジストリ/ネットワーク)が無いか

止血(まずこれ)

  • 上書きコピーではなく、配布先フォルダを一度退避/削除し、フォルダ丸ごと入れ替える
  • 更新差が疑わしい端末は、同一ZIPから展開して揃える(配布経路を揃える)

恒久(設計へ寄せる)

  • 起動ログに「成果物バージョン」と「主要依存の情報」を出す(端末差が言語化できる)
// 恒久: 起動時に “揃っているか” をログへ残し、端末差の切り分けを速くする
_logger.LogInformation("AppVersion={Version}", typeof(Program).Assembly.GetName().Version);
_logger.LogInformation("BaseDirectory={Dir}", AppContext.BaseDirectory);
_logger.LogInformation("OS={OS}", Environment.OSVersion);
_logger.LogInformation("ProcessArch={Arch}", System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture);
_logger.LogInformation("Framework={Fx}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);

5. 再発防止の掟: ルール化(運用・規約)

観点 ルール案 レビューで見るポイント
同期ブロック UIスレッドで .Result/.Wait を使わない UIイベントから同期待ちが出ていないか
画面更新 UI更新はUIスレッドに集約する BeginInvoke 乱用で順序が壊れていないか
DoEvents 原則使わない 再入で状態破壊していないか
計測 開始/終了の計測ログを残す 重い箇所の見える化があるか
過剰イベント TextChanged/Resize内で重処理しない 連打イベントに重処理が乗っていないか
レイアウト 更新はバッチ化(Suspend/Resume) Layout/ResizeでControlを触り続けていないか
トレイ NotifyIconは必ずDispose、終了手順を用意 トレイ残骸やプロセス残りが起きないか
多重起動 Mutex等で単一起動を担保 起動連打で増殖しないか
成果物/依存 フォルダ丸ごと入れ替えで揃える 上書き運用で差分が残っていないか

6. 関連リンク


7. 禁書庫: 現場の即効チェック(印刷して机に置く)

  • Break All で UI スレッドの停止位置を確認する
  • .Result/.Wait/Invoke/DoEvents をスタック上で探す
  • Stopwatch で開始/終了の計測を入れて「一瞬」を可視化する
  • Resize/TextChanged/Layout の中に重処理が居ないかを見る
  • 端末差があるなら成果物フォルダを丸ごと入れ替えて再現を見る
  • トレイ常駐なら「終了手順」と「Dispose順」を確認する
  • 二重起動するなら Mutex 等の単一起動制御を入れる

連載Indexへ戻る: S00_門前の誓い_総合Index

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?