Pourquoi utiliser le Generic Host .NET et BackgroundService dans une application de bureau

· Mis à jour le: · · C#, .NET, Generic Host, BackgroundService, WPF, WinForms, Développement Windows, Conception

Dès qu’un outil Windows ou une application résidente grandit un peu, le traitement situé en dehors de l’UI se multiplie progressivement. Scrutation périodique (polling), surveillance de fichiers, reconnexion, traitement de file d’attente, initialisation au démarrage, flush à la fermeture. Au début, on s’en sort avec Form_Load, OnStartup ou Task.Run, mais si cela continue de croître ainsi, on ne sait plus bien qui démarre quoi, qui l’arrête, ni qui surveille les exceptions.

C’est une situation où, avant même de se demander comment écrire async / await, il vaut mieux décider qui porte la responsabilité de la durée de vie du traitement. C’est là qu’interviennent utilement le Generic Host de .NET et BackgroundService.

Pour ce qui est de async / await côté thread UI, voir WPF / WinForms : async/await et le thread UI en une page - où revenir après un await, Dispatcher, ConfigureAwait, et où se bloquent .Result / .Wait() et Bonnes pratiques async/await en C# - Un tableau de décision pour Task.Run et ConfigureAwait. Cet article se concentre sur ce qui se trouve un cran plus loin : l’organisation du « démarrage et de l’arrêt de l’application dans son ensemble ».

Ce qui pourrit discrètement en pratique se situe à peu près là.

  • Des Task.Run qui poussent un peu partout dans les formulaires et les ViewModels
  • Des conditions d’arrêt de boucles résidentes dispersées sous forme de drapeaux bool
  • Un traitement encore en cours à la fermeture, si bien que l’application ne se referme pas toujours complètement
  • Des points d’entrée séparés pour la journalisation / la configuration / la DI, un par technologie
  • La tentation de tout nettoyer avec Environment.Exit, en sautant les blocs finally

Cet article s’appuie principalement sur des applications Windows WPF / WinForms / résidentes en .NET 6 ou supérieur, et organise les raisons pour lesquelles le Generic Host / BackgroundService paie discrètement, jusqu’où il vaut la peine de l’introduire, et où le fait de faire les choses à la légère se transforme en bourbier.

Par ailleurs, le code qui apparaît dans cet article est publié sur GitHub sous la forme d’un ensemble d’exemples complets, buildables et exécutables (une bibliothèque, une démo console qui illustre tout le parcours du démarrage jusqu’au graceful shutdown, et des tests unitaires).

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

Aligner la terminologie d’abord

Ce genre de discussion devient soudain difficile à suivre si le sens des mots reste flou. Nous allons donc d’abord fixer grossièrement les termes utilisés dans cet article.

  • Generic Host
    • C’est la fondation qui prend en charge ensemble le « démarrage », les « dépendances », la « configuration », la « journalisation » et l’« arrêt » d’une application .NET.
    • Ce n’est pas un mécanisme réservé à ASP.NET Core : on peut aussi l’utiliser dans les applications console, les workers et les applications de bureau.
  • Host / IHost
    • C’est l’objet concret obtenu après le build.
    • On le démarre avec StartAsync et on l’arrête avec StopAsync.
  • Hosted Service
    • C’est un traitement résident qui est démarré et arrêté en s’accrochant à la durée de vie du host.
    • On l’écrit en implémentant IHostedService, ou, le plus souvent, en héritant de BackgroundService.
  • BackgroundService
    • C’est une aide d’implémentation pratique pour IHostedService.
    • On peut écrire le corps du traitement de longue durée dans ExecuteAsync, ce qui facilite l’organisation des boucles de surveillance et des traitements périodiques.
  • lifetime
    • Dans cet article, ce terme désigne « le moment où le traitement commence, le moment où il se termine, et qui a la responsabilité de l’arrêter ».
    • Il ne s’agit pas d’une simple durée d’existence, mais d’une gestion de la durée de vie qui inclut la responsabilité de démarrage et la responsabilité d’arrêt.
  • graceful shutdown
    • Plutôt qu’un arrêt forcé, il s’agit d’émettre un signal d’arrêt et de terminer après avoir autant que possible mis de l’ordre dans le traitement en cours.
    • Cela recouvre par exemple « ne pas démarrer le cycle suivant », « décider jusqu’où faire avancer la file » ou « attendre le close ou le flush ».
  • DI
    • Abréviation de Dependency Injection (injection de dépendances) : une façon de recevoir les objets dépendants via un conteneur, plutôt que de les assembler en dur du côté appelant.
    • Pour cet article, il suffit de comprendre cela comme « configurer ensemble, au point d’entrée, le logger, les paramètres et les readers, plutôt que de multiplier les new un peu partout ».

