0

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?
}
Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • @AlexeiLevenkov the [duplicate question](https://stackoverflow.com/questions/22449518/how-to-use-async-and-await-in-timer) shows how to use `Task.Delay` to repeat an action a predefined number of times, with a predefined interval. In my case I want to use a `Timer` that can be dynamically controlled (disabled, re-enabled, interval changed etc) during the execution of the application. I can't use the solution presented in the [duplicate question](https://stackoverflow.com/questions/22449518/how-to-use-async-and-await-in-timer) to solve my problem. – Theodor Zoulias Jul 02 '19 at 01:31
  • It shows all information you'd need to implement complete class (including how to deal with interval changing... maybe not exactly showing that you can use almost infinite interval to `Enabled = false`... but that's should be somewhat easy). You should [edit] this post with information you've provided in the comment for it to be re-opened... but I'm afraid it will look like "write code for me" (would definitely look that way I you ask someone else to do the edit). Code alone for properly tested production ready async timer likely will be pushing boundaries what SO answer should be... – Alexei Levenkov Jul 02 '19 at 01:53
  • @AlexeiLevenkov my question is not about how to write a new class, it is about how to make the existing `System.Timers.Timer` class awaitable, by implementing an extension method. This is clearly stated in my question. Are you sure that I can achieve this objective by applying the knowledge provided in the [single answer](https://stackoverflow.com/a/22453097/11178549) of the duplicate question? You could argue that my question is lacking effort from my part, but I assure you that I did made the effort of searching for duplicates before posting... – Theodor Zoulias Jul 02 '19 at 02:13
  • I think i understand what you want to achieve, but its also seemingly changing the entire premise of a timer (actually its not even a timer anymore) its a shared task.delay. this would be easier to write from scratch (IMO). However you could probably get away with an extension method and a `TaskCompletionSource` and some thread safety, though once again this seems just a little contrived – TheGeneral Jul 02 '19 at 02:25
  • 1
    @TheodorZoulias sorry, I totally misunderstood your question. Now I see what you are asking based on the answer... it still makes no sense to me why would you want to do that... but I guess you have your reasons. – Alexei Levenkov Jul 02 '19 at 03:46
  • No problem @AlexeiLevenkov. I am not sure either about where this could be useful. Consider it to be experimental stuff. – Theodor Zoulias Jul 02 '19 at 03:55

3 Answers3

6

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.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Thanks @StephenCleary! A small/rare chance of an unnoticed event seems not very important, since these are recurring events that convey no additional information. I was more worried about possible memory leak issues, so I tested your code by observing the managed memory while making around 1000 awaits, and the result is that the memory consumption is not increasing. So I think that your solution is perfect! Now I hope that I'll find some practical application for this thing. :-) – Theodor Zoulias Jul 02 '19 at 02:58
1

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, and cannot be changed. If you want to change it, you must create a new PeriodicTimer instance.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
0

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.

Kind Contributor
  • 17,547
  • 6
  • 53
  • 70
  • Thanks Todd for the answer. The `AbsolutePollIntervals` is an interesting class, with unique functionality. My question though is not about asynchronous delays with precise intervals, but about a way to control these intervals interactively. For example after initiating the await of a normal `Task.Delay(1000)`, there is no way to change the duration of the delay. You can either cancel the task, or await it to complete. The idea of using an awaitable `Timer` is to make this interactive control possible, by using its properties `Interval` and `Enabled`. – Theodor Zoulias Sep 20 '20 at 11:38
  • @TheodorZoulias Understood. I will expand my answer further, and keep the other good stuff for others. – Kind Contributor Sep 20 '20 at 12:12
  • Keep in mind that IMHO Stephen Cleary's [answer](https://stackoverflow.com/a/56844580/11178549) is already 100% sufficient. It will be hard to beat it. :-) – Theodor Zoulias Sep 20 '20 at 12:19
  • @TheodorZoulias Updated. Note: this updated version is untested, but it conveys the idea. This might work better for others, it uses Task.Delay instead of a Timer, and has some interesting controls that can be used to achieve new things. – Kind Contributor Sep 20 '20 at 12:32