WinForms 非同期処理の正解 — BackgroundWorker / Task.Run / async-await の使い分け

みなさんこんにちは!ヒロポンです!!

今回は WinForms 業務SE現場でガチで踏みやすいやつ!!の話。

「DataAdapter.Fill で5万件取ってる間、画面が3秒固まってユーザーから問い合わせ来た」「Task.Run で書いたら InvalidOperationException で詰まった」「async-await 試したら書き方が分からない・既存 BackgroundWorker と混ざって混乱」みたいなWinForms 非同期処理の事故って、業務SE で誰しも一回はやらかしますよね??

俺も2社目くらいの流通系SIer時代に、CSV エクスポートで5万件処理をUI スレッドで動かしてしまって、画面が3秒固まる事故をやらかしました。夕方の運用報告で気づいて半日デバッガで追ってハマった末に、Task.Run でオフロードする形に書き換え。さらに別案件で Task.Run の中で dataGridView1.Rows.Add(...) を直接呼んでクロススレッド例外で詰まって、Control.Invoke でマーシャリングするパターンを学んだ。

C# の WinForms には非同期処理が3パターンあります:

  • BackgroundWorker(VS2010 時代の名残・Designer 配置可・イベントベース)
  • Task.Run + Control.Invoke(モダンTask ベース・マーシャリング必須)
  • async-await + Task.Run(最新・UI スレッド自動復帰・読みやすい)

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 環境で、3パターンの使い分けと、進捗報告(IProgress)キャンセル(CancellationToken)、**クロススレッド回避(Control.Invoke)**を、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • 新規なら async-await + Task.Run 一択(読みやすい・保守性◎・UI スレッド自動復帰)
  • レガシー保守で BackgroundWorker 既存なら継続(混在は避ける)
  • Task.Run 内で UI を触りたい時は Control.Invoke 必須(クロススレッド例外回避)
目次

定石1: BackgroundWorker — 旧式だが Designer 配置できる

VS2010 時代に標準だった非同期処理クラス。Designer に配置できるので、業務系のレガシーコードでよく見ます:

// ✅ 定石1: BackgroundWorker でファイル取り込み
using System.ComponentModel;

public partial class ImportForm : Form
{
    private BackgroundWorker _worker;

    public ImportForm()
    {
        InitializeComponent();

        _worker = new BackgroundWorker
        {
            WorkerReportsProgress = true,
            WorkerSupportsCancellation = true,
        };
        _worker.DoWork += Worker_DoWork;
        _worker.ProgressChanged += Worker_ProgressChanged;
        _worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
    }

    private void btnStart_Click(object sender, EventArgs e)
    {
        _worker.RunWorkerAsync();
    }

    private void Worker_DoWork(object sender, DoWorkEventArgs e)
    {
        // バックグラウンドスレッドで動く(UI 触れない)
        for (int i = 0; i < 100; i++)
        {
            if (_worker.CancellationPending) { e.Cancel = true; return; }
            Thread.Sleep(50);
            _worker.ReportProgress(i + 1);
        }
    }

    private void Worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        // UI スレッドで動く(直接コントロール触れる)
        progressBar1.Value = e.ProgressPercentage;
    }

    private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        if (e.Error != null) { MessageBox.Show($"エラー: {e.Error.Message}"); return; }
        if (e.Cancelled) { lblStatus.Text = "キャンセルされました"; return; }
        lblStatus.Text = "完了";
    }
}

ポイント:

  1. 3つのイベント連動: DoWork(バックグラウンド処理)→ ProgressChanged(UI 更新)→ RunWorkerCompleted(完了処理)
  2. ReportProgress(int) で進捗報告(0〜100 の int)
  3. CancellationPending でキャンセル判定
  4. DoWork 内の例外は e.Error で受ける(try-catch で握り潰すと検出できない)

業務系のレガシーコードベースで BackgroundWorker が既に統一されてるなら、新規もそれに揃えるのが保守性で正解。新規プロジェクトなら原則 async-await 寄せだけど、既存統一は崩さないのが業務SE的判断っす。

定石2: Task.Run + Control.Invoke でモダンに書く

BackgroundWorker を使わず、Task.Run でバックグラウンド処理しつつ UI 更新は Control.Invoke でマーシャリング:

// ✅ 定石2: Task.Run + Control.Invoke
using System.Threading.Tasks;

public partial class ImportForm : Form
{
    private void btnStart_Click(object sender, EventArgs e)
    {
        Task.Run(() => DoHeavyWork());
    }

    private void DoHeavyWork()
    {
        // バックグラウンドスレッドで動く(UI 触れない)
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(50);

            // UI 更新は Control.Invoke でマーシャリング
            int percent = i + 1;
            this.BeginInvoke(new Action(() =>
            {
                progressBar1.Value = percent;
            }));
        }

        // 完了通知も同様
        this.BeginInvoke(new Action(() =>
        {
            lblStatus.Text = "完了";
        }));
    }
}

ポイント:

  1. Task.Run(() => ...) でバックグラウンドスレッドプールに投げる
  2. UI 更新は this.BeginInvoke(new Action(...)) でマーシャリング
  3. Invoke(同期・呼び出し元待機)vs BeginInvoke(非同期・呼び出し元待たない)

stock-14 の Timer 記事で紹介した RunOnUiThread ヘルパーを共通基底フォームに置いておくと、BeginInvoke を毎回書かずに済んでいい感じになります。ただし、async-await が使える環境なら次の定石3の方が圧倒的に読みやすいので、Task.Run + Invoke は async-await を許可してない古い C# 5.0 未満環境のレガシー保守 くらいの位置付けっす。

定石3: async-await + Task.Run — 新規プロジェクトの本命

C# 5.0 以降(.NET Framework 4.5+)で書ける、最も読みやすい非同期処理:

// ✅ 定石3: async-await + Task.Run(新規プロジェクトの本命)
using System.Threading.Tasks;

public partial class ImportForm : Form
{
    private async void btnStart_Click(object sender, EventArgs e)
    {
        btnStart.Enabled = false;
        try
        {
            // バックグラウンドで重い処理
            string result = await Task.Run(() => DoHeavyWork());

            // ↓ ここは UI スレッドに自動復帰している(Invoke 不要)
            lblStatus.Text = "完了";
            txtResult.Text = result;
        }
        catch (Exception ex)
        {
            MessageBox.Show($"エラー: {ex.Message}");
        }
        finally
        {
            btnStart.Enabled = true;
        }
    }

    private string DoHeavyWork()
    {
        // バックグラウンドスレッドで動く処理
        Thread.Sleep(3000);
        return "5万件処理完了";
    }
}

