dotnet-background-services
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-background-services
Agent 安装分布
Skill 文档
dotnet-background-services
Patterns for long-running background work in .NET applications. Covers BackgroundService, IHostedService, hosted service lifecycle, and graceful shutdown handling.
Scope
- BackgroundService and IHostedService patterns
- Hosted service lifecycle and startup ordering
- Graceful shutdown and cancellation handling
- Periodic work, polling, and queue-draining loops
Out of scope
- DI registration mechanics and service lifetimes — see [skill:dotnet-csharp-dependency-injection]
- Async/await patterns and cancellation token propagation — see [skill:dotnet-csharp-async-patterns]
- Project scaffolding — see [skill:dotnet-scaffold-project]
- Testing strategies for background services — see [skill:dotnet-testing-strategy] and [skill:dotnet-integration-testing]
- Channel fundamentals and drain patterns — see [skill:dotnet-channels]
Cross-references: [skill:dotnet-csharp-async-patterns] for async patterns in background workers, [skill:dotnet-csharp-dependency-injection] for hosted service registration and scope management, [skill:dotnet-channels] for Channel patterns used in background work queues.
BackgroundService vs IHostedService
| Feature | BackgroundService |
IHostedService |
|---|---|---|
| Purpose | Long-running loop or continuous work | Startup/shutdown hooks |
| Methods | Override ExecuteAsync |
Implement StartAsync + StopAsync |
| Lifetime | Runs until cancellation or host shutdown | StartAsync runs at startup, StopAsync at shutdown |
| Use when | Polling queues, processing streams, periodic jobs | Database migrations, cache warming, resource cleanup |
BackgroundService Patterns
Basic Polling Worker
public sealed class OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Order processor started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider
.GetRequiredService<IOrderProcessor>();
var processed = await processor.ProcessPendingAsync(stoppingToken);
if (processed == 0)
{
// No work available -- back off to avoid tight polling
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Expected during shutdown -- do not log as error
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing orders");
// Back off on error to prevent tight failure loops
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
logger.LogInformation("Order processor stopped");
}
}
// Registration
builder.Services.AddHostedService<OrderProcessorWorker>();
Critical Rules for BackgroundService
- Always create scopes —
BackgroundServiceis registered as a singleton. InjectIServiceScopeFactory, not scoped services directly. - Always handle exceptions — by default, unhandled exceptions in
ExecuteAsyncstop the host (configurable viaHostOptions.BackgroundServiceExceptionBehavior). Wrap the loop body in try/catch. - Always respect the stopping token — check
stoppingToken.IsCancellationRequestedand pass the token to all async calls. - Back off on empty/error — avoid tight polling loops that waste CPU. Use
Task.Delaywith the stopping token.
IHostedService Patterns
Startup Hook (Cache Warming, Migrations)
public sealed class CacheWarmupService(
IServiceScopeFactory scopeFactory,
ILogger<CacheWarmupService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Warming caches");
using var scope = scopeFactory.CreateScope();
var cache = scope.ServiceProvider.GetRequiredService<IProductCache>();
await cache.WarmAsync(cancellationToken);
logger.LogInformation("Cache warmup complete");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Startup + Shutdown (Resource Lifecycle)
public sealed class MessageBusService(
ILogger<MessageBusService> logger) : IHostedService
{
private IConnection? _connection;
public async Task StartAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Connecting to message bus");
_connection = await CreateConnectionAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Disconnecting from message bus");
if (_connection is not null)
{
await _connection.CloseAsync(cancellationToken);
_connection = null;
}
}
private static Task<IConnection> CreateConnectionAsync(
CancellationToken ct)
{
// Connection setup logic
throw new NotImplementedException();
}
}
Hosted Service Lifecycle
Understanding the startup and shutdown sequence is critical for correct behavior.
Startup Sequence
IHostedService.StartAsyncis called for each registered service in registration orderBackgroundService.ExecuteAsyncis called afterStartAsynccompletes (it runs concurrently — the host does not wait for it to finish)- The host is ready to serve requests after all
StartAsynccalls complete
Important: ExecuteAsync must not block before yielding to the caller. The first await in ExecuteAsync is where control returns to the host. If you have synchronous setup before the first await, keep it short or move it to StartAsync via an override.
public sealed class MyWorker : BackgroundService
{
// StartAsync runs to completion before the host is ready.
// Override only if you need guaranteed pre-ready initialization.
public override async Task StartAsync(CancellationToken cancellationToken)
{
// Initialization that MUST complete before host accepts requests
await InitializeAsync(cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// This runs concurrently with the host
while (!stoppingToken.IsCancellationRequested)
{
await DoWorkAsync(stoppingToken);
}
}
private Task InitializeAsync(CancellationToken ct) => Task.CompletedTask;
private Task DoWorkAsync(CancellationToken ct) => Task.CompletedTask;
}
Shutdown Sequence
IHostApplicationLifetime.ApplicationStoppingis triggered- The host calls
StopAsyncon each hosted service in reverse registration order - For
BackgroundService, the stopping token is cancelled, thenStopAsyncwaits forExecuteAsyncto complete IHostApplicationLifetime.ApplicationStoppedis triggered
Channels Integration
See [skill:dotnet-channels] for comprehensive Channel<T> guidance including bounded/unbounded options, BoundedChannelFullMode, backpressure strategies, itemDropped callbacks, multiple consumers, performance tuning, and drain patterns.
The most common integration is a channel-backed background task queue consumed by a BackgroundService:
// Channel-backed work queue -- register as singleton
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _queue
= Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(
new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
public ChannelWriter<Func<IServiceProvider, CancellationToken, Task>> Writer => _queue.Writer;
public ChannelReader<Func<IServiceProvider, CancellationToken, Task>> Reader => _queue.Reader;
}
// Consumer worker
public sealed class QueueProcessorWorker(
BackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (await queue.Reader.WaitToReadAsync(stoppingToken))
{
while (queue.Reader.TryRead(out var workItem))
{
try
{
using var scope = scopeFactory.CreateScope();
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing queued work item");
}
}
}
}
}
// Registration
builder.Services.AddSingleton<BackgroundTaskQueue>();
builder.Services.AddHostedService<QueueProcessorWorker>();
Graceful Shutdown
Host Shutdown Timeout
By default, the host waits 30 seconds for services to stop. Configure this for long-running operations:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});
Responding to Application Lifetime Events
public sealed class LifecycleLogger(
IHostApplicationLifetime lifetime,
ILogger<LifecycleLogger> logger) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
lifetime.ApplicationStarted.Register(() =>
logger.LogInformation("Application started"));
lifetime.ApplicationStopping.Register(() =>
logger.LogInformation("Application stopping -- begin cleanup"));
lifetime.ApplicationStopped.Register(() =>
logger.LogInformation("Application stopped -- cleanup complete"));
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Periodic Work with PeriodicTimer
Use PeriodicTimer instead of Task.Delay for more accurate periodic execution:
public sealed class HealthCheckReporter(
IServiceScopeFactory scopeFactory,
ILogger<HealthCheckReporter> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = scopeFactory.CreateScope();
var reporter = scope.ServiceProvider
.GetRequiredService<IHealthReporter>();
await reporter.ReportAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Health check report failed");
}
}
}
}
Agent Gotchas
- Do not inject scoped services into BackgroundService constructors — they are singletons. Always use
IServiceScopeFactory. - Do not use
Task.Runfor background work — useBackgroundServicefor proper lifecycle management and graceful shutdown. - Do not swallow
OperationCanceledException— let it propagate or re-check the stopping token. Swallowing it prevents graceful shutdown. - Do not use
Thread.Sleep— useawait Task.Delay(duration, stoppingToken)orPeriodicTimer. - Do not forget to register —
AddHostedService<T>()is required; merely implementing the interface does nothing.