La Programmation Asynchrone en C# : Comprendre `async` et `await`

Introduction à async et await

L'intégration des mots-clés async et await en C# a transformé la manière d'écrire des opérations non bloquantes, rendant le code asynchrone aussi lisible que le code synchrone.

Un développeur marque une méthode avec le modificateur async pour indiquer qu'elle peut contenir des points d'attente (awaitable expressions). Ces méthodes peuvent alors retourner un void (pour les gestionnaires d'événements, bien que déconseillé pour la plupart des cas), un Task (pour les opérations sans valeur de retour), ou un Task<T> (pour les opérations retournant une valeur de type T).

Le mot-clé await est utilisé à l'intérieur d'une méthode async pour suspendre l'exécution de cette méthode jusqu'à ce que l'opération asynchrone attendue soit terminée. Pendant cette suspension, le contrôle est retourné à l'appelant de la méthode async, permettant au thread d'exécution de continuer à travailler sur d'autres tâches. Une fois l'opération attendue achevée, l'exécution reprend là où elle s'était arrêtée dans la méthode async. Ce mécanisme évite le blocage du thread principal, améliorant ainsi la réactivité des applications.

Exemple Pratique

Considérons l'exemple suivant qui illustre le comportement asynchrone d'une opération longue dans une application console.

using System;
using System.Threading;
using System.Threading.Tasks;

public class DemonstrateurAsynchrone
{
    public static void Main(string[] args)
    {
        Console.WriteLine("Démarrage du programme principal !");
        Console.WriteLine("Je lance une opération en arrière-plan.");
        
        DemonstrateurAsynchrone instance = new DemonstrateurAsynchrone();
        instance.ExecuterFluxAsynchrone(); // Lance la tâche asynchrone sans bloquer le Main
        
        Console.WriteLine("L'opération est en cours, je continue mon travail principal.");
        Console.WriteLine("Le programme principal arrive à sa fin.");
        Console.WriteLine("Appuyez sur Entrée pour quitter...");
        Console.ReadLine(); // Garde la console ouverte pour observer l'exécution asynchrone
    }

    // Méthode asynchrone qui coordonne une opération longue
    public async Task ExecuterFluxAsynchrone()
    {
        Console.WriteLine($"  -> Début de la méthode ExecuterFluxAsynchrone.");
        var resultatOperation = await SimulerTraitementLong(); // Le thread principal est libéré ici
        Console.WriteLine(resultatOperation);
        Console.WriteLine("  -> La méthode ExecuterFluxAsynchrone a terminé son exécution.");
    }

    // Méthode privée simulant un traitement intensif
    private async Task<string> SimulerTraitementLong()
    {
        return await Task.Run(() =>
        {
            Thread.Sleep(10); // Courte pause pour la démonstration de l'ordre d'affichage
            Console.WriteLine("    => L'opération asynchrone a démarré.");
            Console.WriteLine("    => Elle simule un travail intensif sans réelle charge CPU.");
            Thread.Sleep(5500); // Simule une attente de 5.5 secondes
            return "    => Opération asynchrone achevée !";
        });
    }
}

Lors de l'exécution, vous observerez que les messages du programme principal s'affichent avant la fin de l'opération asynchrone, démontrant que le thread principal n'est pas bloqué par le await :

Démarrage du programme principal !
Je lance une opération en arrière-plan.
  -> Début de la méthode ExecuterFluxAsynchrone.
    => L'opération asynchrone a démarré.
L'opération est en cours, je continue mon travail principal.
Le programme principal arrive à sa fin.
Appuyez sur Entrée pour quitter...
    => Elle simule un travail intensif sans réelle charge CPU.
    => Opération asynchrone achevée !
  -> La méthode ExecuterFluxAsynchrone a terminé son exécution.

Thread vs ThreadPool

Historiquement, la création directe d'un Thread pour chaque opération concurrente était la méthode standard. Chaque instance de Thread initialise un nouveau thread d'exécution, généralement en tant que thread de premier plan par défaut. Ce processus est coûteux en ressources, car la création d'un nouveau thread peut consommer environ 1 Mo de mémoire, en plus du temps CPU pour son initialisation et sa gestion.

En revanche, le ThreadPool (pool de threads) opère différemment. Il maintient une collection de threads pré-initialisés et réutilisables, généralement des threads d'arrière-plan. Lorsqu'une tâche est soumise au ThreadPool, il recherche un thread inactif dans son pool. S'il en trouve un, il l'utilise pour exécuter la tâche au lieu d'en créer un nouveau. Cette approche réduit considérablement la surcharge de création de threads et offre des avantages significatifs en matière de gestion des ressources, en particulier dans les scénarios où de nombreuses opérations de courte durée doivent être exécutées en parallèle.

ThreadPool vs Task

Bien que le ThreadPool offre une amélioration notable par rapport à la création manuelle de Thread, il présente des limites, notamment une file d'attente globale qui peut entraîner de la contention pour les ressources partagées lorsque de nombreuses tâches tentent d'accéder au même ensemble de threads.

C'est là que le concept de Task entre en jeu. Le type Task (et Task<TResult>) de la bibliothèque TPL (Task Parallel Library) est bâti sur le ThreadPool, mais il en constitue une amélioration majeure. Plutôt que de s'appuyer uniquement sur la file d'attente globale, Task peut tirer parti de mécanismes de files d'attente locales aux threads de travail, réduisant ainsi la contention et améliorant l'efficacité. De plus, Task fournit une API beaucoup plus riche et flexible pour la composition, l'annulation et la gestion des opérations asynchrones. Il est particulièrement performant sur les architectures multi-cœur, où il peut répartir les charges de travail de manière plus intelligente que le ThreadPool seul, exploitant pleinement le parallélisme matériel.

Task vs async/await

Pour comprendre la supériorité de async/await, considérons un scénario où une application web ou de bureau doit effectuer une opération longue, comme une écriture en base de données, et afficher un message de confirmation une fois celle-ci réussie.

Si l'on utilise Task.Result (ou Task.Wait()) pour obtenir le résultat d'une tâche asynchrone, le thread appelant est bloqué. Par exemple, maTache.Result mettra l'application dans un état d'attente bloquant jusqu'à ce que maTache soit terminée. Cela signifie que l'interface utilisateur gèlera et ne répondra plus pendant toute la durée de l'opération, même si l'opération elle-même est exécutée sur un thread séparé. On parle alors de blocage synchrone dans un contexte asynchrone.

Avec async/await, ce problème est élégamment résolu. Lorsque le mot-clé await est rencontré, le thread d'exécution n'est pas bloqué. Au lieu de cela, il est libéré pour effectuer d'autres tâches. Une fois l'opération asynchrone terminée, le contexte d'exécution est restauré, et la méthode async reprend son exécution juste après le point d'await. L'application reste entièrement réactive, et le message de confirmation peut être affiché sans interruption de l'expérience utilisateur.

En plus d'éviter le blocage, le style de programmation async/await améliore considérablement la lisibilité du code. Il permet d'écrire des séquences d'opérations asynchrones qui ressemblent à du code synchrone et séquentiel, simplifiant la logique complexe des callbacks et des chaînes de promessse, rendant ainsi le code plus facile à comprendre et à maintenir.

Étiquettes: C# asynchronous programming Async Await Task Parallel Library TPL

Publié le 25 août à 12h48