WinForms で UseWaitCursor が戻らないバグの解決法(業務SE目線)

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

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

Cursor.Current = Cursors.WaitCursor を入れたのに砂時計が出ない」「処理は終わってるのに矢印に戻らない」「スピンする間だけチラついて結局矢印のまま」って、業務系の長時間処理書いてたら一回はハマったことないっすか??

俺も2年目くらいの流通系の基幹システム保守で、DataAdapter.Fill で5万件取ってくるバッチボタンに WaitCursor 入れたら、ボタンは固まってるのに砂時計はずっと出ないまま画面が止まる事故に遭遇して、半日デバッガで追った経験があります。

この記事では VS2019 / .NET Framework 4.7.2 / C# 7.3 環境で、UseWaitCursor が戻らないバグの 構造的な原因と4パターンの正解実装 を、コード5スニペットでまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の判断軸も書いてる。

3行で結論:

  • 同期処理なら try-finally + IDisposable ラッパー で確実に戻す
  • 長時間処理なら async/await + Task.Run で UI スレッドを解放する
  • UseWaitCursorマウスがフォーム領域内にある時だけ 効く仕様なので、それを忘れてコード書くと現場で詰まる
目次

なぜ Cursor.Current だけだと砂時計が出ないのか

これは WinForms のメッセージポンプの話を最低限だけしないと腹落ちしないので、ちょい解剖します。

Cursor.Current = Cursors.WaitCursor をセットしても、画面の砂時計表示が更新されるのは WinForms が WM_SETCURSOR メッセージを処理した瞬間なんですよね。んで、UI スレッドが重い処理でブロック中はメッセージポンプが回らないので、設定したカーソルが画面に反映されない。

つまりこういう流れで詰みます:

  1. ボタンクリックハンドラ内で Cursor.Current = Cursors.WaitCursor を実行
  2. 同じハンドラ内で 5万件 SELECT する DataAdapter.Fill を実行(数秒かかる)
  3. UI スレッドが Fill でブロック中なので、Windows のメッセージポンプは止まってる
  4. ユーザーが画面上でマウスを動かしても、WM_SETCURSOR が処理されないので砂時計が反映されない
  5. Fill が終わって Cursor.Current = Cursors.Default を実行 → そこで初めてカーソルが動く

「設定はしてるけど反映されない」って、WinForms の宿命に近いやつなんですよね。だから「Cursor.Current だけで砂時計を出す」は、そもそも長時間処理に向いてないってのが結論。

// ❌ ダメな書き方(業務系で多い)
private void btnLoad_Click(object sender, EventArgs e)
{
    Cursor.Current = Cursors.WaitCursor;
    // ↓ ここで数秒かかる。UI スレッドはブロック中。
    var dt = new DataTable();
    using (var conn = new SqlConnection(_connectionString))
    using (var adapter = new SqlDataAdapter("SELECT * FROM big_table", conn))
    {
        adapter.Fill(dt);
    }
    dataGridView1.DataSource = dt;
    Cursor.Current = Cursors.Default;
    // ↑ ここまで来て初めて表示が変わる(が、もう処理終わってるので一瞬しか出ない)
}

ん?普通にこれでいけそうじゃない??って思うかもだけど、メッセージポンプが止まってる以上、設定値は持っていても画面側に反映するタイミングがそもそも来ないっていう仕組みの問題なんすよね。

解決の方向は構造的に2つしかないです:

  • A: UI スレッドをブロックする処理(同期) → ブロック中でもカーソル反映が安定する書き方にする
  • B: UI スレッドを解放する(非同期) → そもそもブロックしない構造にする

以下で順に見ていきます。

解決1: try-finally で確実に戻す(同期処理向け)

まず一番ベーシックな書き方。UseWaitCursor プロパティ(Form クラスが持ってる)を使うと、WM_SETCURSOR への応答時に WinForms 側が自動で WaitCursor を返してくれるので、Cursor.Current 直叩きより反映が安定する

ただし UseWaitCursor = true を入れたまま例外で抜けたら戻らないので、ここは try-finally で包む:

