WinForms Timer 3兄弟の使い分け — System.Windows.Forms.Timer / System.Threading.Timer / System.Timers.Timer

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

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

DataGridViewを1秒ごとに更新したくてTimer使ったら、InvalidOperationException: Cross-thread operation not validで詰まった」「DesignerにTimer置いてあったの使ったら画面が固まる」「new Timer()って書こうとしたら3つのTimerクラスが候補に出てきて、どれを選べばいいか分からなかった」みたいなWinForms Timerの事故って、業務SEなら一回はやらかしますよね??

俺も2社目くらいの流通系SIer時代に、ログ監視ツールをSystem.Threading.Timerで書いて、コールバックの中で直接dataGridView1.Rows.Add(...)を呼んでInvalidOperationExceptionで詰まった事件をやらかしました。夕方の運用報告で気づいて半日デバッガで追ってハマったやつ。原因は完全にTimerの選択ミス+ UIスレッド違反で、Control.Invokeでマーシャリングするパターンに書き換えて解決。

C#にはTimerが3クラスあって、似た名前で挙動が全く違うんですよね:

  • System.Windows.Forms.Timer(WinForms専用・UIスレッド発火)
  • System.Threading.Timer(スレッドプール発火・軽量)
  • System.Timers.Timer(コンポーネント風・SynchronizingObject次第でUI同期可能)

この記事ではVS2019 / .NET Framework 4.7.2 / C# 7.3 / WinForms環境で、3つのTimerの使い分けと、UIスレッド違反の回避(Control.Invoke)再入禁止フラグSynchronizingObject設定の業務SE現場ルールを、コード6本でまとめます。後ろの「現場メモ」で、業務系チームでルール化した時の話も書いてる。

3行で結論:

  • UI更新が主目的(DataGridView 1秒更新等)→ System.Windows.Forms.Timer一択(最も安全)
  • バックグラウンド計算(ログ集計・監視)→ System.Threading.Timer(UIから更新する時はControl.Invoke
  • Designerに置きたい・コンポーネント風→ System.Timers.Timer + SynchronizingObject = this
目次

System.Windows.Forms.Timer — UIスレッド発火の本命

WinForms専用のTimer。TickイベントがUIスレッド上で発火するので、UIコントロールをそのまま触れる:

// ✅パターン1: System.Windows.Forms.Timer(UI更新の本命)
using System.Windows.Forms;

public partial class MainForm : Form
{
    private Timer _uiTimer;

    public MainForm()
    {
        InitializeComponent();

        _uiTimer = new Timer
        {
            Interval = 1000,   // 1秒ごと
        };
        _uiTimer.Tick += UiTimer_Tick;
        _uiTimer.Start();
    }

    private void UiTimer_Tick(object sender, EventArgs e)
    {
        //ここはUIスレッド上で発火する→コントロールを直接触れる
        labelClock.Text = DateTime.Now.ToString("HH:mm:ss");
        dataGridView1.Refresh();
    }

    private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        _uiTimer.Stop();
        _uiTimer.Dispose();   //終了時に解放
    }
}

ポイント:

  1. TickイベントはUIスレッド上で発火(クロススレッド例外を踏まない)
  2. 長引いた処理が終わるまで次のTickはキューで待つ(重複しない・再入問題なし)
  3. タイマー精度はそこそこ(数十ms単位の誤差あり、メッセージポンプ依存)
  4. FormのFormClosingStop()+ Dispose()が鉄板

業務系の「画面の時計表示」「定期的なDataGridView再描画」「ログ画面の自動スクロール」のようなUI主体の処理なら、これが本命。ん?こんなシンプルでいいの??って思うかもだけど、スレッドを意識せずに書けるのでWinForms業務SEでは詰まりにくいやつなんですよね。

System.Threading.Timer —スレッドプール発火の軽量版

バックグラウンド計算・監視・大量同時タイマーならSystem.Threading.Timer。スレッドプール上で動くのでUIスレッドではない:

// ✅パターン2: System.Threading.Timer(バックグラウンド計算)
using System.Threading;

public partial class LogMonitorForm : Form
{
    private System.Threading.Timer _bgTimer;
    private int _processedCount = 0;

    public LogMonitorForm()
    {
        InitializeComponent();

        // dueTime=0(即座に開始)/ period=5000(5秒ごと)
        _bgTimer = new System.Threading.Timer(BackgroundCheck, null, 0, 5000);
    }

