連載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です。