Sitemap
iamprovidence

iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

The Good, The Bad and The Ugly IHostedService

10 min readJul 17, 2023

--

Press enter or click to view image in full size

In today’s fast-paced digital landscape, applications often need to perform tasks in the background without interrupting the main user experience. Whether it’s processing data, performing scheduled jobs, or handling system events, developers require a reliable and efficient mechanism to manage these background tasks seamlessly. This is where the IHostedService enters the stage.

In this article, we will dive into the world of background jobs. Explore how IHostedServicesimplifies their implementation and execution. You will see why your application may not start because of a long-running task. How to inject a service in your background job. And what is the correct timer to be used within IHostedService. By the end of this article, you will have a comprehensive guide to building a robust IHostedService implementation.

If you are ready, let’s begin.

Give me a reason I wish you would

It is common for your application to perform processes in the background. While in more advanced cases, to do a time-consuming job, you would go with Hangfire, Quartz, Azure functions, and so on, there is still a place, especially in simple scenarios, where IHostedService can be applied.

IHostedService is usually used to solve the next problems:

  • a need to perform periodical background jobs like cache invalidation, email notification, system health monitoring, data synchronization
  • a need to perform one-time action on application startup like data migration, cache warm-up
  • a need to perform ongoing processes like reading messages from a queue

How to implement it?

If you found your issue suits any of the cases above, you are good to start implementing it.

We will start with something simple. Imagine, we want to increase a release number, every time we deploy our application. To do so, we need to implement the IHostedService interface:

interface IHostedService
{
// Triggered when the application started
Task StartAsync(CancellationToken cancellationToken);

// Triggered when the application is shuting down
Task StopAsync(CancellationToken cancellationToken);
}

The implementation itself is quite straightforward:

internal MyHostedService: IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
var releaseNumber = await DB.GetLatestReleaseNumber();
releaseNumber++;
await Db.UpdateReleaseNumber(releaseNumber);
}

public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

The last missing piece is to register our service in the DI container:

services.AddHostedService<MyHostedService>();

It may seem like AddHostedService() is doing some shenanigans under the hood and there is a new lifecycle in the DI container, but in practice, it just registers it as Singleton by calling this one:

public static IServiceCollection AddHostedService<THostedService>(this IServiceCollection services)
where THostedService : class, IHostedService
{

services
.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, MyHostedService>());
}

Of course, you can also manually register your hosted service as Singleton and it will work, but .AddHostedService<T>() has next advantages:

  • readable name
  • it makes sure your service implement IHostedService interface
  • it makes sure the hosted service will be registered as IHostedService, so it can not be injected as MyHostedService in other services by mistake
  • it makes sure hosted service implementation will be registered once and only once, even if .AddHostedService<T>() was called multiple times
  • it is an abstraction level, allowing ASP’s developers to modify the way they manage hosted services, without introducing breaking changes. For example, making it Transient and not Singleton as it used to be

Infinitely running operations

I hope it was clear with a simple example. Let’s make it more complicated this time. Imagine, we want to implement a background job, something like reading a message from a queue every 5 seconds.

The naive implementation will have the next look:

public class QueueReaderBackgroundJob : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var message = await QueueStorage.Peek(cancellationToken);
if (message is not null) HandleMessage(message);

await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}

public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
. . .
}

As soon as you run your application, you will find yourself wondering why nothing happens 🤔 This is a mistake I'm constantly making 😅

⚠️ Notice, your host will discover all implementations of IHostedService and run StartAsync() one by one. It means, if you have a Task inside StartAsync() that is running forever, it will block the launch for the rest of the application.

Press enter or click to view image in full size

Here is a rewritten code from above, with our new knowledge:

public class QueueReaderBackgroundJob : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
// do not await endless Task!
// fire and forget
/*await*/ Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
var message = await QueueStorage.Peek(cancellationToken);
if (message is not null) HandleMessage(message);

await Task.Delay(5_000, cancellationToken);
}
}, cancellationToken);

return Task.CompletedTask;
}
. . .
}

There are a lot of infrastructure details that distract us from our main logic. With one simple background job, it might not be a problem, but as soon as you have a couple of them, it becomes annoying to copy-paste the same code over and over.

The solution would be to introduce an abstract class, that will do it. Turned out that such class already exists. It is called BackgroundService:

abstract class BackgroundService : IHostedService
{
public virtual Task StartAsync(CancellationToken cancellationToken)
{
/*await*/ ExecuteAsync(_stoppingCts.Token);

return Task.CompletedTask;
}

public virtual async Task StopAsync(CancellationToken cancellationToken)
{
. . .
}

protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
}

I have avoided details on how it is implemented, but there are still a few things worth mentioning:

  • it implements IHostesService and works the same way
  • you only need to implement ExecuteAsync(), but it is still possible to override StartAsync() and StopAsync()
  • ExecuteAsync() is an async method without await (fire and forget), because of the same reason described above
  • even though it is running in a fire-and-forget manner, it is still being tracked by the host, meaning exceptions won’t be swallowed and will be bubbled up

Let’s rewrite our service once again, this time inheriting from BackgroundService. Finally, we can use await and infinitive while loop without worrying it will break our application 😌

public class QueueReaderBackgroundJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var message = await QueueStorage.Peek(stoppingToken);
if (message is not null) HandleMessage(message);

await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}

Having such a universal mechanism as BackgroundService, makes IHostedService forgotten almost in all scenarios. The only reason why you would use IHostedService, is if you desperately need access to a host’s lifetime.

Inject a service

Another caveat about BackgoungService you need to know is that there is no simple way to inject a service into it. I mean, there is, but let’s go in the order.

Get iamprovidence’s stories in your inbox

Join Medium for free to get updates from this writer.

In our background service, we have HandleMessage() method. Let’s say it performs, some business logic. The only reason why we have it there, is only because we were in a rush. However now it is finally time to refactor it and move it to a separate service called IOrderService:

public class QueueReaderBackgroundJob : BackgroundService
{
private readonly IOrderService _orderService;

public QueueReaderBackgroundJob(IOrderService orderService)
{
_orderService = orderService;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var message = await QueueStorage.Peek(stoppingToken);

if (message is not null)
{
_orderService.HandleMessage(message.OrderId);
}

await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}

Remember, BackgroundService is registered as Singleton. The code above, will only work if IOrderService is registered as Singleton or Transient.

But let’s say it is not the case 😊. Most of the services are registered as Scoped to maintain their state during request, to build the dependency graph properly or to keep a consistent codebase. Regardless of the reasons, let’s say our IOrderService is scoped too.

So, how do you inject Scoped service into the Singleton? Correct! There is no way 😁 Surely, I am joking. There is a way. But it is full of pain and regrets. You would have to use IServiceScopeFactory:

public class QueueReaderBackgroundJob : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;

public QueueReaderBackgroundJob(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var message = await QueueStorage.Peek(stoppingToken);
if (message is not null)
{
await using var scope = _serviceScopeFactory.CreateAsyncScope();
var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();

orderService.HandleMessage(message.OrderId);
}

await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}

Now you can inject a service into your background job without changing its lifecycle.

There are other ways with factories, lazy delegates, and so on. But all those have a common issue. They all prevent the dependency graph from being validated.

Exceptions in the BackgroundService

Do you remember when I said BackgroundService starts Task in a fire-and-forget manner, but not exactly? It used to be the case, that exceptions would be forgotten, but not anymore.

Now, if an exception occurs inside your BackgroundService it will stop an application.

To avoid that, you can override the default configuration in Startup.cs:

services.Configure<HostOptions>((hostOptions) =>
{
// by default BackgroundServiceExceptionBehavior.StopHost
hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});

Now, it will only stop BackgroundService while the rest of the application will continue running.

However, we would not like to stop our job from running because of some stupid exception 😒. That is why it is common to have global try/catch there:

public class QueueReaderBackgroundJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
// your logic
}
catch (Exception ex)
{
// log an exception
// handle an exception
// additional logic needed to your job
}

await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}

Now, if your background task is executed periodically, you don’t have to worry that exceptions can break it or the whole application will go down.

Timer in the BackgroundService

So far, we have been using Task.Delay() as a timer to schedule a job execution. However, I am going to tell you a secret. Task.Delay() is not a timer 😁. Surely it isn’t. But on a surface level, it works in most cases.

For most of us, it does not matter when exactly the job is executed, as long as there is some delay between its execution. While that may be the case for you, it is not necessarily the case for others.

Having a scheduled job every 5 seconds, I would expect it to be executed at every 5th second, like this 00:00, 00:05, 00:10, 00:15 and so on. That is not the case with Task.Delay(). Let’s say it takes approximately from 3 to 6 seconds for our job to finish, so with a 5 seconds delay it will be executed like this 00:00, 00:08, 00:16. I think, you see a problem. It is not deterministic at all! 😒

