Licence CC BY

L'asynchrone et le multithread en .NET

async et await sont vos amis.

En informatique, il existe un besoin constant dans les programmes : la performance. Plus précisément, le but est d’accomplir le plus rapidement possible une liste de tâches.

Très souvent, les performances sont apportées par un algorithme de meilleure qualité. Mais il n’est pas rare que l’engorgement subsiste. Certains vous encourageront alors à passer à des "langages performants" tels que le C++.

Ce comportement n’est pas forcément adapté à votre besoin. Développer dans ces langages peut, parfois, s’avérer très complexe. En plus changer de technologie, c’est long.

Alors, continuez à utiliser C# ! Vous n’aurez pas besoin d’un doctorat en programmation système pour rendre vos programmes performants. C# possède plein d’outils qui vous permettront d’optimiser tout ça simplement.

Avant tout, je vous propose de vous poser trois questions :

  • Combien de temps mon programme passe-t-il à attendre?
  • Y a-t-il plusieurs traitements que je puisse faire en parallèle1?
  • Mon programme utilise-t-il au mieux les ressources de l’ordinateur (exemple : un processeur multicoeur)?

Vous vous rendrez vite compte qu’en informatique, on peut tout à fait faire un bébé en un mois du moment qu’on a 10 femmes2, et que cela est rendu possible par les techniques d’asynchronisme.

Le tutoriel se veut accessible mais il faudra que vous ayez les bases en programmation. J’entends par là savoir les structures de contrôle de base (du if au using) et savoir ce que représente une classe. Il serait intéressant que vous ayez déjà créé une interface graphique.

Ce tutoriel vous sera particulièrement utile si vous faites des applications mobiles. Dès qu’une tâche demande d’attendre, les applications mobiles vous obligent à utiliser l’asynchrone.


  1. c’est à dire en même temps les uns que les autres

  2. oui, j’ai bien dit 10 femmes, c’est pas une erreur de calcul.

await/async, un couple inséparable

Le C# se veut dès sa conception un langage moderne. Cela signifie qu’il essaie d’apprendre des erreurs et des succès des langages qui ont existé avant lui.

L’un de ces succès a été la mise en place de systèmes de coroutines et de multithreading facilement programmables.

La méthode choisie pour obtenir cette facilité est souvent1 l’introduction de deux mots clefs async et await. Arrivés en C#5/.NET4.5 ces deux mots clefs sont inséparables.

Utiliser async et await

Ces mots clefs sont plutôt débrouillards : ils exécutent à eux seuls une incroyable quantité d’instructions.

Avant d’en détailler les subtilités, faisons en sorte de garder un esprit clair et définissons LE terme le plus important de ce cours : asynchrone.

Mettons nous dans une situation de votre vie courante : faisons cuire des cookies.

Un résumé de la recette peut être celle-ci:

jusqu'à ce que la file des ingrédient soit vide
   prenez l'ingrédient
   enlevez-le de son emballage/coquille
   déterminez la quantité adéquate
   mettez la quantité dans le récipient
   mélangez
formez les cookies
mettez-les au four
attendre quelques dizaines de minutes
sortez les cookies du four

Pour bien comprendre notre problématique, mettez-vous dans la peau d’un système d’exploitation, ou d’un processeur. Vous avez exécuté une série d’instructions assez basiques, et d’un coup, on vous demande d’attendre.

Alors vous attendez2.

Et si quelqu’un vous demande de l’aide, vous dites "non, je dois attendre".

Avouons que cela est peu pratique. Alors vous avez une idée. Vous vous dites "je sais, je vais mettre un minuteur, et je reviendrai dans la cuisine uniquement quand il sonnera".

Voilà, vous venez de faire votre première action asynchrone.

Maintenant, vous êtes libre d’aider ceux qui vous le demandent. La seule condition: il faudra que vous soyez en mesure d’entendre le bip sonore et que vous ne soyez pas trop loin de la cuisine afin de ne pas laisser trop longtemps les cookies cuire. De même il faut que vous gardiez en mémoire que vous attendez ce bip sonore.

Pour une application en C# ça sera le travail de async et await de faire tout ça. Alors allons-y, codons :

namespace test_cookie
{
    class Program
    {
        static void Main(string[] args)
        {
            faitDesCookies();
            Console.In.ReadLine();
        }
        static async Task faitDesCookies() // indique que la méthode va faire quelque chose d'asynchrone
        {
            Queue<Ingredient> liste = Ingredient.GetRecette();
            while (liste.Count > 0)
            {
                Ingredient ingredient = liste.Dequeue();
                ingredient.Take();
                ingredient.UnPackage();
                ingredient.MesureQuantity();
                Console.Out.WriteLine("ajouté");
                Console.Out.WriteLine("On mélange");
            }
            Console.Out.WriteLine("On forme les cookies et on les met au four");
            // attend pendant 5 secondes mais ne truste pas les ressources
            await Task.Factory.StartNew(() => { Console.Out.WriteLine("On met le minuteur."); 
                                                System.Threading.Thread.Sleep(5000);
                                                Console.Out.WriteLine("DRIIIIIIIIIIIIIIIIING"); });
            Console.Out.WriteLine("Sortir les cookies du four");
        }
    }
}

