はじめに
WinFormsで業務アプリを書いていた頃、こんなコードを何度書いたか分からない。
if (label1.InvokeRequired)
label1.Invoke(new Action(() => label1.Text = "処理完了"));
else
label1.Text = "処理完了";
書き忘れると実行時にInvalidOperationExceptionで怒られる。毎回書くのが面倒で、書き忘れてデバッグに時間を溶かす。あの地獄を経験した人には刺さるはずだ。
WinFormsのクロススレッド問題は、時代ごとに「正解」が変わってきた。本記事ではその変遷を、実際に動くコードで比較しながら振り返る。
DevContainerのセットアップは過去記事を参照のこと。ただしdevcontainer(Linux)はビルドとロジックのテストまでで、本記事のようなUIの目視確認にはWindows環境が必要になる。
なぜUIスレッドが1本なのか
WinFormsはWin32のメッセージループに直接乗っかっている。Windowsのコントロールはウィンドウハンドル(HWND)を持ち、そのHWNDを作成したスレッドからしか操作できない仕様になっている。
つまり別スレッドからUIコントロールを触ろうとすると、
System.InvalidOperationException:
コントロールが作成されたスレッド以外のスレッドからコントロール 'label1' がアクセスされました。
この例外が飛ぶ。これはWinFormsの設計上の制約ではなく、Win32の仕様に起因する根本的な問題だ。
問題設定
以下の3パターンすべてで、「ボタンを押すと重い処理(5秒)を実行し、完了したらラベルに結果を表示する」という同じ処理を実装する。UIがフリーズしないことも条件だ。
[ボタン] → 重い処理(別スレッド) → ラベルに結果表示
パターン1:InvokeRequired(2000年代〜)
最も古典的な手法。Control.InvokeRequiredでUIスレッドかどうかを確認し、必要ならInvokeでUIスレッドに処理を委譲する。
private void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
Thread thread = new Thread(() =>
{
// 重い処理(別スレッドで実行)
string result = HeavyWork();
// UIスレッドに戻して更新
UpdateLabel(result);
EnableButton();
});
thread.IsBackground = true;
thread.Start();
}
private void UpdateLabel(string text)
{
if (label1.InvokeRequired)
label1.Invoke(new Action(() => label1.Text = text));
else
label1.Text = text;
}
private void EnableButton()
{
if (button1.InvokeRequired)
button1.Invoke(new Action(() => button1.Enabled = true));
else
button1.Enabled = true;
}
private string HeavyWork()
{
Thread.Sleep(5000); // 重い処理の代わり
return "処理完了!";
}
問題点:
- コントロールごとに
InvokeRequiredチェックが必要 - 書き忘れると実行時例外(デバッグ中は出ないこともある)
- コードが冗長で可読性が低い
「デバッグ中は出ないこともある」には理由がある。クロススレッドの例外チェックは、コントロールのWin32ハンドルが生成された後でないと発火しない。フォーム表示前の早いタイミングで別スレッドからコントロールを触ると、例外が出ないままサイレントに(時には反映もされずに)通ってしまうことがある。「手元では動いたのに実機で落ちた」系のバグの正体はこれだったりする。
パターン2:BackgroundWorker(.NET 2.0〜)
.NET 2.0で登場したBackgroundWorker。RunWorkerCompletedイベントが自動的にUIスレッドで実行されるため、InvokeRequiredを書かなくて済む。当時は革命的だった。キャンセルもCancellationPendingフラグを使えば実装できる。
private BackgroundWorker _worker;
private void Form1_Load(object sender, EventArgs e)
{
_worker = new BackgroundWorker();
_worker.WorkerSupportsCancellation = true;
_worker.DoWork += Worker_DoWork;
_worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
}
private void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
cancelButton.Enabled = true;
label1.Text = "処理中...";
_worker.RunWorkerAsync();
}
private void cancelButton_Click(object sender, EventArgs e)
{
_worker.CancelAsync();
}
private void Worker_DoWork(object sender, DoWorkEventArgs e)
{
// ここは別スレッド
var worker = (BackgroundWorker)sender;
for (int i = 0; i < 50; i++)
{
if (worker.CancellationPending)
{
e.Cancel = true;
return;
}
Thread.Sleep(100);
}
e.Result = "処理完了!";
}
private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
// ここは自動的にUIスレッド(InvokeRequired不要!)
if (e.Cancelled)
{
label1.Text = "キャンセルされました";
}
else if (e.Error != null)
{
label1.Text = $"エラー: {e.Error.Message}";
}
else
{
label1.Text = e.Result?.ToString();
}
button1.Enabled = true;
cancelButton.Enabled = false;
}
問題点:
-
DoWorkとRunWorkerCompletedが分離しており、処理の流れが追いにくい - キャンセルは
CancellationPendingを自分でポーリングしてe.Cancel = trueを立てる必要があり、OperationCanceledExceptionのような例外ベースの中断とは書き味がまったく違う - 進捗報告も
ReportProgressという独自APIが必要 - 複数の非同期処理を組み合わせると一気に複雑になる
パターン3:async/await(C# 5.0〜、2012年〜)
C# 5.0で登場したasync/await。awaitの後はコンパイラが自動的にUIスレッドに戻してくれる(SynchronizationContextの仕組みによる)。
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
label1.Text = "処理中...";
try
{
// Task.Runで別スレッドに投げる
string result = await Task.Run(() => HeavyWork());
// awaitの後は自動的にUIスレッドに戻っている
label1.Text = result;
}
catch (Exception ex)
{
label1.Text = $"エラー: {ex.Message}";
}
finally
{
button1.Enabled = true;
}
}
private string HeavyWork()
{
Thread.Sleep(5000);
return "処理完了!";
}
ポイント:
-
InvokeRequiredが一切不要 - 処理の流れが上から下に読める(同期コードと同じ感覚)
-
try/catch/finallyがそのまま使える - キャンセルも
CancellationTokenで標準的に書ける
比較まとめ
| InvokeRequired | BackgroundWorker | async/await | |
|---|---|---|---|
| 登場時期 | .NET 1.0〜 | .NET 2.0〜 | C# 5.0〜(2012年) |
| InvokeRequired記述 | 毎回必要 | 不要 | 不要 |
| コードの可読性 | 低い | 中程度 | 高い |
| 処理の流れ | 追いにくい | 分離して追いにくい | 上から順に読める |
| キャンセル処理 | 自前実装 | 独自フラグ | CancellationToken |
| 例外処理 | 複雑 | RunWorkerCompletedで確認 | try/catchがそのまま使える |
| 現在の推奨度 | ❌ レガシー | ❌ レガシー | ✅ 現役 |
キャンセル処理も比較してみる
async/awaitの真価はキャンセル処理でも際立つ。
private CancellationTokenSource _cts;
private async void button1_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();
button1.Enabled = false;
cancelButton.Enabled = true;
label1.Text = "処理中...";
try
{
string result = await Task.Run(
() => HeavyWork(_cts.Token), _cts.Token);
label1.Text = result;
}
catch (OperationCanceledException)
{
label1.Text = "キャンセルされました";
}
finally
{
button1.Enabled = true;
cancelButton.Enabled = false;
_cts.Dispose();
}
}
private void cancelButton_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}
private string HeavyWork(CancellationToken token)
{
for (int i = 0; i < 50; i++)
{
token.ThrowIfCancellationRequested();
Thread.Sleep(100);
}
return "処理完了!";
}
BackgroundWorkerのCancellationPendingフラグと比べると、CancellationTokenは.NET全体で統一された標準的な仕組みなので、他のAPIとも自然に組み合わせられる。
おわりに
WinFormsのクロススレッド問題の「正解」はこう変遷してきた。
InvokeRequired(苦行)
→ BackgroundWorker(革命!でも独自仕様)
→ async/await(成仏)
当時InvokeRequiredを書き続けていた身としては、async/awaitの登場は「もっと早く来てくれ」という感想だが、歴史の積み重ねがあってこそ、その価値がわかる。
既存のWinForms資産を保守している方は、新規追加する非同期処理だけでもasync/awaitに切り替えると、コードが格段にスッキリするのでお試しあれ。
環境
- .NET 8.0
- Windows Forms
- Visual Studio 2022 / VS Code + DevContainer
DevContainerのセットアップ方法はこちらの過去記事を参照。
なお devcontainer(Linux)で実行できるのはビルドとロジックの単体テストまで。WinFormsのUI自体(ボタンを押してラベルが更新される見た目)はWin32のGUIなので、実際に動かして確認するにはWindows環境が必要になる。