The only solution would be to use a Timer:

public class QueueReaderBackgroundJob : BackgroundService
{
private System.Timers.Timer timer = null;

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
timer = new System.Timers.Timer(TimeSpan.FromSeconds(5));
timer.Elapsed += async (s, e) => await DoWork(stoppingToken);
timer.Start();

// TODO: implement Dispose()
}

private async Task DoWork(CancellationToken stoppingToken)
{
// there is no while loop 🥄
var message = await QueueStorage.Peek(stoppingToken);
if (message is not null) HandleMessage(message);
}
}

There are several problems with timers:

  • there are too many different timers (System.Timers.Timer, System.Threading.Timer, System.Windows.Forms.Timer, System.Web.UI.Timer, System.Windows.Threading.DispatcherTimer …)
  • the implementation will always vary
  • you have to deal with callbacks
  • you have to deal with .Dispose()
  • you have to deal with Exceptions
  • you have to deal with potential concurrency issues since Timers don’t wait for the previous job to finish
  • you have to write additional infrastructure code

If only there were a solution providing the simplicity of Task.Delay() and functionality of timers. Turned out, there is! It is called PeriodicTimer 😃

public class QueueReaderBackgroundJob : BackgroundService
{
private readonly PeriodicTimer _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// execute job

await _timer.WaitForNextTickAsync(stoppingToken);
}
}
}

Now, it will be executed as expected at every 5th second (00:00, 00:05, 00:10, 00:15) regardless of how much time it takes for a job to finish.

Execution order (advanced *)

The section with the asterisk. It is not by accident. I don’t think you need to know the information described in this section. Believe me, it won’t ever be useful, and you can skip it 😊 This section is only for those who want to know a bit more than needed.

By default, ASP starts executing IHostedServices before middlewares are configured. This means that if you have an http call from your background service to your API, it won’t work, since routing is not fully configured yet.

To change that behavior and start IHostedServices after the application is configured and started, you need to register your service not in Startup but in Program.cs:

public class Program
{
public static void Main(string[] args)
=> CreateHostBuilder(args).Build().Run();

public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
// Register your HostedService AFTER ConfigureWebHostDefaults
.ConfigureServices((services) =>
{
services
.AddHostedService<QueueReaderBackgroundJob>();
});
}

Personally, I don’t like this approach. You have half of the services registered in Startup and half in the Program.cs. It is not obvious why it is done this way, so a comment is a must. It can easily be broken. Stinky, so to say 🤢

The second solution would be to inject IHostApplicationLifetime and wait until ApplicationStarted:

public class QueueReaderBackgroundJob: BackgroundService
{
private readonly IHostApplicationLifetime _appLifetime;

public TestHostedService(IHostApplicationLifetime appLifetime)
{
_appLifetime = appLifetime;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// await without CancellationToken exception
await Task.WhenAny(
Task.Delay(Timeout.Infinite, _appLifetime.ApplicationStarted)
);

// do whatchu' wanna do
. . .
}
}

However, if you are following new trends, you are likely to hear about Minimal API. You don’t have Startup there. Starting from .Net6 IHostedServices by default are executed after middlewares are configured, so this is completely irrelevant.

I told you, this section is useless 😅 But who knows, maybe you have not migrated to the latest version yet 🙃

If you want more, here are some useful links:

Wrapping it up

Background tasks and scheduled jobs are something you might need to use in any application. Even though .NET provides an interface named IHostedService helping you to easily implement background jobs, it is suitable only for one-time actions and requires lots of additional infrastructure code to work. The only reason why it should be used is when you want to have direct access to the Host’s lifecycle.

For other scenarios, since most background tasks will have similar needs in regard to the cancellation tokens management and other typical operations, there is a convenient abstract base class you can derive from, named BackgroundService. Regardless, being designed as a class with shared logic for background jobs, it still lacks some basic capabilities like services injection, exception handling and recurring execution. As a result, there are still lots of infrastructure code to write before you can begin working with it.

It works fine for simple scenarios, but for more advanced consider using other robust tools and libraries such as Hangfire, Quartz, Azure function and so on.

I hope you find this article useful. If so, don’t forget to give it a rating in the form of claps 👏 Add a comment when you know some other fancy behavior of IHosterService 💬 Support me using the link below ☕️ Share it with friends 🗣 Check out my other articles ⬇️ And don’t forget to follow ✅ See you 😉

--

--

iamprovidence
iamprovidence