WinFormsの非同期は「速くする技」ではなく「固めない設計」です。
ここを外すと、UIフリーズ・デッドロック・例外不可視・二重実行が連鎖して、現場が燃えます。
この記事は、現場で揉めやすい判断を「掟」として固定します。
(E04で止血した人は、ここで再発防止まで固めるのが最短です)
断末魔: こんな症状が出たらこの回
- ボタンを押すと画面が固まる(応答なし)
- awaitしたのにUI更新ができない(別スレッド例外/更新されない)
- .Result / .Wait()で固まる(原因不明になりがち)
- 例外が表に出ず、気付かないまま壊れる
- 進捗表示がちらつく、キャンセルできない、二重実行される
凡例: 先に言葉を揃える
初心者がここで詰まりやすいので、用語を短く揃えます。
UIスレッド(メイン) / 背景スレッド(サブ)
| 用語 | 意味 | 重要ポイント |
|---|---|---|
| UIスレッド(メイン) | WinFormsの描画・入力・イベント処理を担当する1本のスレッド | ここが塞がると"応答なし"になる |
| 背景スレッド(サブ) | UI以外の処理(計算/I/O待ち等)を担当させるスレッド | UIコントロールを直接触らない |
async/await
- asyncは「待てる形にする」構文です。スレッドを増やす魔法ではありません。
- CPU負荷を別スレッドに逃がすなら、明示的にTask.Run等で分離します。
awaitの帰還(戻り先)
WinFormsでは既定で「awaitの続きはUIスレッドへ戻る」挙動になります。
この戻り先を前提にUI更新が安全にできます。崩すと事故ります。
fire-and-forget(投げっぱなし)
Taskを開始するが、呼び出し側はawaitせず完了も例外も待たない実装です。
便利ですが、例外が消えやすく運用事故の温床になります。やるなら例外観測が必須です。
ConfigureAwait(false)
「await後に元のコンテキスト(UIなど)へ戻らない」指定です。
主にライブラリ層でUI依存を切るために使います。UI層で乱用するとUI更新が壊れます。
掟: この回で固定する結論(レビュー基準)
- UIは1スレッドで動く。UIスレッドをブロックしない
- awaitは既定でUIへ帰還する(帰還先を理解して設計する)
- 同期ブロック(.Result/.Wait)は原則禁止。asyncは最後まで流す
- async voidはイベントハンドラ以外で使わない
- ライブラリ層はConfigureAwait(false)でUI依存を切る(ただしUI層は原則使わない)
- 投げっぱなし(fire-and-forget)は例外観測を必ず付ける
原則: まずこれだけ守れ(最優先)
原則1: UIスレッドをブロックしない
理由: UIスレッドはメッセージループを回して描画と入力を処理します。ここを止めると"応答なし"になります。
断罪例(UIスレッドで重い処理)
private void button1_Click(object sender, EventArgs e)
{
// NG: UIスレッドで重い処理を走らせる
for (int i = 0; i < 500_000_000; i++) { }
label1.Text = "done";
}
模範例(CPU重いなら別スレッドへ。UIはawaitで待つ)
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
await Task.Run(() => HeavyCpuWork());
label1.Text = "done";
}
finally
{
button1.Enabled = true;
}
}
private static void HeavyCpuWork()
{
for (int i = 0; i < 500_000_000; i++) { }
}
ポイント
| やりがち | なぜダメか | 正しい考え方 |
|---|---|---|
| I/O(HTTP/DB/ファイル)をTask.Runで包む | スレッドを無駄に塞ぐ(遅くなることもある) | I/Oは非同期APIを使う |
| UI更新をTask.Run内でやる | 別スレッド例外/壊れた表示 | UI更新はUIスレッドでやる |
| "ちょっとだけ"同期処理 | 100msでも体感で引っ掛かる | 計測して基準を持つ(E04参照) |
原則2: asyncは最後までasync(同期ブロック禁止)
理由: UIスレッドで.Result/.Waitを使うと、awaitがUIへ戻ろうとして相互待ち(デッドロック)になりやすいからです。
断罪例(.Result/.Waitで固まる)
private void button1_Click(object sender, EventArgs e)
{
var text = GetTextAsync().Result; // NG: UIスレッドで待つ
label1.Text = text;
}
模範例(awaitで待つ)
private async void button1_Click(object sender, EventArgs e)
{
var text = await GetTextAsync();
label1.Text = text;
}
レビューで見るべきキーワード(見つけたら赤信号)
- .Result / .Wait() / Task.WaitAll / Task.WaitAny / GetAwaiter().GetResult()
原則3: awaitの帰還先(UI)を前提にUI更新する
理由: WinFormsではawait後にUIへ戻るのが既定なので、UI更新はawait後に置くと安全です。
模範例(UI更新はawait後に行う)
private async void button1_Click(object sender, EventArgs e)
{
label1.Text = "loading...";
var data = await _service.FetchAsync();
label1.Text = data; // ここはUIへ帰還している前提
}
注意
- "戻らない状態"を作ると、await後のUI更新が壊れます
- UI層でConfigureAwait(false)を付けるのが典型地雷です(後述)
原則4: async voidはイベントハンドラ以外で使わない
理由: async voidは呼び出し側がawaitできず、例外が合流しにくいため、障害が観測されず運用が詰みます。
断罪例(async voidの一般メソッド)
public async void SaveAsync()
{
await _repo.SaveAsync();
}
模範例(Taskを返す)
public async Task SaveAsync()
{
await _repo.SaveAsync();
}
例外
- WinFormsイベントハンドラは契約がvoidなのでasync voidでよい(ただし例外は必ず捕捉/ログ)
原則5: ライブラリ層はConfigureAwait(false)でUI依存を切る
理由: UI以外の層(ドメイン/アプリ/インフラ)はUIへ戻る必要がありません。戻り先を固定しない方が安全で再利用しやすいです。
模範例(ライブラリでUI帰還を切る)
public async Task<string> FetchAsync(CancellationToken ct)
{
using var res = await _http.GetAsync("https://example", ct).ConfigureAwait(false);
return await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
注意(UI層は原則ConfigureAwait(false)を使わない)
- UI層で付けるとawait後にUIへ戻らない可能性が出ます
- その状態でUI更新すると別スレッド例外/更新されない事故になります
推奨: ここまでやると事故が激減する(実務テンプレ)
推奨1: 二重実行防止と例外処理をテンプレ化する
理由: 非同期は「もう一度押される」だけで簡単に破綻します。テンプレ化で事故の総量を下げます。
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
try
{
await RunAsync();
MessageBox.Show("完了しました");
}
catch (BusinessException ex)
{
MessageBox.Show(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error. Operation={Operation}", "Run");
MessageBox.Show("予期しないエラーが発生しました。ログを確認してください。");
}
finally
{
btnRun.Enabled = true;
}
}
private Task RunAsync() => _useCase.ExecuteAsync();
運用の具体案
- "UIイベントはtry/catch/finallyで囲う"を規約化
- ボタン無効化は共通ヘルパで統一(やり忘れ防止)
推奨2: 進捗通知はIProgressでUIへ安全に返す
理由: UI更新を背景スレッドから直接やると事故ります。IProgressはUI側で受けてUI側で更新できる形にできます。
private async void btnRun_Click(object sender, EventArgs e)
{
var progress = new Progress<int>(p => progressBar1.Value = p);
await _useCase.ExecuteAsync(progress, CancellationToken.None);
}
public async Task ExecuteAsync(IProgress<int> progress, CancellationToken ct)
{
for (int i = 0; i <= 100; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(30, ct).ConfigureAwait(false);
progress.Report(i);
}
}
推奨3: キャンセルはCancellationTokenで通す
理由: 長時間処理は止められないだけで障害扱いになります。最初からTokenを通すと運用が楽になります。
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 _useCase.ExecuteAsync(_cts.Token);
MessageBox.Show("完了しました");
}
catch (OperationCanceledException)
{
MessageBox.Show("キャンセルしました");
}
finally
{
btnStart.Enabled = true;
btnCancel.Enabled = false;
}
}
private void btnCancel_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}
推奨4: fire-and-forgetは明示して例外を観測する
理由: 投げっぱなしの最大の罪は「失敗しても気付けない」ことです。やるなら観測可能にします。
private void FireAndForget(Task task)
{
task.ContinueWith(t =>
_logger.LogError(t.Exception, "Background task failed"),
TaskContinuationOptions.OnlyOnFaulted);
}
FireAndForget(_useCase.WarmUpAsync());
禁止: 地獄への片道切符
禁止1: UIスレッドで.Result/.Wait()
- デッドロック典型
- フリーズ典型
禁止2: async voidを業務ロジックで使う
- 例外が観測されず落ち方が不明になる
- 呼び出し側が待てず整合性が崩れる
禁止3: Task.RunでI/Oを包む
- I/Oは非同期APIを使うべき
- Task.RunはCPU逃がし用途
禁止4: UI層でConfigureAwait(false)を安易に入れる
- await後にUIを触って別スレッド例外/更新されない
- UI層は既定の帰還を活かす
判例集: 迷いが出やすいパターン(OK/NG)
判例1: デッドロック典型(同期ブロック)
NG
var x = _service.FetchAsync().Result;
OK
var x = await _service.FetchAsync();
判例2: await後のUI更新が例外になる(ConfigureAwaitの誤用)
NG(UI層で帰還を切ってしまう)
var x = await _service.FetchAsync().ConfigureAwait(false);
label1.Text = x; // NG: UIではない可能性
OK(UI層は既定の帰還を活かす)
var x = await _service.FetchAsync();
label1.Text = x;
判例3: 長時間処理で二重起動する
OK(ボタン無効化とfinally復帰)
btnRun.Enabled = false;
try { await RunAsync(); }
finally { btnRun.Enabled = true; }
判例4: 例外がどこにも出ない(async voidの一般メソッド)
NG
public async void DoWorkAsync()
{
await Task.Delay(1);
throw new Exception();
}
OK(Taskを返して境界で捕捉)
public async Task DoWorkAsync()
{
await Task.Delay(1);
throw new Exception();
}
断罪チェックリスト(レビュー用)
- UIスレッドで.Result/.Wait()が存在しないか
- async voidがイベントハンドラ以外に存在しないか
- I/OにTask.Runを被せていないか
- 例外は境界で1回ログされ、握り潰されていないか
- UI更新はUIスレッドで行われているか(ConfigureAwait(false)の混入を確認)
- 進捗とキャンセルが必要な処理にIProgressとCancellationTokenが通っているか
- fire-and-forgetが明示され、例外が観測される設計か