タイマーは、遅延の後や、繰り返しの間隔でコードを実行します。1分ごとにキャッシュを更新する、30秒ごとに下書きを保存する、サービスをポーリングする、時計を更新する、といった用途です。.NETには見た目の似たタイマークラスがいくつかありますが、コールバックがどこで実行されるか、どう制御するかが異なります。このページではそれぞれを扱い、処理にかかった時間を測る Stopwatch も紹介します。
どのタイマーを使うか
| タイマー | コールバックが実行される場所 | スタイル | 用途 |
|---|---|---|---|
System.Threading.Timer | スレッドプール | コールバックのデリゲート | サービスやライブラリでのバックグラウンド処理 |
System.Timers.Timer | スレッドプール(既定) | Elapsed イベント、Start/Stop | イベントを好むコンポーネント形式のコード |
PeriodicTimer(.NET 6以降) | 非同期ループが実行される場所 | await WaitForNextTickAsync() | 非同期コード。ティックが重ならない |
System.Windows.Forms.Timer、WPFの DispatcherTimer | UIスレッド | Tick イベント | デスクトップアプリでのコントロールの更新 |
最初の2つはスレッドプールのスレッドで発火するので、そのコールバックはプログラムの他の部分と同時に、さらには互いに同時にも実行されることがあります。UIのタイマーはUIスレッドで実行されるので、コントロールに直接触れられますが、遅いハンドラーはウィンドウを固まらせます。
System.Threading.Timer
コンストラクターは、コールバック、状態オブジェクト、最初のティックまでの遅延、周期を受け取ります。タイマーはすぐに動き始めます。
出力例:
tick 1 at ~200 ms
tick 2 at ~500 ms
tick 3 at ~800 ms
tick 4 at ~1100 ms
stopped after 4 ticks
ここでは2つの点が重要です。Main は待たなければなりません(done.WaitOne())。コンソールプログラムは Main が戻ると終了し、タイマーのコールバックはプロセスを生かし続けないバックグラウンドスレッドで実行されるからです。そしてタイマーは静的フィールドにあります。何も参照していない System.Threading.Timer はガベージコレクションで回収されることがあり、そうなると何も言わずに発火しなくなります。戻ってしまうメソッドの中でタイマーをローカル変数として作ったときに、最もよく問題になります。
Change(dueTime, period) はタイマーのスケジュールを変更します。両方に Timeout.Infinite を指定すると一時停止し、新しい組を指定すると再開します。Dispose() は完全に止めます。
System.Timers.Timer
System.Timers.Timer は同じ仕組みをイベントベースのAPIで包んだものです。Interval を設定し、Elapsed を購読してから、Start() と Stop() を使います。
出力例:
autosave #1
autosave #2
autosave #3
autosave stopped
AutoReset = false にすると1回限りのタイマーになります。1回発火して止まり、Start() を再び呼ぶと次の1回のティックが予約されます。Enabled = true と false は、Start() と Stop() と同じです。ここで完全な名前 System.Timers.Timer を書いているのは、System.Threading にも Timer があるからです。両方の名前空間をインポートしていると、単に Timer と書いたのではあいまいになり、コンパイルできません。
System.Timers.Timer には、WinFormsで Elapsed をUIスレッドへマーシャリングする SynchronizingObject プロパティもあります。実際には、その目的にはUIフレームワーク自身のタイマーのほうが簡単です。
コールバックは重なり、別のスレッドで実行される
上の2つのクラスはどちらも、前のコールバックが終わったかどうかに関係なく、予定どおりにスレッドプールで発火します。コールバックに3秒かかり、周期が1秒なら、3つのコールバックが同時に実行されます。そこから2つの義務が生まれます。
- コールバックが触れるものはすべてスレッドセーフでなければなりません。 カウンターには(例のように)
Interlockedを、それより大きなものにはlockを使います。 - 重なりが問題になるなら防ぎます。 前のティックがまだ実行中ならそのティックを飛ばすか、重なりえない形を使います。
Interlocked による飛ばしのガード:
private int running = 0;
private void OnTick(object state)
{
if (Interlocked.Exchange(ref running, 1) == 1) return; // previous tick still busy
try
{
SyncOrders(); // slow work
}
finally
{
Volatile.Write(ref running, 0);
}
}
例外はもう1つの落とし穴です。System.Threading.Timer のコールバックから漏れた例外はプロセスをクラッシュさせ、System.Timers.Timer は Elapsed ハンドラーからの例外を握りつぶすので失敗に気づけません。タイマーのコールバックの本体は try/catch で囲み、ログを記録します。
PeriodicTimerと非同期ループ
非同期コードでは、反復の間に待つループが最もわかりやすいタイマーです。次の待機は処理が終わってから始まるので、ティックが重なることはなく、例外もキャッチできる場所に現れます。.NET 6では、まさにこのために PeriodicTimer が追加されました。
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await SaveDraftAsync();
}
WaitForNextTickAsync は、タイマーが破棄されると false を返してループを終わらせ、トークンがキャンセルされると OperationCanceledException を投げます。Task.Delay を使うループと違い、PeriodicTimer は一定のリズムを保ちます。処理に2秒かかっても、次のティックは32秒後ではなく、前のティックから30秒後に来ます。
ずれが問題にならないなら、どのバージョンでも Task.Delay を使った同じ形で動きます。
出力例:
poll 1
poll 2
poll 3
poll 4
polling cancelled after 4 rounds
キャンセルが Stop() の代わりになります。ループにトークンを渡し、外からキャンセルします。キャンセルトークンについて詳しくはタスクを参照してください。
Stopwatchで経過時間を測る
タイマーはコードの実行を予約し、Stopwatch はコードにかかった時間を測ります。前にしか進まない高分解能の時計を読むので、時間の計測に適した道具です。
出力例:
string += 64 ms
StringBuilder 0 ms
Same length: True
High resolution clock: True
Start、Stop、Reset、Restart で制御します。Elapsed は TimeSpan で、ElapsedMilliseconds と ElapsedTicks は生の数値を返します。DateTime.Now で時間を測るのは避けます。分解能が10から15 msほどと粗いことがあり、システムの時計が調整されると飛びます。JITのウォームアップやガベージコレクションが1回の実行を歪める本格的なベンチマークには、BenchmarkDotNetライブラリを使います。
タイマーの精度
タイマーの間隔は最小値であって、約束ではありません。Windowsの既定のシステムタイマーの分解能は約15.6 msなので、10 msの間隔はだいたい15か16 msごとに発火し、負荷の高いマシンではスレッドプールのコールバックの開始がさらに遅れることもあります。タイマーが適しているのは「だいたいN秒ごと」です。正確なフレームのタイミングやメディアには、そのために作られたAPIを使います。
よくある間違い
- ローカル変数にしか保存していない
System.Threading.Timer。 ガベージコレクションで回収されて発火しなくなることがあります。フィールドに保持します。 Mainを戻らせてしまう。 プロセスが終了し、タイマーも一緒に消えます。- あいまいな
Timer。System.ThreadingとSystem.Timersを両方インポートしているなら、完全な名前を書きます。 - ティックが決して重ならないと思う。 スレッドプールのタイマーは、処理が間隔より遅いと重なります。
- スレッドプールのタイマーからUIのコントロールに触れる。 UIフレームワークのタイマーを使うか、UIスレッドにマーシャリングし直します。
- コールバック内の処理されない例外。 プロセスをクラッシュさせる(
Threading.Timer)か、消えてしまいます(Timers.Timer)。キャッチしてログを記録します。 Disposeを忘れる。 タイマーはシステムのリソースを保持し、破棄されるまで発火し続けます。
よくある質問
C#ではどのタイマーを使うべきですか?
.NET 6以降の非同期コードなら、ループの中で await timer.WaitForNextTickAsync() を使う PeriodicTimer です。スレッドプールでのコールバックなら System.Threading.Timer。Start、Stop、Elapsed イベントを持つイベントベースのタイマーなら System.Timers.Timer。WinFormsやWPFでは、ハンドラーがUIスレッドで実行されるように、UIフレームワークのタイマーを使います。
C#で数秒ごとにコードを実行するには?
間隔を指定してタイマーを作ります。たとえば new System.Threading.Timer(_ => Refresh(), null, 0, 5000) なら、Refresh を今すぐと、その後5秒ごとに呼びます。そして、それへの参照を保持します。非同期コードでは、PeriodicTimer(.NET 6以降)か await Task.Delay(5000) でループします。ループの形なら、2つのティックが同時に実行されることはありません。
C#でタイマーを止めるには?
System.Timers.Timer なら Stop() を呼ぶ(または Enabled = false にする)か、不要になったら Dispose() を呼びます。System.Threading.Timer なら、一時停止には Change(Timeout.Infinite, Timeout.Infinite) を、完全に止めるには Dispose() を呼びます。すでに始まったコールバックは、タイマーを止めた後も最後まで実行されることがあります。
System.Threading.Timerが動かなくなるのはなぜですか?
タイマーを何も参照していないので、ガベージコレクターがそれを回収し、コールバックが止まったからです。戻ってしまうメソッドの中でタイマーをローカル変数として作ると、こうなります。動かし続けたい間は、フィールドに保存しておきます。
C#で経過時間を測るには?
System.Diagnostics.Stopwatch を使います:var sw = Stopwatch.StartNew(); ... sw.Stop(); として、sw.ElapsedMilliseconds か sw.Elapsed を読みます。高分解能で単調に進む時計を使うので、分解能が粗く、システムの時計が調整されると飛ぶ DateTime.Now の2つの値の差とは異なります。