Warum .NET Generic Host und BackgroundService in Desktop-Apps verwenden

· · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Windows-Entwicklung, Design

Lässt man ein Windows-Tool oder eine dauerhaft laufende App etwas wachsen, nimmt die Verarbeitung außerhalb der UI stetig zu. Periodisches Polling, Dateiüberwachung, Wiederverbindung, Warteschlangenverarbeitung, Initialisierung beim Start, Flush beim Beenden. Zunächst lässt sich das mit Form_Load, OnStartup oder Task.Run überbrücken, aber wächst es unverändert weiter, wird unklar, wer startet, wer stoppt und wer die Ausnahmen im Blick behält.

Das ist ein Punkt, an dem es sich lohnt, noch vor der Frage, wie async/await geschrieben wird, festzulegen, wer die Lebensdauer der Verarbeitung trägt. Genau hier zahlen sich .NETs Generic Host und BackgroundService aus.

Was die UI-Thread-Seite von async/await betrifft, knüpft dieser Artikel an WPF/WinForms async und der UI-Thread auf einen Blick und C# async/await Praxis-Entscheidungstabelle - Task.Run und ConfigureAwait an. Dieses Mal konzentrieren wir uns auf die noch weiter außen liegende Ebene: das Ordnen von „Start und Beendigung der gesamten App“.

In der Praxis bröckelt es meist an folgenden Stellen.

  • Task.Run sprießt an verschiedensten Stellen in Forms oder ViewModels
  • Die Stoppbedingungen dauerhaft laufender Schleifen verteilen sich als bool-Flags
  • Beim Beenden läuft noch Verarbeitung, sodass sich die App gelegentlich nicht vollständig schließt
  • Die Einstiegspunkte für Logging / Konfiguration / DI sind je nach Technik getrennt
  • Die Versuchung, mit Environment.Exit aufzuräumen, wodurch finally-Blöcke übersprungen werden

Dieser Artikel geht überwiegend von WPF-/WinForms-/dauerhaft laufenden Windows-Apps auf .NET 6 oder neuer aus und ordnet, warum Generic Host / BackgroundService unscheinbar, aber wirksam sind, wie weit es sich lohnt, sie einzubringen, und wo Nachlässigkeit sich später rächt.

Angesprochen sind weniger Leser, die bereits wissen, was BackgroundService ist, sondern eher solche, die noch nach dem richtigen Ort und der richtigen Lebensdauer für dauerhaft laufende Verarbeitung suchen. Wer den Namen zum ersten Mal sieht, kann im nächsten Kapitel mit den Begriffen einsteigen; wer ihn bereits nutzt, kann auch direkt bei der Entscheidungstabelle in 2.2 und der Aufteilung in Kapitel 6 beginnen.

Der in diesem Artikel gezeigte Code ist zudem als vollständiges, build- und lauffähiges Beispielpaket (Bibliothek, eine Konsolen-Demo, die den Ablauf vom Start bis zum graceful shutdown vorführt, sowie Unittests) auf GitHub veröffentlicht.

generic-host-backgroundservice-desktop-app - komurasoft-blog-samples (GitHub)

Zuerst die Begriffe klären

Bei einem solchen Thema wird der Text schnell schwer lesbar, wenn die Bedeutung der Begriffe vage bleibt. Deshalb werden die in diesem Artikel verwendeten Begriffe zunächst grob festgelegt.

  • Generic Host
    • Die Grundlage, die „Start“, „Abhängigkeiten“, „Konfiguration“, „Logging“ und „Beendigung“ einer .NET-App gemeinsam verwaltet.
    • Kein reiner ASP.NET-Core-Mechanismus – nutzbar auch in Konsolenanwendungen, Workern und Desktop-Apps.
  • Host / IHost
    • Das konkrete Objekt nach dem Build.
    • Gestartet wird es mit StartAsync, gestoppt mit StopAsync.
  • Hosted Service
    • Dauerhaft laufende Verarbeitung, die an die Lebensdauer des Hosts gekoppelt gestartet und gestoppt wird.
    • Entweder wird IHostedService implementiert, oder – üblicherweise – von BackgroundService geerbt.
  • BackgroundService
    • Eine leicht zu implementierende Hilfe für IHostedService.
    • Der langlebige Hauptteil lässt sich in ExecuteAsync schreiben, wodurch sich Überwachungsschleifen und periodische Verarbeitung leichter ordnen lassen.
  • lifetime
    • In diesem Artikel im Sinn von „wann beginnt die Verarbeitung, wann endet sie, und wer trägt die Verantwortung für das Stoppen“.
    • Nicht nur reine Lebensdauer, sondern Lebensdauerverwaltung einschließlich Start- und Stoppverantwortung.
  • graceful shutdown
    • Kein erzwungenes Beenden, sondern das Setzen eines Stoppsignals, um laufende Verarbeitung möglichst geordnet abzuschließen, bevor die App endet.
    • Dazu gehören etwa „den nächsten Zyklus nicht starten“, „festlegen, wie weit die Warteschlange noch abgearbeitet wird“ oder „auf close bzw. flush warten“.
  • DI
    • Kurz für Dependency Injection: Abhängige Objekte werden über einen Container bezogen, statt sie an der Aufrufstelle fest zu verdrahten.
    • Für diesen Artikel genügt das Verständnis „Logger, Konfiguration und Reader nicht überall einzeln per new erzeugen, sondern am Einstiegspunkt gebündelt konfigurieren“.

