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?

R06:【掟・判例】非同期の掟: UIスレッド、awaitの帰還、デッドロック典型

Last updated at Posted at 2026-01-12

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が明示され、例外が観測される設計か

次なる試練(回遊導線)

  • R07: WinForms作法(ライフサイクル、Dispose、設計時安全)
  • E04:【現場救急Tips】UIフリーズの正体: メッセージループ/Invoke/asyncの即効薬
  • G11:【外伝】メッセージループ入門(Application.Run/固まる理由/DoEventsの扱い)
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?