// ✅ 解決1: try-finally + UseWaitCursor
private void btnLoad_Click(object sender, EventArgs e)
{
    this.UseWaitCursor = true;
    try
    {
        var dt = new DataTable();
        using (var conn = new SqlConnection(_connectionString))
        using (var adapter = new SqlDataAdapter("SELECT * FROM big_table", conn))
        {
            adapter.Fill(dt);
        }
        dataGridView1.DataSource = dt;
    }
    finally
    {
        this.UseWaitCursor = false;
    }
}

こんな感じで finally が呼ばれるので、例外で抜けても戻し漏れがない。UseWaitCursor は WinForms のメッセージ処理側で WM_SETCURSOR 応答時に毎回見てるので、「設定値は持ってるが反映されない」問題が起きにくい。

ただし欠点として、UI スレッドがブロック中なのでメッセージポンプが回らない問題自体は同じ。ボタンを連打されると2回目以降のクリックがキューに溜まって、Fill が終わった瞬間に2連打分の処理が動く事故が残ります。短時間処理(1〜2秒)なら気にしなくていいけど、5秒以上の処理ならこの後の解決3に進んだ方がいい感じに収まる。

あともう1点。UseWaitCursorマウスポインタがフォーム領域内にある時だけ 砂時計に変わる仕様なんですよね。マウスをタスクバーやフォーム外に置きっぱなしだと、処理中は砂時計が出ない。これは後ろの「UseWaitCursor の罠」のセクションで詳しく書きます。

解決2: IDisposable ラッパーでスコープ化する

try-finally を毎回書くのはダルいし、書き忘れたら戻らない。業務系の現場でルール化するなら、IDisposable 実装の WaitCursor スコープクラス を1つ用意して using で囲むのが鉄板:

// ✅ 解決2-a: IDisposable ラッパーの定義
public sealed class WaitCursorScope : IDisposable
{
    private readonly Form _form;
    private readonly bool _previous;

    public WaitCursorScope(Form form)
    {
        _form = form ?? throw new ArgumentNullException(nameof(form));
        _previous = _form.UseWaitCursor;
        _form.UseWaitCursor = true;
    }

    public void Dispose()
    {
        _form.UseWaitCursor = _previous;
    }
}

使う側はいい感じに読みやすくなります:

// ✅ 解決2-b: 使用例
private void btnLoad_Click(object sender, EventArgs e)
{
    using (new WaitCursorScope(this))
    {
        var dt = new DataTable();
        using (var conn = new SqlConnection(_connectionString))
        using (var adapter = new SqlDataAdapter("SELECT * FROM big_table", conn))
        {
            adapter.Fill(dt);
        }
        dataGridView1.DataSource = dt;
    }
}

例外が出ても Dispose() が呼ばれるので、戻し漏れがゼロになる。業務系の保守チームでルール化する時はこのパターン推奨っす。コードレビューで try-finally の書き忘れを指摘する手間も無くなる。

ただし欠点として、ネスト呼び出し(あるメソッドが WaitCursorScope を作って、内部で別のメソッドが更に WaitCursorScope を作る)に遭遇した時、_previous を保存しておかないと外側のスコープが終わった瞬間に false で潰される。上のコードは _previous を保存してるので大丈夫だけど、シンプル版(直接 false を返すだけ)で実装すると詰まる罠があります。

解決3: async/await + Task.Run で UI スレッドを解放する

これが長時間処理の正解。重い処理を Task.Run で別スレッドに逃がして、UI スレッドはメッセージポンプを回し続ける。.NET Framework 4.7.2 でも async/await は普通に書けるので、業務系でも怖がる必要はないっす:

// ✅ 解決3: async/await + Task.Run
private async void btnLoad_Click(object sender, EventArgs e)
{
    btnLoad.Enabled = false;
    using (new WaitCursorScope(this))
    {
        var dt = await Task.Run(() =>
        {
            var t = new DataTable();
            using (var conn = new SqlConnection(_connectionString))
            using (var adapter = new SqlDataAdapter("SELECT * FROM big_table", conn))
            {
                adapter.Fill(t);
            }
            return t;
        });
        dataGridView1.DataSource = dt;
    }
    btnLoad.Enabled = true;
}

