.NET 6 PeriodicTimer with top-of-the-minute timing

Viewed 680

.NET 6 introduced the PeriodicTimer.

I need to do something every minute, at the top of the minute. For example: 09:23:00, 09:24:00, 09:25:00, ...

But with a one minute period - new PeriodicTimer(TimeSpan.FromMinutes(1)) - and starting at 09:23:45, I will get "ticks" at: 09:24:45, 09:25:45, 09:26:45, ...

So it's dependent on the start time.

My workaround is a one-second period, and a check that the current time has seconds equal to 0. Another workaround is to wait for the next minute and then start the timer. Both approaches work but are fiddly and use too fine a resolution.

Is there a built-in or better way to trigger at the top of the minute rather than one-minute-after-start?

2 Answers

AFAIK there is nothing like this available in the standard .NET libraries. And I don't think that it's likely to be added any time soon. My suggestion is to use the third party Cronos library, that does a good job at calculating time intervals¹. You can find a usage example here, by Stephen Cleary. What this library does is to take a DateTime and a Cron expression as input, and calculate the next DateTime that satisfies this expression. It is just a DateTime calculator, not a scheduler.

If you want to get fancy you could include the functionality of the Cronos library in a custom PeriodicTimer-like component, like the one below:

using Cronos;

public sealed class CronosPeriodicTimer : IDisposable
{
    private readonly CronExpression _cronExpression; // Also used as the locker
    private PeriodicTimer _activeTimer;
    private bool _disposed;
    private static readonly TimeSpan _minDelay = TimeSpan.FromMilliseconds(500);

    public CronosPeriodicTimer(string expression, CronFormat format)
    {
        _cronExpression = CronExpression.Parse(expression, format);
    }

    public async ValueTask<bool> WaitForNextTickAsync(
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        PeriodicTimer timer;
        lock (_cronExpression)
        {
            if (_disposed) return false;
            if (_activeTimer is not null)
                throw new InvalidOperationException("One consumer at a time.");
            DateTime utcNow = DateTime.UtcNow;
            DateTime? utcNext = _cronExpression.GetNextOccurrence(utcNow + _minDelay);
            if (utcNext is null)
                throw new InvalidOperationException("Unreachable date.");
            TimeSpan delay = utcNext.Value - utcNow;
            Debug.Assert(delay > _minDelay);
            timer = _activeTimer = new(delay);
        }
        try
        {
            // Dispose the timer after the first tick.
            using (timer)
                return await timer.WaitForNextTickAsync(cancellationToken)
                    .ConfigureAwait(false);
        }
        finally { Volatile.Write(ref _activeTimer, null); }
    }

    public void Dispose()
    {
        PeriodicTimer activeTimer;
        lock (_cronExpression)
        {
            if (_disposed) return;
            _disposed = true;
            activeTimer = _activeTimer;
        }
        activeTimer?.Dispose();
    }
}

Apart from the constructor, the CronosPeriodicTimer class has identical API and behavior with the PeriodicTimer class. You could use it like this:

var timer = new CronosPeriodicTimer("0 * * * * *", CronFormat.IncludeSeconds);
//...
await timer.WaitForNextTickAsync();

The expression 0 * * * * * means "on the 0 (zero) second of every minute, of every hour, of every day of the month, of every month, and of every day of the week."

You can find detailed documentation about the format of the Cron expressions here.

The 500 milliseconds _minDelay has the intention to prevent the remote possibility of the timer ticking twice by mistake. Also because the PeriodicTimer class has a minimum period of 1 millisecond.

For an implementation that uses the Task.Delay method instead of the PeriodicTimer class, and so it can be used by .NET versions previous than 6.0, you can look at the 3rd revision of this answer.

¹ With the caveat that the Cronos library is currently capped to the year 2099 (version 0.7.1).

For completeness, here are the workarounds mentioned in my question.

Tick every second, and wait for top-of-the-minute:

var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));

while (await timer.WaitForNextTickAsync(cancellationToken)) {
  if (DateTime.UtcNow.Second == 0)
    await DoSomething();
}

Delay till top-of-the-minute, then tick every minute:

var delay = (60 - DateTime.UtcNow.Second) * 1000; // take milliseconds into account to improve start-time accuracy
await Task.Delay(delay);
var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

while (await timer.WaitForNextTickAsync(cancellationToken)) {
  await DoSomething();
}

Some notes about accuracy:

  • This will never be perfectly accurate, as discussed in the comments above. But it's good enough in many cases.
  • Small clock drifts will occur and make the per-minute timer more and more inaccurate, and this will only be fixed when the server is restarted. But the per-second timer "self-corrects" on every tick, so although it's "heavier", it will be more accurate over time.
  • The per-second timer can sometimes lose a tick or get a double tick (see comments above). The per-minute timer won't lose ticks, but they may be inaccurate. Whether this matters is something for you to consider.
Related