Site icon A DevOps journey

L’asynchrone avec .Net Core : Premiers pas

D’expérience, la gestion de l’asynchrone n’est pas forcement intuitive pour un développeur ayant peu de pratique sur le sujet. On a l’habitude de travailler, de penser en processus synchrone, les uns après les autres.
Pourtant, l’asynchrone est souvent incontournable en développement! Avec l’avènement du multi-core et des API, c’est un outil puissant, qu’on utilise parfois « sans faire exprès », ou sans le comprendre parfaitement.
Faisons donc un petit tour du sujet, entre théorie et pratique.

Qu’est-ce qu’un processus asynchrone ?

Révision d’un processus synchrone

Par défaut, la programmation est, ce que l’on appelle synchrone. C’est à dire que, lorsqu’on code une instruction, elle est exécutée et résolue avant de pouvoir passer à la suivante.

Dans l’exemple, cas classique du processus synchrone : la fonction A est exécutée, donne son résultat et se termine, puis c’est au tour de la fonction B.

À l’inverse donc, un processus asynchrone permet de lancer simultanément plusieurs instructions à la machine. On demande une instruction à la machine, et indépendamment du résultat (réussite, lenteur, erreur), on passe à l’instruction suivante. Le processus devient ainsi non bloquant.

Pour faire un rapide parallèle avec la vie courante, on peut, par exemple, comparer avec les communications par téléphone (en 1v1) ou par email :

Comparaison avec un processus asynchrone

Pour en revenir au code, côté machine, c’est le système d’exploitation qui va être chargé de gérer les différents processus qui vont s’exécuter en parallèle. Les processus ainsi parallélisés peuvent être utilisés de deux façons différentes :

Si on reprend le même schéma qu’au-dessus, on peut voir que la fonction A est appelée. Mais avant que la fonction n’ait donné son résultat, la fonction B est également appelée. Son exécution étant plus rapide que pour la fonction A, nous avons donc le résultat de B avant A. Le temps total d’exécution du programme s’en trouve réduit.

Exemples de code : synchrone vs asynchrone

Les captures d’écran ci-dessous montrent le même scénario, mais sous forme d’exemple concret. L’application est développée en .Net Core avec Visual Studio. Les sources sont accessibles sur mon github.
J’ai une fonction « ExecuteHeavyTask » qui simule un process lourd, et une fonction « ExecuteLightTask » qui simule un process plus léger.
La capture de gauche nous présente un processus synchrone classique. La capture de droite, elle, nous montre le même enchainement de tâches, mais via un processus asynchrone.
Sur le processus synchrone, on peut constater que la fonction « Heavy » est exécutée, puis la fonction « Light », et que le temps total d’exécution correspond bien au cumul des deux.
Sur le processus asynchrone, au contraire, la fonction « Heavy » est bien exécutée avant la fonction « Light », mais c’est cette dernière qui se termine en premier ! Le temps total d’exécution du programme correspond à la plus lente des deux fonctions.

