dotnet-background-services

📁 novotnyllc/dotnet-artisan 📅 4 days ago
3
总安装量
3
周安装量
#61039
全站排名
安装命令
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-background-services

Agent 安装分布

opencode 3
gemini-cli 3
github-copilot 3
codex 3
kimi-cli 3
cursor 3

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

  1. Always create scopesBackgroundService is registered as a singleton. Inject IServiceScopeFactory, not scoped services directly.
  2. Always handle exceptions — by default, unhandled exceptions in ExecuteAsync stop the host (configurable via HostOptions.BackgroundServiceExceptionBehavior). Wrap the loop body in try/catch.
  3. Always respect the stopping token — check stoppingToken.IsCancellationRequested and pass the token to all async calls.
  4. Back off on empty/error — avoid tight polling loops that waste CPU. Use Task.Delay with 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

  1. IHostedService.StartAsync is called for each registered service in registration order
  2. BackgroundService.ExecuteAsync is called after StartAsync completes (it runs concurrently — the host does not wait for it to finish)
  3. The host is ready to serve requests after all StartAsync calls 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

  1. IHostApplicationLifetime.ApplicationStopping is triggered
  2. The host calls StopAsync on each hosted service in reverse registration order
  3. For BackgroundService, the stopping token is cancelled, then StopAsync waits for ExecuteAsync to complete
  4. IHostApplicationLifetime.ApplicationStopped is 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

  1. Do not inject scoped services into BackgroundService constructors — they are singletons. Always use IServiceScopeFactory.
  2. Do not use Task.Run for background work — use BackgroundService for proper lifecycle management and graceful shutdown.
  3. Do not swallow OperationCanceledException — let it propagate or re-check the stopping token. Swallowing it prevents graceful shutdown.
  4. Do not use Thread.Sleep — use await Task.Delay(duration, stoppingToken) or PeriodicTimer.
  5. Do not forget to registerAddHostedService<T>() is required; merely implementing the interface does nothing.

References