Dieser Text ist mehr als „eine Vorstellung der praktischen Klasse BackgroundService“ – er lässt sich leichter verfolgen, wenn man ihn als die Geschichte davon liest, Start und Beendigung der gesamten App im Host zu bündeln und die lifetime dauerhaft laufender Verarbeitung als Design zu besitzen.

Inhaltsverzeichnis

  1. Zuerst das Fazit (in einem Satz)
  2. Zuerst der Überblick auf einen Blick
    • 2.1. Das Gesamtbild
    • 2.2. Die Entscheidungstabelle für die Platzierung
  3. Warum es in Desktop-Apps wirkt
    • 3.1. UI- und Verarbeitungsverantwortung lassen sich leicht trennen
    • 3.2. Start, Beendigung und Ausnahmen an einem Einstiegspunkt bündeln
    • 3.3. graceful shutdown lässt sich leicht in das Design einbringen
    • 3.4. DI / Logging / Konfiguration stehen von Anfang an bereit
  4. Geeignete Fälle
  5. Ein minimales Konfigurationsbeispiel (WPF)
  6. Wie StartAsync / ExecuteAsync / StopAsync aufzuteilen sind
    • 6.1. StartAsync
    • 6.2. ExecuteAsync
    • 6.3. StopAsync
    • 6.4. Hinweis für .NET 10 und neuer
  7. Häufige Anti-Pattern
  8. Checkliste für die Code-Review
  9. Grobe Entscheidungshilfe
  10. Zusammenfassung
  11. Quellen

1. Zuerst das Fazit (in einem Satz)

  • Der Generic Host ist auch in Desktop-Apps eine sehr überzeugende Grundlage für Start und Lifetime-Verwaltung.
  • BackgroundService ist ein Gefäß, um „langlebige Verarbeitung“ nicht als fire-and-forget Task.Run, sondern mit einer verwalteten Lebensdauer zu betreiben.
  • Am meisten zahlt sich in der Praxis aus, dass sich Startverantwortung / Stoppverantwortung / Ausnahmeüberwachung / Logging / DI / Konfiguration in einem einzigen Design bündeln lassen.
  • Die Lesbarkeit steigt deutlich, wenn StartAsync kurz bleibt, der langlebige Hauptteil in ExecuteAsync liegt und das Aufräumen beim Beenden in StopAsync erfolgt.
  • Besonders gut passt das zu dauerhaft laufenden Apps, Tray-Apps, Geräteüberwachung, periodischer Synchronisierung, geordneter Nachbearbeitung und Wiederverbindungsschleifen.
  • Umgekehrt wirkt es etwas überdimensioniert, wenn auch Verarbeitung, die nur einmal bei einem Tastendruck läuft, komplett in BackgroundService gepackt wird.
  • StopAsync ist praktisch, aber keine Versicherung gegen Prozessabstürze oder erzwungene Beendigung. Wichtig ist auch, nicht zu viel Aufräumarbeit dorthin zu verlagern.

Kurz gesagt zahlen sich Generic Host / BackgroundService in Desktop-Apps weniger deshalb aus, „weil es Hintergrundverarbeitung gibt“, sondern deshalb, „weil man die Lebensdauer dieser Hintergrundverarbeitung als Design besitzen möchte, statt sie als Nebeneffekt der UI mitlaufen zu lassen“.

2. Zuerst der Überblick auf einen Blick

2.1. Das Gesamtbild

Ein Blick auf dieses Diagramm macht den Einstieg deutlich schneller.

Desktop-App startet(WPF / WinForms)Host Build / StartAsyncDI / Logging / Configuration vorbereitenHostedService.StartAsyncBackgroundService.ExecuteAsyncPeriodicTimer / Queue / Wiederverbindung / ÜberwachungsschleifeMainWindow / MainForm anzeigenStatusaktualisierung / Logging / externe I/OUI nutzt Dispatcher / Invoke nur wo nötigNutzerbeendigung / Fatal error / StopApplicationIHost.StopAsyncCancellationToken-BenachrichtigungHostedService.StopAsyncVerbindung close / flush / graceful shutdown

