Scénarios de la programmation asynchrone

Si vous avez besoin de code utilisant les E/S de manière intensive (par exemple, pour récupérer des données d’un réseau, accéder à une base de données, ou lire et écrire dans un fichier), optez pour la programmation asynchrone. L’écriture de code asynchrone est également indiquée si votre code utilise le processeur de manière intensive, notamment pour effectuer un calcul complexe.

C# fournit un modèle de programmation asynchrone de niveau de langage. Ce modèle facilite l’écriture de code asynchrone, en vous évitant d’effectuer des rappels multiples ou de vous conformer à une bibliothèque qui prend en charge l’asynchronisme. Il suit ce que l’on appelle le modèle asynchrone basé sur des tâches (TAP).

Vue d’ensemble du modèle asynchrone

La programmation asynchrone est basée sur les objets Task et Task<T>, qui modélisent les opérations asynchrones. Ces objets sont exposés à l’aide des mots clés async et await. Dans la plupart des cas, le modèle est assez simple :

  • Pour du code utilisant les E/S de manière intensive, vous spécifiez une opération qui retourne un objet Task ou Task<T> dans une méthode async.
  • Pour du code utilisant le processeur de manière intensive, vous spécifiez une opération qui est démarrée sur un thread d’arrière-plan avec la méthode Task.Run.

Le mot clé await trouve ici toute son utilité. Il cède le contrôle à l’appelant de la méthode qui a effectué l’opération await. Au final, c’est ce qui rend une interface utilisateur réactive ou un service élastique. Bien qu’il existe des façons d’aborder le code asynchrone autre que async et await, cet article se concentre sur les constructions au niveau du langage.

Remarque

Dans certains exemples suivants, la classe System.Net.Http.HttpClient est utilisée pour télécharger des données à partir d’un service web. L’objet s_httpClient utilisé dans ces exemples est un champ statique de la classe Program (veuillez consulter l’exemple complet) :

private static readonly HttpClient s_httpClient = new();

Exemple de code utilisant les E/S de manière intensive : télécharger des données d’un service web

Imaginons que vous souhaitez écrire du code qui télécharge des données d’un service web quand un utilisateur appuie sur un bouton, mais qui ne bloque pas le thread d’interface utilisateur. Pour cela, vous pouvez écrire ce code :

s_downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await s_httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

Le code exprime l’intention (télécharger des données de façon asynchrone) sans que cela nécessite des interactions compliquées avec les objets Task.

Exemple de code utilisant le processeur de manière intensive : effectuer un calcul dans un jeu

Supposons que vous développez un jeu pour mobile dans lequel l’appui sur un bouton peut causer des dommages à de nombreux ennemis à l’écran. Le calcul des dommages infligés peut nécessiter beaucoup de ressources. Si ce calcul est effectué sur le thread d’interface utilisateur, le jeu risque d’être considérablement ralenti pendant la durée du calcul.

La meilleure façon de gérer cette situation est de démarrer un thread d’arrière-plan qui effectue le travail à l’aide de Task.Run, et attend son résultat utilisant await. Ainsi, l’interface utilisateur conserve les mêmes performances pendant que le travail est effectué.

static DamageResult CalculateDamageDone()
{
    return new DamageResult()
    {
        // Code omitted:
        //
        // Does an expensive calculation and returns
        // the result of that calculation.
    };
}

s_calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

Ce code exprime clairement l’intention de l’événement de clic du bouton. Il ne nécessite pas de gérer un thread d’arrière-plan manuellement et il effectue le travail de façon à ne rien bloquer.

Les dessous du code

Sur le plan du langage C#, le compilateur transforme votre code en une machine à états qui effectue le suivi des événements, tels que la suspension de l’exécution en présence d’un await et la reprise de l’exécution à la fin d’un travail en arrière-plan.

D’un point de vue théorique, il s’agit d’une implémentation du modèle de promesses d’asynchronisme.