Un exemple de code synchrone
Exemple de code asynchrone

    /// <summary>
    /// Class with 2 methods to easily compare sync and async 
    /// </summary>
    public static class SimpleExample
    {

        /// <summary>
        /// Execute ExecuteHeavyTask and ExecuteLighTask on a classic way
        /// </summary>
        static public void SimpleDemoSync()
        {
            Console.WriteLine("begin simple demo SYNC at " + DateTime.Now.TimeOfDay);
            ExecuteHeavyTask();
            ExecuteLightTask();
        }

        /// <summary>
        /// Execute ExecuteHeavyTask and ExecuteLighTask with an async process
        /// </summary>
        static public async void SimpleDemoAsync()
        {
            var tasks = new List<Task>();

            Console.WriteLine("begin simple demo ASYNC at " + DateTime.Now.TimeOfDay);

            /*
             * You can easily create a task and run it with Task.Run
             */
            var heavyTask = Task.Run(() => { ExecuteHeavyTask(); });
            tasks.Add(heavyTask);

            /*
             * I create a task, with function i want to execute in parameter
             * At this line, function was NOT executed
             * I save the result (task) in a variable to manage this later
             */
            var lightTask = new Task(ExecuteLightTask);

            //I set the Task in a list, to wait all task in one instruction
            tasks.Add(lightTask);

            /*
             * I execute the task. The task execute my function ExecuteHeavyTask. 
             * Without this line, my function was never executed.
             * I can use this to save all my futur process in a list, and fire all process in one shot
             */
            lightTask.Start();

            //I wait all task here. While all task are not ended, we are blocked here
            Task.WaitAll(tasks.ToArray());

            Console.WriteLine("All tasks ended");
        }

        /// <summary>
        /// Simulation for a heavy process un my app
        /// </summary>
        static private void ExecuteHeavyTask()
        {
            Console.WriteLine("Begin - Heavy task done at " + DateTime.Now.TimeOfDay);
            Thread.Sleep(10 * 1000);
            Console.WriteLine("End - Heavy task done at " + DateTime.Now.TimeOfDay);
        }

        /// <summary>
        /// Simulation of a process lighter than "ExecuteHeavyTask"
        /// </summary>
        static private void ExecuteLightTask()
        {
            Console.WriteLine("Begin - Light task done at " + DateTime.Now.TimeOfDay);
            Thread.Sleep(2 * 1000);
            Console.WriteLine("End - Light task done at " + DateTime.Now.TimeOfDay);
        }
    }

Comment ça fonctionne ?

En .Net, la gestion des process asynchrones se passe principalement à travers de la classe Task, elle même enfant de la classe System.Thread. Dans un premier temps, nous allons voir ce que font les tasks, les comprendre, et savoir comment les manipuler.

Si on reprend l’exemple asynchrone ci-dessus, on peut voir que le premier changement effectué est de créer une Task dans laquelle on passe en paramètre la fonction qu’on veut exécuter en asynchrone.

new Task(ExecuteHeavyTask)

Le retour de l’objet Task est stocké, ce qui est très important pour la suite. Cela nous permet :

En l’occurrence, dans l’exemple nous lançons l’exécution des deux tâches l’une après l’autre, puis nous les attendons toutes avec l’instruction WaitAll.

Il est possible d’attendre chaque tâche aussi indépendamment.


Task heavyTask = Task.Run(() => { ExecuteHeavyTask(); });
Task lightTask = new Task(ExecuteLightTask);
lightTask.Start();
heavyTask.Wait();
lightTask.Wait();

De même, si la fonction retourne un résultat, vous pouvez récupérer ce dernier au travers du mot clé await, ou de la propriété Result.
Attention : Il est important de privilégier l’utilisation du mot-clé await. Nous aborderons les raisons lors de l’article suivant : les cas standards.

Task<bool> heavyTask = Task.Run(() => { ExecuteHeavyTask(); return true; });
Task<int> lightTask = Task.Run(() => { ExecuteLightTask(); return 99; });
bool heavyResult = await heavyTask;
int lightResult = lightTask.Result;

Exemples de cas d’usages

Il est facile d’exprimer l’asynchrone en programmation : un appel à un composant externe est par définition asynchrone.

Il y a en effet deux cas d’usage aux processus asynchrone :

Or, tout appel externe devrait, en programmation, être considéré comme un process « hors de contrôle ». Cette définition couvre tout ce qui n’est pas le logiciel directement, qu’il soit ou non sous votre contrôle. On va retrouver notamment les appels :

Il est donc très fréquent de tomber sur des fonctions asynchrones, bien qu’on les rende souvent synchrones en les attendant directement. Le cas le plus commun est l’appel à la base de données. Un appel à la base de données est un processus externe, dont le programme ne sait pas si et quand il répondra. On ne sait pas si la base de données est allumée avant de lancer l’appel, ni si elle est surchargée et prendra le temps de répondre.

L’exemple ci-dessus est donc asynchrone. Mais cet appel est souvent nécessaire, et on le rend alors souvent synchrone car on souhaite le résultat avant d’entreprendre une quelconque action (par programmation via un await, ou il peut arriver que se soit le framework qu’on utilise qui s’en charge).

Dans les articles suivants, nous allons voir les différents cas d’usages de la programmation asynchrone (simple, boucles, base de données, …), et les pièges basiques à éviter (gestion d’erreur, fermeture des tasks, …).

Quitter la version mobile