Ce sujet ne se limite pas à « présenter la classe pratique BackgroundService » : il est plus facile à suivre si on le lit comme une réflexion sur le fait de regrouper le démarrage et l’arrêt de toute l’application dans le host, et de porter la durée de vie (lifetime) du traitement résident comme un élément de conception.

Table des matières

  1. D’abord la conclusion (en une phrase)
  2. Vue d’ensemble en une page
    • 2.1. Vue d’ensemble
    • 2.2. Tableau de décision pour le placement
  3. Pourquoi cela fonctionne bien dans les applications de bureau
    • 3.1. Il devient plus facile de séparer les responsabilités de l’UI et du traitement résident
    • 3.2. Un point d’entrée unique pour le démarrage, l’arrêt et les exceptions
    • 3.3. Il devient plus facile d’intégrer le graceful shutdown à la conception
    • 3.4. DI, journalisation et configuration réunis dès le départ
  4. Les cas où cela convient bien
  5. Exemple de configuration minimale (exemple WPF)
  6. Comment répartir StartAsync / ExecuteAsync / StopAsync
    • 6.1. StartAsync
    • 6.2. ExecuteAsync
    • 6.3. StopAsync
    • 6.4. Remarque pour .NET 10 et versions ultérieures
  7. Antipatterns courants
  8. Liste de contrôle pour la revue de code
  9. Guide de décision rapide
  10. Synthèse
  11. Références

1. D’abord la conclusion (en une phrase)

  • Le Generic Host est une option très solide, même dans une application de bureau, comme fondation pour le démarrage et la gestion de la durée de vie (lifetime).
  • BackgroundService est un conteneur qui permet de placer un « traitement à durée de vie longue » sur une durée de vie gérée, plutôt que de le lancer avec un Task.Run laissé à lui-même.
  • Ce qui compte le plus en pratique, c’est de pouvoir regrouper en un seul endroit la conception de la responsabilité de démarrage / la responsabilité d’arrêt / la surveillance des exceptions / la journalisation / la DI / la configuration.
  • Garder StartAsync court, mettre le corps de longue durée dans ExecuteAsync, et confier le nettoyage de fin à StopAsync rend le tout beaucoup plus lisible.
  • Les applications résidentes, les applications de la zone de notification, la surveillance d’équipements, la synchronisation périodique, le post-traitement ordonné et les boucles de reconnexion s’y prêtent particulièrement bien.
  • À l’inverse, transformer en BackgroundService jusqu’au moindre traitement qui ne s’exécute qu’une fois lors d’un clic de bouton devient un peu excessif.
  • StopAsync est pratique, mais ce n’est pas une assurance contre un crash de processus ou un arrêt forcé. Il est important de ne pas lui confier trop de nettoyage.

En résumé, si le Generic Host / BackgroundService fonctionne bien dans une application de bureau, ce n’est pas tant « parce qu’il y a du traitement en arrière-plan » que « parce qu’on veut porter la durée de vie de ce traitement en arrière-plan comme un élément de conception, et non comme un simple à-côté de l’UI ».

2. Vue d’ensemble en une page

2.1. Vue d’ensemble

Regarder d’abord ce schéma permet d’aller beaucoup plus vite.

Démarrage de l'application de bureau(WPF / WinForms)Build / StartAsync du HostPréparation DI / Logging / ConfigurationHostedService.StartAsyncBackgroundService.ExecuteAsyncPeriodicTimer / file / reconnexion / boucle de surveillanceAffichage de MainWindow / MainFormMise à jour d'état / journalisation / E/S externesUI utilise Dispatcher / Invoke uniquement là où c'est nécessaireFin utilisateur / erreur fatale / StopApplicationIHost.StopAsyncNotification CancellationTokenHostedService.StopAsyncFermeture de connexion / flush / graceful shutdown

