.NET Core
.NET Background Services: Hosted Services und Worker Pattern
Hintergrundverarbeitung ist das Fundament jeder ernsthaften .NET-Anwendung. Sobald Ihre Applikation E-Mails versenden, Reports generieren, Daten synchronisieren oder Domain Events publizieren muss, handelt es sich um Arbeit, die nicht in den HTTP-Request-Response-Zyklus gehoert. Diese Aufgaben in Background Services auszulagern verbessert Antwortzeiten, isoliert Fehler und macht Ihr System grundlegend skalierbarer.
In diesem Artikel gehe ich die wichtigsten Implementierungsoptionen in .NET durch, teile produktionserprobte Patterns und zeige die Fehler, die mir in echten Codebasen am haeufigsten begegnen.
Implementierungsoptionen
IHostedService
IHostedService ist die niedrigste Abstraktionsebene. Es stellt zwei Hooks bereit — StartAsync und StopAsync — und sonst nichts. Das ist perfekt, wenn Sie beim Anwendungsstart eine Verbindung aufbauen, einen Cache aufwaermen oder einen Listener registrieren muessen.
public class CacheWarmupService : IHostedService
{
private readonly ICacheProvider _cache;
private readonly ILogger<CacheWarmupService> _logger;
public CacheWarmupService(ICacheProvider cache, ILogger<CacheWarmupService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Cache wird aufgewaermt...");
await _cache.LoadFrequentDataAsync(cancellationToken);
_logger.LogInformation("Cache-Aufwaermung abgeschlossen.");
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Anwendung wird heruntergefahren, Cache-Warmup-Service stoppt.");
return Task.CompletedTask;
}
}Registrierung in Program.cs:
builder.Services.AddHostedService<CacheWarmupService>();BackgroundService
BackgroundService ist das Arbeitspferd. Es erbt von IHostedService und bietet eine ExecuteAsync-Methode, die waehrend der gesamten Lebensdauer der Anwendung laeuft. Hier implementieren Sie kontinuierliche Worker — Queue-Consumer, Polling-Schleifen, Datei-Watcher.
public class OrderProcessorWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderProcessorWorker> _logger;
public OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Bestellungsverarbeitung gestartet.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var orderQueue = scope.ServiceProvider.GetRequiredService<IOrderQueue>();
var handler = scope.ServiceProvider.GetRequiredService<IOrderHandler>();
var order = await orderQueue.DequeueAsync(stoppingToken);
if (order is not null)
{
await handler.ProcessAsync(order, stoppingToken);
_logger.LogInformation("Bestellung {OrderId} verarbeitet.", order.Id);
}
else
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Graceful Shutdown — erwartet, nicht als Fehler loggen
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler bei der Bestellungsverarbeitung. Neuer Versuch in 10 Sekunden.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
_logger.LogInformation("Bestellungsverarbeitung gestoppt.");
}
}Ein kritisches Detail: BackgroundService wird als Singleton registriert, aber Ihr DbContext und die meisten Business-Services sind Scoped. Erstellen Sie innerhalb der Schleife immer einen neuen IServiceScope. Das zu vergessen ist einer der haeufigsten Fehler, die mir begegnen.
Queue-basierte Verarbeitung mit Channel\<T\>
Fuer In-Process-Producer-Consumer-Szenarien ist System.Threading.Channels.Channel<T> leichtgewichtig und extrem schnell. Ich verwende das, wenn ich Arbeit von einem API-Controller auslagern muss, ohne einen externen Message Broker einzusetzen.
public class BackgroundTaskQueue
{
private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _channel;
public BackgroundTaskQueue(int capacity = 100)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_channel = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(options);
}
public async ValueTask EnqueueAsync(
Func<IServiceProvider, CancellationToken, Task> workItem,
CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(
CancellationToken cancellationToken)
{
return await _channel.Reader.ReadAsync(cancellationToken);
}
}
public class QueueProcessorWorker : BackgroundService
{
private readonly BackgroundTaskQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<QueueProcessorWorker> _logger;
public QueueProcessorWorker(
BackgroundTaskQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _queue.DequeueAsync(stoppingToken);
using var scope = _scopeFactory.CreateScope();
try
{
await workItem(scope.ServiceProvider, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Ausfuehren des Queue-Arbeitselements.");
}
}
}
}Hangfire fuer geplante und wiederkehrende Jobs
Wenn Sie persistente, geplante oder wiederkehrende Jobs mit Dashboard und automatischen Wiederholungen benoetigen, ist Hangfire die praktischste Wahl im .NET-Oekosystem.
// Program.cs
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));
builder.Services.AddHangfireServer();
// Wiederkehrenden Job einrichten
RecurringJob.AddOrUpdate<IReportService>(
"taeglicher-verkaufsbericht",
service => service.GenerateDailySalesReportAsync(CancellationToken.None),
Cron.Daily(hour: 2, minute: 0));
// Fire-and-Forget aus einem Controller
BackgroundJob.Enqueue<IEmailService>(
service => service.SendWelcomeEmailAsync(userId, CancellationToken.None));
// Verzoegerter Job
BackgroundJob.Schedule<ICleanupService>(
service => service.RemoveExpiredTokensAsync(CancellationToken.None),
TimeSpan.FromHours(1));In Produktionssystemen konfiguriere ich Hangfire immer mit einer eigenen Datenbank und vergebe explizite Queue-Namen. So konkurriert der Background-Job-Speicher nicht mit der Primaerdatenbank der Anwendung, und Worker lassen sich unabhaengig skalieren.
Outbox Pattern fuer zuverlaessiges Event-Publishing
Eines der schwierigsten Probleme in verteilten Systemen ist die Sicherstellung, dass ein Datenbankschreibvorgang und ein Event-Publish atomar stattfinden. Wenn Sie eine Bestellung in der Datenbank speichern, aber der Message Broker nicht erreichbar ist, geht das Event verloren. Wenn Sie das Event publizieren, aber die Datenbanktransaktion zurueckgerollt wird, haben Sie ein Phantom-Event.
Das Outbox Pattern loest dies, indem Events innerhalb derselben Datenbanktransaktion in eine Outbox-Tabelle geschrieben werden. Ein Background Service leitet diese Events dann an den Message Broker weiter.
// Schritt 1: In die Outbox innerhalb derselben Transaktion schreiben
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = "OrderPlaced",
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id, order.Total)),
CreatedAt = DateTime.UtcNow,
ProcessedAt = null
});
await _db.SaveChangesAsync(ct); // Eine einzige Transaktion
}
}
// Schritt 2: Background Worker leitet Outbox-Nachrichten weiter
public class OutboxPublisherWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxPublisherWorker> _logger;
public OutboxPublisherWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxPublisherWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
var pending = await db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var message in pending)
{
try
{
await bus.PublishAsync(message.Type, message.Payload, stoppingToken);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Outbox-Nachricht {Id} konnte nicht publiziert werden.", message.Id);
break; // Reihenfolge beibehalten — im naechsten Zyklus erneut versuchen
}
}
await db.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}In Produktionssystemen fuege ich der Outbox-Tabelle immer eine RetryCount-Spalte hinzu und ueberspringe Nachrichten, die einen Schwellenwert ueberschritten haben. Gescheiterte Nachrichten werden in eine separate Tabelle zur manuellen Pruefung verschoben.
Fehlerbehandlung und Retry-Strategie
Ein Background Service, der bei der ersten Exception abstuerzt, ist schlimmer als gar kein Background Service. Robuste Fehlerbehandlung ist nicht verhandelbar.
Exponential Backoff mit Polly
// Retry-Policy definieren
private static readonly AsyncRetryPolicy RetryPolicy = Policy
.Handle<HttpRequestException>()
.Or<TimeoutException>()
.WaitAndRetryAsync(
retryCount: 5,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (exception, delay, attempt, context) =>
{
Log.Warning(exception,
"Versuch {Attempt} nach {Delay}s fuer {Operation}.",
attempt, delay.TotalSeconds, context.OperationKey);
});
// Verwendung innerhalb eines Workers
await RetryPolicy.ExecuteAsync(
async ct => await externalApi.SyncDataAsync(ct),
stoppingToken);Graceful Shutdown mit CancellationToken
Das CancellationToken, das an ExecuteAsync uebergeben wird, signalisiert den Shutdown des Hosts. Es zu respektieren ist entscheidend — wenn Ihr Worker es ignoriert, wird der Host ihn nach dem Shutdown-Timeout (Standard: 30 Sekunden) gewaltsam beenden.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var batch = await FetchBatchAsync(stoppingToken);
foreach (var item in batch)
{
if (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Shutdown angefordert. Batch-Verarbeitung wird unterbrochen.");
return;
}
await ProcessItemAsync(item, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}In Produktionssystemen konfiguriere ich das Shutdown-Timeout immer explizit in Program.cs:
builder.Host.ConfigureHostOptions(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(60);
});Das gibt lang laufenden Batch-Operationen genuegend Zeit, das aktuelle Element abzuschliessen, bevor der Prozess beendet wird.
Background Jobs ueberwachen
Background Services laufen lautlos. Ohne ordentliches Monitoring bleiben Fehler stunden- oder tagelang unbemerkt. Ich habe Systeme gesehen, in denen ein Background Worker wochenlang abgestuerzt war, ohne dass es jemand bemerkt hat.
Health Checks
public class WorkerHealthCheck : IHealthCheck
{
private static DateTime _lastHeartbeat = DateTime.MinValue;
private static readonly TimeSpan Threshold = TimeSpan.FromMinutes(5);
public static void RecordHeartbeat() => _lastHeartbeat = DateTime.UtcNow;
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var elapsed = DateTime.UtcNow - _lastHeartbeat;
if (elapsed < Threshold)
return Task.FromResult(HealthCheckResult.Healthy($"Letzter Heartbeat vor {elapsed.TotalSeconds:F0}s."));
return Task.FromResult(HealthCheckResult.Unhealthy($"Kein Heartbeat seit {elapsed.TotalMinutes:F1} Minuten."));
}
}Rufen Sie WorkerHealthCheck.RecordHeartbeat() am Ende jeder erfolgreichen Schleifeniteration in Ihrem Worker auf.
Strukturierte Metriken
// Mit der .NET 8 Metrics API
private readonly Counter<long> _processedCounter;
private readonly Histogram<double> _processingDuration;
public OrderProcessorWorker(IMeterFactory meterFactory, /* ... */)
{
var meter = meterFactory.Create("App.BackgroundJobs");
_processedCounter = meter.CreateCounter<long>("jobs.processed");
_processingDuration = meter.CreateHistogram<double>("jobs.duration_ms");
}
// Innerhalb der Verarbeitungsschleife
var sw = Stopwatch.StartNew();
await handler.ProcessAsync(order, stoppingToken);
sw.Stop();
_processedCounter.Add(1, new KeyValuePair<string, object?>("job_type", "order"));
_processingDuration.Record(sw.Elapsed.TotalMilliseconds);Exportieren Sie diese Metriken nach Prometheus, Grafana oder Application Insights und richten Sie Alarme ein, wenn die Verarbeitungsrate sinkt oder die Fehleranzahl steigt.
Haeufige Background-Service-Fehler
1. Keinen Service Scope erstellen
Das ist der Fehler Nummer eins. BackgroundService ist ein Singleton. Wenn Sie einen Scoped Service wie DbContext direkt ueber den Konstruktor injizieren, erhalten Sie eine Captive Dependency, die irgendwann eine ObjectDisposedException wirft oder schlimmer — lautlos einen veralteten Context wiederverwendet.
// FALSCH — DbContext ist Scoped, Worker ist Singleton
public class SchlechterWorker : BackgroundService
{
private readonly AppDbContext _db; // Captive Dependency!
public SchlechterWorker(AppDbContext db) => _db = db;
}
// RICHTIG — in jeder Iteration einen Scope erstellen
public class GuterWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public GuterWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// db hier sicher verwenden
}
}
}2. Exceptions lautlos verschlucken
Eine unbehandelte Exception in ExecuteAsync stoppt den Worker in .NET 6+ lautlos. Der Host laeuft weiter, aber der Background Service ist tot. Umschliessen Sie den Schleifenkoerper immer mit try-catch und loggen Sie den Fehler.
3. Den Host-Start blockieren
ExecuteAsync wird beim Host-Start aufgerufen. Wenn Ihre Implementierung nicht yielded (zum Beispiel eine synchrone while(true)-Schleife ohne await), blockiert sie den Start aller anderen Hosted Services. Verwenden Sie immer await in Ihrer Schleife — selbst ein await Task.Yield() am Anfang der Schleife genuegt, um den Start nicht zu blockieren.
4. Idempotenz ignorieren
Background Jobs koennen mehr als einmal ausgefuehrt werden. Netzwerk-Timeouts, Prozess-Neustarts und doppelte Queue-Nachrichten fuehren zu Wiederholungen. Wenn Ihr Handler nicht idempotent ist, bekommen Sie doppelte E-Mails, Doppelabbuchungen oder beschaedigte Daten. Entwerfen Sie jeden Job-Handler so, dass er sicher erneut ausgefuehrt werden kann.
5. Keine Dead-Letter-Strategie
Wenn eine Nachricht wiederholt scheitert, sollte sie die Queue nicht fuer immer blockieren. Implementieren Sie einen Retry-Zaehler und verschieben Sie vergiftete Nachrichten nach einer festgelegten Anzahl von Versuchen in eine Dead-Letter-Queue oder -Tabelle. In Produktionssystemen koppele ich das immer mit einem Alarm, damit das Team fehlgeschlagene Nachrichten zeitnah untersucht.
Fazit
Background Services sind kein Nebenaspekt — sie sind Kerninfrastruktur. Eine gut konzipierte Background-Processing-Schicht behandelt Fehler elegant, publiziert Events zuverlaessig ueber das Outbox Pattern und gibt Ihrem Team durch Health Checks und Metriken volle Sichtbarkeit. Die hier beschriebenen Patterns stammen aus jahrelanger Erfahrung mit dem Betrieb dieser Services in Produktion und verhindern zuverlaessig die schmerzhaftesten Vorfaelle.
Ich unterstuetze gern beim Aufbau einer robusten Background-Processing-Architektur.
Verwandte Artikel
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Microservices-Architektur mit .NET: Design und Umsetzung
Entwerfen Sie Microservices-Architektur mit .NET. Service-Kommunikation und Orchestrierung.
.NET Performance-Optimierung: Profiling und Best Practices
Optimieren Sie die Performance von .NET-Anwendungen. Profiling, Memory Management und Async-Patterns.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen