Creating an awaitable System.Timers.Timer

708 views Asked by At

I had an idea about creating a timer that can be awaited, instead of raising events. I haven't thought of any practical applications yet, and may not be something terribly useful, but I would like to see if it's at least doable as an exercise. This is how it could be used:

var timer = new System.Timers.Timer();
timer.Interval = 100;
timer.Enabled = true;
for (int i = 0; i < 10; i++)
{
    var signalTime = await timer;
    Console.WriteLine($"Awaited {i}, SignalTime: {signalTime:HH:mm:ss.fff}");
}

The timer is awaited 10 times, and the expected output is:

Awaited 0, SignalTime: 06:08:51.674
Awaited 1, SignalTime: 06:08:51.783
Awaited 2, SignalTime: 06:08:51.891
Awaited 3, SignalTime: 06:08:52.002
Awaited 4, SignalTime: 06:08:52.110
Awaited 5, SignalTime: 06:08:52.218
Awaited 6, SignalTime: 06:08:52.332
Awaited 7, SignalTime: 06:08:52.438
Awaited 8, SignalTime: 06:08:52.546
Awaited 9, SignalTime: 06:08:52.660

In this case a simple await Task.Delay(100) would do the same thing, but a timer gives the flexibility of controlling the interval from another part of the program (with the caveat of possible thread safety issues).

Regarding the implementation, I found an article that describes how to make various things awaitable, like a TimeSpan, an int, a DateTimeOffset and a Process. It seems that I must write an extension method that returns a TaskAwaiter, but I am not sure what to do exactly. Does anyone has any idea?

public static TaskAwaiter GetAwaiter(this System.Timers.Timer timer)
{
    // What to do?
}
3

There are 3 answers

1
Stephen Cleary On BEST ANSWER

It seems that I must write an extension method that returns a TaskAwaiter, but I am not sure what to do exactly.

The easiest way to return an awaiter is to get a Task and then call GetAwaiter on it. You can also create custom awaiters, but that's much more involved.

So the question becomes "how do I get a task that is completed when an event is raised?" And the answer to that is to use TaskCompletionSource<T>:

public static class TimerExtensions
{
    public static Task<DateTime> NextEventAsync(this Timer timer)
    {
        var tcs = new TaskCompletionSource<DateTime>();
        ElapsedEventHandler handler = null;
        handler = (_, e) =>
        {
            timer.Elapsed -= handler;
            tcs.TrySetResult(e.SignalTime);
        };
        timer.Elapsed += handler;
        return tcs.Task;
    }

    public static TaskAwaiter<DateTime> GetAwaiter(this Timer timer)
    {
        return timer.NextEventAsync().GetAwaiter();
    }
}

So, that will make your sample code work as expected. However, there is a significant caveat: each await will call GetAwaiter, which subscribes to the next Elapsed event. And that Elapsed event handler is removed before the await completes. So from the time the event fires until the next time the timer is awaited, there is no handler, and your consuming code can easily miss some events.

If this is not acceptable, then you should use IObservable<T>, which is designed around a subscription-then-receive-events model, or use something like Channels to buffer the events and consume them with an asynchronous stream.

4
Kind Contributor On

Task.Delay is still the correct building-block for you to use.

Code

//Uses absolute time from the first time called to synchronise the caller to begin on the next pulse
//If the client takes 2.5xinterval to perform work, the next pulse will be on the 3rd interval
class AbsolutePollIntervals
{
    TimeSpan interval = TimeSpan.Zero;
    public AbsolutePollIntervals(TimeSpan Interval)
    {
        this.interval = Interval;
    }

    //Call this if you want the timer to start before you await the first time
    public void Headstart()
    {
        started = DateTime.UtcNow;
    }

    public void StopCurrentEarly()
    {
        cts.Cancel(); //Interrupts the Task.Delay in DelayNext early
    }

    public void RaiseExceptionOnCurrent(Exception ex)
    {
        nextException = ex; //This causes the DelayNext function to throw this exception to caller
        cts.Cancel();
    }

    public void RepeatCurrent()
    {
        delayAgain = true; //This cuases DelayNext to loop again. Use this with SetNextInterval, if you wanted to extend the delay
        cts.Cancel();
    }

    public void SetNextInterval(TimeSpan interval)
    {
        started = DateTime.MinValue; //No headstart
        this.interval = interval;
    }

    Exception nextException = null;
    DateTime started = DateTime.MinValue;
    CancellationTokenSource cts = null;
    bool delayAgain = false;

    public async Task DelayNext()
    {
        while (true)
        {
            if (started == DateTime.MinValue) started = DateTime.UtcNow;
            var reference = DateTime.UtcNow;
            var diff = reference.Subtract(started);

            var remainder = diff.TotalMilliseconds % interval.TotalMilliseconds;
            var nextWait = interval.TotalMilliseconds - remainder;

            cts = new CancellationTokenSource();
            
            await Task.Delay((int)nextWait, cts.Token);
            cts.Dispose();

            if (nextException != null)
            {
                var ex = nextException; //So we can null this field before throwing
                nextException = null;
                throw ex;
            }

            if (delayAgain == false)
                break;
            else
                delayAgain = false; //reset latch, and let it continue around another round
        }
    }
}

Usage on consumer:

var pacer = new AbsolutePollIntervals(TimeSpan.FromSeconds(1));
for (int i = 0; i < 10; i++)
{
    await pacer.DelayNext();
    Console.WriteLine($"Awaited {i}, SignalTime: {DateTime.UtcNow:HH:mm:ss.fff}");
}
return;

Usage on the controller:

//Interrupt the consumer early with no exception
pacer.StopCurrentEarly();

//Interrupt the consumer early with an exception
pacer.RaiseExceptionOnCurrent(new Exception("VPN Disconnected"));

//Extend the time of the consumer by particular amount
pacer.SetNextInterval(TimeSpan.FromSeconds(20));
pacer.RepeatCurrent();

Result [before Edit, current version not tested]

Awaited 0, SignalTime: 03:56:04.777
Awaited 1, SignalTime: 03:56:05.712
Awaited 2, SignalTime: 03:56:06.717
Awaited 3, SignalTime: 03:56:07.709
Awaited 4, SignalTime: 03:56:08.710
Awaited 5, SignalTime: 03:56:09.710
Awaited 6, SignalTime: 03:56:10.710
Awaited 7, SignalTime: 03:56:11.709
Awaited 8, SignalTime: 03:56:11.709
Awaited 9, SignalTime: 03:56:12.709

As you can see above, they all land close to the 710ms mark, showing that this is an absolute interval (not relative to the duration from when DelayNext is called)


It would be possible for a "controller" to hold a shared reference of AbsolutePollIntervals with a "bludger". With a few extensions to the AbsolutePollIntervals, the controller could change the interval and the start time. It would also be possible to create a QueuedPollIntervals implementation where the controller enqueues different time intervals that are dequeued by the bludger upon DelayNext()


Update 2020-09-20: Done. I implemented some of the "controller" ideas as per Theodor's implicit challenge ;)

This version hasn't been tested, so it's only there to convey the idea. Also, it will need better concurrency care for any production version.

0
Theodor Zoulias On

A new class System.Threading.PeriodicTimer class has been introduced in .NET 6, which is a lightweight awaitable timer with constant period. Here is an example:

var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
for (int i = 0; i < 10; i++)
{
    await timer.WaitForNextTickAsync();
    Console.WriteLine($"Awaited {i}");
}

The ticking interval is configured in the constructor. Starting from .ΝΕΤ 8, the ticking interval can be changed at any time with the Period property.