Für Umgebungen, in denen das Diagramm nicht angezeigt wird, folgt derselbe Ablauf auch als Text.

  1. App-Start (bei WPF App.OnStartup, bei WinForms Main)
  2. Dienste mit Host.CreateApplicationBuilder registrieren und Build aufrufen
  3. Den Host mit IHost.StartAsync starten (hier werden DI, Logging und Konfiguration festgelegt)
  4. Das registrierte HostedService.StartAsync wird aufgerufen
  5. BackgroundService.ExecuteAsync läuft an (der Hauptteil mit Überwachungsschleife, PeriodicTimer, Warteschlangenverarbeitung usw.)
  6. Die UI (MainWindow / MainForm) wird angezeigt. Der Worker aktualisiert den Statusspeicher bzw. das Logging, und die UI liest das in ihrem eigenen Kontext
  7. Bei einer Beendigung durch den Nutzer oder einem schwerwiegenden Fehler wird IHostApplicationLifetime.StopApplication aufgerufen, was zu IHost.StopAsync führt
  8. Das Stoppsignal wird als CancellationToken (stoppingToken) übermittelt, und die Schleife in ExecuteAsync wird verlassen
  9. In HostedService.StopAsync werden Verbindungen geschlossen und Logs geflusht, bevor die App endet

Bei UI-Apps ist es üblich, dass sich die Verantwortung nach und nach über Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / statische Singletons verteilt.

Mit dem Host lässt sich grob folgende Aufteilung erreichen.

  • UI: Oberfläche, Eingabe, Anzeige
  • HostedService / BackgroundService: Dauerhaft laufende Verarbeitung, Überwachung, Warteschlangenverarbeitung, periodische Verarbeitung
  • DI-Dienste: Die eigentliche Geschäftslogik, externe Verbindungen, Konfiguration, Logging

Allein diese Aufteilung verändert die Übersichtlichkeit bei Reviews erheblich.

2.2. Die Entscheidungstabelle für die Platzierung

Ziel Erste Wahl für die Platzierung Begründung
Leichte Initialisierung direkt nach dem Start StartAsync Klare Bedeutung als kurzer, am Start beteiligter Vorgang
Langlebige Überwachung / Polling / Wiederverbindung ExecuteAsync Läuft leicht zusammen mit der Dienstlebensdauer
Stoppbenachrichtigung / flush / close beim Beenden StopAsync graceful shutdown lässt sich leicht zusammen mit CancellationToken schreiben
Konfiguration von Abhängigkeiten, Einstellungen, Logging Host.CreateApplicationBuilder Bündelt den Einstiegspunkt an einer Stelle
Bildschirmaktualisierung UI-Seite Weniger Pannen, wenn der Worker die UI nicht direkt anfasst
Einmalige Verarbeitung pro Tastendruck Gewöhnliche async-Methode Ein HostedService ist hierfür meist nicht nötig
Geordnete Hintergrund-Nachbearbeitung Channel<T> + BackgroundService Lebensdauer und Obergrenze lassen sich leichter verwalten als bei fire-and-forget

Der Wert des Hosts liegt weniger darin, etwas „asynchron machen zu können“, als vielmehr darin, dass die Entscheidung, wo etwas hingehört, klar wird.

3. Warum es in Desktop-Apps wirkt

3.1. UI- und Verarbeitungsverantwortung lassen sich leicht trennen

Bei einer Desktop-App wirkt die UI wie der Hauptdarsteller, doch in der Praxis liegt das Gewicht meist außerhalb der UI.

Zum Beispiel:

  • Statusabgleich alle 10 Sekunden
  • Wiederverbindung zu Geräten oder Servern
  • Dateiüberwachung und -einlesen
  • In eine Warteschlange gestellte Nachbearbeitung
  • Log-Weiterleitung und Metrikversand
  • Cache-Warm-up beim Start

Das sind keine „Bildschirmereignisse“, sondern Verarbeitung, die an der Lebensdauer der gesamten App hängt.

Lässt man das im Code-Behind von Forms oder Fenstern wohnen, beginnen die Verantwortung, beim Schließen des Fensters zu stoppen, die Verantwortung, Ausnahmen abzufangen, und die Verantwortung, über Wiederholungen und Backoff zu entscheiden, sich mit UI-Belangen zu vermischen.

Mit BackgroundService nimmt die Aussage „diese Verarbeitung lebt, solange die App läuft“ die Form von Code an. Das ist unscheinbar, aber wirkungsvoll.

3.2. Start, Beendigung und Ausnahmen an einem Einstiegspunkt bündeln

Auch ohne Host lässt sich in einer Desktop-App Ähnliches erreichen, indem ServiceCollection, ConfigurationBuilder und LoggerFactory einzeln aufgereiht werden.

Diese Form tendiert jedoch dazu, allmählich auseinanderzudriften.

  • DI in Program.cs
  • Konfiguration in einer eigenen statischen Klasse
  • Logging in einer separaten Factory
  • Beendigungslogik in ApplicationExit
  • Dauerhaft laufende Verarbeitung in Task.Run

In diesem Zustand funktioniert zunächst alles. Sieht man sich das jedoch Monate später wieder an, wird schwer erkennbar, wer die Lebensdauer der App trägt.

