4
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?

InvokeRequiredと戦い続けた男が、async/awaitで成仏した話

4
Posted at

はじめに

WinFormsで業務アプリを書いていた頃、こんなコードを何度書いたか分からない。

if (label1.InvokeRequired)
    label1.Invoke(new Action(() => label1.Text = "処理完了"));
else
    label1.Text = "処理完了";

書き忘れると実行時にInvalidOperationExceptionで怒られる。毎回書くのが面倒で、書き忘れてデバッグに時間を溶かす。あの地獄を経験した人には刺さるはずだ。

WinFormsのクロススレッド問題は、時代ごとに「正解」が変わってきた。本記事ではその変遷を、実際に動くコードで比較しながら振り返る。

DevContainerのセットアップは過去記事を参照のこと。ただしdevcontainer(Linux)はビルドとロジックのテストまでで、本記事のようなUIの目視確認にはWindows環境が必要になる。


なぜUIスレッドが1本なのか

WinFormsはWin32のメッセージループに直接乗っかっている。Windowsのコントロールはウィンドウハンドル(HWND)を持ち、そのHWNDを作成したスレッドからしか操作できない仕様になっている。

つまり別スレッドからUIコントロールを触ろうとすると、

System.InvalidOperationException:
  コントロールが作成されたスレッド以外のスレッドからコントロール 'label1' がアクセスされました。

この例外が飛ぶ。これはWinFormsの設計上の制約ではなく、Win32の仕様に起因する根本的な問題だ。


問題設定

以下の3パターンすべてで、「ボタンを押すと重い処理(5秒)を実行し、完了したらラベルに結果を表示する」という同じ処理を実装する。UIがフリーズしないことも条件だ。

[ボタン] → 重い処理(別スレッド) → ラベルに結果表示

パターン1:InvokeRequired(2000年代〜)

最も古典的な手法。Control.InvokeRequiredでUIスレッドかどうかを確認し、必要ならInvokeでUIスレッドに処理を委譲する。

private void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false;

    Thread thread = new Thread(() =>
    {
        // 重い処理(別スレッドで実行)
        string result = HeavyWork();

        // UIスレッドに戻して更新
        UpdateLabel(result);
        EnableButton();
    });
    thread.IsBackground = true;
    thread.Start();
}

private void UpdateLabel(string text)
{
    if (label1.InvokeRequired)
        label1.Invoke(new Action(() => label1.Text = text));
    else
        label1.Text = text;
}

private void EnableButton()
{
    if (button1.InvokeRequired)
        button1.Invoke(new Action(() => button1.Enabled = true));
    else
        button1.Enabled = true;
}

private string HeavyWork()
{
    Thread.Sleep(5000); // 重い処理の代わり
    return "処理完了!";
}

問題点:

  • コントロールごとにInvokeRequiredチェックが必要
  • 書き忘れると実行時例外(デバッグ中は出ないこともある)
  • コードが冗長で可読性が低い

「デバッグ中は出ないこともある」には理由がある。クロススレッドの例外チェックは、コントロールのWin32ハンドルが生成された後でないと発火しない。フォーム表示前の早いタイミングで別スレッドからコントロールを触ると、例外が出ないままサイレントに(時には反映もされずに)通ってしまうことがある。「手元では動いたのに実機で落ちた」系のバグの正体はこれだったりする。


パターン2:BackgroundWorker(.NET 2.0〜)

.NET 2.0で登場したBackgroundWorkerRunWorkerCompletedイベントが自動的にUIスレッドで実行されるため、InvokeRequiredを書かなくて済む。当時は革命的だった。キャンセルもCancellationPendingフラグを使えば実装できる。

private BackgroundWorker _worker;

private void Form1_Load(object sender, EventArgs e)
{
    _worker = new BackgroundWorker();
    _worker.WorkerSupportsCancellation = true;

    _worker.DoWork += Worker_DoWork;
    _worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
}

private void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false;
    cancelButton.Enabled = true;
    label1.Text = "処理中...";
    _worker.RunWorkerAsync();
}

private void cancelButton_Click(object sender, EventArgs e)
{
    _worker.CancelAsync();
}

private void Worker_DoWork(object sender, DoWorkEventArgs e)
{
    // ここは別スレッド
    var worker = (BackgroundWorker)sender;
    for (int i = 0; i < 50; i++)
    {
        if (worker.CancellationPending)
        {
            e.Cancel = true;
            return;
        }
        Thread.Sleep(100);
    }
    e.Result = "処理完了!";
}