ポイントは3つ:

  1. async void はイベントハンドラなのでセーフ(イベントハンドラ以外で async void は禁止)
  2. Task.Run の中では UI コントロールを触らない(別スレッドなのでクロススレッド例外が飛ぶ)
  3. ボタンの Enabled = false で連打防止

UI スレッドが解放されてるので、砂時計はちゃんと反映されるし、画面の再描画も普通に動く。WinForms 業務系で5秒以上かかる処理は、もうこれで書いた方が早い。

ただし欠点として、既存の同期前提のコードに混ぜると async 感染(呼び出し元も async になる伝播)が起きるので、レガシー保守だと書き換え範囲が広がりがち。新規で書ける場面で使うのが現実的っす。

UseWaitCursor の罠 — マウスがフォーム外だと効かない

ここまで「UseWaitCursor は安定して効く」って書いてきたけど、罠がもう一つある。

UseWaitCursor「フォーム領域内にマウスポインタがある時だけ砂時計に変わる」 仕様なんですよね。Windows のカーソルメッセージは「現在マウスポインタが乗っているコントロール」に向けて配信されるので、ユーザーがタスクバーにマウスを置きっぱなしで処理を待ってる場合、砂時計は出ない。

これは Windows OS 仕様なので、UseWaitCursor 側ではどうしようもない。画面全体に砂時計を出したい時は Application.UseWaitCursor を使う:

// 画面全体に WaitCursor を効かせる場合
Application.UseWaitCursor = true;
try
{
    /* 重い処理 */
}
finally
{
    Application.UseWaitCursor = false;
}

ただし Application.UseWaitCursor も .NET Framework のバージョンによって挙動が微妙にブレるという話があって(特に古い環境)、業務系の現場で使うかは社内の動作確認方針次第。俺の経験では Form 単位の this.UseWaitCursor = true で十分で、画面全体まで広げる必要はほぼ無かったっす。

ハマりポイント3つ(実体験ベース)

ここからは実際に俺が踏んだやつを3つ。WinForms 保守で UseWaitCursor 周りに手を入れる時のチェックリスト代わりにどうぞ。

1. 例外時に戻らずユーザー問い合わせが来た(30分溶かした)

Cursor.Current = Cursors.WaitCursortry の中に書いて、catchMessageBox 出して、finally を書き忘れた時のやつ。例外時にメッセージボックスは出るけど砂時計は戻らないので、エンドユーザーから「画面が固まってます」って問い合わせが来た。30分溶かした。それ以来、Cursor 関連は全部 IDisposable ラッパーで囲うルールに変えました。

2. Application.DoEvents() で再入バグを踏んだ(半日デバッガで追った)

「メッセージポンプを強制的に回したい」って動機で Application.DoEvents() を入れる現場って、業務系には根強くあるんですよね。これは禁断の薬で、処理中にもう一度ボタンを押せてしまうので再入バグが起きる。俺は Fill 中に同じボタンが2回呼ばれて DataTable が中途半端に上書きされる事故を踏んで、半日デバッガで追ったことがあります。DoEvents 使うくらいなら、最初から async/await に書き換えた方が早い。

3. 子フォーム表示中に親の砂時計が出続ける(夕方の問い合わせで気づいた)

親フォームで UseWaitCursor = true のまま ChildForm.ShowDialog() を出すと、子フォーム上でも砂時計が出続けるんですよね。これに気づかずリリースして、夕方になって運用部隊から「設定画面が固まってる扱いに見える」って報告が上がってきた。今はサブフォーム呼び出しの直前に一時的に解除する書き方に変えてます。粒度の意識が抜けると、ちょっと先で詰まる。

著者の現場メモ — 業務系チームでのルール化