Points clés à comprendre

  • Le code asynchrone peut être utilisé pour du code utilisant les E/S ou le processeur de manière intensive, mais il est utilisé de manière différente dans chaque scénario.
  • Le code asynchrone utilise les objets Task<T> et Task, qui sont des constructions servant à modéliser le travail effectué en arrière-plan.
  • Le mot clé async définit une méthode comme asynchrone, ce qui vous permet d’utiliser le mot clé await dans le corps de la méthode.
  • Quand le mot clé await est utilisé, il suspend la méthode d’appel et cède le contrôle à son appelant jusqu’à ce que la tâche awaited soit terminée.
  • Le mot clé await peut uniquement être utilisé dans une méthode asynchrone.

Déterminer si un travail utilise le processeur ou les E/S de manière intensive

Les deux premiers exemples de ce guide ont montré comment utiliser async et await pour un travail utilisant les E/S et le processeur de manière intensive. Il est primordial de savoir déterminer si un travail à effectuer utilise les E/S ou le processeur de manière intensive, car ce point peut avoir un impact important sur les performances de votre code et entraîner une utilisation incorrecte de certaines constructions.

Voici deux questions à vous poser avant d’écrire du code :

  1. Votre code doit-il « attendre » quelque chose, par exemple des données d’une base de données ?

    Si la réponse est « oui », le travail utilise les E/S de manière intensive.

  2. Le code doit-il effectuer un calcul très complexe ?

    Si la réponse est « oui », le travail utilise le processeur de manière intensive.

Si le travail à faire utilise les E/S de manière intensive, utilisez async et awaitsansTask.Run. Vous ne devez pas utiliser la bibliothèque parallèle de tâches.

Si le travail à faire utilise le processeur de manière intensive et que la réactivité est une exigence, utilisez async et await, mais transférez le travail sur un autre thread avec Task.Run. Si le travail est approprié pour la concurrence et le parallélisme, envisagez également d’utiliser la bibliothèque parallèle de tâches.

De plus, vous devez toujours mesurer les performances d’exécution de votre code. Par exemple, vous constaterez peut-être que le coût d’un travail utilisant le processeur de manière intensive n’est pas si élevé que cela par rapport à la surcharge des changements de contexte induits par le multithreading. Chaque solution ayant ses compromis, choisissez le meilleur compromis pour votre scénario.

Autres exemples

Les exemples suivants montrent diverses façons d’écrire du code asynchrone dans C#. Ils correspondent à plusieurs scénarios différents que vous êtes susceptible de rencontrer.

Extraire des données d’un réseau

Cet extrait de code télécharge le code HTML à partir de l’URL donnée et compte le nombre d’occurrences de la chaîne « .NET » dans le code HTML. Il utilise ASP.NET pour définir une méthode de contrôleur d’API web qui exécute cette tâche et retourne le nombre.

Notes

Si vous prévoyez d’effectuer une analyse HTML dans le code de production, n’utilisez pas d’expressions régulières. Utilisez plutôt une bibliothèque d’analyse.

[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
    // Suspends GetDotNetCount() to allow the caller (the web server)
    // to accept another request, rather than blocking on this one.
    var html = await s_httpClient.GetStringAsync(URL);
    return Regex.Matches(html, @"\.NET").Count;
}

Voici le même scénario écrit pour une application Windows universelle, qui effectue la même tâche quand l’utilisateur appuie sur un bouton :

private readonly HttpClient _httpClient = new HttpClient();

private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
    // Capture the task handle here so we can await the background task later.
    var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");

    // Any other work on the UI thread can be done here, such as enabling a Progress Bar.
    // This is important to do here, before the "await" call, so that the user
    // sees the progress bar before execution of this method is yielded.
    NetworkProgressBar.IsEnabled = true;
    NetworkProgressBar.Visibility = Visibility.Visible;

    // The await operator suspends OnSeeTheDotNetsButtonClick(), returning control to its caller.
    // This is what allows the app to be responsive and not block the UI thread.
    var html = await getDotNetFoundationHtmlTask;
    int count = Regex.Matches(html, @"\.NET").Count;

    DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}";

    NetworkProgressBar.IsEnabled = false;
    NetworkProgressBar.Visibility = Visibility.Collapsed;
}