Mit dem Generic Host kommen zusammen:

  • Dienstregistrierung
  • Laden der Konfiguration
  • Logging-Konfiguration
  • Start des Hosted Service
  • Stoppbenachrichtigung
  • Gesamtbeendigung über IHostApplicationLifetime

in denselben Rahmen.

Das heißt, der Einstiegspunkt für „wie startet diese App, und wie stoppt sie“ lässt sich leicht an einer Stelle bündeln. Bei dauerhaft laufenden Apps zahlt sich genau das später aus.

3.3. graceful shutdown lässt sich leicht in das Design einbringen

Dauerhaft laufende Verarbeitung ist schwerer zu stoppen als zu starten. Der Start lässt sich in 3 Zeilen schreiben, doch beim Beenden nimmt die Zahl der Dinge, die zu bedenken sind, schlagartig zu.

Zum Beispiel beim Beenden:

  • Laufende I/O soll abgebrochen werden
  • Der nächste Zyklus soll nicht mehr starten
  • Es muss festgelegt werden, wie weit verbleibende Warteschlangeneinträge noch abgearbeitet werden
  • Sockets oder COM-Objekte sollen geschlossen werden
  • Es soll auf Log-Flush und Zustandsspeicherung gewartet werden

Verlagert man das auf FormClosing, vermischt sich das mit Bildschirmbelangen und wird mühsam.

Mit Host / BackgroundService gibt es CancellationToken und StopAsync, sodass „der Weg zum Stoppen“ von Anfang an existiert.

Das ist natürlich keine Magie. Bei einem Absturz oder einem Kill wird StopAsync unter Umständen nicht aufgerufen. Trotzdem sorgt allein das Design „bei einer normalen Beendigung wird über diesen Weg gestoppt“ für deutlich mehr Ruhe.

3.4. DI / Logging / Konfiguration stehen von Anfang an bereit

Der Vorteil des Generic Host beschränkt sich nicht auf BackgroundService.

  • Mit Host.CreateApplicationBuilder steht die Grundlage für DI / Konfiguration / Logging bereit
  • appsettings.json und Umgebungsvariablen lassen sich unmittelbar nutzen
  • ILogger<T> lässt sich in UI und Worker im gleichen Stil verwenden
  • Bei Bedarf lässt sich Konfiguration über die IOptions<T>-Familie bündeln

Besonders bei Windows-Tool-Projekten kommt es häufig vor, dass „Konfiguration und Logger, die anfangs nachlässig in einer statischen Klasse gehalten wurden, weil die App klein war, später zur Belastung werden“.

Wird das von Anfang an auf den Host gelegt, kommt die App weniger schnell aus der Puste, wenn sie etwas zulegt.

4. Geeignete Fälle

Generic Host / BackgroundService zahlen sich besonders in folgenden Fällen aus.

  • Tray-residente Apps mit periodischer Synchronisierung, Überwachung, Benachrichtigungen, Wiederverbindung
  • Apps mit Geräte-/Kamera-/Socket-Verbindungen mit Verbindungserhalt, Überwachung, Wiederholungsversuchen, Statusabfrage
  • Tools zur Dateiintegration mit Überwachung, Einlese-Warteschlange, geordneter Verarbeitung
  • Vorbeugung gegen das Aufblähen interner Tools anfangs klein, aber Konfiguration, Logging und externe I/O werden voraussichtlich zunehmen
  • Apps, bei denen die Qualität der Beendigung wichtig ist bei denen beim Schließen kein halbfertiger Zustand zurückbleiben soll

Umgekehrt gibt es auch Fälle, in denen der Host nicht sofort eingeführt werden muss.

  • Ein kleines Tool, das einmalig startet, einmal verarbeitet und endet
  • Ein Bildschirm ohne nennenswerte Hintergrundverarbeitung, der allein mit UI-Ereignissen auskommt
  • Ein wirklich kleines internes Hilfstool, dessen Abhängigkeiten und Konfiguration kaum wachsen

Der Host ist nicht „zwingend erforderlich“. Sobald jedoch zwei oder mehr dauerhaft laufende Verarbeitungen sichtbar werden, lohnt sich eine positive Prüfung durchaus. Das kommt deutlich günstiger, als später verstreute Task.Run-Aufrufe aufzuräumen.

5. Ein minimales Konfigurationsbeispiel (WPF)

Als Beispiel folgt eine minimale Konfiguration, die in WPF einen Host startet und einen BackgroundService betreibt, der alle 5 Sekunden einen externen Status ausliest. Auch in WinForms ändert sich lediglich der Einstiegspunkt zu Main / ApplicationContext, der Denkansatz bleibt nahezu identisch.

Da der Code in drei Teile aufgeteilt vorgestellt wird, folgt zunächst die Dateistruktur.

