17

The ASP.NET Core docs for background services show a number of implementation examples.

There's an example for starting a service on a timer, though it's synchronous. There's another example which is asynchronous, for starting a service with a scoped dependency.

I need to do both: start a service every 5 minutes, and it has scoped dependencies. There's no example for that.

I combined both examples, but I'm unsure of a safe way to use Timer with an async TimerCallback.

e.g.

public class MyScheduler : IHostedService
{
  private Timer? _timer;
  private readonly IServiceScopeFactory _serviceScopeFactory;

  public MyScheduler(IServiceScopeFactory serviceScopeFactory) => _serviceScopeFactory = serviceScopeFactory;

  public void Dispose() => _timer?.Dispose();

  public Task StartAsync(CancellationToken cancellationToken)
  {
    _timer = new Timer((object? state) => {
      using var scope = _serviceScopeFactory.CreateScope();
      var myService = scope.ServiceProvider.GetRequiredService<IMyService>();
      await myService.Execute(cancellationToken);            // <------ problem
    }), null, TimeSpan.Zero, TimeSpan.FromSeconds(5));

    return Task.CompletedTask;
  }

  public Task StopAsync(CancellationToken cancellationToken) {
    _timer?.Change(Timeout.Infinite, 0);
    return Task.CompletedTask;
  }

}

The timer takes a sync callback, so the problem is the await. What's a safe way to call an async service?

lonix
  • 14,255
  • 23
  • 85
  • 176
  • Is there any possibility that the `myService.Execute` might take more than 5 minutes to complete? In that case, what would you like to happen? Are you OK with overlapping executions of the method? – Theodor Zoulias Mar 27 '22 at 14:00
  • @TheodorZoulias Just copied-pasted that from original examples, overlapping executions is a good point (thanks!) but probably best left for a separate question so I don't make this one too complex. I'll probably use a critical block to ensure only one execution at a time. – lonix Mar 27 '22 at 14:11
  • @lonix or you can also look at using a Queue if you want the service execution to happen in sequence – Nkosi Mar 27 '22 at 14:15
  • 1
    You might find this useful: [Run async method regularly with specified interval](https://stackoverflow.com/questions/30462079/run-async-method-regularly-with-specified-interval). Adding a critical section inside the event handler might result in `ThreadPool` saturation in the long run, because more and more threads might be blocked while the timer is ticking. – Theodor Zoulias Mar 27 '22 at 14:23
  • @lonix example provided – Nkosi Mar 27 '22 at 14:26
  • @TheodorZoulias See accepted solution below, which I think prevents overlapping executions as they are run one by one. – lonix Mar 28 '22 at 11:46
  • 2
    @lonix yes, Artur's [answer](https://stackoverflow.com/a/71637260/11178549) prevents overlapping, because it doesn't involve an event-based `Timer`, that invokes the event handlers on the `ThreadPool`. – Theodor Zoulias Mar 28 '22 at 12:06
  • @TheodorZoulias Thanks. To be clear, "Option 1" prevents overlapping. But what about "Option 2" with the new .NET6 `PeriodicTimer` class? Does it allow or prevent overlapping? – lonix Mar 28 '22 at 12:33
  • 2
    @lonix "Option 2" prevents overlapping as well. The `PeriodicTimer` is an [awaitable timer](https://stackoverflow.com/questions/56844128/creating-an-awaitable-system-timers-timer), not an event-based timer. – Theodor Zoulias Mar 28 '22 at 13:08
  • 1
    @TheodorZoulias Your comments were as helpful as the answers below, thank you! – lonix Mar 28 '22 at 15:01

2 Answers2

32

Use BackgroundService instead of IHostedService

public class MyScheduler : BackgroundService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    public MyScheduler(IServiceScopeFactory serviceScopeFactory) => _serviceScopeFactory = serviceScopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Option 1
        while (!stoppingToken.IsCancellationRequested)
        {
            // do async work
            using (var scope = _serviceScopeFactory.CreateScope())
            {
              var myService = scope.ServiceProvider.GetRequiredService<IMyService>();
              await myService.Execute(stoppingToken);
            }
            await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
        }

        // Option 2 (.NET 6)
        var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // do async work
            // ...as above
        }
    }
}
lonix
  • 14,255
  • 23
  • 85
  • 176
