L’asynchrone avec .Net Core : les cas standards

Maintenant qu’on a vu un peu la théorie et quelques exemples simples sur ce qu’est l’asynchrone, on va pouvoir rentrer dans le vif du sujet avec des cas concrets que vous rencontrerez probablement durant le développement.
Tous les exemples restent accessibles sur le github de la démo.

Les fonctions Async

Il est facile d’écrire des fonctions asynchrones en .Net. Nous avons vu dans l’introduction une méthode via la création de Tasks avec Task.Run().

Une méthode que vous rencontrerez souvent consiste à rendre sa fonction asynchrone en ajoutant simplement le mot-clé async à sa fonction. Dans le tableau ci-dessous, on peut voir la même fonction avec ou sans async. Vous remarquerez que dans l’exemple avec le mot-clé async, la fonction génère un warning par Visual Studio (soulignement en vert). Notre méthode a une fonction purement synchrone, sans utilisation du mot-clé await. L’IDE nous informe donc que malgré l’utilisation d’async, l’exécution sera synchrone !

C’est un cas un peu particulier, que nous expliquerons en détail un peu plus loin. Il faut surtout retenir ici qu’une fonction async doit utiliser le mot-clef await en son sein.

Exemple de méthode synchrone
Exemple de méthode async, mais dont l’exécution sera synchrone

Pour avoir une méthode réellement asynchrone donc, sans utilisation de Task créée manuellement, nous allons donc devoir gérer un process asynchrone à l’intérieur même de la fonction. Rassurez vous, comme signalé dans l’introduction de cette série d’articles, c’est un cas plutôt commun en développement.
Dans l’exemple utilisé ci-dessus, nous avons un process d’écriture dans un fichier. La méthode que nous utilisons (StreamWriter.Writeline) nous propose une méthode synchrone, mais il s’agit en fait d’une abstraction de la méthode asynchrone qui est automatiquement attendue.
Nous allons donc directement utiliser la méthode asynchrone, et l’attendre nous-même. Dans l’exemple ci-dessous, vous pouvez voir aussi que la fonction a été renommée avec le suffixe « Async » pour signaler le caractère asynchrone, et retourne automatiquement un objet Task, ce qui permet par la suite de vérifier l’état de la tâche à un instant T et/ou de l’attendre. Notre fonction est cette fois réellement devenue asynchrone.

Le framework nous propose l’utilisation de la méthode asynchrone

Cas des fonctions attendues (await)

Dans la grande majorité des cas, les fonctions asynchrones qui seront utilisées seront attendues, car on a généralement besoin du résultat, ou de s’assurer que le fonctionnement s’est déroulé correctement.

Pour attendre une fonction asynchrone, il existe plusieurs méthodes :

  • le mot-clé await, comme vu plus haut, qu’on utilise au sein d’une méthode asynchrone
  • L’utilisation de la méthode Wait (pour attendre) ou Result (pour attendre et récupérer le résultat) sur l’objet Task retourné par la méthode Async
  • L’utilisation de tableau de Task avec Task.WaitAll() (qui attend que l’ensemble des taches du tableau soit terminé) ou Task.Any() (qui attend que n’importe quelle tâche soit terminée au sein du tableau en paramètre)

Le fait d’attendre une tâche possède plusieurs avantages :

  • S’assurer du bon fonctionnement, ou non, de la tâche
  • Récupérer la valeur de retour
  • Catch l’exception dans la fonction qui a lancé le processus asynchrone

Attention cependant : attendre directement une tâche asynchrone revient à en faire une tâche synchrone (c’est un peu plus complexe que ça, mais on est sur un article de vulgarisation :)). Dans l’exemple ci-dessous, nous aurions pu utiliser la méthode WriteInFile plutôt que await WriteInFileAsync et arriver au même résultat.

await WriteInFileAsync("d:/WriteLinesAwaited.txt", "Awaited test");
await WriteInFileAsync("d:/WriteLinesAwaited2.txt", "Another test");

Si on souhaite optimiser le temps de traitement dans nos deux fichiers, nous aurions pu écrire le même code en conservant les objets Task au sein d’un tableau, et en attendant l’ensemble. Nous évitons ainsi d’attendre la fin du process 1 pour attaquer le process 2.

var taskList = new List<Task>();
taskList.Add(WriteInFileAsync("d:/WriteLinesAwaited.txt", "Awaited test"));
taskList.Add(WriteInFileAsync("d:/WriteLinesAwaited2.txt", "Another test"));

await Task.WhenAll(taskList);

Cas des fonctions non attendues (discard)

Les fonctions asynchrones non attendues sont peu courantes, car elles présentent certains désavantages assez importants.

  • Absence de retour direct de la fonction (si on souhaite récupérer la valeur d’un return)
///Si on appelle cette fonction avec un [await AwaitedFileDemoAsync()], le résultat renvoyé est bien un booléen, car on attend la fin de son exécution pour avoir son retour final
///Si l'on n'attend pas le résultat, c'est un objet "Task" qui est renvoyé, qui représente le processus en cours d'exécution. On peut attendre la fin de l'exécution loin dans le code.
static public async Task<bool> AwaitedFileDemoAsync()
{
	var path = "d:/WriteLinesAwaited.txt";
	WriteInFile(path, "First line");
	await Task.Run(() => { WriteInFile(path, "Second line"); });
	await WriteInFileAsync(path, "Third line");

	return true;
}
  • En cas d’exception au sein d’une tâche non attendue, impossible de catch sur le process principal

