連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index
WinForms でボタンを押した直後に画面が白くなり、タスクマネージャでは「応答なし」になる。
この時に広く追い始めると、Click や .Wait ではなく別の場所まで見に行ってしまい、時間を使いやすいです。
先に見るのは、UI イベントの中で画面を止めていないか、UI 側で同期的に待っていないか、背景処理から Invoke 完了待ちしていないかの 3 点です。
この記事では、WinForms で起きやすい詰まり方を 1 画面で比べます。
UI スレッドを占有して止まる例、UI 側の .Wait や .Result で止まる例、Invoke 完了待ちが重なって止まる例、Task.Run で CPU 処理を外へ出した例、await で待機中も画面が動く例、BeginInvoke で UI 更新だけ渡す例を並べて、どこで詰まるかを切り分けます。
このページで確認したいのは 3 つです。
固まった時に最初に疑うコード、await を書いていても止まる場面、毎回は出ない引っ掛かりをログへ残す方法です。
サンプルは .NET 8 の WinForms を前提にしていますが、ここで比べる止まり方は Framework 4.8.1 の画面でもほぼ同じように見分けられます。
WinForms が固まった時、症状ごとに当たるコードを絞る
最初から全部追うと、時間だけ減っていきます。
まずは、症状ごとに開く場所と直す方向を絞った方が早いです。
| 症状 | 最初に開く場所 | 最初に grep する文字列 | まず直す方向 |
|---|---|---|---|
| ボタンを押した直後に白くなる |
Click Shown TimerTick
|
Thread.Sleep for while 重い同期処理 |
UI イベントの外へ出す |
| 非同期にしたのに固まる | UI 層のイベントハンドラ |
.Wait .Result GetAwaiter().GetResult()
|
UI 側の同期待ちをやめる |
| 背景処理へ出したのに止まる | 背景処理からの UI 更新 |
Invoke worker.Wait()
|
Invoke 完了待ちを減らす |
| たまに一瞬だけ引っ掛かる | ログ出力、画像処理、同期 I/O 周辺 |
File. Image Save Load GC.
|
遅れた ms と操作名を残す |
| 表示更新だけ多い | 進捗通知や UI 更新関数 |
BeginInvoke IProgress<T>
|
UI 更新経路を 1 本へ寄せる |
保存用チェックリスト
-
ClickShownTimerTickの中に重い処理が残っていないか -
.Wait.ResultGetAwaiter().GetResult()が UI 側に残っていないか - 背景処理から
Invoke完了待ちしていないか - ログ出力、画像処理、同期 I/O が UI スレッドへ残っていないか
- 「何 ms 遅れたか」と「どの操作の直後か」をログへ残せているか
手元で動かしながら確認する場合は、先にサンプルを開いておくと本文と対応づけやすくなります。
GitHubで開く
止まり方ごとに、どこで詰まるかを比べる
同じ「止まったように見える」でも、詰まる場所は同じではありません。
このサンプルは、どこで詰まるかを 1 画面で比べられる形にしています。
| ボタン | どこで詰まる例か | 心拍表示 | 最初に確認するコード | 見つけたら直す方向 |
|---|---|---|---|---|
| UIで重い処理 | UI スレッド占有 | 止まる |
Click の中で重い処理を直接呼んでいないか |
CPU 処理を UI の外へ出す |
.Waitで待機 |
UI 側の同期待ち | 一時的に止まる |
.Wait .Result GetAwaiter().GetResult()
|
UI 側を await へ戻す |
| Invoke + Wait | UI 側と背景側の待ちの重なり | 一時的に止まりやすい |
Invoke と Wait が同時に並んでいないか |
UI 完了待ちを減らす |
| Task.RunでCPU処理 | 実行スレッドの分離 | 止まりにくい | CPU 処理を UI 外へ出しているか | CPU と待機を分けて考える |
| async I/O待機 | 待機中に UI を空ける | 止まりにくい | 非同期 API を await しているか |
待機は非同期 API へ寄せる |
| BeginInvokeで更新 | UI 更新だけを非同期で渡す | 止まりにくい |
BeginInvoke IProgress<T>
|
戻り値不要の更新は待たない |
心拍表示・状態表示・進捗・ログで止まり方を見分ける
このサンプルは、ボタンを押した直後に何が止まり、何が動き続けるかを画面で見分ける形にしています。
画像を見る時は、次の表示だけ押さえれば十分です。
心拍表示
一定間隔でラベルやプログレスを更新しています。
ここが止まる時は、UI スレッドがメッセージを回せていません。
状態表示
今どの処理を動かしたかを表示します。
どの操作の直後に止まったかを対応づける欄です。
作業進捗
背景処理や待機処理の進み具合を出します。
UI が空いていれば、途中経過も動きます。
ログ
操作名、処理開始、処理終了、例外、UI ラグを残します。
毎回は出ない引っ掛かりでも、どの操作の直後だったかを追えます。
補助関数の前提も先に書いておきます。
サンプル内の ReportProgress と AppendLog は UI 更新用の共通関数です。
各ボタンで違いを見やすくするため、補助処理はそろえています。
1. UI スレッドで重い処理を回すと、Click の中でそのまま止まる
最初に見るのは、もっとも典型的な止まり方です。
ボタン押下のイベント内で重い CPU 処理をそのまま回すと、入力も描画も止まります。
private void btnHeavyOnUi_Click(object sender, EventArgs e)
{
MarkAction("UIで重い処理");
ResetProgress();
SetStatus("UIスレッドを3秒占有中");
HeavyCpuWork(3000);
ReportProgress(100);
SetStatus("完了");
}
このケースでは、次の変化が出ます。
- 心拍表示が止まる
- フォームの移動や再描画が止まる
- ログは出ても、途中の画面更新は進まない
ここで確認したいのは、処理時間そのものではなく、Click の中で 3 秒占有している点です。
「重い処理」より先に、「UI スレッドの中で回していないか」を切り分けた方が早いです。
見つけたら、CPU 処理を Task.Run へ出すか、待機処理なら非同期 API へ寄せます。
2. .Wait は UI 側の待ちを消さない
非同期メソッドを呼んでいても、UI 側で同期的に待つと止まります。
このコードは再現用で、差だけ追えるようにタイムアウト復帰付きにしています。
private void btnWait_Click(object sender, EventArgs e)
{
MarkAction(".Waitで待機");
ResetProgress();
SetStatus("危険なWaitを3.5秒だけ再現中");
AppendLog(".Waitはデッドロック化しやすいため、このサンプルでは3.5秒で復帰させる");
var completed = SimulatedAsyncOperation(3000).Wait(3500);
if (!completed)
{
AppendLog(".Waitが継続待ちになったためタイムアウト復帰");
SetStatus("タイムアウト復帰");
return;
}
SetStatus("完了");
}
このケースでは、次の出方になります。
- 心拍表示が止まる
- 非同期メソッドを呼んでいても UI は空かない
- 「
awaitを書いているのに固まる」に見えやすい
この場面で最初に grep する文字列は次のとおりです。
.Wait().ResultGetAwaiter().GetResult()WaitAllWaitAny
先に確認するのは SimulatedAsyncOperation の中身ではなく、呼び出し側が .Wait() へ戻していないかです。
WinForms では、非同期化した処理より先に UI 側の待ち方を見た方が早いです。
見つけたら、UI 層では .Wait と .Result を外し、呼び出し側を await へ戻します。
3. Invoke + Wait は UI 側と背景側の待ちがぶつかる
背景処理から UI を触るために Invoke を使うこと自体は普通にあります。
ただし、背景処理が Invoke 完了を待ち、UI 側も Wait で止まる形になると、待ちが重なって詰まりやすくなります。
このサンプルでは永久停止にはしていません。
その代わり、どこで詰まるかだけを短時間で確認できる形にしています。
private void btnInvokeWait_Click(object sender, EventArgs e)
{
MarkAction("Invoke + Wait");
ResetProgress();
SetStatus("危険な待ちの鎖を3秒だけ再現中");
var worker = Task.Run(() =>
{
Thread.Sleep(300);
Invoke(new Action(() =>
{
AppendLog("InvokeでUI更新");
ReportProgress(50);
}));
});
var completed = worker.Wait(3000);
if (!completed)
{
AppendLog("Invoke完了待ちがUI側Waitと重なりタイムアウト復帰");
SetStatus("タイムアウト復帰");
return;
}
ReportProgress(100);
SetStatus("完了");
}
この例で詰まりが出るのは次の 3 点です。
- 背景側は
Invoke完了を待つ - UI 側は
Waitで止まる - どちらも相手が動くまで先へ進めない
実務ではここへ
lockJoin- 別の
Wait - 同期 I/O
が重なると、呼び出し順の把握が一気に難しくなります。
この例で危ないのは、背景処理が Invoke 完了を待ち、UI 側も Wait で止まっている点です。
戻り値が不要な表示更新なら、BeginInvoke へ寄せた方が待ちの鎖を増やしにくくなります。
4. Task.Run は CPU 処理を UI スレッドの外へ出す
同じ重い処理でも、CPU を回している場所が変わると見え方は変わります。
このサンプルでは、別スレッドへ出した上で、10 回に分けて進捗も更新しています。
private async void btnTaskRunCpu_Click(object sender, EventArgs e)
{
MarkAction("Task.RunでCPU処理");
ResetProgress();
SetStatus("別スレッドでCPU処理中");
await Task.Run(() =>
{
for (var i = 1; i <= 10; i++)
{
HeavyCpuWork(250);
ReportProgress(i * 10);
}
});
SetStatus("完了");
}
このケースでは、次の差が出ます。
- 心拍表示が止まりにくい
- フォームの再描画や移動が残る
- 作業進捗が途中でも動く
ここで分ける基準は単純です。
- CPU を回しているなら
Task.Run - 通信やファイル待ちなら非同期 API
待機時間まで Task.Run へ押し込むと、どこで詰まるかの判断が崩れます。
CPU 処理か待機処理かを分けて見た方が早いです。
見つけたら、CPU を使う部分だけを Task.Run へ出し、待機は非同期 API へ残します。
5. await は待機中に UI スレッドを空ける
次は I/O 待機相当のケースです。
サンプルでは Task.Delay を使った疑似 I/O を呼び、待機中も進捗が動く形にしています。
private async void btnAsyncIo_Click(object sender, EventArgs e)
{
MarkAction("async I/O待機");
ResetProgress();
SetStatus("Task.Delayで待機中");
await SimulatedAsyncOperation(3000);
SetStatus("完了");
}
このケースでは、待機中でも UI は止まりません。
心拍表示も動きます。
ここで押さえたいのは、await は「別スレッド化」そのものではなく、待機中に UI スレッドを空ける書き方だという点です。
通信待ち、ファイル待ち、タイマー待ちのように CPU は遊んでいて完了待ちだけある処理なら、Task.Run より非同期 API の方が自然です。
見つけたら、待機処理は非同期 API へ寄せ、UI 層で同期的に待たない形へ戻します。
6. BeginInvoke は UI 更新だけを渡したい時に待ちを増やしにくい
最後は、背景側から UI 更新だけを投げる場面です。
このサンプルでは、1 回だけ更新するのではなく、進捗を少しずつ送っても待ちが増えにくい形を確認します。
private async void btnBeginInvoke_Click(object sender, EventArgs e)
{
MarkAction("BeginInvokeで更新");
ResetProgress();
SetStatus("待たないUI更新を実行中");
await Task.Run(() =>
{
for (var i = 1; i <= 10; i++)
{
var progress = i * 10;
BeginInvoke(new Action(() =>
{
AppendLog($"BeginInvoke更新: {progress}%");
ReportProgress(progress);
}));
Thread.Sleep(150);
}
});
SetStatus("完了");
}
この形では、次の違いが出ます。
- 背景側が UI 完了を待たない
- 連続更新でも待ちが連ならない
- UI 更新だけなら
Invokeより軽く通る
この例で効いているのは、背景処理が UI 完了を待たない点です。
進捗表示のように戻り値が不要な更新なら、Invoke より BeginInvoke の方が待ちの鎖を増やしにくくなります。
見つけたら、戻り値が不要な表示更新は BeginInvoke か IProgress<T> へ寄せます。
UiLagMonitor で「何 ms 遅れたか」と「どの操作の直後か」を残す
今回のサンプルでは、心拍表示だけでなく、UI ラグをログへ残す処理も入れています。
考え方は単純で、一定間隔で UI へ処理を投げ、その戻りが遅れたらラグとして記録します。
private void OnTimer(object? state)
{
if (uiControl.IsDisposed || !uiControl.IsHandleCreated)
{
return;
}
var id = Interlocked.Increment(ref sequence);
var start = Stopwatch.GetTimestamp();
try
{
uiControl.BeginInvoke(new Action(() =>
{
var end = Stopwatch.GetTimestamp();
var elapsedMs = (end - start) * 1000.0 / Stopwatch.Frequency;
if (elapsedMs >= thresholdMs)
{
logger($"ui_lag seq={id} lag={elapsedMs:F0}ms action={getLastAction()}");
}
}));
}
catch (ObjectDisposedException)
{
}
catch (InvalidOperationException)
{
}
}
この記録で見たいのは次の 2 点です。
- 何 ms 遅れたか
- どの操作の直後か
目安はこのあたりです。
- 100ms 前後: 引っ掛かりが出始める
- 300ms 前後: 明確に重い
- 500ms 以上: フリーズに近く、要確認
毎回は出ない引っ掛かりでも、lag=312ms action=.Waitで待機 のように残れば、ログを開いた瞬間に疑うボタンを絞れます。
目視で追いにくい場面ほど、この記録が効きます。
業務コードで固まった時の調査順
業務コードで WinForms が固まった時は、次の順で開くと早いです。
| 順番 | 開く場所 | 確認するもの | 次に当たる場所 |
|---|---|---|---|
| 1 |
Click Shown TimerTick
|
UI イベント内の重い処理 |
Task.Run 化できるか |
| 2 | UI 層のイベントハンドラ |
.Wait .Result GetAwaiter().GetResult()
|
呼び出し側を await へ戻せるか |
| 3 | 背景処理からの UI 更新 |
Invoke の完了待ち |
BeginInvoke IProgress<T> へ寄せられるか |
| 4 | ログ出力、画像処理、同期 I/O | 100ms 以上の引っ掛かり | 遅延 ms と操作名を残せるか |
| 5 | 例外経路 | 投げっぱなし Task、握りつぶした catch
|
ログへ残っているか |
最初から全部追うより、
UI イベントの中身 → UI 側の待ち → Invoke 周り
の順で当たる方が早いです。
この比較で残る判断基準
今回の比較で残る判断基準は 3 つです。
UI は「重い処理」より「戻らない処理」で止まる
重い処理でも UI の外で動けば、画面は残ります。
逆に短い処理でも、UI イベントの中で待ちを増やすと体感は一気に悪くなります。
await が入っていても、UI 側で待てば止まる
await を書いているかどうかより、
- UI 側で
.Waitしていないか -
.Resultに戻していないか -
Invoke完了待ちが重なっていないか
の方が、WinForms では先に効きます。
UI 更新経路を 1 本へ寄せると追いやすくなる
背景処理から直接 UI を触るより、
-
await後に UI 更新 IProgress<T>BeginInvoke- 共通の UI 更新関数
のどれかへ寄せた方が、どこで UI が止まったかを追いやすくなります。
サンプルを動かす時に確認する点
このサンプルは、正解コードを見る用途より、止まり方の差を見比べる用途の方が合っています。
動かす順番
- UIで重い処理
-
.Waitで待機 - Invoke + Wait
- Task.RunでCPU処理
- async I/O待機
- BeginInvokeで更新
画面で確認する点
- 心拍表示が止まるか
- フォームをドラッグした時に止まるか
- 作業進捗が途中でも動くか
- ログの順番が崩れるか
- 完了表示がいつ出るか
この 5 点だけ見れば、async、Invoke、メッセージループの差が画面と対応づきます。
続けて読むなら
サンプルを手元でそのまま動かしたい場合は、コード一式を置いています。
GitHubで開く
連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index


