連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index
WinFormsの業務アプリでボタンを押した直後、画面が白くなって反応が返らない。
「入力できなくなった?」が先に出て、マウスを動かしてもクリックしても戻らない。
タスクマネージャを開き、CPUやディスクを見て、アプリだけが「応答なし」になっているのに気づく。
止まっているのは処理そのものというより、UIスレッドがメッセージを処理できていない状態だった。
このページでは、UIフリーズを短い手順で切り分け、最短で見当を付ける順番を整理しておく。
このページで手に入るもの
「固まった」が「ここが詰まっている」に変わるところまでをまとめる。
次に同じ現象が出ても、最初の数分で見当を付けて修正へ進める状態にする。
- 症状 -> 原因 -> まずやることの逆引き表(応答なし/白化/一瞬遅延で分ける)
- Break Allで見る場所(UIスレッドの待ち/ロック/同期待ち)
- 一瞬フリーズ検出:UiLagMonitor(遅延ms+直前操作名を残す)
- UIイベント最短テンプレ(UI更新/CPU処理/非同期I/O/例外観測)
- 失敗パターン集(.Result/.Wait、Invoke待ち、DoEvents、ConfigureAwait(false)の置き場所)
先に逆引き(症状 -> 原因 -> 対策)
固まり方だけだと手が止まりやすいので、症状ごとに「疑う所」と「最初の一手」をまとめておく。
ひとまずこの表から始めると、切り分けの順番が見えやすくなる。
アプリ側(WinFormsで起きやすい)
| 症状(見え方) | 疑う所(当たり) | 最初の一手 |
|---|---|---|
| クリック直後に固まる/反応しない | UIイベント内で重い処理、同期I/O、lock待ち | UIイベント直下へStopwatchを入れ、重い区間を確定する。CPU処理はUI外へ、I/Oは非同期APIへ |
| タイトルバーに「応答なし」 | メッセージループ詰まり、UIスレッドが戻らない | デバッグ中断(Break All)でUIスレッドのCall Stackを確認する |
| スクロール/リサイズで白化、チラつき | 再描画/再レイアウト中に重い処理、Invalidate連打 | 描画経路から重い処理を外す。計測で場所を絞る |
| awaitしたのにUIが更新されない | await後の戻り先がUIではない、例外が見えていない | ConfigureAwait(false)の置き場所を点検。投げっぱなしTaskの例外観測を入れる |
| たまに固まる/再現しづらい | .Result/.Wait、Invoke待ち、ロック競合 | UI層の同期待ちを避ける。Invokeで完了待ちしない形へ |
| 一瞬だけ固まる(100ms〜数秒) | GC、同期I/O、ログ出力、画像処理、再入、CPUスパイク | UiLagMonitorで遅延ms+直前操作名を残す。区間計測も足す |
| マウスは動くがアプリだけ固まる | UIスレッド詰まり | Break AllでUIスレッドの待ち(lock/Wait/Invoke/.Result)を探す |
PC/環境側(アプリ外の重さが絡む)
| 症状(見え方) | 疑う所(当たり) | 最初の一手 |
|---|---|---|
| PC全体が重い | CPU/メモリ/ディスクの資源独占 | タスクマネージャで支配資源を特定。I/Oやループを疑う |
| エクスプローラも固まる | ディスク100%やメモリ不足 | ディスクI/O詰まり、スワップを確認する |
| 画面が一瞬ブラック/点滅 | GPUドライバ、描画負荷(アプリ描画が引き金になる場合あり) | イベントログ、GPU周り、描画経路の負荷を確認する |
最短テンプレ(コピペ)
「応答なし」をその場で止める手と、後から原因へ近づくための計測をまとめる。
貼る場所と差し込み位置がコメントで分かるので、まずは差し込んで様子を見るところから始められる。
1) UIイベント用テンプレ(CPU負荷/非同期I/O/例外/進捗)
UI側に残す処理と、UI外へ逃がす処理が分かるように、最小の骨組みを用意しておく。
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
/// <summary>
/// UIイベントの基本形。
/// - UIの操作(Enabled切替/表示更新)はUI側
/// - CPU重い処理は Task.Run でUI外へ
/// - I/O は非同期API(例: HttpClient)へ寄せ、Task.Runで包まない
/// - 例外は掴んで、握りつぶさずログへ出す
/// </summary>
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
// 「何の直後か」を残す(UiLagMonitorとセットで効く)
MarkAction("Run");
var sw = Stopwatch.StartNew();
try
{
// CPU負荷など(UI外へ)
await Task.Run(() => HeavyCpuWork());
// I/O は非同期APIへ(例として Task.Delay を置く)
await Task.Delay(200);
labelStatus.Text = "done";
}
catch (Exception ex)
{
LogError(ex, op: "Run");
labelStatus.Text = "error";
}
finally
{
sw.Stop();
LogInfo($"elapsed={sw.ElapsedMilliseconds}ms op=Run");
btnRun.Enabled = true;
}
}
private static void HeavyCpuWork()
{
// 例: 画像処理、圧縮、集計など
Thread.SpinWait(10000000);
}
private static void LogInfo(string msg) => Debug.WriteLine(msg);
private static void LogError(Exception ex, string op)
=> Debug.WriteLine($"op={op} ex={ex}");
2) 一瞬フリーズ専用:UI遅延モニタ(UiLagMonitor)
BeginInvokeが戻ってくるまでの遅延msで、UIスレッドの詰まりを測る。直前操作名とセットで残すと調査が速い。
using System;
using System.Diagnostics;
using System.Threading;
using System.Windows.Forms;
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;
/// <summary>
/// UI遅延モニタ。
/// - 貼る場所: FormのOnShownで開始、OnFormClosedでDispose
/// - intervalMs: 心拍の間隔(200ms程度が扱いやすい)
/// - thresholdMs: 何ms以上を「遅延」とみなすか(200ms前後が拾いやすい)
/// </summary>
public UiLagMonitor(Control ui, Func<string> getLastAction, int intervalMs = 200, int thresholdMs = 200)
{
_ui = ui;
_getLastAction = getLastAction;
_thresholdMs = thresholdMs;
_timer = new System.Threading.Timer(
callback: (_) =>
{
if (_ui.IsDisposed || !_ui.IsHandleCreated) return;
var id = Interlocked.Increment(ref _seq);
var start = Stopwatch.GetTimestamp();
_ui.BeginInvoke(new Action(() =>
{
var end = Stopwatch.GetTimestamp();
var ms = (end - start) * 1000.0 / Stopwatch.Frequency;
if (ms >= _thresholdMs)
{
var act = _getLastAction();
LogWarn($"ui_lag seq={id} lag={ms:F0}ms action={act}");
}
}));
},
state: null,
dueTime: 0,
period: intervalMs);
}
public void Dispose() => _timer.Dispose();
private static void LogWarn(string msg) => Debug.WriteLine(msg);
}
フォーム側の組み込み例(貼る場所が分かる形)
private string _lastAction = "none";
private UiLagMonitor? _lag;
/// <summary>
/// 直前操作名を残す。ボタン/検索/保存など、起点イベント名を入れる。
/// </summary>
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());
}
private static void SearchCore()
{
Thread.Sleep(300); // 例: 重い処理のつもり
}
3) 投げっぱなしTaskの例外観測(FireAndForget)
例外が見えないと「固まった」に見える場面が出る。例外を観測する補助だけは入れておく。
using System;
using System.Threading.Tasks;
private static void FireAndForget(Task task, Action<Exception> onError)
{
task.ContinueWith(
continuationAction: (t) =>
{
var ex = t.Exception?.GetBaseException() ?? new Exception("Unknown background task error");
onError(ex);
},
cancellationToken: System.Threading.CancellationToken.None,
continuationOptions: TaskContinuationOptions.OnlyOnFaulted,
scheduler: TaskScheduler.Default);
}
解説:メッセージループ/Invoke/asyncを同じ図で整理する
「何が詰まると固まるのか」「await後にどこへ戻るのか」「Invokeは何をしているのか」を同じ見方でまとめる。
用語の整理(最初に迷いを消す)
- UIスレッド:WinFormsのメッセージループ(入力/描画/イベント)を回すスレッド
- 背景スレッド:UI以外の作業(I/O/計算/待ち)を行うスレッド
- メッセージループ:OSから届く入力や描画要求を取り出して処理するループ(止まるとUIが止まる)
- Invoke/BeginInvoke:背景側からUIスレッドへ処理を投げる仕組み(Invokeは完了待ち、BeginInvokeは待たない)
- async/await:待てる形にする仕組みで、スレッドを増やす仕掛けではない
- SynchronizationContext:await後の戻り先を決める仕組み(WinFormsでは通常UIへ戻る)
まず見える形(スレッドの流れ)
UIスレッドが塞がると、入力も描画も止まる。BeginInvokeの帰還が遅れると「一瞬だけ固まる」も検出できる。
評価の目安(体感の切り分けに使う)
- 100ms:引っ掛かりが体感に出始める
- 300ms:「重い」が出やすい
- 500ms:フリーズ扱いで改善対象に入れやすい
区間計測(Stopwatch)とUI遅延モニタ(UiLagMonitor)で「どの操作」「何ms」を残すと、再現が弱い場面でも前へ進む。
判例:原因別に潰す(よくある形 -> 直した形 -> ポイント)
よく出る順に並べ、「形」「直し方」「ポイント」を同じレベル感で書きそろえる。
1) UIイベント内で重い処理を回している
クリック直後に固まる時、まず疑うのがUIイベント内の重い処理。
CPU処理・同期I/O・長いループがUIスレッドに乗ると、入力も描画も止まりやすい。
ありがちな書き方(UIイベントで重い処理が走る)
private void btnRun_Click(object sender, EventArgs e)
{
// ここが肝: UIスレッドで重い処理を回すと、メッセージループが止まりやすい
HeavyWork();
// ここが肝: UI更新はできても、途中の入力/描画が詰まる
label1.Text = "done";
}
手直し例(重い処理はUI外へ、UI更新はawait後へ)
private async void btnRun_Click(object sender, EventArgs e)
{
// ここが肝: 連打/二重実行を防ぐ(体感の安定)
btnRun.Enabled = false;
try
{
// ここが肝: CPU負荷はUI外へ(UIスレッドを塞がない)
await Task.Run(() => HeavyWork());
// ここが肝: await後にUI更新(通常はUIへ戻って続く)
label1.Text = "done";
}
finally
{
// ここが肝: 失敗時も復帰させる(UIが戻らないを避ける)
btnRun.Enabled = true;
}
}
覚えておく所
- CPU負荷は Task.Run、I/Oは非同期API(I/OをTask.Runで包むと見通しが悪くなりやすい)
- await後にUI更新が続くなら、UIへ戻る前提が残る方が追いやすい
- Stopwatchで区間計測を入れ、重い場所を先に確定させる
2) .Result/.Wait でUIスレッドが待っている
「たまに固まる」「再現しづらい」で目立つのがUI側の同期待ち。
awaitの戻り待ちとUIの戻り待ちが絡むと、相互待ちになりやすい。
ありがちな書き方(UI側で同期待ち)
// ここが肝: UIスレッドを止めて待つ(相互待ちになりやすい)
var x = GetAsync().Result;
手直し例(待ちはawaitへ)
// ここが肝: 待ちはawaitへ(UIスレッドを塞がず続きへ進む)
var x = await GetAsync();
覚えておく所
- UI層の .Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult() は候補に上がりやすい
- 一部だけ残ると、切り分けの道筋が見えにくくなる
- 「待ちを作っている場所」から減らすと、調査が前へ進みやすい
3) Invokeで完了待ちしている(Invoke待ち+別の待ちが重なる)
背景側からUIへ投げた処理を「待つ」書き方が入ると、別の待ちと結び付きやすい。
待ちが連なった結果、UI側が詰まりやすくなる。
ありがちな書き方(Invokeで完了を待つ)
// 背景側
// ここが肝: InvokeはUIで実行し終わるまで待つ(待ちの鎖になりやすい)
this.Invoke(new Action(() => label1.Text = "update"));
// ここが肝: さらに別の待ちが乗ると、詰まりが見えにくくなる
SomeOtherWait(); // Join/Waitなど
手直し例1(待たない投げ方に変える)
// 背景側
// ここが肝: BeginInvokeは待たない(鎖を作りにくい)
this.BeginInvoke(new Action(() => label1.Text = "update"));
手直し例2(進捗をIProgressへまとめる)
private async void btn_Click(object sender, EventArgs e)
{
// ここが肝: UI更新の窓口を1つに寄せる(散らかりにくい)
var progress = new Progress<int>((v) => progressBar1.Value = v);
// ここが肝: CPU負荷はUI外へ、UI更新はProgress経由へ
await Task.Run(() => DoWork(progress));
}
覚えておく所
- Invokeは完了待ち、BeginInvokeは待たない
- UI更新が散ると追いづらいので、IProgressや共通関数へまとめると楽になる
- 「待ち+待ち」が続かない流れに変えるのが近道になる
4) UI更新が散っている(背景側からUIへ直接触っている)
背景側からControlを直接更新すると、例外や表示崩れにつながりやすい。
UI更新の窓口を用意しておくと、動きが読みやすくなる。
ありがちな書き方(背景側から直接UIを触る)
private void StartWork()
{
Task.Run(() =>
{
// ここが肝: 背景側から直接UIを触る(例外/不安定さの起点になりやすい)
label1.Text = "working...";
});
}
手直し例(UI更新の窓口を通す)
private void SetTextSafe(Label label, string text)
{
// ここが肝: 終了後に飛んでくる更新を避ける
if (label.IsDisposed) return;
// ここが肝: UIスレッドでなければUIへ投げる
if (label.InvokeRequired)
{
label.BeginInvoke(new Action(() => label.Text = text)); // 待たない
return;
}
// ここが肝: UIスレッドならそのまま更新
label.Text = text;
}
覚えておく所
- UIコントロールはスレッドセーフではない前提
- UI更新の通り道が増えるほど追いづらくなるので、窓口があると助かる
- 終了時のBeginInvokeは IsDisposed/IsHandleCreated を先に見る
5) DoEventsで「一時的に動いた」に見えるが、状態が壊れやすい
DoEventsは途中で別イベントが割り込みやすく、状態の整合が崩れやすい。
その場では動いたように見えても、後から詰まりやすくなる。
ありがちな書き方(DoEventsで割り込みを許す)
// ここが肝: 途中で別イベントが走る(状態が読みづらくなる)
Application.DoEvents();
手直し例(非同期化+進捗+キャンセルへ置き換える)
private CancellationTokenSource? _cts;
private async void btnStart_Click(object sender, EventArgs e)
{
// ここが肝: 前の実行を止められるようにする
_cts?.Cancel();
_cts = new CancellationTokenSource();
// ここが肝: UI更新はProgressへ寄せる(散らかりにくい)
var progress = new Progress<int>((v) => progressBar1.Value = v);
try
{
// ここが肝: UIを塞がずに進める(DoEventsの代替)
await RunLongWorkAsync(progress, _cts.Token);
label1.Text = "done";
}
catch (OperationCanceledException)
{
// ここが肝: キャンセルは例外で流れるので、ここで整える
label1.Text = "canceled";
}
}
private void btnCancel_Click(object sender, EventArgs e)
{
// ここが肝: いつでも止められるようにする
_cts?.Cancel();
}
private static async Task RunLongWorkAsync(IProgress<int> progress, CancellationToken token)
{
for (var i = 0; i <= 100; i++)
{
// ここが肝: 停止点をループ内に置く(止まらないを避ける)
token.ThrowIfCancellationRequested();
// ここが肝: UI更新はProgress経由
progress.Report(i);
// ここが肝: 分割してawait(UIスレッドを塞がない)
await Task.Delay(20, token);
}
}
覚えておく所
- DoEventsは割り込みが起きやすく、状態管理が難しくなる
- 代替は「非同期化」「進捗」「キャンセル」をセットで用意すると扱いやすい
- UiLagMonitorやStopwatchと組み合わせると、遅延の場所が見えやすくなる
6) 例外が見えず、固まったように見える
裏側で例外が起きても表に出てこないと、「終わらない」「反応が返らない」に見えやすい。
UIが詰まっているのか、裏側で止まっているのかの区別も付きにくくなる。
ありがちな書き方(投げっぱなしで例外が見えない)
// ここが肝: 例外がどこにも出ず、消えることがある
Task.Run(() => DoWork());
手直し例(例外を拾ってログへ出す)
// ここが肝: 失敗の事実を残す(固まったように見えるを減らす)
FireAndForget(Task.Run(() => DoWork()), (ex) => LogError(ex, op: "DoWork"));
覚えておく所
- イベント以外の async void は避け、Taskを返す形に合わせる
- 例外を飲み込んで無視すると、手掛かりが消えて切り分けが止まりやすい
- 投げっぱなしTaskには例外観測を付けて、起きた事実を残す
7) ConfigureAwait(false) がUI側に入り、await後のUI更新が止まる
UI側のawaitは、終わったあとに画面更新へ続くことが多い。
そこへ ConfigureAwait(false) が入ると、await後がUIではないスレッドで続き、画面更新が詰まりやすくなる。
- UI側:画面更新が続く前提を残す(ConfigureAwait(false) は基本入れない)
- ライブラリ側:UIに依存しないために ConfigureAwait(false) を検討する(戻り先を決めない)
手直し例(UI側は付けず、ライブラリ側で付ける)
// UI側:await後に画面更新が続くので、戻り先を崩さない
private async void btn_Click(object sender, EventArgs e)
{
// ここが肝: UI側ではConfigureAwait(false)を付けない
var data = await service.GetAsync();
// ここが肝: await後にUI更新(UIスレッドで続く前提)
label1.Text = data;
}
// ライブラリ側:UIに依存しないので、戻り先を決めない
public async Task<string> GetAsync()
{
// ここが肝: ライブラリ側でConfigureAwait(false)(UIへ戻る前提を持たない)
var s = await _client.GetStringAsync(_url).ConfigureAwait(false);
// ここが肝: ここから先はUIを触らない前提で組む
return s;
}
覚えておく所
- UI側は await後に画面更新が続きやすいので、戻り先を崩さない方が扱いやすい
- ConfigureAwait(false) はUI側とライブラリ側で扱いが変わる
- 逆引きの「awaitしたのにUIが更新されない」につながりやすいので、付いている場所から確認する
チェックリスト:レビューで見返す所(表)
迷いが出やすい観点を表にまとめ、チームの判断がぶれにくくなる形にする。
運用ルール(回るための最小セット)
| ルール | 回し方(具体案) | 補足 |
|---|---|---|
| UIイベントで重い処理をしない | レビュー観点に入れる。200ms超は計測ログを残す | CPUはTask.Run、I/Oは非同期API |
| UI層で .Result/.Wait を置かない | PRで全文検索。静的解析の導入も検討 | 同期待ちは設計レビュー対象 |
| UI更新は窓口へまとめる | SetTextSafe / IProgress など共通手段を用意 | 例外の扱いも統一しやすい |
| DoEventsを使わない | 発見したら撤去を前提に扱う | 進捗/キャンセルへ置換 |
| 一瞬フリーズは計測で拾う | UiLagMonitorをデバッグ時に組み込む | 閾値は200ms/500msを目安にする |
| 例外が見えない形を避ける | async void はイベントのみ、投げっぱなしは観測付きにする | catchで無視は避ける |
レビュー観点(表)
| 観点 | 探す場所(具体) | 見つかった時の方向 |
|---|---|---|
| UIイベントがUIを塞いでいないか | Click/Changed/Shown/TimerTick | Task.Runや非同期I/Oへ分解、Stopwatchで計測 |
| 同期待ちが残っていないか | .Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult() | awaitへ統一、設計の待ち構造を見直す |
| 待ちが連鎖していないか | Invokeの完了待ち、lock、Join | BeginInvoke/IProgressへ、待ちの鎖を切る |
| UI更新が散っていないか | 背景側からControl更新 | SetTextSafeやIProgressへまとめる |
| 一瞬フリーズが拾えるか | 主要操作の直後 | UiLagMonitor+MarkAction、閾値を設定 |
| 例外が見えるか | 投げっぱなしTask、catch | 例外観測とログを入れる |
| 終了時の更新が残っていないか | FormClosed後のBeginInvoke | IsDisposed/IsHandleCreated、キャンセル導入 |
障害報告テンプレ(フリーズ編)
「固まった」だけだと調査が止まりやすい。最低限これが埋まると再現と切り分けが進む。
| 項目 | 例 | 意味 |
|---|---|---|
| 発生タイミング | ボタンA押下直後 | 起点イベント |
| 再現率 | 10回中3回 | 条件探索の指針 |
| 操作手順 | 1)起動 2)A 3)B | 再現の核 |
| 固まり方 | 応答なし/白化/一瞬だけ | 原因の当たり |
| OS影響 | 他アプリは動く/OSも重い | 切り分け |
| CPU/メモリ/ディスク | CPU 90%/Disk 100%など | 資源独占の手掛かり |
| 計測ログ | ui_lag lag=450ms action=Search | 一瞬フリーズの手掛かり |
| 例外ログ | 例外の有無/スタック | 例外の可視化 |
| 回避策 | 再起動で復帰など | 重要なヒント |
ふり返り(5問)
読み終えたら、理解できた点と曖昧な点を軽く確認するための5問。
明日どこを手直しするかが見えやすくなる。
-
- UIイベント内の
.Result/.Waitが危ない理由を、短く説明できるか
- UIイベント内の
-
- 「応答なし」の時、Break All から UIスレッドの Call Stack を見て「どこで待っているか」を追えるか
-
- I/O を
Task.Runで包むのが合いにくい理由を説明できるか
- I/O を
-
- UiLagMonitor が測っているのが「UIへ投げた処理が戻るまでの遅延」だと説明できるか
-
- DoEvents を避ける代替として「非同期化 / 進捗 / キャンセル」を挙げられるか
回答例(クリックで展開)
-
- UI側の同期待ちは相互待ちの温床になりやすく、awaitへ統一する方が見通しが良い。
-
- DebugのBreak Allで停止し、UIスレッドのCall StackにWait/lock/Invoke/.Resultが見えたら見当を付ける。
-
- I/Oは待ち時間が主でスレッドを塞ぎやすい。非同期API(HttpClient等)にまとめる方がスレッド消費を抑えやすい。
-
- BeginInvokeがUIスレッドで実行されるまでの時間=UIがメッセージを処理できていない時間、として遅延msを出す。
-
- DoEventsは割り込みで状態が壊れやすい。処理をawaitで分割し、進捗はIProgress、停止はCancellationTokenで扱う。
次なる試練(関連トピック)
次に読むと理解がつながりやすいリンクを並べる。
- S00: 門前の誓い(総合Index/読む順/公開済一覧)
- S02: 現場トラブルシュート早見表
- R06: 非同期の掟(UIスレッド/awaitの帰還/デッドロック典型)
- G11: メッセージループ入門(Application.Runと固まる理由)
- G13: UIスレッドと非同期(SynchronizationContextの正体)
連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index