Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Task, qui es-tu ? partie 9

Si l’utilisation de Task n’est finalement pas si compliquée il n’en reste pas moins vrai que de nombreux détails sont à connaitre pour en tirer pleinement partie. Au-delà les choses peuvent se sophistiquer mais toujours sans trop se compliquer, c’est le cas des Continuations.

Liens rapides vers la série complète

Continuations

Nous avons vu comment obtenir les résultats d’une tâche et comment attendre ceux-ci. Souvent des résultats sont attendus pour être traités ou complétés et ce par d’autres tâches puisque la programmation asynchrone devient une norme et une obligation technique.

Dans un tel cas allons-nous écrire des séries de await ?

Non, il existe les continuations, c’est à dire une méthode permettant de lier une tâche à une autre de telle sorte qu’elle débute immédiatement après la fin de la précédente… Pas de blocage, pas de await, rien, c’est encore mieux, en tout cas sur le papier. La tâche à laquelle on attache une autre tâche est appelée l’antécédent.

Les avantage de cette technique sont nombreux tant du point de vue stylistique, de la lisibilité du code. Toutefois là aussi un await semble plus performant et évite les problèmes de scheduler que nous verrons plus loin.

ContinueWith

Le moyen le plus direct de continuer une tâche par une autre est d’enchainer un ContinueWith. Il existe de nombreuses surcharge de cette méthode que la documentation officielle vous détaillera et qui alourdirait ce billet. Le principe est simple : on utilise ConinueWith sur à une Task en passant en général un Delegate qui sera exécuté à la suite de cette dernière.

Si on ne tient pas compte de tous les paramètres de type object, si on fait abstraction des options de type jeton d’annulation, options de continuations etc, on peut en réalité résumer toutes les variantes de ContinueWith à ces deux duos :

Task ContinueWith(Action<Task>, CancellationToken, TaskContinuationOptions, TaskScheduler);
Task<TResult> ContinueWith<TResult>(Func<Task, TResult>, CancellationToken, TaskContinuationOptions, TaskScheduler);

 

Task ContinueWith(Action<Task<TResult>>, CancellationToken, TaskContinuationOptions, TaskScheduler);
Task<TContinuationResult> ContinueWith<TContinuationResult>(Func<Task<TResult>, TContinuationResult>, CancellationToken, TaskContinuationOptions, TaskScheduler);

 

L’un s’applique à Task, l’autre à Task<T>.

On en conclue qu’il y a deux façons de continuer une tâche,en la faisant suivre d’une autre tâche qui soit retourne un résultat (Func<..>) soit n’en retourne pas (Action<..>). Le delegate de la continuation reçoit toujours en paramètre la tâche antécédent. ContinueWith retourne à son tour une Task ce qui signifie qu’une continuation peut à son tour être continuer par un ContinueWith qui retournera une Task etc…

A noter que le dernier paramètre est le TaskScheduler qui sera utilisé pour la continuation. Malheureusement la valeur par défaut n’est pas Taskscheduler.Default mais TaskScheduler.Current. Cela semble causer pas mal de problèmes et de nombreux utilisateurs conseillent de spécifier le scheduler systématiquement en précisant Taskscheduler.Default. Le même problème semble concerner aussi Task.Factory.StartNew. Un développeur averti en vaut deux !

Au final faut-il utiliser les continuations ?

Du point de vue de l’écriture c’est certain. L’intention du développeur est conservée et visible (je veux que la tâche B soit réalisée uniquement une fois que la tâche A sera terminée). Techniquement il apparait que le gain n’est pas décisif et les petites difficultés sur le scheduler incitent à se dire que si c’est pour compliquer le code autant utiliser une série de await…

TaskFactory.ContinueWhenAny

Le principe reste le même, on attache une tâche à la suite d’une autre. Sauf qu’ici ce n’est pas une autre tâche mais un liste de tâche. Et qu’il ne s’agit pas de continuer n’importe quand mais uniquement quand l’une des tâches s’arrête, peu importe laquelle.

Les problèmes de scheduler étant les mêmes que ceux évoqués plus haut on préfèrera utiliser Task.WhenAny(…).

TaskFactory.ContinueWhenAll

Même chose que la précédente avec une nuance : la continuation n’a lieu que lorsque que toutes les tâches de la liste se sont terminées. Les cas d’utilisations semblent plus nombreux. Mais comme les problèmes de scheduler sont les mêmes, ici aussi on préfèrera Task.WhenAny(…).

Task.WhenAll

Retourne une tâche qui se termine quand toutes les tâches passées en paramètre (liste) sont terminées. par exemple :

var client = new HttpClient();
string[] results = await Task.WhenAll(
    client.GetStringAsync("http://example.com"),
    client.GetStringAsync("http://microsoft.com"));
// results[0] est le HTML de example.com
// results[1] est le HTML de microsoft.com

 

Ici la tâche se terminera quand les deux GetStringAsync seront terminés. Puisqu’il y a plusieurs tâches retournant un résultat celui de la tâche globale est en toute logique un liste de résultats… A la fin de l’opération comme indiqué en commentaire du code l’élément 0 de results sera le code HTML de la page example.com et l’élément 1 sera le code HTML de la page microsoft.com.

La liste de tâches étant un IEnumerable on peut passer le résultat d’une requête LINQ. Il est immédiatement réifié mais on pourra ajouter un ToArray() explicite afin de clarifier le code. L’avantage d’une requête LINQ est qu’il possible de se jouer facilement de situations complexes par exemple lorsque le nombre d’éléments est variable :

IEnumerable<string> urls = ...;
var client = new HttpClient();
string[] results = await Task.WhenAll(urls.Select(url => client.GetStringAsync(url)));

 

Ici peu importe le nombre d’URL dans la liste urls, toutes les pages seront retournées en une seule liste, en une fois et ce dès que toutes les pages auront été chargées.

Task.WhenAny

Cette méthode reprend les mêmes principes que la précédente sauf que la sortie à lieu dès qu’une des tâches passées en paramètre se termine.

Il peut y avoir un intérêt notamment quand on souhaite obtenir une information qui peut exister dans plusieurs sources. Par exemple un fichier se trouvant sur des serveurs miroirs. Ce qu’on souhaite c’est télécharger le plus rapidement, et dans ce cas on peut lancer une tâche WhenAny sur plusieurs serveurs à la fois. On récupèrera le fichier qui arrive le premier. Dans cet exemple il faut faire la part entre la logique qui est parfaite et la réalité (plusieurs téléchargements simultanés prendront de la bande passante et se ralentiront mutuellement). Mais on comprend l’idée.

var client = new HttpClient();
string results = await await Task.WhenAny(
    client.GetStringAsync("http://example.com"),
    client.GetStringAsync("http://microsoft.com"));

 

Cet exemple est très proche du précédent utilisé pour WhenAll, tellement qu’il est identique sauf pour l’appel de WhenAny au lieu de WhenAll.

Dans ce cas le résultat n’est plus une liste mais une tâche. Celle qui terminera la première. Ici on récupèrera le code HTML de la page HTML qui sera chargée le plus vite.

Le “double await” peut être troublant à première lecture, mais il permet de simplifier le code. S’il vous gène il suffit de spécifier les types et de séparer le code, cela revient au même :

var client = new HttpClient();
Task<string> firstDownloadToComplete = await Task.WhenAny(
    client.GetStringAsync("http://example.com"),
    client.GetStringAsync("http://microsoft.com"));
string results = await firstDownloadToComplete; 

 

Rappelez-vous que WhenAny retourne une Task<string> dans notre cas. il y a donc nécessité de faire un await pour le WhenAny en lui-même qui est asynchrone mais aussi pour le Task<string> qui est retourné sinon on le perd. D’où les deux await dans la première version. Await qu’on retrouvent aussi dans la seconde version mais pas l’un derrière l’autre ce qui est moins troublant et décompose mieux cette “double” attente pourtant nécessaire.

Conclusion

C’est dans ces petites choses que async/await et Task peuvent devenir “tricky”. On croit que c’est facile et pan! un coup sur le museau pour venir vous rappeler que ce n’est pas si évident que ça ! Sourire

Dans certains cas on pourrait préférer lire Result pour éviter le await, mais le premier agrège les exceptions ce qui complique leur traitement alors que le second ne le fait pas. C’est une raison de préférer await.

Dans la 10me partie nous aborderons la façon de créer des Task (autrement que par leur constructeur ce qui n’est pas recommandé et vous le savez si vous avez lu les 9 parties jusqu’ici !).

 

Stay Tuned !

blog comments powered by Disqus