みなさんこんにちは!ヒロポンです!!
今回は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仲間いたら、どんどんシェア待ってるぜ!!


コメント