Ce qui arrive souvent dans les applications UI, c’est que les responsabilités se dispersent petit à petit entre Program.cs / App.xaml.cs / Form_Load / Closing / Task.Run / Timer / des singletons statiques.

En introduisant le Host, on peut répartir grossièrement ainsi.

  • UI : écran, saisie, affichage
  • HostedService / BackgroundService : traitement résident, surveillance, traitement de file, traitement périodique
  • Services DI : logique métier réelle, connexions externes, configuration, journalisation

Le simple fait de pouvoir découper les choses ainsi change considérablement la facilité de revue.

2.2. Tableau de décision pour le placement

Ce que vous voulez faire Premier candidat pour le placement Raison
Initialisation légère juste après le démarrage StartAsync Sens clair en tant que traitement court participant au démarrage
Surveillance / polling / reconnexion à durée de vie longue ExecuteAsync Facile à faire tourner avec la durée de vie du service
Notification d’arrêt / flush / close à la fin StopAsync Facile d’écrire le graceful shutdown avec CancellationToken
Configuration des dépendances, paramètres, journalisation Host.CreateApplicationBuilder Permet de regrouper le point d’entrée en un seul endroit
Mise à jour de l’écran Côté UI Moins d’incidents si le worker ne touche pas directement l’UI
Traitement unique par clic de bouton Méthode async classique Il n’est souvent pas nécessaire d’en faire un HostedService
Post-traitement ordonné en arrière-plan Channel<T> + BackgroundService Plus facile de gérer la durée de vie et les limites qu’en mode fire-and-forget

L’intérêt d’introduire le Host tient moins au fait de pouvoir « rendre quelque chose asynchrone » qu’au fait que la décision de l’endroit où placer les choses devient claire.

3. Pourquoi cela fonctionne bien dans les applications de bureau

3.1. Il devient plus facile de séparer les responsabilités de l’UI et du traitement résident

Dans une application de bureau, l’UI semble être le personnage principal, mais en pratique, c’est généralement en dehors de l’UI que le poids s’accumule.

Par exemple :

  • Synchronisation d’état toutes les 10 secondes
  • Reconnexion à un équipement ou à un serveur
  • Surveillance de fichiers et ingestion
  • Post-traitement empilé dans une file
  • Transfert de logs ou envoi de métriques
  • Préchauffage (warm-up) du cache au démarrage

Il ne s’agit pas d’« événements d’écran », mais de traitements accrochés à la durée de vie de l’application dans son ensemble.

Si on les loge dans le code-behind d’un formulaire ou d’une fenêtre, la responsabilité de les arrêter à la fermeture de l’écran, la responsabilité d’attraper leurs exceptions, et la responsabilité de décider des nouvelles tentatives et du backoff commencent à se mélanger avec les préoccupations de l’UI.

Avec BackgroundService, la déclaration « ce traitement vit tant que l’application tourne » prend une forme concrète dans le code. C’est discrètement puissant.

3.2. Un point d’entrée unique pour le démarrage, l’arrêt et les exceptions

Même dans une application de bureau sans Host, on peut faire quelque chose de similaire en alignant individuellement ServiceCollection, ConfigurationBuilder et LoggerFactory.

Mais cette forme a tendance à se disperser petit à petit.

  • La DI dans Program.cs
  • La configuration dans une classe static maison
  • La journalisation dans une factory séparée
  • Le traitement de fin dans ApplicationExit
  • Le traitement résident dans Task.Run

Même dans cet état, ça fonctionne au début. Mais en y revenant quelques mois plus tard, on voit de moins en moins bien qui porte la durée de vie de l’application.

Avec le Generic Host,

  • l’enregistrement des services
  • le chargement de la configuration
  • la configuration de la journalisation
  • le démarrage du hosted service
  • la notification d’arrêt
  • l’arrêt global via IHostApplicationLifetime

entrent tous dans le même cadre.

Autrement dit, il devient facile de regrouper en un seul endroit le point d’entrée de « comment cette application démarre, et comment elle s’arrête ». Dans les applications résidentes, c’est un point qui finit par payer plus tard.

3.3. Il devient plus facile d’intégrer le graceful shutdown à la conception