    private void BackgroundCheck(object state)
    {
        //ここはスレッドプール上で動く→ UIコントロールを直接触ると例外
        var newLogs = LogReader.PollNewEntries();
        _processedCount += newLogs.Count;

        // UI更新はControl.InvokeでUIスレッドにマーシャリング(次のセクション参照)
        if (this.IsHandleCreated && !this.IsDisposed)
        {
            this.BeginInvoke(new Action(()=>
            {
                labelStatus.Text = $"処理済: {_processedCount}件";
            }));
        }
    }

    private void LogMonitorForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        _bgTimer?.Dispose();   // Threading.TimerはStopメソッドがない、Disposeで停止
    }
}

ポイント:

  1. コールバックはスレッドプール上で動く(UI直接更新は禁忌)
  2. Stop()メソッドなし、Dispose()で停止
  3. タイマー精度は高い(数ms精度、Windows.Forms.Timerより正確)
  4. intervalが短いと別スレッドで並行発火する(再入問題が出る)

ログ監視・サーバー死活監視・バックグラウンド集計のようなUI触らない処理で本命。UI更新が必要な場合は次のControl.Invokeパターンで安全にマーシャリングします。

System.Timers.Timer + SynchronizingObject —コンポーネント風

Designerに配置できるTimer系コンポーネントで、SynchronizingObjectプロパティでUIスレッド同期を制御できる:

// ✅パターン3: System.Timers.Timer + SynchronizingObject(コンポーネント風)
using System.Timers;

public partial class DashboardForm : Form
{
    private System.Timers.Timer _timer;

    public DashboardForm()
    {
        InitializeComponent();

        _timer = new System.Timers.Timer(2000)// 2秒ごと
        {
            AutoReset = true,
            SynchronizingObject = this,   // ← Formを指定するとUIスレッド上で発火
        };
        _timer.Elapsed += Timer_Elapsed;
        _timer.Start();
    }

    private void Timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        // SynchronizingObject = thisのおかげでUIスレッド上で動く
        labelMetrics.Text = $"最終更新: {e.SignalTime:HH:mm:ss}";
        chartControl1.Refresh();
    }

    private void DashboardForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        _timer.Stop();
        _timer.Dispose();
    }
}

ハマりポイント: SynchronizingObjectを設定し忘れると、Elapsedがスレッドプール上で発火してUI更新で例外が飛ぶ。Designer経由でTimerコンポーネントを置いた場合は自動でセットされるけど、手書きコードでは設定漏れが頻出する罠っす。

業務系の「Designer中心で開発したい」「コンポーネントとしてカプセル化したい」場面で選ぶTimer。Stop()メソッドあり、例外伝播がSystem.Threading.Timerより柔軟、というメリットもあります。

Control.Invokeでクロススレッド例外を回避する

System.Threading.Timerのコールバックや、別スレッドのTask.RunからUIを更新したい場合は、Control.InvokeまたはBeginInvokeでマーシャリングするのが鉄則:

// ✅パターン4: Control.Invokeでクロススレッド回避
public partial class MyForm : Form
{
    //ヘルパー: UIスレッドでアクションを実行
    private void RunOnUiThread(Action action)
    {
        if (this.IsDisposed || !this.IsHandleCreated)return;

        if (this.InvokeRequired)
        {
            this.BeginInvoke(action);   //非同期マーシャリング(呼び出し元はブロックしない)
        }
        else
        {
            action();   //既にUIスレッドならそのまま実行
        }
    }

    private void BackgroundCallback(object state)
    {
        //別スレッドで動く処理
        var newData = FetchFromApi();

        // UI更新はRunOnUiThreadでマーシャリング
        RunOnUiThread(()=>
        {
            dataGridView1.DataSource = newData;
            labelCount.Text = $"件数: {newData.Count}";
        });
    }
}

ポイント:

  1. InvokeRequiredで「現在のスレッドがUIスレッドかどうか」判定
  2. Invoke(同期・呼び出し元待機)vs BeginInvoke(非同期・呼び出し元待たない)
  3. IsHandleCreated / IsDisposedチェックでフォーム破棄後の例外を予防
  4. ヘルパーメソッド化して使い回すのが業務SE鉄板パターン

俺は流通系SIer時代に、RunOnUiThreadヘルパーを共通基底フォームに置いて、バックグラウンドスレッドからのUI更新は全部このヘルパー経由にする運用に揃えました。コードレビューで「Invoke書き忘れ」を指摘する手間が減るので、業務系チームには結構おすすめのパターンっす。

再入禁止フラグでTick重複を防ぐ

System.Threading.Timer / System.Timers.Timer別スレッドで並行発火するので、intervalが処理時間より短いとTickが重複実行される事故が起きます:

// ✅パターン5:再入禁止フラグでTick重複を防ぐ
public partial class HeavyTaskForm : Form
{
    private System.Timers.Timer _timer;
    private int _busyFlag = 0;   // 0=空き/ 1=実行中

