はじめに
タイマーは何秒おき、何分おきといった間隔で処理を定期的に実行したい場合に利用するクラスです。
この記事では .NET 6.0 で追加されたタスクベースの PeriodicTimer というタイマーについて、いまいち他のタイマーとの使い分けが分からなかったのでまとめてみました。
これまでのタイマー
.NET 5 時点では標準で次の 4 つのタイマーが提供されていました。
# | 種類 | 備考 |
---|---|---|
1 | System.Timers.Timer | |
2 | System.Threading.Timer | |
3 | System.Windows.Forms.Timer | WinForm 用 |
4 | System.Windows.Threading.DispatcherTimer | WPF 用 |
上記のうち 3 と 4 は GUI のイベントループを使った低精度なタイマーなので、サーバー側のプログラミングで利用する場合は 1 か 2 のタイマーを使うことになりますが、使い勝手からほとんどの人は 1 の System.Timers.Timer を利用することが多かったと思います。
これ以外に、System.Web.UI.Timer という ASP.NET WebForm の非同期処理でのみ使えるタイマーもありますが .NET Core 以降では提供されていません。また、Rx の Observable.Interval や Observable.Timer を使っている人もいるかもしれませんね。
各タイマーの実際の利用方法や違いなどは、下記のブログが詳しいです。
PeriodicTimer を使ってみる
とりあえずどんなタイマーかわからないので、まずは使っていましょう。
次のような関数を 5 秒周期で呼び出し、何かキーが入力されたら終了したいとします(関数の実行には微妙に間隔をずらすための Delay が入っています)。また、説明を単純にするため全体的にキャンセル処理を省いていますが、PeriodicTimer は他のタスクベースのメソッド同様 CancellationToken を使ったキャンセルが可能です。
async Task ElapsedActionAsync()
{
var startTime = DateTimeOffset.Now;
Console.WriteLine($"enter {startTime}");
await Task.Delay(3000 + (new Random(Environment.TickCount).Next(0, 1000)));
Console.WriteLine($" {startTime} - {DateTimeOffset.Now}");
}
System.Timers.Timer で実装したら次のようになるでしょう。
// 5 秒間隔で実行するタイマーを定義し、Elapsed イベントに実行したい処理を関連付ける。
using var timer = new System.Timers.Timer { Interval = 5 * 1000 };
timer.Elapsed += async (sender, args) =>
{
await ElapsedActionAsync();
};
timer.Start();
// なにかキーが入力されたらタイマーを停止する
Console.ReadKey();
timer.Stop();
実行すると 5 秒間隔で処理が呼び出されていることが分かりますね。
enter 10/18/2021 10:17:02 PM +00:00
10/18/2021 10:17:02 PM +00:00 - 10/18/2021 10:17:05 PM +00:00
enter 10/18/2021 10:17:07 PM +00:00
10/18/2021 10:17:07 PM +00:00 - 10/18/2021 10:17:11 PM +00:00
enter 10/18/2021 10:17:12 PM +00:00
10/18/2021 10:17:12 PM +00:00 - 10/18/2021 10:17:16 PM +00:00
PeriodicTimer で実装する場合は次のような処理になります。
// 5 秒間隔で実行するタイマーを定義し、別タスクで処理を開始する。
using var timer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(5));
using var t = Task.Factory.StartNew(async () =>
{
while (await timer.WaitForNextTickAsync())
{
await ElapsedActionAsync();
}
});
// なにかキーが入力されたらタイマーを停止する
Console.ReadKey();
んー、このシナリオだとそう変わらないですね。実行結果も System.Timers.Timer と同じものが出力されました。
enter 10/18/2021 10:19:15 PM +00:00
exit 10/18/2021 10:19:15 PM +00:00 - 10/18/2021 10:19:18 PM +00:00
enter 10/18/2021 10:19:20 PM +00:00
exit 10/18/2021 10:19:20 PM +00:00 - 10/18/2021 10:19:23 PM +00:00
PeriodicTimer の特徴として周期毎に実行するコードは一度に複数呼び出されません。これは場合によってはうれしい動作ですが、それを期待しない場合もあるでしょう。また、持っているメソッドは周期を待ち合わせる WaitForNextTickAsync やタイマーを停止してリソースを開放する Dispose などの特定のメソッドだけです。他のタイマーが持っている便利なメソッドを使いたい場合は PeriodicTimer の利用シナリオには合いません。
PeriodicTimer が光る処理は?
では PeriodicTimer が光る処理はどんな処理でしょうか?Qiita の次の記事では、イベントベースの System.Timers.Timer を TaskCompletionSource を使ってタスクベースで処理する例が紹介されています。
この記事にあるように、特定回数実行した後にタイマーを終了して処理を完了するといったパターンの処理をイベントベースのタイマーで書こうとすると、フラグの管理などで処理が煩雑になります。
PeriodicTimer を使って 5 秒間隔で 3 回処理を実施して終了する処理を実装するとこんな感じでしょうか。
// 5 秒間隔で処理を実施し、3 回実行したら終わる
using var timer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(5));
using var t = Task.Factory.StartNew(async () =>
{
for (var i = 0; i < 3; i++)
{
if (!await timer.WaitForNextTickAsync())
break;
await ElapsedActionAsync();
}
Console.WriteLine("終わり");
});
// なにかキーが入力されたらタイマーを停止する(timer は Dispose されると停止する)
Console.ReadKey();
不要なフラグなどを用意しなくてよい分、大分見通しの良いコードになりましたね。
処理にかかる時間が周期を超えたらどうなるの?
PeriodicTimer では周期毎の処理が想定以上に掛かった場合に複数回処理が実行されない特徴があると述べましたが、どのような動作になるのでしょうか?コードを少し修正して 4 回目にタイマーの周期以上に処理に時間をかけてみましょう。
// 5 秒間隔で処理を実施し続ける
using var timer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(5));
using var t = Task.Factory.StartNew(async () =>
{
var sw1 = System.Diagnostics.Stopwatch.StartNew();
var sw2 = System.Diagnostics.Stopwatch.StartNew();
for (var i = 1; true; i++)
{
if (!await timer.WaitForNextTickAsync())
break;
var now = DateTime.Now;
Console.WriteLine($"{i:0} 回目 {now} " +
$"(経過:{sw1.Elapsed.TotalSeconds:0.00} " +
$"全体:{sw2.Elapsed.TotalSeconds:0.00})");
sw1.Restart();
// 4 回に 1 回 6 秒待つ
// await ElapsedActionAsync();
if (i % 4 == 0)
{
await Task.Delay(6 * 1000);
Console.WriteLine($" * delay");
}
else
{
await Task.Delay(1 * 1000);
}
}
Console.WriteLine($"compleated {DateTimeOffset.Now}");
});
// なにかキーが入力されたらタイマーを停止する(timer は Dispose されると停止する)
Console.ReadKey();
実行結果は次のようになりました。
1 回目 10/19/2021 12:38:46 AM (経過:5.01 全体:5.01)
2 回目 10/19/2021 12:38:51 AM (経過:4.97 全体:9.98)
3 回目 10/19/2021 12:38:56 AM (経過:5.00 全体:14.98)
4 回目 10/19/2021 12:39:01 AM (経過:5.00 全体:19.99)
* delay
5 回目 10/19/2021 12:39:07 AM (経過:6.00 全体:25.99)
6 回目 10/19/2021 12:39:11 AM (経過:4.00 全体:29.99)
7 回目 10/19/2021 12:39:16 AM (経過:5.00 全体:34.99)
8 回目 10/19/2021 12:39:21 AM (経過:4.99 全体:39.99)
1 回目が 12:38:46 に始まっているので、5 秒間隔だと次の間隔で処理が起動される予定です。
- 回目 12:38:46
- 回目 12:38:51
- 回目 12:38:56
- 回目 12:39:01
- 回目 12:39:06
- 回目 12:39:11
4 回目の処理で時間が掛かっているため、本来 5 回目の処理は 12:39:06 あたりに起動される予定が 12:39:07 まで遅延しています。ただ、6 回目の処理は予定通り 12:39:11 ごろに起動し、それ以降も 5 秒間隔に戻っています。
では、遅延が 2 周期分発生したらどうなるでしょうか?4 回に 12 秒以待機してみましょう。
1 回目 10/19/2021 12:44:11 AM (経過:5.01 全体:5.01)
2 回目 10/19/2021 12:44:16 AM (経過:4.98 全体:9.99)
3 回目 10/19/2021 12:44:21 AM (経過:5.00 全体:14.99)
4 回目 10/19/2021 12:44:26 AM (経過:5.00 全体:19.99)
* delay
5 回目 10/19/2021 12:44:38 AM (経過:12.00 全体:32.00)
6 回目 10/19/2021 12:44:41 AM (経過:2.99 全体:34.99)
7 回目 10/19/2021 12:44:46 AM (経過:5.00 全体:39.99)
こうなるはずなので
- 回目 12:44:11
- 回目 12:44:16
- 回目 12:44:21
- 回目 12:44:26
- 回目 12:44:31
- 回目 12:44:36
- 回目 12:44:41
5 回目の 12:44:31 がまるまるスキップされたけれど、変に多重に実行されることはありませんでしたね。
おわりに
.NET 6 で追加された PeriodicTimer を使うと、イベントベースではなくタスクベースでプログラミングできるようになるので、処理の種類によっては見やすいコードを書くことができます。
基本的にはこれまでのタイマーで事足りると思いますが、上記のような場合にあんなタイマーがあったなーと思い出してもらえると良いかもしれません。