Accurate, regular scheduling with PeriodicTimer

Using PeriodicTimer to build an accurate, reliable background service

Home DailyDrop

Daily Knowledge Drop

.NET 6 introduced a new timer, the PeriodicTimer which provides a more regular, accurate and reliable way to run code on a specific interval when compared with previous methods.


Task.Delay

Consider a simple background process which needs to run every second:

async Task StartJobAsync(CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        Console.WriteLine(DateTime.Now.ToString("O"));
        
        // Do work
        
        await Task.Delay(1000, token);
    }
}

The method is started, and while a cancellation is not requested via the CancellationToken, the date time is output, and work is done.

Executing the above as follows:

var cancelSource = new CancellationTokenSource();
var jobTask = StartJobAsync(cancelSource.Token);

Console.ReadKey();
cancelSource.Cancel();
Console.ReadKey();

Provides the following output:

2022-07-13T19:48:38.9682679+02:00
2022-07-13T19:48:39.9743395+02:00
2022-07-13T19:48:40.9755190+02:00
2022-07-13T19:48:41.9913331+02:00
2022-07-13T19:48:42.9977149+02:00
2022-07-13T19:48:44.0130672+02:00
2022-07-13T19:48:45.0289959+02:00

Even without any work being done in the iteration, the loop does not iterate every second - in second 43 no work is done at all. At time goes on the divergence from the start time will just grow larger.

This divergence is even more evident if a job is executed and takes longer than the interval:

async Task StartJobAsync(CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        Console.WriteLine(DateTime.Now.ToString("O"));

        // generate a random number between 0 and 3
        var random = new Random();
        if (random.Next(3) == 1)
        {
            // if the random number was 1, simulate a long 
            // running job
            Console.WriteLine("Running long");
            await Task.Delay(1500);
        }

        await Task.Delay(1000, token);
    }
}

Here the random work being performed takes 1.5 second, so instead of the loop taking 1 second, it becomes 2.5 seconds. If the work being done is variable in time, then the interval also becomes varied and irregular:

2022-07-13T19:54:18.5637838+02:00
2022-07-13T19:54:19.6099211+02:00
2022-07-13T19:54:20.6106040+02:00
Running long
2022-07-13T19:54:23.1278907+02:00
2022-07-13T19:54:24.1338937+02:00
2022-07-13T19:54:25.1360588+02:00
2022-07-13T19:54:26.1519535+02:00
Running long
2022-07-13T19:54:28.6815481+02:00
2022-07-13T19:54:29.6844306+02:00

This might be sufficient, however if a more regular, consistent interval is required, look no further than the PeriodicTimer.


Periodic Timer

.NET 6 introduce the PeriodicTimer which runs on a more regular, predictable schedule, using more modern practices:

async Task StartJobAsync(CancellationToken token)
{
    // define the timer to tick every second
    var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1000));

    // loop every tick while the token has not been cancelled
    while (await timer.WaitForNextTickAsync() && !token.IsCancellationRequested)
    {
        Console.WriteLine(DateTime.Now.ToString("O"));
    }
}

With the PeriodicTimer the interval period is defined when the timer is declared, and the WaitForNextTickAsync method will return true every interval period.

Executing the above as follows:

var cancelSource = new CancellationTokenSource();
var jobTask = StartJobAsync(cancelSource.Token);

Console.ReadKey();
cancelSource.Cancel();
Console.ReadKey();

Provides the following output:

2022-07-14T07:23:29.1553510+02:00
2022-07-14T07:23:30.1430203+02:00
2022-07-14T07:23:31.1502777+02:00
2022-07-14T07:23:32.1522676+02:00
2022-07-14T07:23:33.1541686+02:00
2022-07-14T07:23:34.1603689+02:00
2022-07-14T07:23:35.1475597+02:00
2022-07-14T07:23:36.1498775+02:00
2022-07-14T07:23:37.1561782+02:00
2022-07-14T07:23:38.1425797+02:00

There are variations on the milliseconds, but the PeriodicTimer will adjust to keep it as regular as possible.

When introducing work which exceeds the interval period, the PeriodTimer will still try to adjust to keep the ticks as regular as it can:

async Task StartJobAsync(CancellationToken token)
{
    var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1000));

    while (await timer.WaitForNextTickAsync() && !token.IsCancellationRequested)
    {
        Console.WriteLine(DateTime.Now.ToString("O"));

        // randomly perform some work which takes 1.5 seconds
        var random = new Random();
        if (random.Next(3) == 1)
        {
            Console.WriteLine("Running long");
            await Task.Delay(1500);
        }
    }
}

From the results one can see that if a tick gets skipped due to the work running long, the next tick will align with the starting tick and not when the previous tick completed:

2022-07-14T07:27:22.2437461+02:00
Running long
2022-07-14T07:27:23.7708051+02:00
2022-07-14T07:27:24.2487034+02:00
2022-07-14T07:27:25.2491709+02:00
Running long
2022-07-14T07:27:26.7629854+02:00
2022-07-14T07:27:27.2392793+02:00
Running long
2022-07-14T07:27:28.7631133+02:00
2022-07-14T07:27:29.2430825+02:00
Running long
2022-07-14T07:27:30.7529474+02:00
2022-07-14T07:27:31.2606374+02:00
Running long
2022-07-14T07:27:32.7737681+02:00
2022-07-14T07:27:33.2500507+02:00

The timer will always execute on a regular, predictable schedule, even if the work runs long.


Notes

The Periodic Timer is a great addition to the ecosystem. Reliable and easy to use, for simple background processing, this should be the go-to mechanism going forward. For more complex scheduling and processing, something like Hangfire will still have its place for a while to come.


References

Scheduling repeating tasks with .NET 6’s NEW Timer


Daily Drop 134: 09-08-2022

At the start of 2022 I set myself the goal of learning one new coding related piece of knowledge a day.
It could be anything - some.NET / C# functionality I wasn't aware of, a design practice, a cool new coding technique, or just something I find interesting. It could be something I knew at one point but had forgotten, or something completely new, which I may or may never actually use.

The Daily Drop is a record of these pieces of knowledge - writing about and summarizing them helps re-enforce the information for myself, as well as potentially helps others learn something new as well.
c# .net timer schedule interval