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仲間いたら、どんどんシェア待ってるぜ!!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次