流通系の基幹システム保守チームに移った時、過去コードを grep -r "Cursor.Current" . でひっかけたら40箇所近くあって、書き方がバラバラだったんですよね。try-finally がある書き方、ない書き方、Cursor.CurrentUseWaitCursor が混在してる箇所、DoEvents が紛れ込んでる箇所、全部出てきた。

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

  1. 同期処理は using (new WaitCursorScope(this)) で全部囲む(裸の Cursor.Current 禁止)
  2. 5秒以上かかる処理は async/await + Task.Run に書き換える(リファクタ対象)
  3. Application.DoEvents() は新規追加禁止(既存箇所はリファクタ時に削除)

このルール化で、新人がレビュー段階で同じ罠を踏まなくなった。「Cursor 系は書き方を1パターンに揃える」 だけで保守工数がだいぶ下がるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 の現場って、モダン Web から見ると古いって扱いされがちなんだけど、こういう地味な書き方を1パターンに揃える だけで、新人の事故率と保守工数が両方下がる。やる価値はちゃんとあると思ってます。

まとめ

WinForms で UseWaitCursor が戻らない・出ない・チラつく問題は、同期 vs 非同期 の構造で見ると整理しやすい:

状況 推奨パターン
同期・短時間(1〜2秒) using (new WaitCursorScope(this)) で囲む
同期・長時間(5秒以上) async/await + Task.Run に書き換える
例外時の戻し漏れ防止 IDisposable ラッパー一択
画面全体に砂時計 Application.UseWaitCursor(バージョン差注意)
禁止 Application.DoEvents() の新規追加

裸の Cursor.Current を直接書く運用は、書き忘れと例外時の戻し漏れで詰むので、チーム全体で IDisposable ラッパー1択にしてしまう のが現実的。Cursor.Current = Cursors.WaitCursor で2時間溶かす前に、こっちに揃えた方が早いです。

よくある質問

Q1. Cursor.Current = Cursors.WaitCursorthis.UseWaitCursor = true はどっちが正解?

A. 同期処理なら UseWaitCursor の方が反映が安定します。Cursor.Current は次のメッセージポンプ更新までしか維持されないので、長時間処理中に矢印に戻ってしまう挙動が出ることがある。UseWaitCursor は WinForms 側が WM_SETCURSOR への応答で毎回 WaitCursor を返してくれるので、設定したまま処理が回せます。

Q2. .NET Framework 4.7.2 で async/await は使えますか?

A. 普通に使えます。C# 7.3 で async/await は完全サポート、Task.Run も使える。VS2019 + .NET Framework 4.7.2 のレガシー業務系でも特別な NuGet パッケージなしで動きます。async void はイベントハンドラ用途のみで、それ以外は async Task で返してください。

Q3. Application.DoEvents() を使ってる既存コードはどうすれば?

A. 触れる範囲なら async/await + Task.Run に書き換えるのが本命。書き換えコストが大きい場合、最低限 「ボタン連打を Enabled = false で止める」 のだけは入れた方がいい。再入バグはほぼ DoEvents 経由なので、入口を塞ぐだけでも事故率は下がります。

Q4. WaitCursorScope をネストで呼んだ時の挙動は?

A. 本記事の実装は _previous を保存してるので、外側の WaitCursorScope が生きたまま内側を Dispose しても、外側の砂時計は維持されます。ただしシンプル版(_previous なしで Dispose 時に直接 false を入れる版)で実装すると、内側を Dispose した時に外側の WaitCursor も false で潰されるので、ネスト想定するなら保存ロジックを入れておく方が安全っす。

Q5. 砂時計じゃなくて自前のローディング画像を出したい時は?

A. それは UseWaitCursor の領分じゃなく、モードレスのローディングフォームを別スレッドで表示 する設計の話になります。WinForms 標準の砂時計で要件を満たせるならまずそれで、業務要件で「ロード中ですアニメーション」が必要な場合だけ自前フォームを検討するのが現実的です。

ここまでで UseWaitCursor 系の挙動・実装パターン・ハマりどころは押さえた。WinForms の長時間処理周りは関連トピックも多いので、隣接記事も貼っておきます。

関連記事

以上!

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

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

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

コメント

コメントする

CAPTCHA


目次