Juste comme ça, voici la classe ingrédient, pas très bien codée :

   class Ingredient
   {
       public string Name { get; set; }
       public string Action { get; set; }
       public int Quantity { get; set; }
       public void Take()
       {
           Console.Out.WriteLine("J'ai pris " + Name);
       }

       public void UnPackage() {
           Console.Out.WriteLine(Action);
       }

       public void MesureQuantity()
       {
           Console.Out.WriteLine(Quantity);
       }

       public static Queue<Ingredient> GetRecette()
       {
           Queue<Ingredient> liste = new Queue<Ingredient>();
           liste.Enqueue(new Ingredient
           {
               Name = "farine",
               Quantity = 500,
               Action = "Rien de spécial"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "chocolat pâtissier",
               Quantity = 500,
               Action = "Faire des morceaux"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "beurre",
               Quantity = 250,
               Action = "Faire fondre"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "Cassonade",
               Quantity = 200,
               Action = "Enlever les gros morceaux"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "oeufs",
               Quantity = 2,
               Action = "Casser"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "sucre vanillé",
               Quantity = 2,
               Action = "Rien de spécial"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "sel",
               Quantity = 1,
               Action = "Prendre une pincée"

           });
           liste.Enqueue(new Ingredient
           {
               Name = "levure",
               Quantity = 1,
               Action = "Vider le sacher"

           });
           return liste;
       }
   }

Hey, il y a une erreur dans ton code, ta méthode elle retourne pas de Task!

Eh eh! je vous avais prévenu : async et await font vraiment beaucoup, beaucoup de choses. Et notamment il y a une sorte de "return magique" qui est fait.

Plus précisemment, quand vous dites qu’une méthode est async, vous annoncez que ça sera une tâche qui va devoir un jour attendre. Du coup le compilateur va regarder le type de retour de la fonction. Et il s’attend à soit Task soit Task<Un type personnel>.

Une fois cela fait, il va instancier cet objet, et y exécuter le code de la méthode. Si vous avez dit async Task il comprend "la tâche à exécuter ne retourne rien". C’est pourquoi je n’ai pas besoin de faire de return dans ma fonction. A l’opposé si j’avais demandé un async Task<int> il se serait attendu à ce que je retourne un int.

Vous trouverez dans la documentation que async void est aussi possible MAIS il est fortement déconseillé. Il n’est là que pour un seul cas : quand vous voulez créer un event listener. Nous reviendrons plus tard sur ce cas.

Plus tard je fais donc appelle à await. Ce dernier va dire au programme principal "bon là je vais devoir attendre un signal, alors je te laisse libre mais reviens me voir rapidement".

Et ton histoire de Task factomachin là?

await n’est capable d’attendre que des fonctions asynchrones. On a vu qu’en fait une fonction asynchrone est une Task qui a été créé par async. Mais on peut aussi la créer nous même,pour cela, il faut utiliser Task<Votre Type De Retour>.Factory.StartNew(()=>lecode de la fonction; return ce_qu_il_faut). C’est à la main la même chose que lorsqu’on avait fait grâce à async.

L’objet Task volume 1

Task est un objet particulièrement intéressant car il ouvre les portes d’un nombre assez conséquent de fonctionnalités et de concepts. Nous l’explorerons tout au long de ce cours, mais je tenais à vous introduire un deuxième concept important dans ce cours : le parallélisme.

Reprenons notre recette de cookies, et pallions à un problème majeur : nous n’avons pas nettoyé notre cuisine !

L’idée serait de dire "nettoyons notre cuisine pendant que nous attendons".

Techniquement, il existe deux grandes solutions pour paralléliser des tâches :

  • Utiliser deux unités de calculs différentes, autrement dit les coeurs de votre processeur;
  • Utiliser les coroutines.

L’idée derrière les coroutines est celle-ci :

  • lancer un "cadenceur"3 en tâche de fond;
  • lancer les coroutines une par une dans le processus du cadenceur;
  • régulièrement le cadenceur vérifie l’état des coroutines et avertit le programme principal de leur fin.
Schéma de fonctionnement d'une coroutine
Schéma de fonctionnement d’une coroutine4.

L’idée maintenant sera de lancer les Task asynchrones puis de demander "attend qu’elles soient toutes finies".

Et ça se fait très simplement :

