みなさんこんにちは!ヒロポンです!!
今回は 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 = "完了";
}
}
ポイント:
- 3つのイベント連動: DoWork(バックグラウンド処理)→ ProgressChanged(UI 更新)→ RunWorkerCompleted(完了処理)
ReportProgress(int)で進捗報告(0〜100 の int)CancellationPendingでキャンセル判定- 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 = "完了";
}));
}
}
ポイント:
Task.Run(() => ...)でバックグラウンドスレッドプールに投げる- UI 更新は
this.BeginInvoke(new Action(...))でマーシャリング Invoke(同期・呼び出し元待機)vsBeginInvoke(非同期・呼び出し元待たない)
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万件処理完了";
}
}
ポイント:
async voidはイベントハンドラのみ OK(それ以外はasync Task)await Task.Run(...)で重い処理をバックグラウンドへ- await の後は UI スレッドに自動復帰(Invoke 不要)
- try-catch でそのまま例外を受けられる(同期コードと同じ書き方)
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
定石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)orReportProgress(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行ルール にまとめた:
- 新規は
async-await + Task.Run一択(BackgroundWorker は既存維持のみ・混在禁止) async voidはイベントハンドラのみ(その他はasync Task/async Task<T>)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 Task か async 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 スレッド系の隣接トピックも貼っておきます。
関連記事
- WinForms で UseWaitCursor が戻らないバグの解決法(業務SE目線) —
async/awaitで UI スレッドを解放しつつカーソル戻しを整理する時に効く - WinForms Timer 3兄弟の使い分け — System.Windows.Forms.Timer / System.Threading.Timer / System.Timers.Timer — Timer 系のクロススレッド例外と Control.Invoke パターンに効く
- C# 例外処理の正解 — try-catch-finally / using / Exception フィルタ (when句) の使い分け —
async voidのイベントハンドラ内例外処理に効く
以上!
同じ罠でハマってる業務SE仲間いたら、どんどんシェア待ってるぜ!!


コメント