C#で『何秒後に1回』『直ちに1回』『何秒後から一定間隔』を実装するなら、バックグラウンド処理ではSystem.Threading.Timer、イベント形式を使いたいサービスではSystem.Timers.Timer、非同期処理を順番に待つならPeriodicTimerまたはTask.Delayが候補です。UIアプリでは、画面を直接更新できるUIスレッド用タイマーを選びます。タイマー名だけで決めず、実行スレッド、重複実行、停止方法、例外処理を先に設計してください。
最も重要な注意点は、タイマーが『前回の処理終了を待って次を始める』とは限らないことです。コールバックが間隔より長いと同時実行される可能性があり、共有データの破損、同じメールの二重送信、重複課金などにつながります。正確な時刻に必ず1回だけ行う永続ジョブには、プロセス内タイマーだけでなく、ジョブスケジューラー、キュー、データベース上の冪等性を組み合わせます。
用途からタイマーを選ぶ
| 要件 | 第一候補 | 理由と注意 |
|---|---|---|
| 軽量な同期コールバック | System.Threading.Timer | ThreadPool上で実行。dueTimeとperiodを直接指定 |
| Elapsedイベントで構成 | System.Timers.Timer | AutoResetで単発・反復を切替。既定はThreadPool |
| 非同期処理を1回ずつ待つ | PeriodicTimer | awaitループとCancellationTokenを組み合わせやすい |
| 指定時間後に1回だけ非同期実行 | Task.Delay | 単純でキャンセルを伝えやすい |
| WinFormsの画面更新 | System.Windows.Forms.Timer | UIメッセージループ上で動作 |
| WPFの画面更新 | DispatcherTimer | Dispatcherキューと優先度に統合 |
System.Threading.TimerとSystem.Timers.Timerは、通常のデスクトップUI部品を直接操作するためのタイマーではありません。コールバックは作成元スレッドではなくThreadPoolスレッドで実行されるため、UI更新にはフォームやDispatcherへのマーシャリングが必要です。反対に、UIタイマーはUIスレッドが長時間塞がると遅れます。バックグラウンド作業と表示更新を分離し、結果だけをUIへ渡します。
3つの実行パターン
x秒後に1回だけ実行
System.Threading.Timerでは初回待機時間をdueTimeへ、繰り返さないことをTimeout.InfiniteTimeSpanへ指定します。次の例は2秒後に1回だけコールバックを呼びます。ローカル変数だけでタイマーを作ると参照が失われる可能性があるため、実際のクラスではフィールドに保持し、終了時に破棄します。
private Timer? _timer;
public void StartOnceAfterDelay()
{
_timer = new Timer(
callback: OnTimer,
state: null,
dueTime: TimeSpan.FromSeconds(2),
period: Timeout.InfiniteTimeSpan);
}
private void OnTimer(object? state)
{
// 短時間で完了する同期処理を呼び出す
}
直ちに1回だけ実行
初回待機をTimeSpan.Zero、周期を無限にすれば、ThreadPoolで可能になった時点に1回実行されます。『呼び出し元の行で同期的に即時実行』という意味ではなく、スケジューリングの遅延はあり得ます。今すぐ同じスレッドで実行すべき初期化なら普通のメソッド呼び出しを使い、タイマーを介在させる理由を明確にします。
_timer = new Timer(
OnTimer,
null,
TimeSpan.Zero,
Timeout.InfiniteTimeSpan);
x秒後に開始し、その後n秒間隔で実行
初回待機と周期を別々に指定します。次の例は5秒後に開始し、30秒間隔でコールバックを要求します。ただし、30秒ごとの要求時刻は処理終了時刻から30秒後とは限りません。処理が30秒を超える可能性があるなら、後述の排他、停止後の再予約、またはPeriodicTimerの逐次awaitを使います。
_timer = new Timer(
OnTimer,
null,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(30));
System.Threading.Timerを安全に使う
参照を保持して破棄する
公式ドキュメントは、アクティブなタイマーでも参照がなければガベージコレクションの対象になり得ると説明しています。タイマーを所有するサービスや画面のフィールドに保持し、ライフサイクル終了時にDisposeまたは利用可能な対象フレームワークではDisposeAsyncを呼びます。開始メソッドを複数回呼べる設計なら、古いインスタンスを先に停止し、二重登録を防ぎます。
Dispose()を呼んだ瞬間に、すでにキューへ入ったコールバックが必ず消えるとは限りません。停止フラグやCancellationTokenを処理本体にも渡し、終了中は新しい仕事を受け付けないようにします。アプリ終了時は『タイマーを止める』『進行中処理を待つ』『外部接続を閉じる』の順序を決め、タイムアウト時のログも残します。
重複実行を防ぐ
共有状態を守るだけならlockやInterlockedを使えますが、コールバックをロック待ちで積み上げると、遅延が解消した後に古い仕事が連続実行されます。監視のポーリングのように『実行中なら次回をスキップ』できる処理では、Interlocked.Exchangeで実行権を取得し、finallyで解放します。すべての回を処理する必要があるなら、タイマーからチャネルやキューへ投入し、単一コンシューマーで順番に処理します。
private int _running;
private void OnTimer(object? state)
{
if (Interlocked.Exchange(ref _running, 1) == 1)
return;
try
{
RunOneCycle();
}
catch (Exception ex)
{
_logger.LogError(ex, "Timer cycle failed.");
}
finally
{
Volatile.Write(ref _running, 0);
}
}
例外を境界で処理する
タイマーコールバックは呼び出し元が直接awaitする通常メソッドと違い、例外の伝わり方を見落としやすい場所です。処理境界で例外を記録し、再試行可能な一時障害と、設定ミスやデータ不整合のような恒久障害を分けます。無限再試行は外部サービスをさらに圧迫するため、回数制限、指数バックオフ、サーキットブレーカー、運用通知を要件に応じて設計します。
System.Timers.Timerの使い方
System.Timers.TimerはElapsedイベントを公開し、Intervalで間隔、AutoResetで反復を制御します。AutoReset = falseなら1回、既定のtrueなら繰り返しです。イベントハンドラーを解除し、タイマーを停止・破棄する所有者を決めます。EnabledやStartを複数箇所から変更すると状態が追いにくくなるため、開始と停止の窓口を1つにします。
private readonly System.Timers.Timer _timer = new(30_000)
{
AutoReset = false
};
public void StartOnce()
{
_timer.Elapsed += OnElapsed;
_timer.Start();
}
private void OnElapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
try
{
RunOneCycle();
}
finally
{
_timer.Elapsed -= OnElapsed;
}
}
SynchronizingObjectを設定するとイベントを特定のUIスレッドへマーシャリングできる場面がありますが、画面の破棄後にイベントが到着する競合は別に扱う必要があります。WinFormsなら画面専用タイマー、WPFならDispatcherTimerを使う方が意図が明確な場合があります。UIが閉じたら購読解除と破棄を行い、ハンドラー内で破棄済みコントロールへアクセスしないようにします。
非同期処理はPeriodicTimerで逐次化する
PeriodicTimerは各tickをWaitForNextTickAsyncで待つため、コールバック型より非同期フローを読みやすくできます。ループ内の処理をawaitしてから次のtickを待つ構造なら、同じループが自然に重複しません。ただし処理時間が周期を超えても、過ぎたtickが一回ずつ無制限にキューへ積まれる仕組みではありません。次の待機がすぐ完了する場合もあるため、厳密な時刻スケジュールとは区別します。
public async Task RunAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await RunOneCycleAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Periodic cycle failed.");
}
}
}
1回だけ遅延実行する非同期処理はTask.Delay(delay, cancellationToken)で十分なことが多く、タイマーを保持する必要がありません。ライブラリ内部で勝手にCancellationTokenを作らず、ホストや呼び出し元の停止要求を受け取ります。キャンセルは通常の終了経路として扱い、停止要求によるOperationCanceledExceptionをエラーログで埋め尽くさないようにします。
タイマーを本番運用へ載せる設計
冪等性と永続化
プロセス停止、再起動、スケールアウトがある環境では、メモリ内タイマーだけで『一度だけ』を保証できません。複数インスタンスが同じタイマーを持てば同じ仕事を同時に始めます。データベースの一意制約、処理済みキー、分散ロック、メッセージキューなどを使い、同じ要求が複数回来ても結果が壊れない冪等性を用意します。
時刻と周期の期待値
タイマーはリアルタイム制御器ではありません。OSスケジューリング、ThreadPool枯渇、GC、サスペンド、負荷により遅延します。『毎日9時』『月末締め』のような暦ベース処理では、タイムゾーン、夏時間、休日、実行漏れの追いつき方を持つジョブスケジューラーが適します。タイマーでは予定時刻、実開始、終了、結果を記録し、遅延を監視します。
監視とテスト
ログにはタイマーの起動・停止、周期、実行ID、予定時刻、開始・終了、所要時間、スキップ理由、例外を含めます。テストでは実時間を何十秒も待つのではなく、処理本体をタイマーから分離し、直接呼び出して検証します。時間抽象化を利用できる対象フレームワークでは時刻を差し替え、キャンセル、重複、遅延、例外、停止中のコールバックを再現します。
症状別の切り分け
| 症状 | 主な原因 | 確認・対処 |
|---|---|---|
| タイマーが途中で止まる | 参照を保持していない、所有者が破棄 | フィールド保持とライフサイクルを確認 |
| 同じ処理が重なる | 処理時間が周期超過、複数インスタンス | 排他、キュー、冪等性、分散実行数を確認 |
| UI更新で例外 | ThreadPoolからコントロールへアクセス | UIタイマーまたはDispatcherへ切り替える |
| 終了後も処理が走る | キュー済みコールバック、購読解除漏れ | 停止フラグ、待機、解除、Dispose順を見直す |
| 予定時刻から遅れる | ThreadPool枯渇、長時間処理、サスペンド | 所要時間とスレッドプールを監視しジョブ基盤を検討 |
| 例外後に動かない | 例外処理・再予約の不足 | 境界で記録し、再試行方針を明示 |
課金、決済、在庫引当、証明書更新、バックアップ削除など失敗時の影響が大きい処理、複数ノードで一度だけ実行すべき処理、秒単位の正確性が必要な処理は、単純なTimer実装のまま本番投入しません。アプリケーション基盤担当者と、永続ジョブ、リーダー選出、再実行、監査、手動復旧を設計してください。
よくある質問
System.Threading.TimerとSystem.Timers.Timerの違いは?
前者はTimerCallbackをThreadPoolで呼ぶ軽量な仕組み、後者はElapsedイベントとAutoResetなどの部品モデルを持ちます。どちらも既定ではUIスレッド用ではなく、重複実行を自動で防ぐものでもありません。
1回だけならTimerが必要ですか?
同期コールバックを遅延させるならTimerを使えます。非同期コードではTask.DelayとCancellationTokenの方が単純な場合があります。即時同期実行なら通常のメソッド呼び出しで十分です。
async voidをElapsedハンドラーに書いてよいですか?
イベントの都合でasync voidになる場合は、例外と終了待機を特に慎重に扱う必要があります。可能なら非同期処理をTaskとして分離し、所有者が追跡できるPeriodicTimerやバックグラウンドサービスを検討します。
TimerをDisposeすれば実行中処理も止まりますか?
必ずしも止まりません。キュー済みまたは実行中のコールバックが残る場合があります。処理本体へキャンセルを伝え、終了を待つ手順を用意します。
重複実行をlockで防げますか?
共有状態は守れますが、待機する呼び出しが積み上がる可能性があります。スキップ、単一コンシューマー、停止後の再予約など要件に合う方式を選びます。
毎日決まった時刻の処理にも使えますか?
単純な周期タイマーだけでは再起動、夏時間、実行漏れ、複数ノードを扱いにくいため、永続ジョブスケジューラーを優先します。
公式情報源
まとめ
3つの実行パターンは、dueTimeを遅延またはゼロ、periodを無限または周期へ設定すれば表現できます。しかし本番品質を決めるのは構文より、参照保持、例外境界、重複防止、キャンセル、Dispose後の競合、UIスレッド、冪等性です。同期コールバックはSystem.Threading.Timer、イベント型はSystem.Timers.Timer、逐次的な非同期処理はPeriodicTimerやTask.Delayを基準に選び、永続性が必要な仕事はジョブ基盤へ移してください。

コメント