Attendre la fin de plusieurs tâches

Vous pouvez avoir un scénario qui nécessite de récupérer plusieurs éléments de données simultanément. L’API Task fournit deux méthodes, Task.WhenAll et Task.WhenAny, que vous pouvez utiliser pour écrire du code asynchrone qui spécifie une attente non bloquante sur plusieurs travaux en arrière-plan.

Cet exemple vous montre comment récupérer des données User pour plusieurs userId.

private static async Task<User> GetUserAsync(int userId)
{
    // Code omitted:
    //
    // Given a user Id {userId}, retrieves a User object corresponding
    // to the entry in the database with {userId} as its Id.

    return await Task.FromResult(new User() { id = userId });
}

private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
    var getUserTasks = new List<Task<User>>();
    foreach (int userId in userIds)
    {
        getUserTasks.Add(GetUserAsync(userId));
    }

    return await Task.WhenAll(getUserTasks);
}

Voici une façon plus succincte d’écrire ce code, en utilisant LINQ :

private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
    var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
    return await Task.WhenAll(getUserTasks);
}

Si vous choisissez de combiner LINQ avec du code asynchrone, pour réduire la quantité de code, faites-le avec précaution. Du fait que LINQ utilise l’exécution différée, les appels asynchrones ne sont pas effectués immédiatement comme c’est le cas dans une boucle foreach, sauf si vous forcez l’itération de la séquence générée avec un appel à .ToList() ou .ToArray(). L’exemple ci-dessus utilise Enumerable.ToArray pour exécuter la requête avec impatience et stocker les résultats dans un tableau. Cela force le code id => GetUserAsync(id) à s’exécuter et à démarrer la tâche.

Informations et conseils importants