Le traitement résident est plus difficile à arrêter qu’à démarrer. C’est vraiment le cas. Le démarrage tient en 3 lignes, mais l’arrêt a un goût de boue.

Par exemple, à l’arrêt, on peut vouloir :

  • Annuler les E/S en cours
  • Empêcher le démarrage du cycle suivant
  • Décider jusqu’où faire avancer les éléments restants dans la file
  • Fermer les sockets ou les objets COM
  • Attendre le flush des logs ou la sauvegarde de l’état

Si on confie tout cela à FormClosing, ça se mélange avec les contraintes de l’écran et ça devient pénible.

Avec le Host / BackgroundService, comme on dispose de CancellationToken et de StopAsync, la « voie pour s’arrêter » existe dès le départ.

Ce n’est bien sûr pas magique. En cas de crash ou de kill, il arrive que StopAsync ne soit jamais appelé. Mais le simple fait d’avoir la conception « en cas de sortie normale, on s’arrête par cette voie » rend les choses beaucoup plus calmes.

3.4. DI, journalisation et configuration réunis dès le départ

Les atouts du Generic Host ne se limitent pas à BackgroundService.

  • Host.CreateApplicationBuilder réunit la fondation DI / configuration / journalisation
  • appsettings.json et les variables d’environnement sont faciles à utiliser tels quels
  • ILogger<T> peut être utilisé de la même manière aussi bien par l’UI que par les workers
  • Si besoin, la configuration peut être regroupée via la famille IOptions<T>

Dans les projets d’outils Windows en particulier, il est assez fréquent que « la configuration et le logger, qu’on avait rangés sans soin dans des static parce que l’application était petite au départ, deviennent pénibles plus tard ».

En les plaçant dès le départ sur le host, on réduit l’essoufflement au moment où l’application commence à grossir un peu.

4. Les cas où cela convient bien

Le Generic Host / BackgroundService a tendance à particulièrement bien fonctionner dans des cas comme ceux-ci.

  • Applications résidentes dans la zone de notification Avec synchronisation périodique, surveillance, notifications, reconnexion
  • Applications connectées à des équipements / caméras / sockets Avec maintien de connexion, surveillance, nouvelles tentatives, lecture d’état
  • Outils d’intégration de fichiers Avec surveillance, file d’ingestion, traitement ordonné
  • Prévention du grossissement des outils internes Petit au départ, mais la configuration, la journalisation et les E/S externes semblent devoir augmenter
  • Applications où la qualité de l’arrêt compte On ne veut pas laisser un état à moitié terminé en fermant l’application

À l’inverse, il y a aussi des cas où il n’est pas nécessaire d’introduire le host tout de suite.

  • Un petit outil qui se lance une fois, traite une fois, puis se termine
  • Un écran quasiment sans traitement en arrière-plan, qui se suffit des seuls événements UI
  • Un tout petit outil auxiliaire interne dont les dépendances et la configuration n’augmenteront presque pas

Le Host n’est pas « obligatoire ». Cependant, dès que l’on voit apparaître deux traitements résidents ou plus, on peut sérieusement envisager de l’adopter. C’est bien moins coûteux que de nettoyer plus tard une colonie de Task.Run.

5. Exemple de configuration minimale (exemple WPF)

À titre d’exemple, voici une configuration minimale qui démarre un host en WPF et fait tourner un BackgroundService qui lit un état externe toutes les 5 secondes. En WinForms, seul le point d’entrée change pour Main / ApplicationContext ; la logique reste quasiment identique.

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);
    }
}

Il y a trois points clés dans cette forme.

  1. Démarrer le host avant d’afficher l’UI
  2. Attendre explicitement StopAsync (await) à la sortie
  3. Regrouper la DI / les hosted services / le shutdown timeout au point d’entrée

Rendre OnExit async demande un peu d’attention à cause des contraintes propres au framework UI, mais écrire clairement le flux « arrêter le host à la sortie » a une réelle valeur.

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.");
    }
}

Ce qui compte ici, c’est d’écrire ExecuteAsync simplement, comme une « boucle while gérée ».

  • La cadence via PeriodicTimer
  • L’arrêt via stoppingToken
  • Les exceptions journalisées
  • Si une dépendance scoped est nécessaire, ouvrir un scope à chaque itération