Datei Inhalt Abschnitt
App.xaml.cs Erzeugung des Hosts, DI-Registrierung, StartAsync / StopAsync, Anzeige von MainWindow 5.1
DevicePollingBackgroundService.cs Dauerhaft laufende Schleife, die alle 5 Sekunden den Status ausliest 5.2
StatusStore.cs Zustand, den Worker und UI gemeinsam nutzen. Der Record DeviceStatus liegt ebenfalls hier 5.3
IDeviceStatusReader.cs / DeviceStatusReader.cs Die tatsächliche Verarbeitung, die den Status extern ausliest Im Text ausgelassen. Die Implementierung befindet sich im GitHub-Beispiel
MainWindow.xaml / MainWindow.xaml.cs Bildschirm. Liest StatusStore und zeigt ihn an Im Text ausgelassen. Gewöhnlicher WPF-Bildschirmcode

Das GitHub-Beispiel bringt diese Struktur in eine über die Konsole lauffähige Form und enthält neben den Implementierungen von BackgroundService und StatusStore auch eine Demo, die den Ablauf vom Start bis zum graceful shutdown durchspielt, sowie Unittests.

5.1. App.xaml.cs

using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace DesktopHostSample;

public partial class App : Application
{
    private IHost? _host;

    protected override async void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        HostApplicationBuilder builder = Host.CreateApplicationBuilder(e.Args);

        builder.Services.Configure<HostOptions>(options =>
        {
            options.ShutdownTimeout = TimeSpan.FromSeconds(15);
        });

        builder.Services.AddSingleton<MainWindow>();
        builder.Services.AddSingleton<StatusStore>();
        builder.Services.AddScoped<IDeviceStatusReader, DeviceStatusReader>();
        builder.Services.AddHostedService<DevicePollingBackgroundService>();

        _host = builder.Build();

        await _host.StartAsync();

        MainWindow mainWindow = _host.Services.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }

    protected override async void OnExit(ExitEventArgs e)
    {
        if (_host is not null)
        {
            await _host.StopAsync();
            _host.Dispose();
        }

        base.OnExit(e);
    }
}

Diese Form hat drei entscheidende Punkte.

  1. Den Host vor der Anzeige der UI starten
  2. Beim Beenden StopAsync explizit awaiten
  3. DI / Hosted Service / Shutdown-Timeout am Einstiegspunkt bündeln

ShutdownTimeout ist die Standard-Obergrenze, bis zu der IHost.StopAsync auf die Beendigungslogik wartet. Der Standardwert unterscheidet sich je nach Version: 5 Sekunden bei .NET 6, 30 Sekunden ab .NET 7. Hier wird 15 Sekunden eingetragen, um die Obergrenze selbst passend zur langsamsten Beendigungslogik festzulegen. Als Richtwert dient „Timeout der laufenden I/O plus die für close/flush benötigte Zeit“, zuzüglich eines kleinen Puffers. Ist der Wert zu kurz, wird mitten im Flush abgebrochen; ist er zu lang, wirkt die App wie eine, die „nicht schließt“ – wer nicht einfach den Standard laufen lässt, sondern den Wert einmal bewusst festlegt, reduziert Pannen.

OnExit als async zu deklarieren erfordert aus UI-Framework-Gründen etwas Sorgfalt, doch der Ablauf „beim Beenden den Host stoppen“ explizit auszuschreiben, lohnt sich sehr.

5.2. BackgroundService

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace DesktopHostSample;

public sealed class DevicePollingBackgroundService(
    IServiceScopeFactory scopeFactory,
    StatusStore statusStore,
    ILogger<DevicePollingBackgroundService> logger) : BackgroundService
{
    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Device polling service is starting.");
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Device polling loop started.");

        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                using IServiceScope scope = scopeFactory.CreateScope();
                IDeviceStatusReader reader =
                    scope.ServiceProvider.GetRequiredService<IDeviceStatusReader>();

                DeviceStatus status = await reader.ReadAsync(stoppingToken);
                statusStore.Update(status);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Device polling failed.");
            }
        }

        logger.LogInformation("Device polling loop finished.");
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        logger.LogInformation("Device polling service is stopping.");
        await base.StopAsync(cancellationToken);
        logger.LogInformation("Device polling service stopped.");
    }
}

Wichtig ist hier, ExecuteAsync schlicht als „verwaltete while-Schleife“ zu schreiben.

  • Der Takt über PeriodicTimer
  • Das Stoppen über stoppingToken
  • Ausnahmen werden protokolliert
  • Werden scoped Abhängigkeiten benötigt, wird jedes Mal ein Scope eröffnet

In dieser Form wird „wo beginnt diese dauerhaft laufende Verarbeitung, wo stoppt sie, und wo werden Fehlschläge sichtbar“ deutlich leichter lesbar.

5.3. Zustandsfreigabe nicht direkt an die UI koppeln

Greift der Worker direkt auf UI-Objekte zu, tritt das UI-Thread-Problem letztlich genau dort erneut auf.

Deshalb zunächst:

  • Der Worker aktualisiert einen Statusspeicher oder eine Messaging-Schicht
  • Die UI liest bzw. übernimmt diesen Zustand im eigenen Kontext

Diese Trennung ist sicherer.

StatusStore lässt sich beispielsweise als eine solche schlanke gemeinsame Schicht gestalten.

namespace DesktopHostSample;

public sealed class StatusStore
{
    private readonly object _gate = new();
    private DeviceStatus _current = DeviceStatus.Empty;

    public DeviceStatus Current
    {
        get
        {
            lock (_gate)
            {
                return _current;
            }
        }
    }

    public void Update(DeviceStatus next)
    {
        lock (_gate)
        {
            _current = next;
        }
    }
}

public sealed record DeviceStatus(string Message)
{
    public static readonly DeviceStatus Empty = new("No Data");
}

Wird eine sofortige Benachrichtigung der UI benötigt, kommen Dispatcher / BeginInvoke / Ereignisse / ein Messenger infrage. Diese Verantwortung vermischt sich jedoch weniger, wenn sie an der UI-Grenze gehalten wird.

6. Wie StartAsync / ExecuteAsync / StopAsync aufzuteilen sind

Vermischen sich diese drei, trübt sich der Kopf des Lesers schnell. Als Ausgangspunkt bewährt sich folgende Aufteilung.

6.1. StartAsync

StartAsync ist der Ort für kurze, am Start beteiligte Verarbeitung.

Geeignet:

  • Start-Logging
  • Leichter Beginn von Subscriptions
  • Vorbereitung eines schnell abgeschlossenen Anfangszustands
  • Minimale Ordnung vor und nach base.StartAsync

Ungeeignet:

  • Ein Warm-up, das Dutzende Sekunden dauert
  • Endlosschleifen
  • Ein Hauptteil, der schwere I/O aneinanderreiht

Wird StartAsync schwer, wirkt der Start der gesamten App träge. Wer diesen Ort eher als Platz für das „Startsignal“ betrachtet, hat weniger Pannen.

6.2. ExecuteAsync

ExecuteAsync ist der Hauptteil der Dienstlebensdauer.

Geeignet:

  • Polling
  • Überwachungsschleifen
  • Wiederverbindungsschleifen
  • Konsumenten, die einen Channel<T> lesen
  • Periodische Verarbeitung
  • Verarbeitung im Allgemeinen, die „bis zum Stopp lebt“

Drei Kniffe sind hier wichtig.

  1. Das CancellationToken von Anfang bis Ende durchreichen
  2. Verhindern, dass die gesamte Schleife durch eine Ausnahme stillschweigend stirbt
  3. Wiederholungen und Backoff nicht ad hoc immer weiter anhäufen

BackgroundService ist praktisch, kann sich aber unbeaufsichtigt auch zu einer „riesigen Schleife, die alles aufsaugt“ entwickeln. Es liest sich besser, wenn die eigentliche Verarbeitung in separate Dienste ausgelagert wird und ExecuteAsync selbst auf Lebensdauerverwaltung und Orchestrierung ausgerichtet bleibt.

6.3. StopAsync

StopAsync ist der Ort für das Aufräumen bei normaler Beendigung.

Geeignet:

  • Stopp-Logging
  • Auflösen von Timern / Subscriptions / Überwachung
  • Aufräumen von Ressourcen, die explizit geschlossen oder geflusht werden sollen
  • Warten auf den Abschluss über base.StopAsync

Wichtig ist jedoch auch, nicht zu viel von StopAsync zu erwarten.

  • Der Prozess ist abgestürzt
  • Er wurde zwangsweise beendet
  • Er wurde vom Betriebssystem gekillt

Bei solchen Beendigungsarten wird StopAsync unter Umständen gar nicht erst durchlaufen.

Deshalb gilt:

  • Persistierung möglichst im Normalbetrieb in kleinen Schritten erledigen
  • Kein Design wählen, bei dem Konsistenz nur beim Beenden hergestellt wird
  • Das Cleanup idempotent gestalten

Das sind die wichtigen Punkte. Wer versucht, allein beim Beenden die Welt zu retten, landet meist in Unklarheit.

6.4. Hinweis für .NET 10 und neuer

Als Breaking Change in .NET 10 (Release November 2025) hat sich das Verhalten geändert: BackgroundService.ExecuteAsync wird nun vollständig als Hintergrund-Task ausgeführt.

Zuvor gab es das etwas verwirrende Verhalten, dass der synchrone Teil vor dem ersten await beim Start den Beginn anderer Dienste blockierte. Mit dieser Änderung wird die Panne, dass „die ersten Zeilen von ExecuteAsync den Start belasteten“, seltener. Umgekehrt gilt: Zielt das Projekt auf .NET 9 oder früher, gilt weiterhin das alte Verhalten. Prüfen Sie zuerst, welches auf Ihr eigenes Projekt zutrifft.

