Strange C# Tricks 8: Background service, the easy way.
Strange C# Tricks is a series of articles about some cool and obscure tricks I learned over my career. They may be helpful only in some specific scenarios, but learning new ideas is never a bad thing.
By now we are all used to HTTP requests, and the main thing that we expect from them is that is are fast. When we make a HTTP request to a remote service we want to get the response ASAP.
But what do to when an HTTP request requires us to perform an action that we know will take a long time? For example, if we need to clear a large cache domain, or start deleting thousands of rows from a DB table.
We can use a queue/message-based service like Kafka or RabbitDB to handle this asynchronously, but this can be very complex time-consuming, and more expensive. What to do if we need to do something that takes longer than a regular HTTP request but not so long that we want to add a message broker to our service?
Here is where ASP.NET hosted services come into play.
A hosted service is a class with background task logic that can run in the back ground of our ASP.NET server.
There are 2 variations of this:
1. The IHostedSevice: typically relegated to short-running tasks. the interface defines two methods for objects that are managed by the host:
- StartAsync(CancellationToken)
- StopAsync(CancellationToken)
2. BackgroundService: introduced as an extension for long-running or concurrent tasks. the interface defines one method for objects that are managed by the host:
- ExecuteAsync(CancellationToken stoppingToken)
Both need to be registered using the AddHostedService method.
builder.Services.AddHostedService<MyBackgroundService>();
Let's see a more concrete example
In my API there are 2 long-running tasks that need to be performed.
public interface IMonitorService
{
Task Monitor(MonitorSettings settings);
}
public interface IImportingService
{
Task Import(ImportingSettings settings);
}
The implementation of these tasks is not important here, just assume they do some network calls.
We of course register them in the ASP.Net Program.cs file.
builder.Services.AddTransient<IMonitorService, MonitorService>();
builder.Services.AddTransient<IImportingService, ImportingService>();
Now we need a way pass to the Background service instruction about how to start our monitoring or importing task, and we want to make sure the task will be executed in the order they were received.
Sounds like we need to use a queue, let create a class to hold it. (I am using a ConcurrentQueue as it is thread-safe)
public class TasksToRun
{
private readonly ConcurrentQueue<TaskSettings> _tasks = new();
public TasksToRun() => _tasks = new ConcurrentQueue<TaskSettings>();
public void Enqueue(TaskSettings settings) => _tasks.Enqueue(settings);
public TaskSettings? Dequeue()
{
var hasTasks = _tasks.TryDequeue(out var settings);
return hasTasks ? settings : null;
}
}
We also need to register it as a singleton so we can get the same instance for the DI in any class in our project.
builder.Services.AddSingleton<TasksToRun, TasksToRun>();
The TaskSettings class that we store in the ConcurrentQueue looks like this:
public class TaskSettings
{
public MonitorSettings? MonitorSettings { get; set; }
public ImportingSettings? ImportingSettings { get; set; }
public static TaskSettings FromMonitorSettings
(MonitorSettings monitorSettings)
{
return new TaskSettings { MonitorSettings = monitorSettings };
}
public static TaskSettings FromImporterSettings
(ImportingSettings importingSettings)
{
return new TaskSettings { ImportingSettings = importingSettings };
}
}
It's just a container for either ImportingSettings or MonitorSettings, we use a single class to store both of the setting objects so we can easily use a single queue.
Now, we need to create a controller to add tasks to our queue.
[ApiController]
[Route("[controller]")]
public class TasksController : ControllerBase
{
private readonly TasksToRun _tasksToRun;
public TasksController(TasksToRun tasksToRun)
{
_tasksToRun = tasksToRun;
}
[HttpGet("monitor")]
public Task<string> Monitor()
{
_tasksToRun.Enqueue(HostingSettings.FromMonitorSettings(new MonitorSettings
{
ApiKey = "xxxxxx"
}));
return Task.FromResult("monitoring");
}
[HttpGet("import")]
public Task<string> Import()
{
_tasksToRun.Enqueue(HostingSettings.FromImporterSettings(new ImportingSettings
{
Source = "http://someData.com",
Count = 100
}));
return Task.FromResult("importing");
}
}
As you can see, we get the taskToRun from the DI and just add it to the queue when we need the background service to execute something. We can return a response to the user without waiting for the task to finish.
Now, let's see the background service
public class MainBackgroundTaskService : BackgroundService
{
private readonly TasksToRun _tasks;
private readonly IMonitorService _monitorService;
private readonly IImportingService _importingService;
//get number of seconds from config
private readonly TimeSpan _timeSpan = TimeSpan.FromSeconds(1);
public MainBackgroundTaskService(TasksToRun tasks,
IMonitorService monitorService,
IImportingService importingService)
{
_tasks = tasks;
_monitorService = monitorService;
_importingService = importingService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(_timeSpan);
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
var taskToRun = _tasks.Dequeue();
if (taskToRun != null)
{
var taskToRun = _tasks.Dequeue();
if (taskToRun == null) continue;
//call the relevant service based on what
// settings object in not NULL
if (taskToRun?.ImportingSettings != null)
{
await _importingService .Monitor(taskToRun.ImportingSettings);
}
if (taskToRun?.MonitorSettings != null)
{
await _monitorService.Import(taskToRun.MonitorSettings);
}
}
}
}
}
Don't forget that we need to register it
builder.Services.AddHostedService<MainBackgroundTaskService>();
The service is pretty simple, we get from the DI the taskToRun object and implementation of the service that we want to run, and we define a TimeSpan to define how often we want to sample the queue.
In the ExecuteAsync method, we define a PeriodicTimer to help us wait for the next time that we want to read from the queue (using timer.WaitForNextTickAsync).
Then we just need to read from the queue, and if we get a task check what setting object is not null to call the relevant service.
We await each task so make sure that they execute one at a time but we don't have to.
if (taskToRun != null)
{
var taskToRun = _tasks.Dequeue();
if (taskToRun == null) continue;
//call the relevant service based on what
// settings object in not NULL
if (taskToRun?.ImportingSettings != null)
{
//start to excute but dont wait for completion
_importingService .Monitor(taskToRun.ImportingSettings);
}
if (taskToRun?.MonitorSettings != null)
{
//start to excute but dont wait for completion
_monitorService.Import(taskToRun.MonitorSettings);
}
}
We can also execute tasks in parallel
while (!stoppingToken.IsCancellationRequested &&
await timer.WaitForNextTickAsync(stoppingToken))
{
var settingsList= new List<TaskSettings>();
while (!_tasks.IsEmpty)
{
settingsList.Add(_tasks.Dequeue());
}
var tasksToExecute = settingsList.Select(t =>
t?.ImportingSettings != null
? _importingService .Monitor(taskToRun.ImportingSettings)
: (t?.MonitorSettings != null ?
_monitorService.Import(taskToRun.MonitorSettings); :
Task.CompletedTask));
await Task.WhenAll(tasksToExecute);
}
Once we have a mechanism to ream from the task queue we can execute them in whatever way we need.
As you can background and hosted services are a pretty simple and flexible way to execute stuff behind the scenes.
You can see the full code here.
Edit: I wrote a second article that explores a more generic solution for creating a background service.