Programmer avec les pools de threads en C#

Troisième partie : async et await

Le pool de threads est un mécanisme facile d'utilisation permettant de tirer profit de la parallélisation. L'amélioration des performances ou de l'expérience utilisateur ne sont que deux exemples classiques.

C'est une série de quatre tutoriels dans laquelle vous allez apprendre à programmer un pool de threads en C#.

Dans cette troisième partie, nous allons découvrir l'intégration poussée du pool de threads via les mots clés async et await.

33 commentaires Donner une note à l'article (5)

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Présentation

Bienvenue dans le troisième tutoriel de cette série consacrée à l'utilisation du pool de threads.

Dans le premier article, nous avons vu les avantages qu'il pouvait y avoir à utiliser le pool de threads plutôt que la création de thread, et nous avons vu comment les utiliser.

Dans le second article, nous sommes allés plus loin et nous avons notamment vu comment chaîner des tâches, ainsi que l'utilisation d'une fabrique de tâches.

Dans le présent article, nous allons découvrir les mots clés async et await en C#, qui permettent d'utiliser une approche asynchrone d'une manière presque transparente pour le développeur.

En général, asynchrone ne signifie pas multitâche. Par exemple, le langage JavaScript, qui est asynchrone dans sa philosophie, s'est longtemps exécuté dans un environnement monotâche (l'introduction des Web Workers a quelque peu changé la donne).

Avec la plateforme .Net, la programmation asynchrone est généralement multitâche, et lorsque c'est le cas, utilise des threads… du pool de threads !

Ce tutoriel est décomposé en deux parties :

  • la première, est l'utilisation de async et de await pour mettre en place une programmation asynchrone ;
  • la seconde, plus technique, décrit le fonctionnement interne de cette notion.

II. Mise en œuvre

II-A. Programmation asynchrone

Pour montrer la mise en œuvre d'une programmation asynchrone, nous allons partir sur l'exemple suivant :

Hello world
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
private static string HelloWord()
{
    List<string> list = new List<string>();
    Thread.Sleep(2000);
    list.Add("Hello");
    Thread.Sleep(2000);
    list.Add("world");
    return String.Join(" ", list);
}

Sur cet exemple, on construit la phrase « Hello world » par étapes successives. Les appels à Thread.Sleep sont présents pour simuler une opération prenant du temps. On pourrait imaginer récupérer la phrase mot par mot depuis le réseau par exemple.

Le souci de cette méthode est que l'appelant est bloqué tant que la méthode n'a pas terminé son exécution.

Avec ce que nous avons vu lors des tutoriels précédents, nous pouvons écrire une méthode qui ne bloquera pas l'appelant. Pour cela, au lieu de renvoyer un String, nous allons renvoyer un Task<string>.

Programmation asynchrone
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
private static Task<string> HelloWorld()
{
    return Task<string>.Run(() =>
    {
        Task<string> result;
        List<string> list = new List<string>();

        Task.Delay(2000).Wait();
        list.Add("Hello");
                
        result = Task<string>.Run(() =>
        {
            Task.Delay(2000);
            return "world";
        });

        list.Add(result.Result);
        return String.Join(" ", list);
    });
}

L'appelant ne sera alors bloqué que lorsqu'il aura effectivement besoin du résultat et qu'il y accédera via la propriété Result.

Comme on peut le constater, la mise en œuvre de l'approche asynchrone est relativement lourde. Il faut créer un Task que l'on va retourner à l'appelant. Ensuite, l'instance de Task retournée devra à son tour créer un Task afin d'attendre le mot suivant.

Voyons maintenant la méthode utilisant les mots clés async et await :

Avec async/await
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
private static async Task<string> HelloWorldAsync()
{
    List<string> list = new List<string>();
    await Task.Delay(2000);
    list.Add("Hello");
    await Task.Delay(2000);
    list.Add("world");
    return String.Join(" ", list);
}

On constate que cette version est très proche de la première version synchrone. La séquence des instructions est la même. Les seules modifications présentes sont :

  • la présence du mot clé async au niveau de la signature de la méthode ;
  • l'utilisation du mot clé await avant les appels à Task.Delay.