Avec la programmation asynchrone, gardez à l’esprit certains détails qui peuvent empêcher un comportement inattendu.

  • Les méthodes async doivent contenir un mot cléawaitdans leur corps pour pouvoir être suspendues.

    Il ne faut pas oublier ce point. Si await n’est pas utilisé dans le corps d’une méthode async, le compilateur C# génère un avertissement, mais le code est compilé et exécuté comme s’il s’agissait d’une méthode standard. Cela est incroyablement inefficace, car la machine à états générée par le compilateur C# pour la méthode asynchrone n’accomplit rien.

  • Ajoutez « Async » comme suffixe de chaque nom de méthode asynchrone que vous écrivez.

    Cette convention de .NET aide à différencier les méthodes synchrones et asynchrones. Cela n’est pas obligatoirement pour certaines méthodes qui ne sont pas explicitement appelées par votre code (par exemple, les gestionnaires d’événements et les méthodes de contrôleur web). L’attribution d’un nom explicite pour ces méthodes a moins d’importance.

  • async voiddoit être utilisé uniquement pour les gestionnaires d’événements.

    L’utilisation de async void est le seul moyen de permettre le fonctionnement des gestionnaires d’événements asynchrones, car les événements n’ont pas de types de retour (et ne peuvent donc pas utiliser les objets Task et Task<T>). Toute autre utilisation de la méthode async void ne suit pas le modèle TAP et peut être difficile à implémenter, comme expliqué ci-après :

    • Les exceptions levées dans une méthode async void ne peuvent pas être interceptées en dehors de cette méthode.
    • Les méthodes async void sont très difficiles à tester.
    • Les méthodes async void peuvent avoir des effets secondaires gênants si l’appelant n’attend pas de méthodes asynchrones.
  • Définissez le thread avec précaution si vous utilisez des expressions lambda asynchrones dans des expressions LINQ.

    Dans LINQ, les expressions lambda utilisent l’exécution différée, ce qui signifie que l’exécution du code peut s’arrêter à un point que vous n’aviez pas prévu. L’introduction de tâches bloquantes dans ce code peut facilement provoquer un interblocage si le code n’est pas écrit correctement. De plus, l’imbrication de code asynchrone peut rendre la logique d’exécution du code plus compliquée. Combiner du code asynchrone et du code LINQ offre beaucoup de possibilités, mais nécessite d’être fait avec précaution et de manière claire.

  • Écrivez du code qui attend certaines tâches de façon non bloquante.

    Si vous choisissez de bloquer le thread actuel pour attendre la fin d’une Task, vous risquez de provoquer des interblocages et le blocage de threads de contexte, et de gérer moins facilement les erreurs. Le tableau suivant fournit des conseils pour attendre la fin de tâches de façon non bloquante :

    Élément à utiliser... Au lieu de... Pour...
    await Task.Wait ou Task.Result Extraire le résultat d’une tâche en arrière-plan
    await Task.WhenAny Task.WaitAny Attendre la fin d’une tâche
    await Task.WhenAll Task.WaitAll Attendre la fin de toutes les tâches
    await Task.Delay Thread.Sleep Attendre pendant une période
  • Envisagez d’utiliserValueTaskle cas échéant

    Le retour d’un objet Task à partir de méthodes async peut introduire des goulots d’étranglement au niveau des performances dans certains chemins. Task est un type référence. Si vous l’utilisez, vous allouez donc un objet. Dans les cas où une méthode déclarée avec le modificateur async retourne un résultat mis en cache, ou si elle s’exécute de manière synchrone, le coût en termes de temps induit par les allocations supplémentaires peut s’avérer significatif dans les sections de code critiques pour les performances. Cela peut devenir coûteux si ces allocations se produisent dans des boucles serrées. Pour plus d’informations, consultez Types de retour asynchrones généralisés.

  • Envisagez d’utiliserConfigureAwait(false)

    Une question courante est la suivante : « Quand dois-je utiliser la méthode Task.ConfigureAwait(Boolean) ? ». La méthode permet à une instance Task de configurer son awaiter. Il s’agit d’une considération importante et sa définition incorrecte peut potentiellement avoir des implications sur les performances et même des interblocages. Pour plus d’informations sur ConfigureAwait, consultez la FAQ ConfigureAwait.

  • Limitez l’écriture de code avec état.

    Écrivez du code qui ne dépend pas de l’état d’objets globaux ou de l’exécution de certaines méthodes. Le code doit uniquement dépendre des valeurs de retour des méthodes. Pourquoi ?

    • La logique du code sera plus facile à comprendre.
    • Le code sera plus facile à tester.
    • Combiner du code asynchrone et du code synchrone est beaucoup plus simple.
    • Les concurrences critiques peuvent généralement être évitées.
    • Rendre le code dépendant des valeurs de retour facilite la coordination du code asynchrone.
    • En prime, le code fonctionne parfaitement avec l’injection de dépendances.

L’objectif recommandé est d’atteindre une transparence référentielle complète ou quasi-complète dans votre code. Votre base de code sera alors prévisible, testable et facile à gérer.

Exemple complet

Le code suivant est le texte complet du fichier Program.cs de l’exemple.

using System.Text.RegularExpressions;
using System.Windows;
using Microsoft.AspNetCore.Mvc;

class Button
{
    public Func<object, object, Task>? Clicked
    {
        get;
        internal set;
    }
}

class DamageResult
{
    public int Damage
    {
        get { return 0; }
    }
}

class User
{
    public bool isEnabled
    {
        get;
        set;
    }

    public int id
    {
        get;
        set;
    }
}

public class Program
{
    private static readonly Button s_downloadButton = new();
    private static readonly Button s_calculateButton = new();

    private static readonly HttpClient s_httpClient = new();

    private static readonly IEnumerable<string> s_urlList = new string[]
    {
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/shows/net-core-101/what-is-net",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://dotnetfoundation.org",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            "https://learn.microsoft.com/xamarin"
    };

