7

.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?

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
lonix
  • 14,255
  • 23
  • 85
  • 176
  • 1
    All the previous timers like System.Threading.Timer allowed you to specify both an offset and a period so the job could start at a specific time. Why use `PeriodicTimer` if you don't really need asynchronous waiting? Even then though, you can emulate the initial offset with `async Task.Delay(target-DateTime.Now);`. – Panagiotis Kanavos Jun 24 '22 at 06:45
  • @PanagiotisKanavos Yeah, but I like this new one because of its async capabilities. I wish it had more functionality though. Your comment has a nice workaround, thank you. – lonix Jun 24 '22 at 06:47
  • That said, if you want to schedule jobs it's better to use a purpose-built library like Hangfire or Coravel. There's a lot more to running jobs than just making a callback every 1 minute – Panagiotis Kanavos Jun 24 '22 at 06:48
  • What async capabilities are you looking for? Previous timers executed their callback in a threadpool thread. That callback could be async itself. – Panagiotis Kanavos Jun 24 '22 at 06:49
  • @PanagiotisKanavos I was using it as in example [here](https://stackoverflow.com/a/71637260), for a hosted service. – lonix Jun 24 '22 at 06:51
  • The question is wrong. Server timers are asynchronous. Only `System.Windows.Timer` is synchronous because it uses Windows messages and runs on the UI thread. [System.Threadin.Timer](https://learn.microsoft.com/en-us/dotnet/api/system.threading.timer?view=net-6.0) `executing a method on a thread pool thread at specified intervals.` The timer itself doesn't run anywhere, it's an OS service. You can have an `async void` callback if you want. – Panagiotis Kanavos Jun 24 '22 at 07:02
  • Besides, even after `await timer.WaitForNextTickAsync()` where will *your* code run? You'd be back in the BackgroundService sync context, running on a single threadpool thread. Just like Threading.Timer. The *real* difference is that if your job takes too long, a `System.Threading.Timer` will fire again and execute concurrently with the old job, while the `await` loop won't. – Panagiotis Kanavos Jun 24 '22 at 07:05
  • 3
    Your workaroud (one-second period and checking for seconds=0) might spradically fail. Because timers are not 100% exact it might happen that you get an event at 01:01:59.999 and the next at 01:02:01.001 (so none with seconds=0) or an event at 01:01:00.001 and the next at 01:02:00.999 (so two with seconds=0). – Klaus Gütter Jun 24 '22 at 07:09
  • @KlausGütter that's why timer libraries run at higher frequencies. It's not just 1ms either, the OS timer resolution is 16.5 ms. For jobs scheduled by the minute or even by seconds though, milliseconds don't matter. If you want really accurate timers you'd have to use the specialized multimedia timers that ... aren't exposed in .NET – Panagiotis Kanavos Jun 24 '22 at 07:12
  • @KlausGütter the question of skew appears almost every week when someone tries to naively animate UI elements using a normal Timer and encounters freezing or skips. – Panagiotis Kanavos Jun 24 '22 at 07:14

2 Answers2

5

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).

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • All the server timers do this already, by allowing an initial delay. A simple `await Task.Delay(startTime-DateTime.Now)` before creating the timer will do the trick. Cronos uses the same timers and probably works at a higher frequency to avoid skews. The *real* difference between `PeriodicTimer` and `System.Threading.Timer` is that `System.Threading.Timer` will fire whether the callback is still running or not, while `PeriodicTimer` allows only a single execution. – Panagiotis Kanavos Jun 24 '22 at 07:09
  • @PanagiotisKanavos `probably works at a higher frequency` I didn't know the .net framework exposed such detail. Interesting. – lonix Jun 24 '22 at 07:20
  • @PanagiotisKanavos the Cronos library knows nothing about frequencies. All it does is to take a `DateTime`, and calculate the next `DateTime` based on a Cron expression. What you'll do with the resulting `DateTime` is up to you. – Theodor Zoulias Jun 24 '22 at 07:20
  • @TheodorZoulias what's the point of all this code then? You end up doing what the OP's code did, only worse, because now you depend on the single-fire timer inside `Task.Delay`. – Panagiotis Kanavos Jun 24 '22 at 07:26
  • @lonix btw currently I don't fully understand the logic behind the `GetNextOccurrence` returning a nullable `DateTime`. I can understand a `null` value if the expression is like `"0 0 30 2 *"`, because the month February has 29 days at maximum. But some other scenarios are unfathomable. I opened an issue on GitHub about it [here](https://github.com/HangfireIO/Cronos/issues/51). – Theodor Zoulias Jun 24 '22 at 10:37
  • 1
    @lonix recently I encountered a scenario where getting an exception would be unexpected and corruptive. I ended up handling it like this: `catch (Exception ex) { Debug.Fail("Unexpected error", ex.ToString()); throw; }` – Theodor Zoulias Jun 24 '22 at 17:19
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.
lonix
  • 14,255
  • 23
  • 85
  • 176
  • You could use `await Task.Delay(target-DateTime.Now); var timer =....` to start the timer on a specific time. Or use one of the other timer classes. The [Backround Tassks](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-6.0&tabs=visual-studio#timed-background-tasks) doc page shows how to create a timed background service using System.Threading.Timer. Instead of `Timespan.Zero` for the initial delay you can use `target-DateTime.Now` – Panagiotis Kanavos Jun 24 '22 at 06:52
  • On the other hand, all timers slip, and even the libraries run at *higher* frequencies, checking the target time, just like *your* code does. The OS can delay until it schedules the callback. Or, there may be many jobs. The library won't use a new timer for every one, it will use a single timer and check for jobs to execute when it fires – Panagiotis Kanavos Jun 24 '22 at 06:56
  • 1
    There's nothing wrong with your solutions btw. Complexity has its own cost which may be just too much if you don't care about millisecond accuracy. If you did, you'd need a multimedia timer too, normal timers and libraries wouldn't work. The *real* difference is that `PeriodicTimer` allows only one execution at a time. Sometimes that's important, sometimes it's not. A `PeriodicTimer` can be used to ensure HTTP calls are made in specific intervals for example, as an easy way to throttle – Panagiotis Kanavos Jun 24 '22 at 07:20
  • If anything, this is a better solution than the accepted one – Panagiotis Kanavos Jun 24 '22 at 07:26
  • @PanagiotisKanavos Thanks. I'm conflicted though as he put in effort to help me, was first to answer, and I did learn about the Cronos lib which seems very popular. :) – lonix Jun 24 '22 at 07:28
  • 1
    Both solutions are practical, and get the job done. None of them is perfect though. The first is somewhat inaccurate and inefficient, while the second does not auto-adapt to system-wise clock adjustments. You could probably improve slightly the second solution by including the `DateTime.UtcNow.Millisecond` in the calculation of the `delay`. – Theodor Zoulias Jun 24 '22 at 11:11
  • The second solution is efficient and reasonable, sacrificing a bit of precision (however much latency exists in the first callback will affect all later waits). The first solution isn't even correct, you absolutely need to do edge detection (look for a change in the minute field) and not level detection (because any jitter at all can result in zero or two matches instead of one). – Ben Voigt Jul 15 '22 at 21:13
  • @BenVoigt Thanks. Do you have an example that shows what you mean? – lonix Jul 16 '22 at 00:19
  • 1
    Klaus gave one correct and one incorrect example here: https://stackoverflow.com/questions/72739162/net-6-periodictimer-with-top-of-the-minute-timing/72739940?noredirect=1#comment128482410_72739162 His second example should have been "01:02:00.001 and the next at 01:02:00.999" – Ben Voigt Jul 17 '22 at 18:30