List<Task> tasks = new List<Task>();
// On lance les tâches mais on ne les attend pas
tasks.Add(Task.Factory.StartNew(() => { Console.Out.WriteLine("On met le minuteur."); 
                                                System.Threading.Thread.Sleep(5000);
                                                Console.Out.WriteLine("DRIIIIIIIIIIIIIIIIING"); });
tasks.Add(Task.Factory.StartNew(()=> {System.Threading.Thread.Sleep(4000);/*trop facile de nettoyer :p*/});
await Task.WhenAll(tasks);// puis on attend que tout soit fini

Une fonction WaitAll existe mais ce n’est pas une méthode asynchrone, elle met en pause le programme. Je vous conseille d’utiliser cette méthode dans votre fonction main qui ne peut être asynchrone. A l’opposé, partout ailleurs, vous souhaiterez la majorité du temps utiliser WhenAll

Si vos Task retournaient une valeur, un entier par exemple, vous pourriez retrouver l’ensemble des valeurs dans le tableau que retourne WhenAll.

L’objet Task est vraiment primordial, il est à la base de toute un paradigme de programmation asynchrone appellé "Task-based Asynchronous Pattern" qu’on peut traduire par "Programmation Asynchrone basée sur les Tâches". Vous vous doutes bien qu’il existe de ce fait deux autre "Patterns", nous le verrons plus tard. Je vous conseille néanmoins d’utiliser le TAP.


  1. Et cela n’est pas une mince affaire comme le prouve l’exemple de la version 3.5 du langage python

  2. Et le processeur passe beaucoup de temps à attendre, comme le montre ce billet

  3. En anglais, ça se dit scheduler. Toujours bon à garder pour les recherches sur le web.

  4. Vous pourrez trouver plus d’information sur ce lien, en anglais.

TP: Un chargeur de liens web

Le but du TP qui va suivre est de vous permettre de vous mettre en confiance avec les notions que nous venons de voir tout en vous permettant d'explorer l’API.

Il se découpe en trois morceaux:

  1. Le programme de base que vous devez être capable de faire seul. La correction sera néanmoins donnée.
  2. Quelques pistes d’amélioration qui s’appuient sur les notions déjà vues, aucune correction ne sera donnée néanmoins. Je vous conseille de poser vos questions sur le forum avec le tag async.
  3. Une notion pratique un peu plus avancée qui demandera un peu d’exploration et de compréhension.

# L’exercice de base

Énoncé

Votre mission, si vous l’acceptez : créer un système qui permet d’extraire tous les liens présents dans plusieurs pages web. Une fois ces liens obtenus, il vous faudra envoyer une requête vers ces liens et sauvegarder la page html qui leur est associé. En fin de programme, vous afficherez le temps mis pour récupérer toutes ces pages.

Vous pouvez utiliser une application console, WinForm, WPF à votre convenance.

Pour envoyer des requêtes, je vous conseille de regarder HttpWebRequest. Pour traiter le HTML vous pouvez utiliser le package HtmlAgility ou si vous aimez Linq : LinqToXML.

Pour mieux observer l’asynchronisme des tâches, je vous conseille de terminer chaque tâche par un Console.WriteLine("Nom de la tâche " + une_autre_info). De même, je vous conseille d’exécuter plusieurs fois le même programme pour observer l’ordre d’exécution des tâches. Enfin, ayiez un oeil sur votre gestionnaire de ressources. Pour y accéder, accédez au gestionaire des tâche ctrl+Maj+echap puis "ouvrir le gestionnaire de ressources".

Le gestionnaire de ressources nous indique le nombre de threads. 30 dans notre cas
Le gestionnaire de ressources nous indique le nombre de threads. 30 dans notre cas

Notre but n’est pas de faire un aspirateur complet de site web, juste de télécharger les pages actuelles et d’en tirer les liens et d’eux même les télécharger. Vous pourrez bien sûr ajouter vos propres amélioration pour faire un aspirateur MAIS, je tenais à vous rappeler que si vous désirez aspirer un site web, il est fortement recommandé d’en demander l’autorisation au propriétaire afin que vous ne mettiez pas en péril sa disponibilité.

Correction

using HtmlAgilityPack;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace WebGetter
{
    class Program
    {
        static void Main(string[] args)
        {
            List<string> urls = new List<string>();
            string[] _urls = {"http://francoisdambrine.me"};
            urls.AddRange(_urls);
            GetLinksThenDownloadPages(urls);
            Console.ReadLine();
        }
        static async void GetLinksThenDownloadPages(IEnumerable<string> urls)
        {
            List<string> downloadedUrls = await GetLinks(urls);
            await DownloadFilesFromLinks(downloadedUrls);
        }
        static async Task<List<string>> GetLinks(IEnumerable<string> startUrls)
        {
            List<Task<List<string>>> tasks = new List<Task<List<string>>>();
            foreach(string url in startUrls)
            {
                tasks.Add(DownloadLinksFromUrlAsync(url));
            }
            return (from list in await Task<List<string>>.WhenAll(tasks)
                    from link in list
                    select link).ToList();
        }

        static async Task<List<string>> DownloadLinksFromUrlAsync(string url)
        {
            HttpWebRequest request = WebRequest.Create(url) as HttpWebRequest;
            HttpWebResponse response = await request.GetResponseAsync() as HttpWebResponse;
            using (var streamReader = new StreamReader(response.GetResponseStream()))
            {
                string htmlCode = streamReader.ReadToEnd();
                HtmlDocument parsed = new HtmlDocument();
                parsed.LoadHtml(htmlCode);
                IEnumerable<string> aElement = from element in parsed.DocumentNode.Descendants()
                                               where element.Name.ToString().ToLower() == "a"
                                               where element.Attributes["href"] != null
                                               select element.Attributes["href"].Value;
                return aElement.ToList();
            }

        }

        static async Task WriteFileFromUrl(string url)
        {
            try {
                HttpWebRequest request = WebRequest.Create(url) as HttpWebRequest;
                HttpWebResponse response = await request.GetResponseAsync() as HttpWebResponse;
                Guid g = Guid.NewGuid();
                using (StreamReader streamReader = new StreamReader(response.GetResponseStream()))
                {
                    using (StreamWriter writer = new StreamWriter(g.ToString() + ".html"))
                    {
                        await writer.WriteAsync(await streamReader.ReadToEndAsync());
                        Console.WriteLine(g.ToString() + ".html : " + url);
                    }
                }
            }
            catch(UriFormatException exception)
            {
                Console.Error.WriteLine(exception.Message);
            }
            catch(WebException exception)
            {
                Console.Error.WriteLine(url + " was correct but not downloaded due to " + exception.Message);
            }
        }

        static async Task DownloadFilesFromLinks(IEnumerable<string> list)
        {
            List<Task> tasks = new List<Task>();
            foreach(string url in list)
            {
                tasks.Add(WriteFileFromUrl(url));
            }
            await Task.WhenAll(tasks);
        }
    }
}
correction

Si vous avez une version de visualstudio qui le supporte, vous pourrez observer sur le profileur que le processeur est vraiment peu utilisé par notre programme mais qu’il l’est plus souvent que si nous devions attendre à chaque fois le téléchargement de chaque page.

Le profileur nous dit que le processeur attend beaucoup
Le profileur nous dit que le processeur attend beaucoup

Quelques améliorations

Comme je l’ai dit avant de proposer la correction vous pouvez tout à fait songer à aspirer un site web entier.

Pour cela il faudra donc être un peu plus efficace sur la manière de faire :

  • il faudra faire en sorte que les liens vers l’extérieur du site ne soient pas pris en compte;
  • il faudra trouver un moyen de ne pas télécharger deux fois le même lien;
  • il faudra aussi télécharger les images et les ressources externes telles que les CSS et les stocker dans le bon sous dossier;

Allons plus loin : la progression des tâches

Le TAP ne permet pas à priori d’ajouter le concept de "progression", dans le flot d’exécution des tâches. En effet, comme le TAP ne s’occupe que du concept de "tâche", ce que le programme sait faire avec async et await c’est démarrer la tâche, détecter qu’elle est terminée et nettoyer le contexte.

Pour s’occuper de la progression, il faudra déléguer cette fonctionnalité à une autre classe. Le Framework .NET vous propose alors une interface IProgress<T> pour vous assurer qu’à chaque fois que vous devez faire cette délégation elle est faite sur le même modèle.

Vous devrez donc choisir le type de données qui sert à notifier la progression. La pratique la plus courante au sein du framework .NET est d’implémenter un IProgress<long> qui vous permet de notifier une estimation numérique de l’avancée.

Par exemple, si vous êtes en train de lire un fichier, vous pouvez faire cette classe:

class AfficherLesProgresDeLecture: IProgress<long>{
    public DateTime StartDate{get; private set;}
    public AfficherProgresDeLecture(){
        StartDate = DateTime.Now();
    }
    public Report(long value){
        int seconds = (DateTime.Now() - StartDate).Seconds
        Console.out.WriteLine(value.ToString() + " octets lus en " + seconds.ToString() + "secondes");
    }
}

Ensuite il suffit d’appeler await stream.ReadAsync(buffer, 0, 1024, new AfficherLesProgresDeLecture());. Ainsi la méthode ReadAsync appellera la méthode Report à chaque fois qu’elle lit 1024 octets.

Notons que pour vous éviter à réimplémenter une classe complète mais juste la méthode Report, la classe Progress<T> existe et vous permet de préciser l’ensemble des méthodes à réaliser à chaque rapport du progrès grâce à l’évènement OnReport.

Je vous laisse explorer un peu l’API pour créer une barre de progression dans la ligne de commande ou pour mettre à jour le composant ProgressBar dans une application graphique.

Si vous avez besoin d’aide, n’hésitez pas à poser vos questions sur le forum avec les tag async.

Partage de ressources

Lorsque l’on exécute un programme ligne après ligne, on ne se rend pas compte combien on se facilite la tâche en ce qui concerne les ressources.

Par exemple, revenons à notre recette.

Lorsque nous avions parallélisé nos tâches, nous avions d’un côté la mise en route du four et d’un autre le nettoyage de la cuisine.

Pour simplifier les choses j’avais juste dit qu’à chaque fois on ne faisait qu’attendre. Mais en fait ça va un peu plus loin.

Pour "faire cuire" disons que la tâche se dégage ainsi:

prendre le plat
ouvrir le four
mettre le plat dans le four
fermer le four
attendre 15 minutes
ouvrir le four
prendre le plat
enlever les cookies du plat
poser le plat dans l'évier pour nettoyage

tandis que la partie "nettoyage" se détaille en :

nettoyer le plan de travail
pour chaque ustensile ou plat dans l'évier:
    nettoyer l'ustensile
    rincer l'ustensile
    essuyer l'ustensile
    ranger l'ustensile

Du coup le plat à cookie, on le lave ou pas?

En effet, que faisons nous? On peut se rendre compte que notre plat pose problème : il est utilisé par les deux tâches. Je pense qu’il est évident pour tous qu’il ne peut à la fois être dans le four et être nettoyé. Alors, que faire?

En fait il faudra modifier la seconde tâche pour dire:

nettoyer le plan de travail
pour chaque ustensile ou plat dans l'évier:
    attendre que l'ustensile soit disponible
    nettoyer l'ustensile
    rincer l'ustensile
    essuyer l'ustensile
    ranger l'ustensile

La solution paraît facile dit comme ça, mais, elle peut mener à pas mal de problèmes, notamment un dead lock c’est à dire une coroutine A qui attend qu’une coroutine B libère une ressource. Pendant ce temps la coroutine B attend que A libère la même ressource.

Ce cas arrivera souvent quand vous voudrez partager une liste d’objets entre plusieurs Task pour que certaines consomme la liste alors que d’autres y ajoute des objets.

Je vous conseille fortement d’éviter de partager des ressources. Préférez exécuter d’abord toutes les tâches qui obtiennent des objets puis celles qui les consomme.

Par contre si vous n’y arrivez pas, lire la suite vous permettra de vous en sortir.

Pour attendre qu’une ressource soit "disponible", il existe globalement deux possibilités mises en place par le système d’exploitation:

  • La sémaphore (qui peut être un domaine très complexe à comprendre)
  • Le MutEx, abréviation de Mutuellement Exclusif.

L’un comme l’autre sont à envisager dans le cadre du multi thread et du multiprocess. Mais ils demandent des notions que ce cours n’a pas pour but d’aborder. Du coup, nous allons utiliser ce que .NET appelle SemaphoreSlim, qui est un objet qui permet de gérer les ressources des coroutines/Task.

Cet objet se trouve dans System.Threading et il se base sur deux compteurs :

  • le premier est le compteur de ressources libre. A priori le nombre de ressource libre initial doit valoir 1 ou 0.
  • le second est le compteur de ressource disponibles au maximum. A priori il sera toujours de 1.

L’utilisation se fait ainsi:

        static void Main(string[] args)
        {
            Task t = RunAsync();
            t.Wait();
            Console.In.ReadLine();
        }
        public static async Task RunAsync()
        {
            using (SemaphoreSlim verrouRessource = new SemaphoreSlim(0, 1))// on crée un sémaphore qui ne peut libérer qu'une ressource mais qui la marque comme occupée pour l'instant
            {
                Queue<int> ressourcePartage = new Queue<int>();
                //on prépare les tâches
                List<Task> taches = new List<Task>();
                Console.Out.WriteLine("Start write");
                taches.Add(EnvoieDansLaFile(ressourcePartage, verrouRessource));
                Console.Out.WriteLine("Start read");
                taches.Add(LireLaFile(ressourcePartage, verrouRessource));
                verrouRessource.Release();//on peut commencer
                await Task.WhenAll(taches.ToArray());
            }
        }
        public static async Task EnvoieDansLaFile(Queue<int> file, SemaphoreSlim verrou)
        {
            for (int i = 0; i < 15; i++)
            {
                await verrou.WaitAsync();//on attendq que la ressource soit libre
                file.Enqueue(i);//on utilise la ressource
                verrou.Release();//on dit qu'on a finit d'utiliser la ressource pour l'instant
                System.Console.Out.WriteLine("On a ajouté " + i.ToString());
                Thread.Sleep(100);//on attend histoire que l'exemple soit utile
            }
        }

        public static async Task LireLaFile(Queue<int> file, SemaphoreSlim verrou)
        {
            bool first = true;
            await verrou.WaitAsync();//on va utiliser la ressource
            while (file.Count > 0 || first)
            {
                // pour éviter le cas où cette tâche serait exécutée avant la première fois où on met un entier
                if (file.Count > 0 && first)
                {
                    first = false;
                }
                System.Console.Out.WriteLine(file.Dequeue());
                verrou.Release();//on enlève le verrou
                System.Console.Out.WriteLine("on attend le prochain tour");
                Thread.Sleep(150);
                await verrou.WaitAsync();//on va utiliser la ressource au prochain tour de boucle
            }
        }

Le résultat montre bien le phénomène :

Exécution en parallèle avec ressource partagée
Exécution en parallèle avec ressource partagée

Vous noterez que j’ai initié mon SemaphoreSlim avec aucune ressource disponible. Cela m’a permis de déclencher les tâches de manière à ce qu’elles exécutent toute la partie préparatoire puis bloque sur la ressource. Ensuite j’ai dit que la ressource était libre, ce qui a eu pour conséquence de lancer les tâches.

Le cas particulier de la fenêtre graphique

Si jusque là j’ai présenté les "ressources" comme quelque chose dans laquelle on peut lire ou écrire (ce qui peut être une liste, une imprimante, un objet connecté…), il existe un cas particulier qu’il me faut absolument vous présenter : l’interface graphique.

Si ce cas est si particulier, c’est qu’il est particulièrement verrouillé pour éviter de donner au logiciel un état instable.

De plus, votre interface graphique doit toujours être fluide. Et c’est là que les problèmes arrivent. En effet, si votre interface doit réagir à une action de l’utilisateur, disons un click, l’évènement est exécuté de manière synchrone au moteur de rendu de la fenêtre. C’est à dire que votre code sera exécuter par le même code qui affiche la fenêtre.

Cela pose donc un problème lorsque vous devez faire des calculs importants ou que vous devez attendre. Pendant le temps de l’exécution de votre évènement, l’interface sera figée.

Heureusement, l’objet Task et à async/awaint vous éviteront ce soucis : le thread principal continuera de s’exécuter de manière fluide.

## Cas numéro 1 : la modification de l’interface se fait avant ou après la tâche, pas pendant

Pour gérer ce cas, vous n’aurez pas vraiment de nouveau concept à apprendre, vous serez peut être simplement heureux d’apprendre qu’un délégué peut tout à fait être async. Par exemple, si vous avez une tâche asynchrone à réaliser au click :

public async Task VotreTache(){
   await QuelquechoseDeLong();
}

private async void DoClick(object sender, EventArg args){ //votre délégué pour le click
    this.Button1.Text = "Before the task"
    await VotreTache();
    this.Button1.Text = "After the task"
}

Rien de très compliqué, on notera simplement que le listener d’événement est le seul cas où l’on tolère que votre fonction asynchrone retourne void.

## Cas numéro 2: la tâche elle-même modifie l’interface au cours de son exécution

Ce cas arrivera par exemple si votre tâche récupère des données de plusieurs sources différentes. Les affiche, puis les traite pour par exemple changer la couleur d’un indicateur ou l’état d’une barre de progression.

Il me faut alors dire que temporairement, le temps de changer l’interface, la tâche (ou une sous tâche) doit s’exécuter dans le thread de la GUI. Pour cela, nous allons utiliser la notion de contexte d’exécution.

Si les Task peuvent être démarrées dans un thread différent, ce n’est pas une nécessité. Vous pouvez leur demander, de s’exécuter dans le même thread que la tâche parente ou dans un contexte qui ne demande pas l’établissement de nouveaux thread.

A l’opposée, les parties de la tâche que vous désirez paralléliser peuvent être lancées dans une threadpool.

public async Task VotreTache(){
   
   await QuelquechoseDeLongQuiNeTouchePasALaGui().ConfigureAwait(continueOnCapturedContext:false);
   await QuelquechoseDeLongQuiToucheALaGui().ConfigureAwait(continueOnCapturedContext:true);
}

private async void DoClick(object sender, EventArg args){ //votre délégué pour le click
    this.Button1.Text = "Before the task"
    await VotreTache().ConfigureAwait(continueOnCapturedContext:true);//on continue sur la GUI
    this.Button1.Text = "After the task"
}

Cas numéro 3 : l’objet Task Chapitre 2

Vous connaissez peut être l’installateur de VisualStudio qui possède deux barres de progression : une pour le téléchargement et une pour l’installation.

Le principe de cet installateur est de vous faire gagner du temps en installant les fichiers déjà téléchargés et profiter de ce temps d’installation pour télécharger les autres.

L'installateur de VS2013
L'installateur de VS2013

Si on devait représenter le code abstrait de cette fonctionnalité ça serait :

Procédure d'installation de VS
Procédure d'installation de VS

On peut donc imaginer un système avec des événements. Mais comme finalement chaque tâche est très séquencée, on peut simplement dire :

  • télécharge
  • puis notifie la barre d’avancement
  • puis attend que l’installation soit disponible
  • puis installe
  • puis notifie la barre d’avancement

et lancer ces tâches de manière parallèle.

L’objet Task possède une méthode qui exprime cela : ContinueWith qui a le bon goût de permettre à chaque ContinueWith de s’exécuter dans le contexte désiré (donc l’UI par exemple).

Pour simplifier les choses j’ai supposé que chaque objet "dépendance" représentait un ensemble de paquets à installer indépendant des autres (comme ça on n’a pas à gérer les conflits).

Le code final pourrait ressembler à ça :

public async Task DownloadAndInstall(List<Dependance> dptList)
{
   List<Task<Dependance>> tasks = new List<Task<Dependance>>();
   foreach(Dependance dpt in dptList)
    {
        Task<Dependance> downloadTask = Download(dpt).ContinueWith(
                                (downloadTask, dpt)=>{ progressBarDownload.update(1); 
                                                  return dpt;},
                                TaskScheduler.FromCurrentSynchronizationContext()
                            ).ContinueWith((t, dpt)=>{ InstallDependance(parentTask, dpt); return dtp;}
                            ).ContinueWith(
                                (installTask, dpt)=>{ progressBarInstall.update(1); return dpt;},
                                TaskScheduler.FromCurrentSynchronizationContext()
                            );
        tasks.Add(downloadTask);
     }
     await Task.WhenAll(tasks);
}

Vous noterez que la dépendance est passée de tâche en tâche et qu’à chaque fois la tâche parente aussi.

Les autres modèles de programmation

EventBased Asynchronous Pattern

Vous vous souvenez sûrement de la première utilité que j’ai donnée à la programmation asynchrone dans le premier chapitre: éviter de bloquer le programme lorsqu’il attend quelque chose.

Nous avons observé la manière la plus conseillée de faire lorsqu’on utilise C# : le task-based asynchron pattern. Mais une des premières méthodes qui a été historiquement développée dans le langage utilisait une philosophie différente directement basée sur la programmation événementielle.

En C# implémenter un évènement est très simple il suffit de déclarer dans notre classe public event TypeEvenement OnEvenement; puis d’y ajouter les actions qui doivent se passer lorsque l’événement est déclenché.

De ce fait tout un pan de la programmation asynchrone peut être pilotée à partir d’événements. C’est ce qu’on appelle l’EventBased Asynchron Pattern.

Le principe est de se baser sur une convention qui appellera les méthodes et les événements associés au fur et à a mesure. Les règles sont les suivantes :

  1. Créer une méthode FaireQuelqueChose. Elle sera charger de faire la chose de manière synchrone ou bloquante
  2. Créer la méthode FaireQuelqueChoseAsync

    • elle ne retourne rien
    • elle a les mêmes paramètres que FaireQuelqueChose
  3. Créez un événement qui s’appelle FaireQuelqueChoseCompleted et qui a la signature que vous désirez par exemple public event FaireQuelqueChoseCompletedHandler FaireQuelqueChoseCompleted;
  4. Le plus simple étant de définir FaireQuelqueChoseCompletedHandler comme ceci : public delegate void FaireQuelqueChoseCompletedHandler (object sender, FaireQuelqueChoseCompletedEventArgs e); (sans oublier de définir FaireQuelqueChoseCompletedEventArgs)

Bref vous l’aurez compris, ça peut vite devenir long et répétitif à implémenter, tous les conseils se trouvent ici. Sachez juste que les BackgroundWorker du framework .NET fonctionnent selon ce principe.

#Asyncronous Programming Model

En complément du EAP, le framework .NET propose d’ajouter une autre manière de faire de l’Asynchrone à partir d’une interface nommée IAsyncResult.

Le principe est celui-ci :

  • séparer la tâche en trois parties :

    • le lancement (BeginFaireQuelqueChose)
    • faire la chose
    • terminer et traiter le résultat (EndFaireQuelqueChose)

Ce patron de conception est détaillé ici et est présent dans les objets tels que HttpWebRequest comme vous avez pu le constater.


Nous voici donc à la fin de ce tutoriel sur l’utilisation de la programmation asynchrone avec C#. Souvenez vous de ces trois règles d’or :

  • Lorsque vous commencez à faire de l’asynchrone, faites le de bout en bout;
  • Toujours retourner un Task ou un Task<quelquechose> sauf si vous créez un écouteur d’événement;
  • Configurez le contexte pour que par défaut il s’exécute sur plusieurs thread sauf dans le cas d’une tâche que touche à l’UI.

6 commentaires

Coucou,

Petites coquilles :

  • Je ne sais pas si on dit "au mieux" au lieu de "aux mieux".
  • await/async ont été introduit en C#5 pas C#3.
  • Je crois qu'on dit "série d'instructions" (au pluriel) et pas "série d'instruction".
  • Je crois qu'on dit "fonctions asynchrones" (au pluriel) et pas "fonctions asynchrone".
  • L'expression c'est "Votre mission, si toutefois vous l'acceptez", pas "Votre mission, si vous l'acceptez" (autant aller jusqu'au bout :D )

Sinon le tuto ne dit pas c'est quoi la différence entre utiliser async/await et un pool de connexion avec des threads par exemple (avantages/inconvénients, lequel privilégier dans quel situation ?).
Y a-t-il un thread implicitement créé à chaque fois que l'on ajoute un Task ?
Comment l'ordonnanceur gère le temps accordé et la transition de l'exécution d'un Task puis d'un autre ?

+0 -0

Sinon le tuto ne dit pas c'est quoi la différence entre utiliser async/await et un pool de connexion avec des threads par exemple (avantages/inconvénients, lequel privilégier dans quel situation ?).

La facilité.

Y a-t-il un thread implicitement créé à chaque fois que l'on ajoute un Task ?

Si, c'est dit. Si on configure le Task pour qu'il ne soit pas sur le même contexte d'exécution, il lancera un thread.

Comment l'ordonnanceur gère le temps accordé et la transition de l'exécution d'un Task puis d'un autre ?

C'est hors scope du cours.

Pour les orthotypo, je les corrigerai. Mais il y a le bouton "signaler une erreur" tu sais.

La facilité.

Facilité… d'écriture ? Tout ce qui je vois c'est un nouveau pattern pour faire du multithread, malheureusement je ne ressens pas la facilité pour autant car je ne saisi pas la différence avec l'écriture traditionnelle, les avantages que cela procure et le fonctionnement interne.

Si, c'est dit. Si on configure le Task pour qu'il ne soit pas sur le même contexte d'exécution, il lancera un thread.

Donc Task.WhenAll crée juste 1 thread dans lequel tous les Task s'exécutent séquentiellement par ordre d'ajout ? Ou 1 thread peut passer d'un Task à un autre et revenir sur un Task par lequel il est déjà passé jusqu'à complétion de tous les Task ? :-/

Mais il y a le bouton "signaler une erreur" tu sais.

Je ne le savais pas, je vois souvent les erreurs écrit dans les commentaires, maintenant si je trouve d'autres erreurs/coquilles j'utiliserais cette fonctionnalité ;)

Sinon par rapport à tes exemples, en effectuant quelques recherches, j'ai vu qu'on privilégiais l'utilisation de Task.Run() à Task.Factory.StartNew() depuis .NET 4.5 (source).

+0 -0

our autant car je ne saisi pas la différence avec l'écriture traditionnelle, les avantages que cela procure et le fonctionnement interne.

  • Facilité d'écriture
  • Orientation en mode "Tâche" permet d'être proche de la pensée humaine. L'impératif technique qu'est le Thread (ou pourquoi pas le Process enfant si tu utilises un contexte de multiprocessing) est masqué. Cela permet notamment de mettre en exergue les problèmes de ressources partagées. En plus cela découple bien plus facilement le contexte d'exécution et l'exécution elle même.

Donc Task.WhenAll crée juste 1 thread dans lequel tous les Task s'exécutent séquentiellement par ordre d'ajout ? Ou 1 thread peut passer d'un Task à un autre et revenir sur un Task par lequel il est déjà passé jusqu'à complétion de tous les Task ? :-/

Je ne sais pas, car en plus d'être configurable c'est quelque chose dont je me fous totalement en fait. Je délègue la gestion de la ressource processeur au framework, c'est pas à moi, programmeur de l'application de s'inquiéter pour ça. Les ingénieurs de chez microsoft l'ont forcément mieux fait que moi.

Sinon par rapport à tes exemples, en effectuant quelques recherches, j'ai vu qu'on privilégiais l'utilisation de Task.Run() à Task.Factory.StartNew() depuis .NET 4.5 (source).

Oui, c'est quelque chose que j'ajouterai dans mon tutoriel plus tard. L'idée de ce tuto est avant tout de faire comprendre le principe de tâche bloquante et la manière de régler le problème le plus simplement qui soit. Task.Run n'est qu'un wrapper avec 8 surcharges. Il est clairement plus "orienté débutant" d'expliquer les concepts un par un avec des exemples précis que de balancer un wrapper qui a 8 surcharges et dire "bon là c'est quand tu veux l'annuler, là c'est quand tu veux l'exécuter dans un contexte…". En plus Task.run ne se trouve pas encore dans la majorité des programmes open source. Or comme programmer c'est avant tout lire du code, je préfère donner des points de repères fiables à mes lecteurs. Par contre le tuto évoluera, oui, et il parlera de task.run.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte