Running Background Tasks in .NET Applications
Many applications need to run background tasks: processing queues, sending emails, cleaning up old data, polling external APIs, or performing scheduled maintenance. Hosted services with Needlr provide a clean way to implement these background tasks by automatically discovering and registering IHostedService and BackgroundService implementations, eliminating the manual registration boilerplate.
Needlr automatically discovers classes that implement IHostedService or inherit from BackgroundService and registers them with the dependency injection container. This means you can write background workers as regular classes, and Needlr handles the registration automatically. This article explains how hosted services work with Needlr, how to implement background workers, and how to manage their lifecycle.
If you are new to background processing in .NET, the concepts of hosted services and background workers are part of the standard .NET hosting model. For developers familiar with background tasks, this article shows how Needlr simplifies the implementation while maintaining full compatibility with .NET's hosting infrastructure.
This article focuses specifically on hosted services and background workers. Topics like automatic service discovery, web application setup, and service lifetimes are covered in other articles. Here we are concerned with long-running background tasks.
Understanding Hosted Services
Hosted services are classes that implement IHostedService or inherit from BackgroundService. They are registered with the dependency injection container and started automatically when the application host starts. They run in the background for the lifetime of the application and are stopped gracefully when the application shuts down.
Here is a basic example without Needlr:
public class EmailQueueProcessor : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Process email queue
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
// Manual registration
services.AddHostedService<EmailQueueProcessor>();
With Needlr, the registration happens automatically:
using NexusLabs.Needlr.Injection;
using NexusLabs.Needlr.Injection.SourceGen;
var serviceProvider = new Syringe()
.UsingSourceGen()
.BuildServiceProvider();
// EmailQueueProcessor is automatically discovered and registered as a hosted service
Needlr recognizes classes that implement IHostedService or inherit from BackgroundService and registers them automatically, so you do not need to write registration code.
Implementing BackgroundService
The BackgroundService class is an abstract base class that simplifies implementing long-running background tasks. It provides a virtual ExecuteAsync method that you override with your background logic.
Here is an example of a background service that processes a queue:
using Microsoft.Extensions.Hosting;
public class OrderQueueProcessor : BackgroundService
{
private readonly IOrderQueue _queue;
private readonly IOrderService _orderService;
private readonly ILogger<OrderQueueProcessor> _logger;
public OrderQueueProcessor(
IOrderQueue queue,
IOrderService orderService,
ILogger<OrderQueueProcessor> logger)
{
_queue = queue;
_orderService = orderService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order queue processor started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Try to dequeue an order
var order = await _queue.DequeueAsync(stoppingToken);
if (order != null)
{
_logger.LogInformation("Processing order {OrderId}", order.Id);
await _orderService.ProcessOrderAsync(order);
_logger.LogInformation("Order {OrderId} processed successfully", order.Id);
}
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing order queue");
// Wait before retrying
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Order queue processor stopped");
}
}
// IOrderQueue and IOrderService are automatically discovered and registered
public interface IOrderQueue
{
Task<Order?> DequeueAsync(CancellationToken cancellationToken);
}
public class InMemoryOrderQueue : IOrderQueue
{
private readonly Queue<Order> _queue = new();
public Task<Order?> DequeueAsync(CancellationToken cancellationToken)
{
return Task.FromResult<Order?>(_queue.TryDequeue(out var order) ? order : null);
}
}
public interface IOrderService
{
Task ProcessOrderAsync(Order order);
}
public class OrderService : IOrderService
{
public async Task ProcessOrderAsync(Order order)
{
// Process the order
await Task.CompletedTask;
}
}
The OrderQueueProcessor is automatically discovered by Needlr and registered as a hosted service. When the application starts, it begins processing the queue in the background. When the application shuts down, the CancellationToken is signaled, and the service stops gracefully.
Implementing IHostedService Directly
If you need more control over the start and stop behavior, you can implement IHostedService directly instead of inheriting from BackgroundService:
using Microsoft.Extensions.Hosting;
public class HealthCheckService : IHostedService
{
private readonly IHealthChecker _healthChecker;
private readonly ILogger<HealthCheckService> _logger;
private Timer? _timer;
public HealthCheckService(
IHealthChecker healthChecker,
ILogger<HealthCheckService> logger)
{
_healthChecker = healthChecker;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Health check service starting");
_timer = new Timer(async _ =>
{
try
{
var isHealthy = await _healthChecker.CheckHealthAsync();
_logger.LogInformation("Health check result: {IsHealthy}", isHealthy);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during health check");
}
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Health check service stopping");
_timer?.Change(Timeout.Infinite, 0);
_timer?.Dispose();
return Task.CompletedTask;
}
}
public interface IHealthChecker
{
Task<bool> CheckHealthAsync();
}
public class HealthChecker : IHealthChecker
{
public async Task<bool> CheckHealthAsync()
{
// Perform health checks
return await Task.FromResult(true);
}
}
Implementing IHostedService directly gives you explicit control over start and stop behavior, which is useful when you need to manage timers, connections, or other resources that require explicit cleanup.
Periodic Background Tasks
Many background services need to run periodically. Here is an example of a service that runs on a schedule:
using Microsoft.Extensions.Hosting;
public class DataCleanupService : BackgroundService
{
private readonly IDataRepository _repository;
private readonly ILogger<DataCleanupService> _logger;
private readonly TimeSpan _interval = TimeSpan.FromHours(1);
public DataCleanupService(
IDataRepository repository,
ILogger<DataCleanupService> logger)
{
_repository = repository;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Data cleanup service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
_logger.LogInformation("Starting data cleanup");
await _repository.CleanupOldDataAsync(TimeSpan.FromDays(30));
_logger.LogInformation("Data cleanup completed");
// Wait for the interval before running again
await Task.Delay(_interval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during data cleanup");
// Wait before retrying
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
_logger.LogInformation("Data cleanup service stopped");
}
}
This service runs the cleanup task every hour, with error handling and logging. The CancellationToken ensures the service stops gracefully when the application shuts down.
Real-World Example: Multi-Service Background Processing
Let's look at a complete example that demonstrates multiple background services working together:
using NexusLabs.Needlr.Injection;
using NexusLabs.Needlr.Injection.SourceGen;
using Microsoft.Extensions.Hosting;
var serviceProvider = new Syringe()
.UsingSourceGen()
.BuildServiceProvider();
// All hosted services are automatically discovered and registered
// Email sending service
public class EmailSendingService : BackgroundService
{
private readonly IEmailQueue _emailQueue;
private readonly IEmailSender _emailSender;
private readonly ILogger<EmailSendingService> _logger;
public EmailSendingService(
IEmailQueue emailQueue,
IEmailSender emailSender,
ILogger<EmailSendingService> logger)
{
_emailQueue = emailQueue;
_emailSender = emailSender;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Email sending service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var email = await _emailQueue.DequeueAsync(stoppingToken);
if (email != null)
{
await _emailSender.SendAsync(email);
_logger.LogInformation("Email sent to {Recipient}", email.To);
}
else
{
// No emails to send, wait a bit
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending email");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Email sending service stopped");
}
}
// Cache warming service
public class CacheWarmingService : BackgroundService
{
private readonly ICache _cache;
private readonly IProductService _productService;
private readonly ILogger<CacheWarmingService> _logger;
public CacheWarmingService(
ICache cache,
IProductService productService,
ILogger<CacheWarmingService> logger)
{
_cache = cache;
_productService = productService;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Cache warming service started");
// Warm the cache on startup
await WarmCacheAsync();
// Then refresh periodically
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
await WarmCacheAsync();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error warming cache");
}
}
_logger.LogInformation("Cache warming service stopped");
}
private async Task WarmCacheAsync()
{
_logger.LogInformation("Warming cache");
var products = await _productService.GetAllProductsAsync();
foreach (var product in products)
{
_cache.Set($"product:{product.Id}", product, TimeSpan.FromHours(1));
}
_logger.LogInformation("Cache warmed with {Count} products", products.Count());
}
}
// Automatically discovered services
public interface IEmailQueue
{
Task<Email?> DequeueAsync(CancellationToken cancellationToken);
}
public class InMemoryEmailQueue : IEmailQueue
{
private readonly Queue<Email> _queue = new();
public Task<Email?> DequeueAsync(CancellationToken cancellationToken)
{
return Task.FromResult<Email?>(_queue.TryDequeue(out var email) ? email : null);
}
}
public interface IEmailSender
{
Task SendAsync(Email email);
}
public class SmtpEmailSender : IEmailSender
{
public async Task SendAsync(Email email)
{
// SMTP implementation
await Task.CompletedTask;
}
}
In this example, two background services run simultaneously:
EmailSendingService: Processes the email queue continuouslyCacheWarmingService: Warms the cache on startup and refreshes it periodically
Both services are automatically discovered and registered by Needlr, and they run in the background for the lifetime of the application.
Service Lifetimes and Hosted Services
Hosted services are registered as singletons by default, which means a single instance runs for the lifetime of the application. This is the correct lifetime for background services, as they need to persist for the application's lifetime.
Dependencies injected into hosted services can have any lifetime:
- Singleton dependencies are shared across all services
- Scoped dependencies are resolved from a scope created for the hosted service
- Transient dependencies create a new instance for each resolution
Here is an example that shows different dependency lifetimes:
public class ProcessingService : BackgroundService
{
private readonly ISingletonService _singletonService; // Shared across all services
private readonly IScopedService _scopedService; // Scoped to this service
private readonly ITransientService _transientService; // New instance each time
public ProcessingService(
ISingletonService singletonService,
IScopedService scopedService,
ITransientService transientService)
{
_singletonService = singletonService;
_scopedService = scopedService;
_transientService = transientService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// All dependencies are properly resolved based on their lifetimes
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
}
Needlr automatically resolves dependencies with the correct lifetimes, ensuring that hosted services work correctly with the dependency injection container.
Graceful Shutdown
Hosted services should handle shutdown gracefully. The CancellationToken passed to ExecuteAsync is signaled when the application is shutting down, allowing services to finish current work and clean up resources.
Here is an example that demonstrates graceful shutdown:
public class GracefulShutdownService : BackgroundService
{
private readonly ILogger<GracefulShutdownService> _logger;
public GracefulShutdownService(ILogger<GracefulShutdownService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Service started");
try
{
while (!stoppingToken.IsCancellationRequested)
{
// Do work
await DoWorkAsync(stoppingToken);
// Wait with cancellation support
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested
_logger.LogInformation("Cancellation requested, shutting down gracefully");
}
// Cleanup
await CleanupAsync();
_logger.LogInformation("Service stopped");
}
private async Task DoWorkAsync(CancellationToken cancellationToken)
{
// Perform work, checking cancellation token periodically
for (int i = 0; i < 100 && !cancellationToken.IsCancellationRequested; i++)
{
// Do work
await Task.Delay(10, cancellationToken);
}
}
private async Task CleanupAsync()
{
// Clean up resources
await Task.CompletedTask;
}
}
This service checks the cancellation token periodically and handles OperationCanceledException gracefully, ensuring clean shutdown.
Best Practices
When implementing hosted services with Needlr, follow these best practices:
Use BackgroundService for Simple Cases: Inherit from BackgroundService for most scenarios. It provides a clean abstraction for long-running tasks.
Implement IHostedService for Complex Cases: Use IHostedService directly when you need explicit control over start and stop behavior, such as managing timers or connections.
Handle Cancellation Gracefully: Always respect the CancellationToken and handle OperationCanceledException to ensure clean shutdown.
Log Start and Stop Events: Log when services start and stop to aid debugging and monitoring.
Handle Errors Appropriately: Catch and log errors, then decide whether to retry or stop the service.
Use Appropriate Intervals: Choose appropriate delays between work cycles to balance responsiveness with resource usage.
Test Hosted Services: Test hosted services independently by creating them manually and controlling their lifecycle in tests.
Comparison with Other Background Processing Approaches
Hosted services compare favorably with other background processing approaches:
Thread-Based Approaches: Hosted services integrate with .NET's hosting model, providing better lifecycle management than manual thread creation.
Timer-Based Approaches: Hosted services provide better cancellation support and lifecycle management than System.Timers.Timer or System.Threading.Timer.
Task.Run Approaches: Hosted services are properly registered with the DI container and have explicit start/stop lifecycle, unlike fire-and-forget Task.Run calls.
Needlr's automatic discovery makes hosted services even easier to use by eliminating registration boilerplate.
Conclusion
Hosted services provide a clean, integrated way to run background tasks in .NET applications. Needlr simplifies hosted service implementation by automatically discovering and registering IHostedService and BackgroundService implementations, eliminating manual registration code.
Whether you are processing queues, running scheduled tasks, or performing periodic maintenance, hosted services with Needlr provide a robust foundation for background processing while maintaining full compatibility with .NET's hosting infrastructure.
Frequently Asked Questions
How does Needlr discover hosted services?
Needlr automatically discovers classes that implement IHostedService or inherit from BackgroundService during assembly scanning and registers them as hosted services.
Do I need to manually register hosted services?
No, Needlr automatically discovers and registers hosted services. You just need to implement IHostedService or inherit from BackgroundService, and Needlr handles the registration.
What service lifetime do hosted services have?
Hosted services are registered as singletons, which is the correct lifetime for background services that run for the application's lifetime.
How do I handle graceful shutdown in hosted services?
Respect the CancellationToken passed to ExecuteAsync and handle OperationCanceledException to ensure clean shutdown. The cancellation token is signaled when the application is shutting down.
Can I have multiple hosted services?
Yes, you can have multiple hosted services. Needlr discovers and registers all of them, and they all run simultaneously in the background.
How do I test hosted services?
Create hosted services manually in tests and control their lifecycle. You can inject mock dependencies and call StartAsync and StopAsync directly.
Can hosted services use scoped dependencies?
Yes, hosted services can use scoped dependencies. The DI container creates a scope for the hosted service, and scoped dependencies are resolved within that scope.
