Hintergrund-Services: PeriodicTimer und CancellationToken (Teil 32)

Cover aus dem Dateisystem laden, Datenbanken pflegen, Online-APIs abfragen — all das dauert Millisekunden bis Sekunden. Läuft es auf dem UI-Thread, friert die Anwendung ein. Der Nutzer sieht ein weißes Fenster und denkt, die App sei abgestürzt. In WinUI 3 gibt es kein eingebautes IHostedService wie in ASP.NET. EchoPlay löst das Problem mit eigenen Singleton-Services, die per Task.Run und PeriodicTimer im Hintergrund laufen — ohne den UI-Thread jemals zu blockieren.

Das Hintergrund-Muster: BackgroundCoverService

Der BackgroundCoverService zeigt das zentrale Muster für alle Hintergrund-Services in EchoPlay. Die Klasse ist ein Singleton — sie existiert genau einmal für die gesamte Laufzeit der App. Beim App-Start wird Start() aufgerufen, das eine nicht-blockierende Hintergrund-Schleife startet. Die Methode kehrt sofort zurück, die App startet weiter, und der Service arbeitet im Hintergrund.

public sealed class BackgroundCoverService : IDisposable
{
    private readonly IServiceScopeFactory _scopeFactory;
    private CancellationTokenSource? _cts;
    private Task? _backgroundTask;

    private static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);

    public void Start()
    {
        if (_backgroundTask is not null) return;
        _cts = new CancellationTokenSource();
        _backgroundTask = RunAsync(_cts.Token);
    }

    private async Task RunAsync(CancellationToken ct)
    {
        // Kurz warten, damit die App vollständig startet
        await Task.Delay(3000, ct).ConfigureAwait(false);

        while (!ct.IsCancellationRequested)
        {
            try
            {
                await DoWorkAsync(ct);
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                _logger.Warning($"Fehler: {ex.Message}");
            }

            await Task.Delay(Interval, ct).ConfigureAwait(false);
        }
    }
}

Fünf Details sind hier entscheidend. Erstens: Start() speichert den zurückgegebenen Task in einem Feld, aber wartet nicht darauf — die Methode ist synchron und kehrt sofort zurück. Zweitens: Ein CancellationToken ermöglicht sauberes Herunterfahren. Statt den Thread brutal abzubrechen, signalisiert die App „bitte aufhören“, und die Schleife reagiert darauf beim nächsten Durchlauf. Drittens: Das try-catch umschließt jede einzelne Iteration — ein Fehler beim Cover-Laden stoppt nicht den gesamten Service, sondern nur diesen einen Durchlauf. Viertens: ConfigureAwait(false) teilt dem Runtime mit, dass der Code nach dem await nicht zurück auf den UI-Thread muss. Und fünftens: Das initiale Task.Delay(3000) gibt der App Zeit, Datenbank-Migrationen abzuschließen, bevor der Hintergrund-Service auf die Datenbank zugreift.

IServiceScopeFactory: Datenbankzugriff im Hintergrund

Hier steckt ein häufiges Problem in Desktop-Anwendungen mit Dependency Injection: Der Hintergrund-Service ist ein Singleton und lebt für die gesamte Laufzeit. Der DbContext ist dagegen als Scoped registriert — er soll kurzlebig sein, damit Änderungen in der Datenbank sichtbar werden und kein Memory-Leak durch Entity-Tracking entsteht. Ein Singleton kann keinen Scoped-Service direkt injizieren. Die Lösung: Der Service bekommt eine IServiceScopeFactory und erstellt pro Arbeitseinheit einen eigenen Scope.

private async Task DoWorkAsync(CancellationToken ct)
{
    using IServiceScope scope = _scopeFactory.CreateScope();
    IEpisodeDataService episodeService = scope.ServiceProvider
        .GetRequiredService<IEpisodeDataService>();

    // Scope wird am Ende des using-Blocks freigegeben
    // → DbContext wird korrekt disposed
}

Das using vor IServiceScope garantiert, dass der Scope — und damit der DbContext — am Ende des Blocks freigegeben wird. Würdest du den DbContext stattdessen direkt in den Singleton injizieren, hätte er dieselbe Lebensdauer wie die App. Er würde immer mehr Entitäten tracken, immer mehr Speicher verbrauchen, und irgendwann veraltete Daten liefern, weil er nichts von den Änderungen anderer Scopes mitbekommt.

Fire-and-Forget: Die kontrollierte Ausnahme

Manche Aufgaben starten beim App-Start, ohne dass jemand auf das Ergebnis wartet. In EchoPlay betrifft das zum Beispiel die Datenbankpflege — das Purge alter Soft-Delete-Einträge. Der Code sieht so aus:

// In App.xaml.cs beim Start
_ = Task.Run(async () =>
{
    try
    {
        await maintenance.PurgeAsync(dbPurgeDays);
    }
    catch (Exception ex)
    {
        _appLogger?.Warning($"DB-Purge fehlgeschlagen: {ex.Message}");
    }
});

Das _ = am Anfang ist bewusstes Verwerfen des Tasks — du wartest nicht auf das Ergebnis. Aber die eiserne Regel lautet: Jedes _ = Task.Run(...) braucht ein try-catch im Inneren. Ohne catch verschwindet die Exception stillschweigend — kein Log-Eintrag, kein Fehlerhinweis, und du suchst stundenlang nach einem Bug, der nur im Hintergrund auftritt.

Synchron vor der Anzeige: Die Faustregel

Nicht alles gehört in den Hintergrund. Cover-Daten, die sofort sichtbar sein müssen, werden vor der Anzeige geladen — synchron aus Sicht der UI. Der Trick ist die Reihenfolge: Zuerst werden lokale Cover in der Datenbank sichergestellt (ein schneller Dateisystem-Read), dann werden Cover von lokalen auf Online-Episoden kopiert (eine SQL-Operation im Millisekundenbereich), und erst danach werden die Episoden geladen und angezeigt. Die Cover sind zu diesem Zeitpunkt bereits da.

// In MediathekOnlineViewModel.SelectSeriesAsync:
// 1. Lokale Cover in DB sicherstellen (schnell, Dateisystem-Read)
await _backgroundCoverService.EnsureLocalCoversForSeriesAsync(card.Title);

// 2. Cover aus lokalen auf Online-Episoden kopieren (SQL, Millisekunden)
await coverCopy.CopyFromMatchingEpisodesAsync(card.Id);

// 3. Episoden laden und anzeigen – Cover sind jetzt schon da

Die Faustregel ist einfach: Schnelle Operationen wie Datenbank-Reads und SQL-Kopien laufen synchron vor der Anzeige. Langsame Operationen wie Netzwerk-Requests und Dateisystem-Scans laufen im Hintergrund. Und wo möglich zeigt die UI einen Platzhalter, bis die Hintergrund-Daten eintreffen — das nennt sich progressives Laden. Der Nutzer sieht sofort etwas und die Details erscheinen, sobald sie bereit sind.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu