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


目次