C#でAwaitableなタイマーを作成する

 `TaskCompletionSource`を利用して、`System.Timers.Timer`にインターバル時間の経過を待機できるタスクを追加しました。

開発・実行環境
Visual Studio 2019 Community
.Net Framework 4.7.2
C# 7.3

#イベントベース非同期処理をタスクベース非同期に変換する

一定の時間間隔で何らかの処理を行いたいとき、タイマーを利用することが多々あります。
私はこのような目的でよく`System.Timers.Timer`を利用します。このタイマーは、`Interval`プロパティで指定したインターバル時間毎に`Elapsed`イベントを発行することで、利用者に時間経過を通知します。イベントベースで処理する場合、例えば一定回数だけイベントが発行されたらタイマーを停止して処理をやめる、といったことを行いたい場合、コードが煩雑になりがちで、また処理の流れに沿った直感的なコーディングは難しいです。そこで、`TaskCompletionSource`を利用してイベントをタスクに変換する方法をつい最近（今更）知ったので、`Elapsed`イベントを待機できるタスクを追加した`AwaitableTimer`クラスを作成してみました。

```C#:AwaitableTimer.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;

namespace TimerSample
{
    /// <summary>
    /// <see cref="System.Timers.Timer"/>のインターバル時間の経過イベントを待機できるタスクを提供します。
    /// </summary>
    public class AwaitableTimer : System.Timers.Timer
    {
        #region field

        private TaskCompletionSource<DateTime> _tcs;

        #endregion

        #region constructor

        /// <summary>
        /// デフォルトコンストラクタ
        /// </summary>
        public AwaitableTimer() : base()
        {
            Elapsed += OnElapsed;
        }

        /// <summary>
        /// インターバル時間、およびインターバル経過イベントを繰り返し発生させるかどうかを指定してタイマーを作成します。
        /// </summary>
        /// <param name="interval"></param>
        /// <param name="autoReset"></param>
        public AwaitableTimer(TimeSpan interval, bool autoReset) : base(interval.TotalMilliseconds)
        {
            AutoReset = autoReset;
            Elapsed += OnElapsed;
        }

        /// <summary>
        /// インターバル時間を指定して初期化
        /// </summary>
        /// <param name="interval"></param>
        public AwaitableTimer(TimeSpan interval) : base(interval.TotalMilliseconds)
        {
            Elapsed += OnElapsed;
        }

        #endregion

        private void OnElapsed(object sender, ElapsedEventArgs e)
        {
            if (_tcs != null)
            {
                _tcs.TrySetResult(e.SignalTime);
                _tcs = null;
            }
        }

        private void OnTaskCanceled()
        {
            if (_tcs != null)
            {
                _tcs.TrySetException(new TaskCanceledException(_tcs.Task));
                _tcs = null;
            }
        }

        /// <summary>
        /// インターバル時間とインターバル経過イベントを繰り返し発生させるかどうかを指定して、タイマーを開始します。
        /// </summary>
        /// <param name="interval"></param>
        /// <param name="autoReset"></param>
        /// <returns></returns>
        public static AwaitableTimer StartNew(TimeSpan interval, bool autoReset)
        {
            var timer = new AwaitableTimer(interval, autoReset);
            timer.Start();
            return timer;
        }

        /// <summary>
        /// タイマーの経過時間をリセットして再開します
        /// </summary>
        public void Restart()
        {
            Stop();
            Start();
        }

        /// <summary>
        /// インターバル経過イベントを待機するタスク。タイマーが止まっている場合は、即時に<see cref="DateTime.Now"/>を返します。
        /// </summary>
        /// <param name="token"></param>
        /// <returns></returns>
        public async Task<DateTime> WaitElapsedAsync(CancellationToken token = default)
        {
            if (Enabled)
            {
                _tcs = new TaskCompletionSource<DateTime>(TaskCreationOptions.RunContinuationsAsynchronously);

                var register = token.Register(OnTaskCanceled);

                using (register)
                {
                    return await _tcs.Task;
                }
            }
            else
            {
                return DateTime.Now;
            }
        }
    }
}
```

例えば以下のように使います。この例では指定した時間間隔で、指定した回数だけ何らかの処理(ここではAsyncMethod)を行います。何らかの理由でインターバル時間内に処理が終わっていなかったらタイムアウトとし、ループを抜け出します。時間内に処理が終わったら、次のインターバルを待ちます。

```C#
using System;
using static System.Console;
using System.Threading.Tasks;

namespace TimerSample
{
    class Program
    {
        static readonly Random Random = new Random();

        static async Task Main(string[] args)
        {
            while (true)
            {
                if (ReadKey().Key == ConsoleKey.Escape)
                    break;

                Clear();

                WriteLine("インターバル時間をミリ秒で指定");

                if (!double.TryParse(ReadLine(), out var interval))
                    continue;

                WriteLine("繰り返し回数を指定");
                if (!int.TryParse(ReadLine(), out var n))
                    continue;

                WriteLine("不具合の起こる確率を指定");
                if (!double.TryParse(ReadLine(), out var p))
                    continue;

                bool timeout = false;
                bool isRunning = false;

                async Task AsyncMethod()
                {
                    int delay = 0;

                    if (Random.NextDouble() > p)
                    {
                        delay = (int)(0.5 * interval);
                    }
                    else
                    {
                        delay = (int)(2 * interval);
                    }

                    isRunning = true;
                    await Task.Delay(delay);
                    isRunning = false;
                }

                using (var intervalTimer = new AwaitableTimer(TimeSpan.FromMilliseconds(interval), true))
                {
                    intervalTimer.Elapsed += (s, e) =>
                    {
                        if (isRunning)
                        {
                            timeout = true;
                        }
                    };

                    intervalTimer.Start();
                    var startTime = DateTime.Now;
                    var previous = startTime;

                    for (var i = 0; i < n; i++)
                    {
                        await AsyncMethod();

                        if (timeout)
                        {
                            WriteLine("インターバル時間内に処理が終わりませんでした！");
                            timeout = false;
                            break;
                        }

                        var now = await intervalTimer.WaitElapsedAsync();
                        var period = now - previous;
                        previous = now;
                        WriteLine($"ラップ{i + 1:000}　経過時間(トータル)：{(now - startTime).TotalMilliseconds:f2} ms　経過時間(インターバル)：{period.TotalMilliseconds:f2} ms");
                    }
                }

                WriteLine("終了");
            }
        }
    }
}
```

`Elapsed`イベントを利用したタイムアウト判定と、async/awaitを利用したインターバル時間経過の待機を併用しています。全てイベントベースで書くよりもいくらか簡単かつ直感的になった気がします。

実行結果
>インターバル時間をミリ秒で指定
1000
繰り返し回数を指定
20
不具合の起こる確率を指定
0.1
ラップ001　経過時間(トータル)：1000.15 ms　経過時間(インターバル)：1000.15 ms
ラップ002　経過時間(トータル)：2000.73 ms　経過時間(インターバル)：1000.57 ms
ラップ003　経過時間(トータル)：3002.09 ms　経過時間(インターバル)：1001.36 ms
ラップ004　経過時間(トータル)：4002.23 ms　経過時間(インターバル)：1000.14 ms
ラップ005　経過時間(トータル)：5002.55 ms　経過時間(インターバル)：1000.32 ms
インターバル時間内に処理が終わりませんでした！
終了

##参考資料
[タスクとイベント ベースの非同期パターン (EAP)](https://docs.microsoft.com/ja-jp/dotnet/standard/asynchronous-programming-patterns/interop-with-other-asynchronous-patterns-and-types#tasks-and-the-event-based-asynchronous-pattern-eap)