Artur
  • 4,595
  • 25
  • 38
  • 2
    You have `asp.net-core-5` tag on your question, but be aware that .NET 5.0 reaches the [end of support on 08/05/22](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core), so you better upgrade to 6.0 and use `PeriodicTimer`. – Artur Mar 27 '22 at 14:40
  • I like this approach. It is way simpler than what I suggested. – Nkosi Mar 27 '22 at 14:51
  • Thanks. To use scoped dependencies as I showed above, would it be done in the same way using your solution? i.e. it's "safe" to create a scope inside the while loop and resolve services from there on each tick – lonix Mar 28 '22 at 03:54
  • Yes, the same way. – Artur Mar 28 '22 at 04:03
  • A reason I like "Option 1" more than my code above - you can't get multiple overlapping executions. It allows only one at a time. – lonix Mar 28 '22 at 11:46
  • Looking at this again, I would put the scope creation and `GetRequiredService` outside the loop, so it's done only once. What do you think? – lonix Jun 22 '22 at 04:18
  • You need a new scope for each iteration. – Artur Jun 23 '22 at 12:07
  • Ah yes, I forgot the question was for a "scoped" dependency. For scoped or transient you need a new scope for each iteration - you are right. (If the dependency is singleton *AND its dependencies* are not scoped/transient, then it would be okay to resolve once. But the best approach is never to do that.) – lonix Jun 26 '22 at 04:47
  • How do you actually run? what calls ExecuteAsync? – user1034912 Oct 15 '22 at 10:34
  • @user1034912 you need to call `services.AddHostedService()` in `ConfigureServices` method. See more info here: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-6.0&tabs=visual-studio – Artur Oct 15 '22 at 10:58
2

Create an event with async handler and raise it every interval. The event handler can be awaited

public class MyScheduler : IHostedService {
    private Timer? _timer;
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public MyScheduler(IServiceScopeFactory serviceScopeFactory) => _serviceScopeFactory = serviceScopeFactory;

    public void Dispose() => _timer?.Dispose();
    
    public Task StopAsync(CancellationToken cancellationToken) {
        _timer?.Change(Timeout.Infinite, 0);
        return Task.CompletedTask;
    }
    
    public Task StartAsync(CancellationToken cancellationToken) {
        performAction += onPerformAction; //subscribe to event
        ScopedServiceArgs args = new ScopedServiceArgs {
            ServiceScopeFactory = _serviceScopeFactory,
            CancellationToken = cancellationToken
        };
        _timer = new Timer((object? state) =>  performAction(this, args), //<-- raise event
            null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
        return Task.CompletedTask;
    }
  
    private event EventHandler<ScopedServiceArgs> performAction = delegate { };
    
    private async void onPerformAction(object sender, CancellationArgs args) {
        using IServiceScope scope = args.ServiceScopeFactory.CreateScope();
        IMyService myService = scope.ServiceProvider.GetRequiredService<IMyService>();
        await myService.Execute(args.CancellationToken);
    }
    
    class ScopedServiceArgs : EventArgs {
        public IServiceScopeFactory ServiceScopeFactory {get; set;}
        public CancellationToken CancellationToken {get; set;}
    }

}
Nkosi
  • 235,767
  • 35
  • 427
  • 472
  • Thank you Nkosi... I need some time to digest this and repurpose into a hosted service, given me much to start on, thanks. – lonix Mar 27 '22 at 14:34
  • @lonix I like [@Arthur's approach](https://stackoverflow.com/a/71637260/5233410) better for what you wanted to do. Take a look at – Nkosi Mar 27 '22 at 14:52
  • His approach solves my problem but I learnt much from your solution, thank you very much! – lonix Mar 28 '22 at 02:57