    private static void Calculate()
    {
        // <PerformGameCalculation>
        static DamageResult CalculateDamageDone()
        {
            return new DamageResult()
            {
                // Code omitted:
                //
                // Does an expensive calculation and returns
                // the result of that calculation.
            };
        }

        s_calculateButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI while CalculateDamageDone()
            // performs its work. The UI thread is free to perform other work.
            var damageResult = await Task.Run(() => CalculateDamageDone());
            DisplayDamage(damageResult);
        };
        // </PerformGameCalculation>
    }

    private static void DisplayDamage(DamageResult damage)
    {
        Console.WriteLine(damage.Damage);
    }

    private static void Download(string URL)
    {
        // <UnblockingDownload>
        s_downloadButton.Clicked += async (o, e) =>
        {
            // This line will yield control to the UI as the request
            // from the web service is happening.
            //
            // The UI thread is now free to perform other work.
            var stringData = await s_httpClient.GetStringAsync(URL);
            DoSomethingWithData(stringData);
        };
        // </UnblockingDownload>
    }

    private static void DoSomethingWithData(object stringData)
    {
        Console.WriteLine("Displaying data: ", stringData);
    }

    // <GetUsersForDataset>
    private static async Task<User> GetUserAsync(int userId)
    {
        // Code omitted:
        //
        // Given a user Id {userId}, retrieves a User object corresponding
        // to the entry in the database with {userId} as its Id.

        return await Task.FromResult(new User() { id = userId });
    }

    private static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
    {
        var getUserTasks = new List<Task<User>>();
        foreach (int userId in userIds)
        {
            getUserTasks.Add(GetUserAsync(userId));
        }

        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDataset>

    // <GetUsersForDatasetByLINQ>
    private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
    {
        var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDatasetByLINQ>

    // <ExtractDataFromNetwork>
    [HttpGet, Route("DotNetCount")]
    static public async Task<int> GetDotNetCount(string URL)
    {
        // Suspends GetDotNetCount() to allow the caller (the web server)
        // to accept another request, rather than blocking on this one.
        var html = await s_httpClient.GetStringAsync(URL);
        return Regex.Matches(html, @"\.NET").Count;
    }
    // </ExtractDataFromNetwork>

    static async Task Main()
    {
        Console.WriteLine("Application started.");

        Console.WriteLine("Counting '.NET' phrase in websites...");
        int total = 0;
        foreach (string url in s_urlList)
        {
            var result = await GetDotNetCount(url);
            Console.WriteLine($"{url}: {result}");
            total += result;
        }
        Console.WriteLine("Total: " + total);

        Console.WriteLine("Retrieving User objects with list of IDs...");
        IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
        var users = await GetUsersAsync(ids);
        foreach (User? user in users)
        {
            Console.WriteLine($"{user.id}: isEnabled={user.isEnabled}");
        }

        Console.WriteLine("Application ending.");
    }
}

// Example output:
//
// Application started.
// Counting '.NET' phrase in websites...
// https://learn.microsoft.com: 0
// https://learn.microsoft.com/aspnet/core: 57
// https://learn.microsoft.com/azure: 1
// https://learn.microsoft.com/azure/devops: 2
// https://learn.microsoft.com/dotnet: 83
// https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio: 31
// https://learn.microsoft.com/education: 0
// https://learn.microsoft.com/shows/net-core-101/what-is-net: 42
// https://learn.microsoft.com/enterprise-mobility-security: 0
// https://learn.microsoft.com/gaming: 0
// https://learn.microsoft.com/graph: 0
// https://learn.microsoft.com/microsoft-365: 0
// https://learn.microsoft.com/office: 0
// https://learn.microsoft.com/powershell: 0
// https://learn.microsoft.com/sql: 0
// https://learn.microsoft.com/surface: 0
// https://dotnetfoundation.org: 16
// https://learn.microsoft.com/visualstudio: 0
// https://learn.microsoft.com/windows: 0
// https://learn.microsoft.com/xamarin: 6
// Total: 238
// Retrieving User objects with list of IDs...
// 1: isEnabled= False
// 2: isEnabled= False
// 3: isEnabled= False
// 4: isEnabled= False
// 5: isEnabled= False
// 6: isEnabled= False
// 7: isEnabled= False
// 8: isEnabled= False
// 9: isEnabled= False
// 0: isEnabled= False
// Application ending.

Autres ressources