WinFormsで"awaitしたのに固まる"は、ほぼ「帰還先」と「同期ブロック」の問題です。
ここが腹落ちすると、E04 の止血が再現性を持ちます。
さらに一歩進めると、"再現しないフリーズ"の多くが「設計で踏まない」状態になります。
本回は、次を狙います。
- awaitの帰還先(=どのスレッドに戻るか)を説明できる
- .Result/.Waitがなぜ地雷かを"構造"で理解する
- WinFormsで安全に非同期初期化(起動/画面表示直後)を設計する
- チームでConfigureAwait方針を決める時の論点を持つ
先に規約(レビュー基準)から固めたい場合は R06 をどうぞ。
メッセージループ側の理解は G11 に寄せています(リンクは公開後差し替え)。
掟: 今回守るルール
- UI層で
.Result.Waitを禁止(同期ブロック根絶) - イベント以外で
async void禁止 - UI更新はUIスレッドで行う(別スレッド直叩き禁止)
- ライブラリ側の
ConfigureAwaitは方針を持つ(乱用しない)
試練: 要件
必須
- awaitの帰還先がUIスレッドになる理由を説明できる
- デッドロックの典型を再現して説明できる
発展
- UIフリーズを避けた非同期初期化をShownで開始できる
- キャンセル/進捗を正攻法で通せる
地獄
- ライブラリ側とUI側でConfigureAwait方針を整理できる(チーム合意の形にできる)
0. まずここから: UIスレッド/スレッドプール/SynchronizationContext
最初に言葉を揃えます。
| 用語 | 意味 | WinFormsでの要点 |
|---|---|---|
| UIスレッド(メインスレッド) | コントロールを生成し、メッセージループを回すスレッド | コントロールは原則ここでしか触らない |
| スレッドプール | Task.Runなどで使われる背景スレッド群 | UI更新は直接不可(戻す仕組みが必要) |
| SynchronizationContext | "このコンテキストへ戻る"ための窓口 | WinFormsではUIスレッドに紐づく |
重要: asyncは"別スレッド化"ではない
async/awaitは「待てる形にする」仕組みであって、スレッドを増やす魔法ではありません。
CPUの重い処理を別スレッドに逃がしたいなら Task.Run などで明示します。
1. awaitの帰還: なぜUIに戻るのか(=SynchronizationContext)
WinFormsでUIイベントから await すると、既定では「UIのSynchronizationContext」を捕捉します。
結果として、await の後はUIスレッドに帰還しやすくなります。
1-1. 帰還の整理(実務で困るのはここ)
| 実行場所 | await後の帰還 | 何が嬉しい/何が危険か |
|---|---|---|
| WinFormsのUIイベント(Click等) | 原則UIへ戻る | UI更新がそのまま書ける。同期ブロックが混じると地獄 |
| Task.Runの中 | スレッドプールへ戻る | UI更新はできない。UIへ戻す設計が必要 |
| UI非依存のライブラリ | 方針次第(通常は戻す必要なし) | 性能/デッドロック回避にConfigureAwait(false)を検討(乱用は事故) |
2. デッドロック典型: .Result/.Waitがなぜ詰むのか
いちばん多い地雷です。WinFormsでこれが残っているだけで"たまに固まる"が起きます。
2-1. 断罪例(最小再現)
private void btn_Click(object sender, EventArgs e)
{
// NG: UIスレッドを塞ぐ(同期ブロック)
var x = GetAsync().Result;
label1.Text = x;
}
private async Task<string> GetAsync()
{
await Task.Delay(1000);
return "ok";
}
2-2. なぜ固まるか(構造)
超要点はこれです。
- UIスレッドが
.Resultで待つ(=メッセージループが止まる) -
GetAsyncはawait後にUIへ戻ろうとする(帰還先がUI) - UIは待ちで塞がっているので、戻れず永遠に詰む
図解代わり(短く)
| 役者 | 状態 |
|---|---|
| UIスレッド |
.Resultで待機中(動かない) |
| GetAsyncの続き(継続) | UIへ戻りたいがUIが止まっているので実行できない |
2-3. 模範例(awaitで最後まで流す)
private async void btn_Click(object sender, EventArgs e)
{
var x = await GetAsync();
label1.Text = x;
}
3. "awaitしたのにUI更新できない"の典型: 帰還先を壊している
帰還がUIであることを前提に書いているのに、途中でUIへ戻らない形を混ぜると例外/不具合になります。
3-1. 断罪例(Task.Run内でUI更新)
private async void btn_Click(object sender, EventArgs e)
{
await Task.Run(() =>
{
// NG: 背景スレッドからUIを触る
label1.Text = "working...";
});
}
3-2. 正攻法(UI更新はUIで、重い処理だけ外へ)
private async void btn_Click(object sender, EventArgs e)
{
label1.Text = "working...";
var result = await Task.Run(() => HeavyCpuWork());
label1.Text = result;
}
3-3. UIへ戻す"入口"を作る(BeginInvokeを散らさない)
private void Ui(Action action)
{
if (IsDisposed || !IsHandleCreated) return;
if (InvokeRequired) BeginInvoke(action);
else action();
}
// 使用例
private void Report(string text) => Ui(() => label1.Text = text);
4. 非同期初期化(Shownで開始): 起動フリーズを避ける
Load/ShownはUIスレッドで動きます。ここで重い初期化を同期でやると起動が固まります。
WinFormsでは「Shownで非同期開始」+「エラー/二重起動防止」の形が安定です。
4-1. 模範テンプレ(Shownで1回だけ開始)
private bool _initialized;
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
if (_initialized) return;
_initialized = true;
try
{
UseWaitCursor = true;
Enabled = false;
await InitializeAsync(); // 最後までawait
}
catch (Exception ex)
{
// ここで必ずログ/表示(握り潰すと"固まった"に見える)
MessageBox.Show(this, $"初期化に失敗しました: {ex.Message}");
Close();
}
finally
{
Enabled = true;
UseWaitCursor = false;
}
}
private async Task InitializeAsync()
{
// I/Oなら非同期API。CPU重いならTask.Run
await Task.Delay(200);
}
4-2. よくある失敗
| 失敗 | なぜ危険か | 正解 |
|---|---|---|
| OnLoadで重い同期処理 | 初期描画/入力が詰まる | Shownで非同期開始 |
| 例外を握り潰す | "固まった"に見える | 境界でログ/表示 |
| 二重起動(Shownが複数回) | 初期化が二重に走る | フラグで1回に固定 |
5. 進捗/キャンセル: UIで事故らせない正攻法
DoEvents回避にも直結します。
5-1. 進捗はIProgressをUIで作る
private async void btnRun_Click(object sender, EventArgs e)
{
btnRun.Enabled = false;
try
{
var progress = new Progress<int>(p =>
{
progressBar1.Value = p;
label1.Text = $"{p}%";
});
await _useCase.ExecuteAsync(progress, CancellationToken.None);
}
finally
{
btnRun.Enabled = true;
}
}
public async Task ExecuteAsync(IProgress<int> progress, CancellationToken ct)
{
for (int i = 0; i <= 100; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(20, ct).ConfigureAwait(false);
progress.Report(i);
}
}
5-2. キャンセルは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 _useCase.ExecuteAsync(new Progress<int>(_ => { }), _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();
6. async void禁止(イベント以外): 例外不可視が"フリーズ"に見える
WinFormsのイベントは void 契約なので async void は許容されます。
それ以外で async void を作ると、呼び出し側が待てず、例外も扱いづらくなります。
6-1. 断罪例
// NG: 呼び出し側が待てない。例外も追いにくい
public async void SaveAsync()
{
await _repo.SaveAsync();
}
6-2. 模範例
public async Task SaveAsync()
{
await _repo.SaveAsync();
}
7. ConfigureAwait方針: 乱用すると事故る、放置すると詰む(上級者向け)
ここは「正解が1つ」ではなく、チームの設計方針になります。論点を揃えます。
7-1. 前提
- UI層はawait後にUIへ戻りたいケースが多い
- UI非依存のライブラリ層はUIへ戻る必要がないケースが多い
-
.Result/.Waitが混じると、帰還先がUIのままだとデッドロックしやすい
7-2. 現実的な方針案(おすすめ順)
| 方針 | 内容 | 向いているチーム |
|---|---|---|
| 方針A(堅い運用) | UI層: 既定(await) / ライブラリ層: 必要箇所でConfigureAwait(false) | WinFormsが中心で、層分離ができている |
| 方針B(段階導入) | まずUI層の同期ブロック根絶を最優先。ConfigureAwaitは最小限 | 既存資産が大きく、移行中 |
| 方針C(全面適用) | ライブラリ層は原則ConfigureAwait(false) | 上級者比率が高く、レビューで徹底できる |
7-3. 模範例(ライブラリ層で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)を安易に入れると、await後にUIを触って例外になる - "とりあえず全部false"は、WinFormsでは危険です(層を分けて扱う)
8. 計測: "一瞬だけ固まる"はログでは見えない(最短で勝つ)
止血の計測は E04 を推奨します。
ここでは非同期の境界で最低限やる形を置きます。
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await RunAsync();
}
finally
{
sw.Stop();
_logger.LogInformation("elapsedMs={ElapsedMs} op=Run", sw.ElapsedMilliseconds);
}
容赦なき断罪: レビュー観点
| 観点 | 典型NG | 何が起きる | 一言での対策 |
|---|---|---|---|
| 同期ブロック |
.Result .Wait
|
デッドロック/フリーズ | asyncを最後まで流す |
| async void乱用 | ロジックがasync void | 例外不可視/待てない | Taskを返す |
| Task.RunでI/O包み | DB/HTTPをTask.Run | スレッド浪費/詰まり | 非同期I/O API |
| 背景からUI直叩き | label.Text = |
例外/不定動作 | UIへ戻す(入口を作る) |
| ConfigureAwait乱用 | UI層にfalse混入 | await後UI操作で事故 | 層ごとに方針 |
コピペ用チェックリスト
- UI層に.Result/.Wait/WaitAll/WaitAny/GetAwaiter().GetResult()が存在しない
- イベント以外のasync voidが存在しない
- Task.RunでI/O待ちを包んでいない(HTTP/DB/ファイル)
- 背景スレッドからUIコントロールを直接触っていない
- 例外が境界で必ずログ/表示され、握り潰されていない
- ConfigureAwaitは「層の方針」として使われている(場当たりで混ぜない)