連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
WinFormsで「画面が固まる」「クリックが効かない」「Resizeでカクつく」「×で消えたのにプロセスが残る」「起動連打で増える」が出たとき、原因当てより先に観測点を揃える。
Break All の Call Stack とイベント時系列ログで、.Result/.Wait の同期ブロック、Invoke の相互待ち、過剰イベント、レイアウト連鎖、NotifyIcon 寿命、多重起動、成果物差分を短時間で切り分け、止血から恒久へつなげる。
このページは、原因当てより先に、現場で最短に到達するための「観測点を揃える→止血→恒久」を整理する救急メモとする。
0. 30秒で結論(まず止血)
- 「固まった」「クリックが効かない」= UIスレッドが止まっている前提で観測する
- 「一瞬止まる」= 1回の重さより短い処理の積み重ねを疑う(開始/終了ログで可視化する)
- 最初の1分はこれだけ:
- Visual Studio の Break All(全スレッドの Call Stack)
- UIスレッドがどこで止まっているかを確定する
- イベント時系列ログ(開始/終了+Stopwatch)
- スタック上で
.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/.Waitをawaitに置き換える
// 悪い例: 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. 関連リンク
- E04: 止血と計測(ログ/タイムアウト/計測の型)
- G11: 構造理解(メッセージループとUI帰還)
- G13: 実戦整理(同期ブロック/デッドロック/帰還先)
- R06: 規約化(レビュー基準へ落とす)
7. 禁書庫: 現場の即効チェック(印刷して机に置く)
- Break All で UI スレッドの停止位置を確認する
-
.Result/.Wait/Invoke/DoEventsをスタック上で探す - Stopwatch で開始/終了の計測を入れて「一瞬」を可視化する
- Resize/TextChanged/Layout の中に重処理が居ないかを見る
- 端末差があるなら成果物フォルダを丸ごと入れ替えて再現を見る
- トレイ常駐なら「終了手順」と「Dispose順」を確認する
- 二重起動するなら Mutex 等の単一起動制御を入れる
連載Indexへ戻る: S00_門前の誓い_総合Index