    public HeavyTaskForm()
    {
        InitializeComponent();

        _timer = new System.Timers.Timer(1000){ AutoReset = true };
        _timer.Elapsed += Timer_Elapsed;
        _timer.Start();
    }

    private void Timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        //既に実行中なら今回のTickはスキップ(再入禁止)
        if (Interlocked.CompareExchange(ref _busyFlag, 1, 0)!= 0)return;

        try
        {
            DoHeavyTask();   // 1秒以上かかる重い処理
        }
        finally
        {
            Interlocked.Exchange(ref _busyFlag, 0);   //確実にフラグを戻す
        }
    }
}

Interlocked.CompareExchange(ref _busyFlag, 1, 0)!= 0の意味は「_busyFlagが0なら1にしてfalseを返す(処理続行)、1ならそのままでtrueを返す(スキップ)」のアトミック操作。複数スレッドからの再入をいい感じに防げます。

try-finally_busyFlag = 0に戻すのは、例外時もフラグが解除されるようにするため。書き忘れると例外1回でタイマーが完全に止まる事故になるので、業務系チームのコードレビュー観点に入れておきたいパターンっす。

機能比較表—どれを選ぶべきか

観点 System.Windows.Forms.Timer System.Threading.Timer System.Timers.Timer
名前空間 System.Windows.Forms System.Threading System.Timers
発火スレッド UIスレッド スレッドプール スレッドプール(or SynchronizingObject指定先)
UI直接更新 ✅そのままOK ❌ Invoke必要 SynchronizingObject次第
Designer配置 ✅標準 ❌コード必須 ✅標準
タイマー精度 △(数十ms誤差) ◎(数ms精度) ◎(数ms精度)
Tick重複 なし(順次実行) あり(再入対策必須) あり(再入対策必須)
Stopメソッド ✅あり ❌ Disposeで停止 ✅あり
例外伝播 UIスレッド経由 コールバック内で握り潰される Elapsed内で握り潰される
主な用途 UI主体の更新 バックグラウンド計算 コンポーネント設計

業務SEの判断軸:

  • 画面の定期更新(時計・グリッドrefresh・進捗表示)System.Windows.Forms.Timer一択
  • UI触らない監視・集計System.Threading.Timer
  • Designer配置・コンポーネント風System.Timers.Timer + SynchronizingObject = this
  • どっち選ぶか迷ったらSystem.Windows.Forms.Timer(UI系の安全パイ)

ハマりポイント—実体験ベースの本番事故3点

1. System.Threading.TimerのコールバックからUI直接更新で例外(半日デバッガで追ってハマった)

ログ監視ツールをSystem.Threading.Timerで書いて、コールバックの中でdataGridView1.Rows.Add(...)を呼んでInvalidOperationException: Cross-thread operation not validで詰まった事件。夕方の運用報告で「画面が落ちる」って報告で気づいて半日デバッガで追ってハマった末に、Control.Invokeでマーシャリングするパターンに書き換えて解決。それ以来、業務系チームで「バックグラウンドTimerからUI更新はRunOnUiThreadヘルパー経由」のルールを入れた。

2. interval短すぎてTick重複処理(30分溶かした)

別案件で、System.Timers.Timer(500)で500msごとに重い処理を呼んでいて、処理が1秒以上かかる時に並行で2スレッド発火→ DataTable同時編集で例外事件。30分溶かした末に、Interlocked.CompareExchangeで再入禁止フラグを入れて解決。それ以来、System.Threading.Timer / System.Timers.Timerを使う時は再入禁止フラグをセットで書くルールに揃えました。

3. Tick中にFormを閉じてGCハマった(数日プロファイラで追った)

System.Threading.TimerをFormの_bgTimerフィールドに保持していて、Formを閉じても_bgTimer.Dispose()を呼んでなくて、バックグラウンドスレッドが残ってアプリ終了に時間がかかる事件。数日プロファイラで追ってようやく気付いたFormClosing_bgTimer?.Dispose();を呼ぶように直して解決。WinFormsのTimerはFormのライフサイクルに合わせてStop + Disposeを業務系チーム規約に入れた。

俺の現場メモ—業務系チームでのTimerルール

流通系SIer時代に、過去コードをgrep -rn "Timer" .でひっかけたら、80箇所近く出てきたんですよね。書き方がバラバラで、System.Windows.Forms.Timerusing System.Threading;の中でTimerと書いて型解決ミスしてる箇所、System.Timers.TimerSynchronizingObject未設定でUI例外踏んでる箇所、Dispose忘れでスレッド残留してる箇所、全部入り。

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

  1. UI更新が絡むTimerはSystem.Windows.Forms.Timer寄せ(迷ったらこれ・新規禁止対象は他2系統)
  2. System.Threading.Timer / System.Timers.TimerからUI更新はControl.Invokeヘルパー経由(直接UI触らない)
  3. FormClosingStop()+ Dispose()を確実に呼ぶ(バックグラウンドスレッド残留防止)