Du point de vue de l'appelant, les méthodes HelloWorldTask et HelloWorldAsync sont totalement interchangeables. Il n'y a aucune différence d'utilisation entre les deux. Seule la manière d'implémenter l'asynchronisme diffère.

Le mot clé async présent au niveau de la signature de la méthode informe seulement le compilateur que l'on souhaite implémenter une méthode asynchrone, et ainsi d'autoriser l'utilisation du mot clé await au sein même de la méthode.

Son influence s'arrêterait là, si le mot clé ne modifiait pas non plus le type de retour de la méthode. En effet, l'argument passé au niveau du return est bien une instance de String. Or, dans la signature de la fonction, on constate que la méthode retourne un Task<string>. Ainsi, la présence de async transforme automatiquement le type de retour T en Task<T>.

Si la méthode n'a pas de valeur de retour, alors sa signature doit simplement faire apparaître un Task comme valeur de retour. Ensuite, au sein de la méthode, il n'y aura pas d'instruction return.

Le mot clé await sert à préciser les points d'asynchronisme. En présence de ce mot clé, on indique au compilateur qu'à cet endroit précis, on appelle une méthode dont l'exécution sera longue, par exemple, à cause d'une connexion réseau (ou comme dans le cas présent, à cause d'un délai).

Lorsqu'il rencontre ce mot clé, le compilateur va tout simplement générer un code qui, au lieu d'attendre la fin de la méthode, va rendre la main à l'appelant. La méthode continuera son exécution dès lors qu'elle sera en mesure de le faire.

Il est tout à fait possible de définir une méthode async qui n'utilise pas le mot clé await. Cela ne génère pas d'erreur à la compilation, juste un warning.

Corollaire de la remarque ci-dessus. Il est tout à fait envisageable que tous les chemins d'exécution de la méthode n'utilisent pas le mot clé await. La méthode sera alors « synchrone », dans le sens où l'appelant reprendra la main une fois que la méthode aura terminé son exécution.

Le mot clé await ne peut pas s'utiliser avec n'importe quelle méthode. Il y a une condition : il faut que le type de retour de la méthode soit du type Task. Notamment, il n'est pas nécessaire que la méthode « attendue » soit elle-même déclarée avec le mot clé async.

async est véritablement une directive donnée au compilateur, et bien qu'apparaissant au niveau de la déclaration de la fonction, ce mot clé n'influe en rien sur la signature de la fonction. Aussi, il n'est pas possible de déclarer deux méthodes ayant même nom et mêmes paramètres, donc la seule distinction sera la présence ou non de ce mot clé.

III. Contexte

Outre la simplicité syntaxique, ce qui rend cette notion si attractive est la notion de contexte. Une méthode async s'exécutant de manière asynchrone s'exécute dans le même contexte que la méthode appelante.

Dit ainsi, cela n'a pas beaucoup de sens. Aussi, prenons un exemple beaucoup plus parlant : la gestion des interfaces graphiques. Classiquement, une application Windows Form dispose d'un thread graphique, qui est le seul autorisé à mettre à jour des composants graphiques.

Si une opération prend du temps, alors le thread se retrouve bloqué, ce qui gèle l'interface graphique.

Sans thread
Sélectionnez
1.
2.
3.
4.
5.
private void sansThreadButton_Click(object sender, EventArgs e)
{
    LongTask();
    boutonLabel.Text = "Sans thread";
}

Pour éviter d'avoir ce gel, on utilise une autre tâche afin d'exécuter l'opération longue pour ne pas bloquer le thread graphique. Mais lorsque le thread aura terminé son exécution et que l'on souhaitera mettre à jour un composant, on ne pourra pas le faire directement, sous peine de lever une exception InvalidOperationException (reconnaissable aisément au message d'erreur associé : Opération inter-threads non valide).

Sans patron d'invocation
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
private void sansPatternButton_Click(object sender, EventArgs e)
{
    LongTaskAsync().ContinueWith(x => {
        try
        {
            boutonLabel.Text = "Sans pattern";
        }
        catch(Exception ex)
        {
            MessageBox.Show(ex.ToString(), ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
    });            
}
Image non disponible
Opération inter-threads non valide

Généralement, on utilise le patron d'invocation (InvokePattern) afin de contourner le problème :

Avec patron d'invocation
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
private void avecPatternButton_Click(object sender, EventArgs e)
{
    LongTaskAsync().ContinueWith(x => {
        try
        {
            SetTextLabel("Avec pattern");
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.ToString(), ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
    });
}

private void SetTextLabel(string newLabel)
{
    if (boutonLabel.InvokeRequired)
    {
        boutonLabel.Invoke(new Action<string>(SetTextLabel), newLabel);
    }
    else
    {
        boutonLabel.Text = newLabel;
    }
}

On constate que la mise en œuvre du patron n'est pas sans conséquence sur notre code.

C'est là que la notion de contexte prend tout son sens. En effet, le contexte va imposer à la tâche créée de s'exécuter sur le thread graphique. Il sera donc possible de mettre à jour directement un composant sans passer par le patron d'invocation.

Avec async/await
Sélectionnez
1.
2.
3.
4.
5.
private async void avecAsyncAwaitButton_Click(object sender, EventArgs e)
{
    await LongTaskAsync();
    boutonLabel.Text = "Avec async/await";
}

C'est tout ! Il n'y a besoin de rien d'autre ! On retrouve donc la simplicité d'écriture de la fonction « naïve » sans avoir à en subir les inconvénients.

III-A. Un peu plus loin avec les contextes

Il est important de bien comprendre cette notion de contexte de synchronisation dans la mesure où elle influe sur le fonctionnement de async et de await.

Quel que soit le contexte, async et await permettent de réaliser une implémentation asynchrone d'une méthode.

Maintenant, la manière dont cet asynchronisme est réalisé va dépendre du contexte de synchronisation dans lequel on se situe.

III-A-1. Contexte par défaut, ou sans contexte

Le pool de threads est utilisé pour l'exécution des tâches. Le contexte asynchrone est ici multitâche.

Plus précisément, le thread courant exécute la tâche jusqu'à rencontrer un premier point d'asynchronisme (une instruction await).

C'est seulement à partir de ce premier point d'asynchronisme que l'exécution de la tâche reprend, non plus sur le thread courant, mais sur un thread du pool de threads.

III-A-2. Contexte graphique

Le pool de threads n'est pas utilisé. Seul le thread graphique l'est. Nous sommes donc en présence d'un contexte asynchrone mais monotâche.

Aussi, si le thread graphique se retrouve bloqué, alors toutes les tâches se retrouvent également bloquées.

Il peut être facile de se retrouver dans une situation d'interblocage. Un cas typique est d'utiliser conjointement async/await avec la méthode Task.Wait ou d'accéder à Task<T>.Result. Task.Wait ou un accès à Task<T>.Result attendent que la tâche ait terminé son exécution, mais en suspendant le thread en cours d'exécution, et donc ici le thread graphique. L'environnement étant monotâche, le thread graphique ne peut pas exécuter la tâche en attente, ce qui crée une situation de blocage.

Un même code, mais dans le contexte par défaut ne provoquera pas cette situation. Voir la section III.B pour de plus amples informations.

III-A-3. Choix du contexte

Lors de la création d'une tâche, il est possible de définir le contexte que l'on souhaite utiliser. Le choix est restreint, puisqu'il est seulement possible :

  • d'utiliser le contexte courant ;
  • de ne pas utiliser le contexte courant, et donc d'utiliser le contexte par défaut (l'utilisation du pool de threads).

Pour cela, il suffit lors de l'appel d'une méthode asynchrone, d'appeler la méthode ConfigureAwait en lui passant la valeur false.

Définition du contexte d'exécution
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
private async void button1_Click(object sender, EventArgs e)
{
    try
    {
        await LongTaskAsync().ConfigureAwait(false);
        boutonLabel.Text = "Avec async/await";
    }
    catch(Exception ex)
    {
        MessageBox.Show(ex.ToString(), ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error);
    }
}

Le code ci-dessus utilise aussi async/await, mais lève cette fois-ci une exception lorsqu'il essaie de modifier directement la valeur de la propriété boutonLabel.Text. C'est à cause de l'appel à ConfigureAwait qui fait que l'exécution de la tâche se fait en dehors du contexte graphique. Ici, il est donc nécessaire d'utiliser à nouveau le patron d'invocation si on souhaite pouvoir mettre à jour des éléments graphiques.

Il faut bien garder à l'esprit que le choix du contexte n'a d'influence qu'à partir du premier await, lorsqu'il faut reprendre l'exécution d'une tâche suspendue. Avant ce premier await, l'exécution se fait sur le thread appelant.

III-B. Mixage async/await et usage direct du pool de threads

Dans les précédents tutoriels, nous avons vu l'utilisation directe des pools de threads, et dans celui-ci, l'usage indirect via le mécanisme async/await.

Il est important de ne pas utiliser les deux approches ensemble, au risque de se retrouver dans des situations d'interblocage en fonction du contexte dans lequel on se situe.

Dans le contexte par défaut, les risques sont limités, mais dans un contexte monotâche, le blocage est vite venu.

Voici un exemple d'usage mixte :

Usage mixte
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
static void Main(string[] args)
        {
            Task task = usageMixte();
            task.Wait();
            Console.WriteLine("Fin du programme");
        }

        private static async Task usageMixte()
        {
            Task task = Task.Delay(5000);
            await task;            
        }

Ici, on constate que la méthode usageMixte est asynchrone et utilise async/await. Le programme principal récupère l'instance de Task correspondante et utilise la méthode Wait().

C'est exactement le genre de code pouvant mener à un blocage en fonction du contexte d'exécution.

Examinons un peu le fonctionnement du programme afin de comprendre pourquoi :

  1. On récupère l'instance Task via un appel à usageMixte() ;
  2. Lors de l'appel de cette méthode, un await a été rencontré. Autrement dit, la méthode n'a pas fini son exécution, mais a rendu la main au thread courant ;
  3. Le thread continue donc son exécution et arrive sur l'appel à la méthode task.Wait(). Le thread courant se bloque donc jusqu'à ce que la tâche ait terminé son exécution ;
  4. Pour pouvoir terminer l'appel à la tâche, il est donc nécessaire d'aller piocher un thread disponible dans le pool de threads, puisque le thread courant est bloqué.

Ainsi, dans un contexte « classique » multithread, cela ne pose pas de soucis. Par contre, dans un contexte monothread (comme l'est le contexte graphique), cela provoque un blocage, puisque le thread se met en attente d'une tâche que seul le thread graphique peut exécuter !

Ce genre de soucis est particulièrement délicat à détecter, puisqu'il dépend du contexte dans lequel le code s'exécute.

Ne jamais utiliser les deux approches conjointement. C'est à vos risques et périls !

IV. Gestionnaires d'évènements

Précédemment dans ce tutoriel, nous avons vu comment écrire une méthode asynchrone en utilisant le mécanisme async/await, et les répercutions que cela avait sur le code, notamment le type de retour d'une fonction :

  • Task maFonction(), si maFonction ne renvoie pas de valeur ;
  • Task<T> maFonction(), si maFonction renvoie normalement une valeur de retour de type T.

Il existe un dernier cas dont je n'ai pas parlé jusqu'à présent : une fonction asynchrone retournant void au lieu d'un Task ou d'un Task<T>.

Une telle fonction trouve particulièrement son intérêt avec les gestionnaires d'évènements. Il est ainsi possible d'ajouter une méthode asynchrone en réponse à un évènement, et donc, de rendre asynchrone le traitement d'un évènement.

Évènement asynchrone
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Début du programme");
            OnCustomEvent += handlerAsync;
            OnCustomEvent += handlerSync;
            OnCustomEvent(null, null);
            Console.WriteLine("Retour au programme principal");
            Console.ReadLine();
        }

        private static void handlerSync(object sender, EventArgs e)
        {
            Console.WriteLine("Appel synchrone");
        }

        private static async void handlerAsync(object sender, EventArgs e)
        {
            Console.WriteLine("Appel asynchrone début");
            await Task.Delay(2000);
            Console.WriteLine("Appel asynchrone fin");           
        }

        public delegate void OnCustomEventHandler(object sender, EventArgs e);
        public static event OnCustomEventHandler OnCustomEvent;
    }

L'exécution d'un tel programme va avoir la sortie suivante :

Image non disponible
Résultat de l'exécution d'un évènement

L'exécution de ce programme est ce à quoi nous pouvons nous attendre. Les traces insérées, via la méthode Console.WriteLine, nous montrent bien que suite au déclenchement de l'évènement :

  • le premier gestionnaire est appelé. « Appel asynchrone début ». On note l'absence de « Appel asynchrone fin ». Comme l'instruction await a été rencontrée dans le code, l'exécution est redonnée à la tâche parente et le reste de l'exécution sera exécutée de manière asynchrone ;
  • le second gestionnaire est appelé. « Appel synchrone » ;
  • la main est rendue au programme principal ;
  • le gestionnaire asynchrone, qui n'avait pas terminé son exécution, la termine.

Ainsi donc, il est possible de gérer de manière asynchrone un évènement. À noter que la gestion asynchrone ne commence réellement qu'une fois une instruction await rencontrée. Comme plus haut dans ce tutoriel, un gestionnaire déclaré avec le mot clé async, mais n'utilisant pas le mot clé await sera exécuté de manière synchrone.

Une telle méthode asynchrone ne renvoyant pas une instance de Task, il est impossible de gérer la tâche via son instance Task. Notamment, il est impossible de vérifier l'état de son exécution.

Une exception non gérée dans une telle méthode asynchrone met fin immédiatement au programme, même si le code d'appel de l'évènement est dans un bloc try/catch. C'est tout à fait normal puisque le gestionnaire étant asynchrone, il est strictement impossible de savoir où en sera l'exécution du code au moment où l'exception sera levée.

Aussi, dans le cas où une exception risque de se produire, il est nécessaire de l'attraper via un bloc try/catch.

Il est tout à fait possible d'enregistrer des gestionnaires synchrones et d'autres asynchrones pour le même évènement.

V. Approfondissement

Il y a encore tellement de choses à dire sur le sujet.

On pourrait décrire le fonctionnement de ces mots clés, notamment le code généré par le compilateur. Sans entrer dans les détails (qui mériteraient un tutoriel dédié), sachez qu'il s'agit d'une machine à états. Vous pouvez trouver plus d'information sur la MSDN à l'adresse suivante : https://msdn.microsoft.com/fr-fr/library/mt674882.aspx.

Certains frameworks permettent l'utilisation de méthode async/await. Par exemple, le framework MVC met à disposition des mécanismes pour rendre les actions au niveau des contrôleurs asynchrones. https://www.asp.net/mvc/overview/performance/using-asynchronous-methods-in-aspnet-mvc-4. Cela permet de minimiser le temps pendant lequel un thread est occupé par le traitement d'une requête (et donc potentiellement, de traiter plus de requêtes avec un même nombre de ressources).

VI. Conclusion

À travers ce tutoriel, nous avons donc vu les mots clés async et await ainsi que leur utilisation.

Le principal intérêt de cette notion est de faciliter l'écriture de code asynchrone en l'écrivant dans un style presque synchrone.

Dans le prochain tutoriel et dernier tutoriel de cette série, nous verrons comment mettre en œuvre des tâches périodiques selon différentes approches, et surtout les différences entre ces différentes approches.

Je tiens aussi à remercier tomlev pour sa relecture technique et f-leb pour ses corrections orthographiques.

VII. Références

VII-A. Code source

Le code source de tous les exemples fournis à travers ce tutoriel est disponible en libre téléchargement.

VII-B. Références techniques

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2016 François DORIN. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.