0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

G13:【外伝】UIスレッドと非同期の実戦: awaitの帰還とデッドロック典型を潰す

Posted at

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 で待つ(=メッセージループが止まる)
  • GetAsyncawait 後に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は「層の方針」として使われている(場当たりで混ぜない)

次なる試練(回遊導線)

  • R06: 非同期の掟(レビュー基準/事故の根絶)
  • E04: UIフリーズの正体(止血手順/計測で捕まえる)
  • G11: メッセージループ入門(構造の補強)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?