ポイント:

  1. async void はイベントハンドラのみ OK(それ以外は async Task
  2. await Task.Run(...) で重い処理をバックグラウンドへ
  3. await の後は UI スレッドに自動復帰(Invoke 不要)
  4. try-catch でそのまま例外を受けられる(同期コードと同じ書き方)
  5. btnStart.Enabled = false で連打防止

ん?こんなにシンプルでいいの??って思うかもだけど、コンパイラが内部で SynchronizationContext を使って await 前後でスレッドを切り替えてくれるので、UI 復帰のためのマーシャリング処理が全部隠蔽される。WinForms には WindowsFormsSynchronizationContext が自動設定されてるので、業務系でそのまま使えます。

業務系の C# 7.3 + .NET Framework 4.7.2 環境で書けるなら全部これ寄せにすると、コードがいい感じに読みやすくなります。

定石4: IProgress で進捗を UI に細かく反映

async-await でも進捗報告したい時は IProgress<T> インターフェイスを使う:

// ✅ 定石4: IProgress<T> で進捗報告(async-await 版 BackgroundWorker.ReportProgress)
using System;
using System.Threading.Tasks;

public partial class ImportForm : Form
{
    private async void btnStart_Click(object sender, EventArgs e)
    {
        btnStart.Enabled = false;
        progressBar1.Value = 0;

        // Progress<T> は内部で Control.Invoke 相当を行う(UI スレッドに自動復帰)
        var progress = new Progress<int>(percent =>
        {
            progressBar1.Value = percent;
            lblPercent.Text = $"{percent}%";
        });

        try
        {
            await Task.Run(() => DoHeavyWork(progress));
            lblStatus.Text = "完了";
        }
        finally
        {
            btnStart.Enabled = true;
        }
    }

    private void DoHeavyWork(IProgress<int> progress)
    {
        for (int i = 0; i < 100; i++)
        {
            Thread.Sleep(50);
            progress?.Report(i + 1);   // 進捗報告(UI スレッドで反映される)
        }
    }
}

ポイント:

  • Progress<T> のコンストラクタで UI 更新ロジックを渡す
  • progress.Report(percent) で進捗を UI スレッドに反映(内部で SynchronizationContext を使う)
  • 業務系の長時間処理に進捗バーを付ける時に効く

BackgroundWorker.ReportProgress の機能が、async-await に自然に組み込めるパターン。新規プロジェクトでは IProgress 寄せ、レガシー保守では BackgroundWorker 継続、という判断軸でいい感じに整理できます。

定石5: CancellationToken でキャンセル可能にする

長時間処理にキャンセルボタンを付ける時の鉄板パターン:

// ✅ 定石5: CancellationToken でキャンセル可能
using System.Threading;
using System.Threading.Tasks;

public partial class ImportForm : Form
{
    private CancellationTokenSource _cts;

    private async void btnStart_Click(object sender, EventArgs e)
    {
        _cts = new CancellationTokenSource();
        btnStart.Enabled = false;
        btnCancel.Enabled = true;

        try
        {
            await Task.Run(() => DoHeavyWork(_cts.Token), _cts.Token);
            lblStatus.Text = "完了";
        }
        catch (OperationCanceledException)
        {
            lblStatus.Text = "キャンセルされました";
        }
        finally
        {
            btnStart.Enabled = true;
            btnCancel.Enabled = false;
            _cts.Dispose();
        }
    }

    private void btnCancel_Click(object sender, EventArgs e)
    {
        _cts?.Cancel();   // CancellationTokenSource 経由でキャンセル要求
    }

    private void DoHeavyWork(CancellationToken ct)
    {
        for (int i = 0; i < 100; i++)
        {
            ct.ThrowIfCancellationRequested();   // キャンセル時に例外で抜ける
            Thread.Sleep(50);
        }
    }
}

ポイント:

  • CancellationTokenSource を保持してキャンセルボタンから Cancel()
  • ThrowIfCancellationRequested() で OperationCanceledException を投げて抜ける
  • try-catch で OperationCanceledException を受けるとキャンセル時の処理を書ける

業務系の長時間バッチ・大量データ取り込みで「ユーザーがキャンセルしたい」要件が出てきた時、これが本命のパターンっす。

定石6: Wait() / Result でデッドロック — 業務系の禁忌パターン

UI スレッドで Task.Wait()task.Result を呼ぶとデッドロックするので、これは事故事例として共有:

// ❌ NG: UI スレッドで Wait() / Result を呼ぶとデッドロック
private void btnStart_Click(object sender, EventArgs e)
{
    var result = Task.Run(() => DoHeavyWork()).Result;   // ← UI スレッド固まる
    // ...
}

// ✅ OK: await で書き直す(UI スレッド解放される)
private async void btnStart_Click(object sender, EventArgs e)
{
    var result = await Task.Run(() => DoHeavyWork());
    // ...
}

task.Result / task.Wait() は内部で呼び出しスレッドが Task の完了を待つので、UI スレッドが Task の継続コード(UI スレッドに復帰しようとする)を実行できなくなって、デッドロックします。async-await が使える環境では Wait() / Result を呼ばない、というのが業務SE鉄則っす。

比較表 — 3パターンの使い分け

観点 BackgroundWorker Task.Run + Invoke async-await + Task.Run
動作環境 .NET Framework 2.0+ .NET Framework 4.0+ .NET Framework 4.5+
Designer 配置
コード量 多い(3イベント) 中(Invoke で書く) 少ない(同期コード並み)
可読性 △(イベント連動が散らばる) △(Invoke で見通し悪化) ◎(同期的に書ける)
UI 復帰 ProgressChanged / Completed で自動 Invoke で手動 await で自動
進捗報告 ReportProgress(int) 手動 Invoke IProgress<T>
キャンセル CancelAsync / CancellationPending 手動 CancellationToken
例外伝播 e.Error で受ける catch で受ける try-catch で受ける
主な用途 レガシー保守・Designer 配置 C# 4.0 環境 新規プロジェクト

業務SEの判断軸:

  • 新規・C# 7.3+async-await + Task.Run(最優先)
  • レガシー保守で BackgroundWorker 統一済 → BackgroundWorker 継続(混在禁止)
  • C# 4.0 環境のレガシーTask.Run + Invoke
  • 進捗報告したい時IProgress<T>(async-await)or ReportProgress(BackgroundWorker)
  • キャンセルさせたい時CancellationToken 寄せ

ハマりポイント — 実体験ベースの本番事故3点

1. UI スレッド5万件 Fill で画面固まった(半日デバッガで追ってハマった)

CSV エクスポートで DataAdapter.Fill の5万件取得を UI スレッドで動かしていて、画面が3秒固まる事件。夕方の運用報告で気づいて半日デバッガで追ってハマった末に、async-await + Task.Run でオフロードして解決。それ以来、5秒以上かかる処理は async-await でバックグラウンドに逃がすを業務系チーム規約にした。

2. Task.Run 内で UI 直接更新でクロススレッド例外(30分溶かした)

別案件で Task.Run(() => { dataGridView1.Rows.Add(...); }) と書いて InvalidOperationException: Cross-thread operation not valid で詰まった事件。30分溶かした末に Control.Invoke でマーシャリングするパターンに書き換え。これも stock-14 (Timer) と同じパターンで、バックグラウンドスレッドからの UI 更新は RunOnUiThread ヘルパー経由を業務系チーム規約に揃えました。

3. UI スレッドで Task.Wait() 呼んでデッドロック(数日プロファイラで追った)

レガシーコードを async-await に書き換える途中で、一時的に同期的に呼びたかったので Task.Run(() => DoHeavyWork()).Wait(); と書いてしまい、UI スレッドが永続的に固まる事件。数日プロファイラで追ってようやく気付いたasync void のイベントハンドラに書き換えて await 寄せにして解決。Wait() / Result は新規禁止ルールを入れた。

著者の現場メモ — 業務系チームでの非同期処理規約

流通系SIer時代に、過去コードを grep -rnE "BackgroundWorker|Task\.Run|async " . でひっかけたら、80箇所近く 出てきたんですよね。書き方がバラバラで、BackgroundWorker と Task.Run が混在してる Form、async void で何でも書いてる箇所、Task.Wait() でデッドロックリスクある箇所、全部入り。

んで、後輩と一緒に 3行ルール にまとめた:

  1. 新規は async-await + Task.Run 一択(BackgroundWorker は既存維持のみ・混在禁止)
  2. async void はイベントハンドラのみ(その他は async Task / async Task<T>
  3. Task.Wait() / Task.Result は新規禁止(デッドロック防止)

このルール化で、画面固まり / クロススレッド / デッドロックの3大事故が消えた。書き方を1パターンに揃えるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 のレガシー業務系って、async-await が普通に使える環境なのに、書き方が C# 4.0 時代の Task.Run + Invoke で止まってるコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。

まとめ

状況 推奨パターン
新規プロジェクト・C# 7.3+ async-await + Task.Run
レガシー保守・BackgroundWorker 統一 BackgroundWorker 継続(混在禁止)
C# 4.0 環境のレガシー Task.Run + Control.Invoke
Task.Run 内で UI 触りたい Control.Invoke / BeginInvoke
進捗報告 IProgress<T> (async-await) / ReportProgress (BGW)
キャンセル CancellationToken 寄せ
Task.Wait() / Task.Result 新規禁止(デッドロック)
async void イベントハンドラのみ

WinForms 非同期処理の事故は、「3パターンの違いを把握」「async-await 寄せ」「UI 更新は Invoke or await」 の3点で9割消えます。Task.Run で重い処理を逃がすだけで、DataAdapter.Fill 5万件で画面固まりは予防できる。業務系の 「書き方を1パターンに揃える」 が本命の対処です。

よくある質問

Q1. BackgroundWorker と async-await、新規プロジェクトならどっちを使うべき?

A. .NET Framework 4.5+ なら原則 async-await が本命です。コードが同期的に書けて読みやすく、戻り値の型安全性も効く。BackgroundWorker は VS2010 時代の名残で、3つのイベント(DoWork / ProgressChanged / RunWorkerCompleted)を連動させる必要があってコード量が多い。「Designer に置きたい」「既存コードベースが BackgroundWorker で揃っている」場合だけ BackgroundWorker を継続するのが現代的な判断軸です。

Q2. Task.Run の中で UI コントロールを更新したらどうなりますか?

A. InvalidOperationException: Cross-thread operation not valid(クロススレッド例外)が飛びます。Task.Run のコールバックはスレッドプール上で動くので、UI コントロールを直接触ると例外。回避策は Control.Invoke または BeginInvoke で UI スレッドにマーシャリングする書き方。stock-14 の Timer 記事と同じ仕組みなので、RunOnUiThread ヘルパーを共通基底フォームに置いて使い回すのが業務SE鉄板です。

Q3. async-await で UI スレッドに自動復帰するのはなぜ?

A. C# のコンパイラが SynchronizationContext を内部で使って、await の前後でスレッドを切り替えています。WinForms の UI スレッドには WindowsFormsSynchronizationContext が設定されていて、await した後の継続コードを UI スレッドにディスパッチする仕組み。これにより await Task.Run(...) の後で UI コントロールを直接触れる、という魔法のような挙動になっています。ConfigureAwait(false) を付けると UI 復帰を抑制できますが、UI 触らない処理用の最適化なので業務系では原則そのまま使えば OK です。

Q4. async-await の戻り値型はどう使い分ける?

A. Task / Task<T> が原則、async void はイベントハンドラのみです。async void だと呼び出し元が await できないので例外を補足できず、保守性が落ちる。ボタンクリックなどのイベントハンドラだけは async void で OK(イベントハンドラの仕様上 void 戻り値が適切)。それ以外のメソッドは async Taskasync Task<T> で書くのが業務SE鉄則です。

Q5. 重い処理の進捗を UI に細かく反映したい時は?

A. async-await なら IProgress<T>、BackgroundWorker なら ReportProgress を使います。IProgress<T> は async-await でもイベントハンドラの ProgressChanged 相当のことができて、進捗値(0〜100 のパーセント)と現在処理中の項目を UI に渡せる。実装は var progress = new Progress<int>(percent => progressBar1.Value = percent); の1行で、Task.Run に渡して中で progress.Report(percent) を呼ぶだけ。BackgroundWorker からの移行も自然です。

ここまでで WinForms 非同期処理の3パターン・進捗報告・キャンセル・デッドロック回避は押さえた。WinForms スレッド系の隣接トピックも貼っておきます。

関連記事

以上!

同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次