このルール化で、Timer周りのクロススレッド例外とGCハマりが消えた。3兄弟の使い分けを1パターンに揃えるだけで保守工数も事故率も両方下がるので、業務系チームには結構おすすめのルールっす。

C# 7.3 + .NET Framework 4.7.2 + WinFormsのレガシー業務系って、Timer系APIは10年以上変わってないのに、書き方が現場ごとにバラバラなコードベースが本当に多い。書き方のアップデートだけで保守工数が下がる実例だと思ってます。

まとめ

状況 推奨Timer
UI主体の定期更新(DataGridView refresh・時計表示) System.Windows.Forms.Timer
バックグラウンド計算・監視 System.Threading.Timer
Designer配置・コンポーネント風 System.Timers.Timer + SynchronizingObject
バックグラウンドTimerからUI更新 Control.Invoke / BeginInvokeでマーシャリング
intervalが処理時間より短い Interlocked.CompareExchangeで再入禁止
Form終了時 FormClosingStop()+ Dispose()
迷ったら System.Windows.Forms.Timer(安全パイ)

WinFormsのTimer事故は、「3クラスの違いを把握」「UI更新はInvoke」「Stop + Disposeを忘れない」の3点で9割消えます。「new Timer()と書いて型解決でブレる」「クロススレッド例外で詰まる」は業務SE現場の典型パターンなので、書き方を1パターンに揃えるのが本命の対処です。

よくある質問

Q1. WinFormsでTimerを使うなら、どのTimerクラスを選べばいい?

A. UI更新が主目的ならSystem.Windows.Forms.Timer一択です。TickイベントがUIスレッド上で発火するので、DataGridViewLabelをTickの中で直接書き換えられて、クロススレッド例外を踏みません。バックグラウンド計算(ログ集計・監視・タイマー精度が要る)ならSystem.Threading.Timer、Designer配置やコンポーネント風に扱いたいならSystem.Timers.Timer + SynchronizingObjectを選ぶのが業務SEの判断軸です。

Q2. System.Threading.TimerのコールバックからUIを更新したらどうなる?

A. InvalidOperationException: Cross-thread operation not valid(クロススレッド例外)が飛びます。System.Threading.Timerのコールバックはスレッドプール上で動くので、UIコントロール(DataGridView / Label等)を直接触ると例外。回避策はControl.InvokeまたはBeginInvokeでUIスレッドにマーシャリングする書き方。this.Invoke(new Action(()=> label1.Text = "updated"));のパターンが業務SE定番です。

Q3. System.Timers.TimerSynchronizingObjectって何ですか?

A. Elapsedイベントを発火させる対象スレッドを指定するプロパティです。既定値はnullで、nullのままだとイベントはスレッドプール上で発火しUI更新で例外が飛ぶ。timer.SynchronizingObject = this;Formインスタンス)を設定すると、FormのUIスレッド上でElapsedが発火するのでUI更新が安全になる。WinForms DesignerからTimerコンポーネントを置いた場合は自動でセットされますが、手書きコードでは設定漏れが頻出する罠です。

Q4. Tickの処理が長引いて次のTickと被ったら?

A. System.Windows.Forms.TimerはTickがUIスレッド上で順次実行されるので、長引いた処理が終わるまで次のTickはキューで待つ(重複しない)。一方System.Threading.Timer / System.Timers.Timerは別スレッドで並行発火するので、intervalが処理時間より短いとTickが重複実行される事故が起きます。再入禁止フラグ(if (Interlocked.CompareExchange(ref _busy, 1, 0)!= 0)return;)でガードするのが定石です。

Q5. TimerをStop / Disposeするタイミングは?

A. FormのFormClosingイベントでStop + Dispose、もしくはusingで囲うのが鉄則です。Tickの処理中にアプリ終了すると、System.Threading.Timer系はバックグラウンドスレッドが残ってGC処理にハマるケースがあります。FormClosingtimer.Stop(); timer.Dispose();を呼ぶか、Form.Componentsに登録して自動破棄させるのが業務SEで安全です。

ここまででWinForms Timer 3兄弟の使い分け・UI同期・現場ルール化は押さえた。WinFormsスレッド系の隣接トピックも貼っておきます。

関連記事

以上!

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

執筆者

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

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

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

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

コメント

コメントする

CAPTCHA


目次