Avec cette forme, « où ce traitement résident commence-t-il, où s’arrête-t-il, et où ses échecs deviennent-ils visibles » devient beaucoup plus facile à lire.

5.3. Ne pas connecter directement le partage d’état à l’UI

Si le worker touche directement des objets UI, les problèmes de thread UI finissent par ressurgir là.

Il est donc plus sûr de commencer par cette séparation :

  • le worker met à jour une couche de stockage d’état ou de messagerie
  • l’UI lit / reflète cet état dans son propre contexte

StatusStore peut par exemple être une couche partagée fine comme celle-ci.

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");
}

Si une notification immédiate à l’UI est nécessaire, on utilise Dispatcher / BeginInvoke / des événements / un messenger, etc. Mais cette responsabilité se mélange moins si elle est portée à la frontière de l’UI.

6. Comment répartir StartAsync / ExecuteAsync / StopAsync

Quand ces trois éléments se mélangent, l’esprit du lecteur s’embrouille vite. Pour commencer, la répartition suivante est plutôt stable.

6.1. StartAsync

StartAsync est l’endroit où placer un traitement court qui participe au démarrage.

Ce qui convient :

  • Le log de démarrage
  • Le démarrage léger d’un abonnement
  • La préparation d’un état initial qui se termine rapidement
  • Une mise en ordre minimale autour de base.StartAsync

Ce qui ne convient pas :

  • Un warm-up qui prend des dizaines de secondes
  • Une boucle infinie
  • Un corps de traitement enchaînant des E/S lourdes

Si StartAsync devient lourd, même le démarrage global de l’application paraît poussif. Considérer cet endroit comme celui où l’on écrit « le signal de démarrage » limite les incidents.

6.2. ExecuteAsync

ExecuteAsync est le corps de la durée de vie du service.

Ce qui convient :

  • Le polling
  • Les boucles de surveillance
  • Les boucles de reconnexion
  • Les consommateurs qui lisent un Channel<T>
  • Le traitement périodique
  • Tout traitement qui « vit jusqu’à l’arrêt »

Il y a trois astuces ici.

  1. Faire passer le CancellationToken de bout en bout
  2. Éviter qu’une exception ne tue silencieusement la boucle entière
  3. Ne pas empiler trop de retries et de backoff au coup par coup

BackgroundService est pratique, mais si on le laisse faire, il peut aussi devenir « une boucle géante qui aspire tout ». Il est plus lisible d’extraire le traitement réel dans un service séparé et de recentrer ExecuteAsync lui-même sur la gestion de la durée de vie et l’orchestration.

6.3. StopAsync

StopAsync est l’endroit où faire le ménage lors d’une sortie normale.

Ce qui convient :

  • Le log d’arrêt
  • La levée des timers / abonnements / surveillances
  • Le nettoyage des ressources que l’on veut close / flush explicitement
  • L’attente de la fin via base.StopAsync

Mais il est aussi important de ne pas trop attendre de StopAsync.

  • Le processus a planté
  • Il a été arrêté de force
  • Il a été tué par l’OS

Dans ce genre de sortie, il se peut tout simplement que StopAsync ne soit jamais exécuté.

Donc,

  • faire la persistance en petites quantités en temps normal, autant que possible
  • ne pas concevoir une cohérence qui ne se retrouve qu’à la sortie
  • rendre le cleanup idempotent

voilà ce qui compte. Vouloir sauver le monde uniquement à la sortie finit généralement dans la confusion.

6.4. Remarque pour .NET 10 et versions ultérieures

Parmi les changements survenus depuis 2025, sous .NET 10, le comportement a évolué : l’intégralité de BackgroundService.ExecuteAsync s’exécute désormais comme une tâche en arrière-plan.

Auparavant, il existait un comportement un peu difficile à cerner, où la partie synchrone avant le premier await bloquait le démarrage des autres services. Avec ce changement, l’incident où « les premières lignes d’ExecuteAsync alourdissaient le démarrage » devient moins probable.

Cela dit, sur le plan de la conception, il reste plus lisible de séparer :

  • le traitement court qui participe au démarrage → StartAsync
  • le corps de longue durée → ExecuteAsync

Si l’on souhaite contrôler le timing de démarrage de manière plus stricte, IHostedLifecycleService entre en ligne de compte. C’est le genre de point discret qui finit par payer quand une application résidente grossit.

7. Antipatterns courants

7.1. Démarrer une boucle infinie dans Window_Loaded / Form_Shown

C’est facile au début. Mais la responsabilité d’arrêt et la responsabilité des exceptions restent collées côté UI.

Dès que des conditions comme « arrêter quand l’écran se ferme », « ne pas arrêter en cas de réduction dans la zone de notification », ou « redémarrer en cas de changement de configuration » commencent à se multiplier, ça devient vite pénible.

7.2. Lancer un Task.Run sans jamais s’en occuper

Task.Run en lui-même n’est pas fautif. Ce qui pose problème, c’est l’absence de propriétaire pour la durée de vie et les exceptions.

En particulier, si l’on démarre un traitement résident avec Task.Run(async () => { while (...) { ... } }),

  • quand se termine-t-il ?
  • qui l’attend ?
  • comment observe-t-on ses exceptions ?
  • combien de temps attendre à la sortie ?

deviennent flous.

Le simple fait de le placer sur un BackgroundService rend tout cela beaucoup plus facile à organiser.

7.3. Toucher directement l’UI depuis un BackgroundService

C’est une mine. Les problèmes de thread UI et les problèmes de durée de vie se mélangent d’un coup.

Il est plus sûr que le worker ne manipule pas directement l’UI, et pose une frontière via l’un de :

  • l’état
  • les événements
  • les messages
  • la file (queue)

7.4. Faire reposer tout le traitement de sauvegarde important sur le seul StopAsync

StopAsync aide en cas de sortie normale, mais ce n’est pas le Jugement dernier.

Une conception qui ne sauvegarde qu’à la sortie, ne fait le flush qu’à la sortie, n’atteint la cohérence qu’à la sortie,

s’effondre en cas de crash.

7.5. Utiliser le host, mais couper court avec Environment.Exit

C’est également fréquent.

Appeler Environment.Exit en se disant « bon, c’est trop compliqué, autant tout arrêter » coupe soi-même la voie de graceful shutdown que le host maintient.

Si une erreur fatale doit entraîner l’arrêt complet, il est plus naturel de commencer par utiliser IHostApplicationLifetime.StopApplication() pour passer par la voie officielle d’arrêt.

8. Liste de contrôle pour la revue de code

Pour la revue d’une application de bureau utilisant le Generic Host / BackgroundService, examiner les points suivants dans l’ordre aide à y voir clair.

  • Ce traitement est-il accroché à la durée de vie de l’application, ou s’agit-il d’un simple traitement d’événement UI ?
  • Les responsabilités de démarrage sont-elles correctement réparties entre StartAsync / ExecuteAsync / StopAsync ?
  • StartAsync n’est-il pas devenu trop lourd ?
  • ExecuteAsync transmet-il le CancellationToken jusqu’au bout ?
  • Le hosted service ne tient-il pas directement une dépendance scoped ?
  • Le worker ne touche-t-il pas directement des objets UI ?
  • Les exceptions ne sont-elles pas avalées silencieusement ?
  • La boucle de nouvelles tentatives n’est-elle pas devenue infinie et à haute fréquence ?
  • Y a-t-il une limite supérieure au temps d’attente à la sortie ?
  • N’y a-t-il pas d’arrêt mêlant Environment.Exit ou une hypothèse de kill du processus ?

Avec cette liste de contrôle, la différence entre « on a ajouté le Host en attendant » et « la durée de vie est organisée comme un élément de conception » devient beaucoup plus visible.

9. Guide de décision rapide

Ce que vous voulez faire Premier choix
Aligner la DI / la journalisation / la configuration pour toute l’application Host.CreateApplicationBuilder
Faire tourner une boucle résidente BackgroundService
Tourner à intervalle fixe PeriodicTimer + BackgroundService
Écouler un post-traitement ordonné Channel<T> + BackgroundService
Utiliser un scoped service IServiceScopeFactory.CreateScope()
Notifier toute l’application d’une sortie normale IHostApplicationLifetime.StopApplication()
Mise à jour de l’UI Dispatcher / Invoke côté UI
Opération d’écran ponctuelle Méthode async classique
Contrôle strict du cycle de vie au démarrage Envisager IHostedLifecycleService