Trotzdem bleibt aus Design-Sicht die Aufteilung

  • Kurze, am Start beteiligte Verarbeitung → StartAsync
  • Der langlebige Hauptteil → ExecuteAsync

die besser lesbare Wahl.

Wer den Startzeitpunkt strenger kontrollieren möchte, sollte auch IHostedLifecycleService in Betracht ziehen. Das ist einer der unscheinbaren Punkte, die sich auszahlen, sobald eine dauerhaft laufende App an Umfang zunimmt.

7. Häufige Anti-Pattern

7.1. Eine Endlosschleife in Window_Loaded / Form_Shown starten

Anfangs ist das bequem. Doch die Stopp- und Ausnahmeverantwortung bleiben fest an der UI-Seite kleben.

Sobald Bedingungen wie „beim Schließen des Fensters stoppen“, „beim Minimieren in den Tray nicht stoppen“, „bei Änderung der Einstellungen neu starten“ zunehmen, wird es schnell mühsam.

7.2. Task.Run fire-and-forget betreiben

Task.Run an sich ist nicht das Problem. Das Problem ist, dass niemand die Lebensdauer und die Ausnahmen besitzt.

Startet man dauerhaft laufende Verarbeitung insbesondere mit Task.Run(async () => { while (...) { ... } }), wird Folgendes vage:

  • Wann endet sie?
  • Wer wartet auf sie?
  • Wie werden Ausnahmen sichtbar?
  • Wie lange wird beim Beenden gewartet?

Allein die Übertragung auf BackgroundService macht das deutlich übersichtlicher.

7.3. Direkter UI-Zugriff aus BackgroundService

Das ist eine Mine. UI-Thread-Probleme und Lifetime-Probleme vermischen sich dabei auf einen Schlag.

Statt dass der Worker die UI direkt anfasst, ist es sicherer, eine Grenze über eines von

  • Zustand
  • Ereignisse
  • Nachrichten
  • eine Warteschlange

zu ziehen.

7.4. Wichtige Speicherlogik ausschließlich auf StopAsync verlagern

StopAsync hilft bei normaler Beendigung, ist aber nicht das Jüngste Gericht.

Ein Design, das nur beim Beenden speichert, nur beim Beenden flusht, nur beim Beenden Konsistenz herstellt, bricht bei einem Absturz zusammen.

7.5. Trotz Host-Nutzung nachlässig mit Environment.Exit beenden

Auch das kommt häufig vor.

Wird Environment.Exit nach dem Motto „das ist jetzt zu umständlich, beenden wir es einfach“ aufgerufen, durchtrennt man damit selbst den graceful-shutdown-Weg, den der Host bereithält.

Soll bei einem schwerwiegenden Fehler die gesamte App beendet werden, ist es naheliegender, zunächst IHostApplicationLifetime.StopApplication() zu verwenden und den regulären Weg zum Stoppen zu nehmen.

8. Checkliste für die Code-Review

Bei der Review einer Desktop-App, die Generic Host / BackgroundService verwendet, hilft es, folgende Punkte der Reihe nach zu prüfen.

  • Handelt es sich um Verarbeitung, die an der App-Lebensdauer hängt, oder lediglich um UI-Ereignisverarbeitung?
  • Ist die Startverantwortung angemessen auf StartAsync / ExecuteAsync / StopAsync aufgeteilt?
  • Ist StartAsync nicht zu schwer geworden?
  • Reicht ExecuteAsync das CancellationToken bis zum Schluss durch?
  • Hält der Hosted Service scoped Abhängigkeiten nicht direkt selbst?
  • Fasst der Worker keine UI-Objekte direkt an?
  • Werden Ausnahmen nicht stillschweigend verschluckt?
  • Ist die Wiederholungsschleife nicht unbegrenzt und hochfrequent?
  • Gibt es eine Obergrenze für die Wartezeit beim Beenden?
  • Mischt sich keine Beendigung ein, die auf Environment.Exit oder Prozess-Kill setzt?

Mit dieser Checkliste wird der Unterschied zwischen „wir haben einfach mal den Host eingebaut“ und „wir haben die Lebensdauer als Design geordnet“ deutlich sichtbar.

9. Grobe Entscheidungshilfe

Ziel Erste Wahl
DI / Logging / Konfiguration der gesamten App bündeln Host.CreateApplicationBuilder
Eine dauerhaft laufende Schleife betreiben BackgroundService
In festen Intervallen laufen lassen PeriodicTimer + BackgroundService
Geordnete Nachbearbeitung abarbeiten Channel<T> + BackgroundService
Einen scoped Service verwenden IServiceScopeFactory.CreateScope()
Die gesamte App über eine normale Beendigung benachrichtigen IHostApplicationLifetime.StopApplication()
UI aktualisieren Dispatcher / Invoke auf UI-Seite
Einmalige Bildschirmoperation Gewöhnliche async-Methode
Strenge Lifecycle-Kontrolle beim Start IHostedLifecycleService in Betracht ziehen

10. Zusammenfassung

Der Grund, Generic Host / BackgroundService in eine Desktop-App zu bringen, ist nicht „weil man wie im Web schreiben möchte“.

Was wirklich zählt, sind diese drei Punkte.

  1. Start- und Stoppverantwortung lassen sich an einer Stelle bündeln
  2. Die Lebensdauer langlebiger Verarbeitung lässt sich als Design besitzen
  3. graceful shutdown lässt sich vom Einstiegspunkt an behandeln, nicht nachträglich angeflanscht

Windows-Tools und dauerhaft laufende Apps starten oft klein, doch Überwachung, Synchronisierung, Wiederverbindung, Warteschlangen, Logging und Konfiguration nehmen nach und nach zu. Wird das dann als Nebeneffekt des UI-Codes betrieben, wird es später still, aber sicher mühsam.

Umgekehrt genügt schon die Trennung in

  • UI als UI
  • Dauerhaft laufende Verarbeitung als Hosted Service
  • Die eigentliche Verarbeitung als DI-Dienst
  • Beendigung über StopAsync und CancellationToken

um vieles deutlich zu ordnen.

Spektakulär ist das nicht. Aber ein solches unscheinbares Design zahlt sich in der Praxis solide aus. Es reduziert die unangenehme Zähigkeit von „beim Schließen wird es manchmal seltsam“ und „niemand weiß, wo eigentlich gestoppt wird“.

Wenn Sie bei einem Windows-Tool oder einer dauerhaft laufenden App bei der Umstellung auf BackgroundService, beim Design von Start und Beendigung, bei Überwachungsschleifen, bei der Ordnung der Lebensdauer von COM-, Socket- oder Dateiüberwachung oder bei der Eingrenzung von Fehlern beim Beenden nicht weiterkommen, sprechen Sie uns gerne an – ausgehend von einem Design-Review oder einer Klärung der Ausrichtung.

11. Quellen

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Windows-App-Entwicklung

Ein Thema, das der Entwicklung von Desktop-Anwendungen selbst sehr nahekommt – von Hintergrundverarbeitung über periodische Verarbeitung und Wiederverbindung bis hin zur Beendigungslogik.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Lässt sich der Generic Host auch in Desktop-Apps nutzen?
Ja. Der Generic Host ist kein reiner ASP.NET-Core-Mechanismus, sondern lässt sich auch in Konsolenanwendungen, Workern sowie WPF-/WinForms-Desktop-Apps als Grundlage nutzen, die Start, Abhängigkeiten, Konfiguration, Logging und Beendigung gemeinsam verwaltet. Besonders gut passt er zu dauerhaft laufenden Apps, Tray-Apps, Geräteüberwachung, periodischer Synchronisierung, geordneter Nachbearbeitung und Wiederverbindungsschleifen.
Wofür wird BackgroundService verwendet?
Als Gefäß, um „langlebige Verarbeitung“ nicht als fire-and-forget Task.Run, sondern mit einer verwalteten Lebensdauer zu betreiben. BackgroundService ist eine leicht zu implementierende Hilfe für IHostedService, mit der sich der Hauptteil von Überwachungsschleifen oder periodischer Verarbeitung in ExecuteAsync schreiben lässt. Die Aussage „diese Verarbeitung lebt, solange die App läuft“ nimmt dadurch die Form von Code an, und Startverantwortung, Stoppverantwortung, Ausnahmeüberwachung, Logging, DI und Konfiguration lassen sich in einem einzigen Design bündeln.
Wie sollten StartAsync, ExecuteAsync und StopAsync aufgeteilt werden?
Die Lesbarkeit steigt, wenn StartAsync für kurze, am Start beteiligte Initialisierung genutzt wird, der langlebige Hauptteil in ExecuteAsync liegt und die Stoppbenachrichtigung sowie Flush/Close beim Beenden in StopAsync erfolgen. Verarbeitung, die nur einmal bei einem Tastendruck läuft, sollte dagegen eine gewöhnliche async-Methode bleiben – alles in BackgroundService zu packen, wirkt schnell überdimensioniert.
Darf die gesamte Beendigungslogik in StopAsync gebündelt werden?
Besser nicht. StopAsync ist praktisch, aber keine Versicherung gegen einen Prozessabsturz oder eine erzwungene Beendigung – deshalb ist wichtig, nicht zu viel Aufräumarbeit dorthin zu verlagern. Graceful shutdown (Abbruch laufender I/O, Anhalten des nächsten Zyklus, Umgang mit verbleibenden Warteschlangeneinträgen, Schließen von Verbindungen, Log-Flush) sollte über CancellationToken und StopAsync gestaltet werden, während gleichzeitig eine separate Grundannahme bestehen muss, dass auch ein anormales Ende nichts zerstört.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog