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.
Table des matières
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 :
- Le téléphone est synchrone : lorsque vous passez un appel, vous êtes dans l’attente que la communication réussisse (ou non), vous êtes occupé durant le temps de la communication, mais vous devez terminer votre appel avant de lancer le prochain.
- Les emails sont asynchrones : vous pouvez envoyer vos emails les uns après les autres à vos proches sans attendre que le précédent ait été reçu.
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 :
- Les processus attendus : Nous allons lancer un ou plusieurs processus asynchrones, qui s’exécuteront en dehors du processus de base. Puis nous nous assurerons qu’ils se sont bien terminés, soit par sécurité, soit pour récupérer un résultat.
Attention : Attendre instantanément une tâche asynchrone revient à utiliser une tâche synchrone !
- Les processus non attendus : ces processus peuvent être utilisés lorsqu’un travail est demandé, mais qu’il est considéré comme non bloquant, quel que soit son résultat (réussite, échec, ou erreur).
Attention : Bien que ce cas d’utilisation ait son utilité, il présente aussi certains risques. Je vous explique les cas d’usages ainsi que les risques et leurs gestions un peu plus loin.
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.
/// <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 :
- De lancer l’exécution de la tâche
- De vérifier l’état de la tâche à un instant T
- D’attendre son exécution
- De récupérer le résultat une fois qu’elle est terminée
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 :
- Gérer une multitude de tâches qui peuvent être parallélisées (exemple : je veux zipper indépendamment les centaines de fichiers de mon dossier sharepoint).
- Gérer un processus dont on ne sait quand il répondra ou même s’il le fera.
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 :
- au service de base de données
- au système de fichier
- à une API externe (REST, SOAP, autre..)
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, …).