private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
    // ここは自動的にUIスレッド(InvokeRequired不要!)
    if (e.Cancelled)
    {
        label1.Text = "キャンセルされました";
    }
    else if (e.Error != null)
    {
        label1.Text = $"エラー: {e.Error.Message}";
    }
    else
    {
        label1.Text = e.Result?.ToString();
    }
    button1.Enabled = true;
    cancelButton.Enabled = false;
}

問題点:

  • DoWorkRunWorkerCompletedが分離しており、処理の流れが追いにくい
  • キャンセルはCancellationPendingを自分でポーリングしてe.Cancel = trueを立てる必要があり、OperationCanceledExceptionのような例外ベースの中断とは書き味がまったく違う
  • 進捗報告もReportProgressという独自APIが必要
  • 複数の非同期処理を組み合わせると一気に複雑になる

パターン3:async/await(C# 5.0〜、2012年〜)

C# 5.0で登場したasync/awaitawaitの後はコンパイラが自動的にUIスレッドに戻してくれる(SynchronizationContextの仕組みによる)。

private async void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false;
    label1.Text = "処理中...";

    try
    {
        // Task.Runで別スレッドに投げる
        string result = await Task.Run(() => HeavyWork());

        // awaitの後は自動的にUIスレッドに戻っている
        label1.Text = result;
    }
    catch (Exception ex)
    {
        label1.Text = $"エラー: {ex.Message}";
    }
    finally
    {
        button1.Enabled = true;
    }
}

private string HeavyWork()
{
    Thread.Sleep(5000);
    return "処理完了!";
}

ポイント:

  • InvokeRequiredが一切不要
  • 処理の流れが上から下に読める(同期コードと同じ感覚)
  • try/catch/finallyがそのまま使える
  • キャンセルもCancellationTokenで標準的に書ける

比較まとめ

InvokeRequired BackgroundWorker async/await
登場時期 .NET 1.0〜 .NET 2.0〜 C# 5.0〜(2012年)
InvokeRequired記述 毎回必要 不要 不要
コードの可読性 低い 中程度 高い
処理の流れ 追いにくい 分離して追いにくい 上から順に読める
キャンセル処理 自前実装 独自フラグ CancellationToken
例外処理 複雑 RunWorkerCompletedで確認 try/catchがそのまま使える
現在の推奨度 ❌ レガシー ❌ レガシー ✅ 現役

キャンセル処理も比較してみる

async/awaitの真価はキャンセル処理でも際立つ。

private CancellationTokenSource _cts;

private async void button1_Click(object sender, EventArgs e)
{
    _cts = new CancellationTokenSource();
    button1.Enabled = false;
    cancelButton.Enabled = true;
    label1.Text = "処理中...";

    try
    {
        string result = await Task.Run(
            () => HeavyWork(_cts.Token), _cts.Token);
        label1.Text = result;
    }
    catch (OperationCanceledException)
    {
        label1.Text = "キャンセルされました";
    }
    finally
    {
        button1.Enabled = true;
        cancelButton.Enabled = false;
        _cts.Dispose();
    }
}

private void cancelButton_Click(object sender, EventArgs e)
{
    _cts?.Cancel();
}

private string HeavyWork(CancellationToken token)
{
    for (int i = 0; i < 50; i++)
    {
        token.ThrowIfCancellationRequested();
        Thread.Sleep(100);
    }
    return "処理完了!";
}

BackgroundWorkerCancellationPendingフラグと比べると、CancellationTokenは.NET全体で統一された標準的な仕組みなので、他のAPIとも自然に組み合わせられる。


おわりに

WinFormsのクロススレッド問題の「正解」はこう変遷してきた。

InvokeRequired(苦行)
  → BackgroundWorker(革命!でも独自仕様)
    → async/await(成仏)

当時InvokeRequiredを書き続けていた身としては、async/awaitの登場は「もっと早く来てくれ」という感想だが、歴史の積み重ねがあってこそ、その価値がわかる。

既存のWinForms資産を保守している方は、新規追加する非同期処理だけでもasync/awaitに切り替えると、コードが格段にスッキリするのでお試しあれ。


環境

  • .NET 8.0
  • Windows Forms
  • Visual Studio 2022 / VS Code + DevContainer

DevContainerのセットアップ方法はこちらの過去記事を参照。
なお devcontainer(Linux)で実行できるのはビルドとロジックの単体テストまで。WinFormsのUI自体(ボタンを押してラベルが更新される見た目)はWin32のGUIなので、実際に動かして確認するにはWindows環境が必要になる。

4
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
4
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?