10. Synthèse

La raison d’introduire le Generic Host / BackgroundService dans une application de bureau n’est pas « parce qu’on veut écrire le code à la façon web ».

Ce qui compte vraiment, ce sont ces trois points.

  1. Regrouper en un seul endroit les responsabilités de démarrage et d’arrêt
  2. Porter la durée de vie des traitements de longue durée comme un élément de conception
  3. Traiter le graceful shutdown dès le point d’entrée, plutôt qu’en le rajoutant après coup

Les outils Windows et les applications résidentes, même petits au départ, voient la surveillance, la synchronisation, la reconnexion, les files, la journalisation et la configuration s’accumuler petit à petit. Si on les gère alors comme un simple à-côté du code UI, cela devient silencieusement pénible par la suite.

À l’inverse, le simple fait de séparer

  • l’UI en tant qu’UI
  • le traitement résident en tant que hosted service
  • le traitement réel en tant que services DI
  • l’arrêt via StopAsync et CancellationToken

met déjà pas mal d’ordre.

Il n’y a rien de spectaculaire là-dedans. Mais ce genre de conception discrète paie vraiment en pratique. Elle réduit cette désagréable viscosité du genre « ça devient bizarre de temps en temps à la fermeture » ou « on ne sait pas où on arrête les choses ».

Si vous êtes bloqué sur un outil Windows ou une application résidente — passage à BackgroundService, conception du démarrage / de l’arrêt, boucles de surveillance, mise en ordre de la durée de vie des connexions COM / sockets / surveillance de fichiers, ou isolation d’anomalies à la fermeture — n’hésitez pas à nous consulter, à partir d’une revue de conception ou d’une clarification des orientations.

11. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Le Generic Host peut-il être utilisé dans une application de bureau ?
Oui. Le Generic Host n'est pas un mécanisme réservé à ASP.NET Core : il sert aussi de fondation, dans les applications console, les workers et les applications de bureau WPF / WinForms, pour prendre en charge ensemble le démarrage, les dépendances, la configuration, la journalisation et l'arrêt. Il convient particulièrement bien aux applications résidentes, aux applications de la zone de notification (tray), à la surveillance d'équipements, à la synchronisation périodique, au post-traitement ordonné et aux boucles de reconnexion.
À quoi sert BackgroundService ?
C'est un conteneur qui permet de placer un « traitement à durée de vie longue » sur une durée de vie gérée, plutôt que de le lancer avec un Task.Run laissé à lui-même. C'est une aide d'implémentation pratique pour IHostedService, qui permet d'écrire le corps d'une boucle de surveillance ou d'un traitement périodique dans ExecuteAsync. La déclaration « ce traitement vit tant que l'application tourne » prend alors une forme concrète dans le code, ce qui permet de regrouper en un seul endroit la conception de la responsabilité de démarrage, de la responsabilité d'arrêt, de la surveillance des exceptions, de la journalisation, de l'injection de dépendances (DI) et de la configuration.
Comment répartir StartAsync, ExecuteAsync et StopAsync ?
La lisibilité s'améliore nettement si l'on répartit ainsi : StartAsync pour une courte initialisation participant au démarrage, ExecuteAsync pour le corps à durée de vie longue, et StopAsync pour la notification d'arrêt, le flush et la fermeture en fin de vie. En revanche, un traitement qui ne s'exécute qu'une seule fois lors d'un clic de bouton peut très bien rester une méthode async classique ; transformer systématiquement tout en BackgroundService devient vite excessif.
Peut-on tout confier à StopAsync pour le traitement de fin ?
Ce n'est pas recommandé. StopAsync est pratique, mais ce n'est pas une assurance contre un crash de processus ou un arrêt forcé ; il est donc important de ne pas lui confier trop de nettoyage. Il faut concevoir le graceful shutdown (annulation des E/S en cours, arrêt du prochain cycle, politique de traitement des éléments restants dans la file, fermeture des connexions, flush des logs) autour du CancellationToken et de StopAsync, tout en gardant séparément l'hypothèse que l'application ne doit pas se corrompre même en cas d'arrêt anormal.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog