G11:【外伝】メッセージループ入門: Application.RunとUIが固まる本当の理由
「ボタンを押したら固まった」「応答なしになった」
WinFormsを触っていると、一度は必ず遭遇します。
多くのケースは"重い処理"が原因です。
ただし現場では、それだけでは説明し切れないフリーズも混ざります。たとえば「たまに固まる」「一瞬だけ固まる」「再現しない」などです。
この回は、WinFormsが生きている仕組みである"メッセージループ"を軸に、UIが固まる理由を腹落ちさせます。
仕組みが繋がると、次の3つがまとめて避けられるようになります。
- なぜ"応答なし"になるのか(描画/入力が止まる理由)
- なぜDoEventsが嫌われるのか(再入で壊れる理由)
- なぜ.Result/.Waitが地雷なのか(待ち合わせで詰む理由)
先に止血したい場合は E04 をどうぞ。
非同期のレビュー基準を固めたい場合は R06 が先に効きます。
掟: 今回守るルール
- UIスレッドはメッセージを捌くのが本業
- UIスレッドで同期ブロックをしない
- DoEventsは原則禁止。使うなら副作用を説明できる場合のみ
- 固まりは"原因箇所の特定"が勝ち筋。証拠を残して詰める
試練: 要件
必須
- UIが固まる理由をメッセージループで説明できる
- DoEventsを使わずに進捗表示や中断を設計できる
発展
- "固まる瞬間"の計測ログを入れられる
- Invoke/BeginInvokeの使い分けができる
地獄
- デッドロック典型をメッセージループ観点で説明できる
0. まずここから: Application.Runがやっていること
起動コードはだいたいこうです。
[STAThread]
static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
Application.Runが始めるのが"メッセージループ"です。雑に言うとこうです。
- OSから届くメッセージを取り出す
- 適切なウィンドウやコントロールに振り分ける
- それをフォームが閉じるまで繰り返す
イメージ(擬似コード)
while (formIsAlive)
{
var msg = GetMessage();
DispatchMessage(msg); // 入力/描画/タイマー/クリック等
}
重要なのはここです。
UIスレッドがこのループを回せない時間が増えるほど、入力も描画も止まり、OSは"応答なし"と判断します。
1. 図解代わり: 固まりの代表パターン
WinFormsの固まりは、ほぼ次のどれかです。
| 事象 | 起きていること(メッセージループ視点) | 代表原因 | 正攻法 |
|---|---|---|---|
| 応答なし | UIスレッドがメッセージを処理できない | UIで重い処理/Thread.Sleep/同期I/O | CPUはTask.Run/I/Oは非同期API |
| UI更新されない | 描画メッセージが詰まって反映されない | 同期待ち/長時間イベント処理 | awaitへ統一/処理を分割 |
| たまに固まる | 待ち合わせや競合が絡む | .Result/.Wait/Invoke待ち/lock競合 | 同期ブロック根絶/設計見直し |
| 一瞬だけ固まる | 数百msだけポンプが止まる | GC/同期ログ/画像処理/再入 | 計測で捕まえる |
E04 は「止血手順(最短)」に寄せています。
このG11は「なぜそうなるか(構造)」に寄せています。
2. 最小再現: UIスレッド占有で固める
これは最短で固まります。理由は単純で、UIイベント内でUIスレッドを占有しているからです。
private void btnFreeze_Click(object sender, EventArgs e)
{
Thread.Sleep(3000); // UIスレッド占有
label1.Text = "done";
}
説明(メッセージループ視点)
- Clickイベントが終わるまで、UIスレッドは他のメッセージを捌けない
- 結果として入力も描画も止まる
3. 代表メッセージを知る: 何が詰まると何が止まるか
メッセージループに届くものの例です。暗記は不要ですが、現象と結び付けると調査が速くなります。
| 代表的なメッセージ(概念) | 何が起きる | 詰まるとどう見える |
|---|---|---|
| 入力(マウス/キー) | クリック/移動/キー入力 | 反応しない |
| 描画(再描画) | 画面更新/塗り直し | 白化/更新されない |
| タイマー | 定期処理/点滅/進捗 | 止まる/間延びする |
結論は変わりません。
どのメッセージもUIスレッドが捌きます。だからUIスレッドを塞いだら全部止まります。
4. DoEventsが嫌われる理由: 再入が起きるから
DoEventsは「溜まっているメッセージを一旦捌く」ので、見た目だけは動きます。
しかし副作用として、処理途中に別イベントが割り込む"再入"が起きます。ここから状態不整合が始まります。
4-1. 再入の副作用例1: 二重実行が刺さる
断罪例(DoEventsで二重クリックが刺さる)
private bool _running;
private void btnRun_Click(object sender, EventArgs e)
{
if (_running) return;
_running = true;
btnRun.Enabled = false;
// NG: ここでDoEventsすると、同じClickが割り込める
Application.DoEvents();
HeavyWork(); // 途中状態のまま別イベントが動く可能性
btnRun.Enabled = true;
_running = false;
}
何が起きるか
- DoEventsでメッセージを捌く
- その瞬間に同じボタンのClickが再び入る
- "1回しか走らない"前提が崩れて二重実行、状態破壊、再現性低下
正攻法(DoEventsを使わず、awaitでUIに戻す)
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
try
{
await Task.Run(() => HeavyWork());
}
finally
{
btnRun.Enabled = true;
}
}
4-2. 再入の副作用例2: 途中状態を別イベントが踏む
断罪例(途中でコントロールやコレクションを作り替えている最中に割り込みが入る)
private readonly List<int> _items = new();
private void btnBuild_Click(object sender, EventArgs e)
{
_items.Clear();
for (int i = 0; i < 1000; i++)
{
_items.Add(i);
// NG: 途中状態で別イベントが割り込む
Application.DoEvents();
}
label1.Text = _items.Count.ToString();
}
何が怖いか
- 途中状態で別ボタンが_itemsを参照して例外
- Closeが割り込みDisposeされ、その後のUIアクセスで落ちる
- ロックや非同期が絡むと調査不能になりやすい
結論
- DoEventsは"動く"代わりに"デバッグ不能"へ寄ります
- 使うなら「再入しても壊れない不変条件」を説明できる必要があります
- 説明できないなら使わないのが正解です
5. DoEventsの代替: 非同期 + 進捗 + 中断
DoEventsの代替は、だいたいこの3点セットです。
5-1. CPU負荷はTask.Runで逃がす
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
try
{
progressBar.Style = ProgressBarStyle.Marquee;
await Task.Run(() => HeavyWork());
}
finally
{
progressBar.Style = ProgressBarStyle.Blocks;
btnRun.Enabled = true;
}
}
注意
- I/OはTask.Runで包まない。非同期APIを使う
- 詳細は R06 の掟に寄せています
5-2. 進捗はIProgressでUIへ戻す
private async void btnRun_Click(object sender, EventArgs e)
{
var progress = new Progress<int>(p =>
{
progressBar.Value = p;
label1.Text = $"{p}%";
});
await Task.Run(() => DoWork(progress));
}
private static void DoWork(IProgress<int> progress)
{
for (int i = 0; i <= 100; i++)
{
Thread.Sleep(20);
progress.Report(i);
}
}
5-3. 中断はCancellationTokenで通す
private CancellationTokenSource? _cts;
private async void btnStart_Click(object sender, EventArgs e)
{
_cts?.Dispose();
_cts = new CancellationTokenSource();
btnStart.Enabled = false;
btnCancel.Enabled = true;
try
{
await Task.Run(() => DoWorkCancelable(_cts.Token));
}
catch (OperationCanceledException)
{
MessageBox.Show("キャンセルしました");
}
finally
{
btnStart.Enabled = true;
btnCancel.Enabled = false;
}
}
private void btnCancel_Click(object sender, EventArgs e) => _cts?.Cancel();
private static void DoWorkCancelable(CancellationToken ct)
{
for (int i = 0; i < 200; i++)
{
ct.ThrowIfCancellationRequested();
Thread.Sleep(20);
}
}
6. Invokeで待つ設計になっていないか: 典型デッドロック
Invokeは「UIで実行されるまで待つ」ので、待ち合わせと混ぜると詰みます。
6-1. 断罪例: UIがWaitで待つ + 背景がInvokeで待つ
private void btnRun_Click(object sender, EventArgs e)
{
var t = Task.Run(() =>
{
// 背景からUIへ同期呼び出し -> UIが動かないと戻れない
this.Invoke(new Action(() => label1.Text = "working..."));
HeavyWork();
});
// NG: UIスレッドが待つ -> メッセージループが止まる
t.Wait();
}
何が起きるか(メッセージループ視点)
- UIスレッドはt.Waitでブロックしてメッセージを捌けない
- 背景スレッドはInvokeでUI実行を待つ
- お互い待つ -> デッドロック
6-2. 正攻法: await + BeginInvoke or IProgress
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
try
{
label1.Text = "working...";
await Task.Run(() => HeavyWork());
label1.Text = "done";
}
finally
{
btnRun.Enabled = true;
}
}
使い分けの基準
| API | ブロックするか | 基本方針 |
|---|---|---|
| Invoke | する | 原則避ける。待ち合わせと混ぜると事故る |
| BeginInvoke | しない | UI更新の基本形。予約して戻る |
| IProgress | しない | 進捗通知の第一選択。UIへ安全に戻せる |
7. 計測ログが入っているか: 一瞬フリーズの勝ち筋
"固まった"はログだけでは見えないことが多いです。測って捕まえます。
7-1. 区間計測: 操作単位のStopwatch
private async void btnSearch_Click(object sender, EventArgs e)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await SearchAsync();
}
finally
{
sw.Stop();
_logger.LogInformation("elapsedMs={ElapsedMs} op=Search", sw.ElapsedMilliseconds);
}
}
目安
- 100ms: 引っ掛かり開始
- 300ms: 重い
- 500ms: フリーズ扱いで改善対象
7-2. UI遅延検知: UIが捌けていない時間を測る
BeginInvokeが返ってくるまでの遅延で、"UIポンプの詰まり"を検知します。
public sealed class UiLagMonitor : IDisposable
{
private readonly Control _ui;
private readonly System.Threading.Timer _timer;
private readonly int _thresholdMs;
public UiLagMonitor(Control ui, int intervalMs = 200, int thresholdMs = 200)
{
_ui = ui;
_thresholdMs = thresholdMs;
_timer = new System.Threading.Timer(_ =>
{
if (_ui.IsDisposed || !_ui.IsHandleCreated) return;
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)
{
System.Diagnostics.Debug.WriteLine($"UI lag {ms:F0}ms");
}
}));
}, null, 0, intervalMs);
}
public void Dispose() => _timer.Dispose();
}
導入例(フォーム表示後に監視開始)
private UiLagMonitor? _lag;
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
_lag = new UiLagMonitor(this, intervalMs: 200, thresholdMs: 200);
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
_lag?.Dispose();
_lag = null;
base.OnFormClosed(e);
}
容赦なき断罪: レビュー観点
| 観点 | 典型NG | なぜダメか | 代替 |
|---|---|---|---|
| UIイベントで長時間処理 | Thread.Sleep, 重いループ | ポンプが止まり応答なし | await + Task.Run or 非同期I/O |
| 同期ブロックが残る | .Result, .Wait | デッドロック/フリーズの温床 | asyncを最後まで流す |
| DoEventsが入る | Application.DoEvents | 再入で順序が壊れる | 進捗はIProgress, 中断はToken |
| UI直叩き | 背景からlabel.Text | スレッド安全でない | IProgress, BeginInvoke |
| Invokeで待つ設計 | UIがWait + 背景がInvoke | 相互待ちで詰む | await, BeginInvoke |
| 計測がない | "たまに固まる" | 調査不能 | Stopwatch, UiLagMonitor |
コピペ用チェックリスト
- UI層に.Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult()が存在しない
- UIイベント内にThread.Sleep, 同期I/O, 重い処理が存在しない
- DoEventsが存在しない(再入しても壊れない説明ができないなら撤去)
- Invokeで待っていない(待ち合わせと混ぜていない)
- 主要操作にStopwatch計測が入っている
- 一瞬フリーズがUiLagMonitor等で検知できる(少なくともDebug時)
次なる試練
- G13: UIスレッドと非同期(SynchronizationContext/awaitの帰還/デッドロック典型)(リンク: TBD)