Sur l’exemple ci-contre, on peut voir que le premier process (non attendu) ne lève aucune exception.

Le second, attendu, permet au contraire de catch l’exception levée à l’intérieur.

  • Si le process ayant lancé la tâche se termine avant la fin de cette dernière, elle est détruite sans attendre la fin de son exécution

Sur l’exemple ci-contre :

  • une première version du process est lancée avec un await, et s’exécute sans soucis dans la console
  • Une deuxième version est lancée en asynchrone, sans await. Tout se déroule sans accroc car la fonction DemoAsyncProcessCutBeforeEnd continue de fonctionner en parallèle
  • La troisième version est détruite avant d’avoir pu écrire le log « After sleep », car la fonction ayant lancée le process s’est terminé avant que le processus sous-jacent soit lui-même terminé

Malgré ces désavantages importants, certains cas peuvent être utiles, notamment quand on a besoin d’exécuter des actions non critiques pour le coeur de l’application, qui ne doivent pas être bloquantes, et dont on peut permettre la perte éventuelle. Voici quelques cas :

  • L’inscription de logs de debugs, qui pourrait connaitre des exceptions ou des lenteurs
  • L’appel à une API externe pour un service considéré comme non critique (remonté de tracking marketing par exemple)
  • etc.

Comment fonctionne exactement l’async/await?

Nous avons vu plus haut comment se présentait les fonctions async, et à quoi servait le mot-clé await. L’utilisation semble simple, d’autant que l’IDE nous assiste sur ce type de process.

Pourtant, on a pu voir tout au début un cas un peu particulier : une fonction async, mais dont l’IDE nous prévenait que son exécution serait synchrone. C’est justement les cas que nous allons étudier ici.

Parfois, vous aurez de la chance et l’IDE vous préviendra de ce cas étrange: vous le verrez. Mais la plupart du temps, vous ne le verrez pas ou, pire, l’IDE ne préviendra pas. Et le comportement de l’application peut être inattendu.

Nous allons voir: dans quel cas l’exécution d’une fonction async se fera de façon synchrone, et dans quel cas ne le fera t-elle pas?
Ce qu’il faut bien comprendre, c’est que le mot-clé await permet d’attendre un process asynchrone, c’est-à-dire qu’une fonction en bout de chaine gère un ou plusieurs objets Task en parallèle, et autorise cette gestion. L’utilisation d’async/await au travers des fonctions du développeur permet de consommer, en bout de chaine, ce type de fonction, qu’elles aient été codées par le développeur ou proposées au travers de librairies. Avec quelques exemples proposés ci-dessous se sera plus clair.

Exemple d’erreur 1 : fonction async sans await

Une première composante essentielle est la présence d’une fonction async au sein de la fonction, que l’on va attendre avec le mot clé await. Sans cela, l’IDE repère facilement le souci et vous préviendra directement que quelque chose cloche. Dans un tel cas, si votre fonction est déclarée async, mais sans avoir de gestion de await en interne, alors elle s’exécutera comme si elle n’était pas async.

public static async Task FalseAsyncFunction()
{
	Console.WriteLine("i'm a async function, but i work like a classic function");
}

Exemple d’erreur 2 : fonction async avec un faux await

Autre besoin fondamental : il faut avoir une vraie gestion du await en bout de chaine. En effet, cela ne sert à rien d’imbriquer une fonction async dans une autre fonction async en espérant la rendre asynchrone. Non seulement ça ne fonctionnera pas, mais en plus de cela, l’IDE sera incapable de vous alerter.

Exemple d’erreur 3 : fonction async avec un await bypass

Enfin, il faut que votre fonction async utilise le mot clé await. Si le await fait partie d’un conditionnel (if/else par exemple) et n’est pas utilisé, alors la fonction ne sera pas asynchrone.

/// <summary>
/// This function can be use as async when useAsyncTimer = true
/// If useAsyncTimer = false, this function was always executed synchroniously, with or without await
/// </summary>
/// <param name="processNumber"></param>
/// <param name="useAsyncTimer"></param>
/// <returns></returns>
public static async Task ProcessWithTimer(int processNumber, bool useAsyncTimer = true)
{
	Console.WriteLine("Before sleep : " + processNumber + " - " + DateTime.Now.ToString("mm:ss"));

	if (useAsyncTimer)
		await Task.Delay(1000);
	else
		Thread.Sleep(1000);

	Console.WriteLine("After sleep : " + processNumber + " - " + DateTime.Now.ToString("mm:ss"));
}

Voici, ci-dessous, deux exemples du code présenté juste au-dessus en faisant varier le booléen pour voir les effets du synchrone et de l’asynchrone avec le async/await. Dans le cas où la fonction utilise réellement un await, la fonction est bien considérée et exécutée de façon asynchrone. Les process 2 et 3 sont exécutés en parallèle, comme le montre la console. Dans le cas où on passe par Thread.Sleep, et donc plus par un await, la fonction est exécutée de façon synchrone, comme le montre le résultat de la console. Les appels à la fonction sont exactement les mêmes.

Le processus tourne de façon synchrone, malgré la présence d’async/await
L’exécution du même code, mais de façon asynchrone. Les process 2 et 3 s’exécutent simultanément. Le process 3 est détruit avant d’être terminé

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *