Utilisation des « Tasks » dans .NET Framework

Par Thibault Herviou, consultant d’IVIDATA LINK

Ividata Life Sciences
Ividata LINK
11 min readAug 8, 2017

--

Entre les interfaces graphiques souhaitées de plus en plus réactives et les machines multi-cores, il est impossible de ne pas/plus prendre en compte les tâches, qu’elles soient parallèles, continues ou asynchrones.

C’est pourquoi le .Net intègre des Framework pour simplifier l’écriture de Threads s’exécutant simultanément afin que nos applications restent réactives tout en maximisant les performances de l’ordinateur de l’utilisateur.

TPL (Task Parallel Library)

La bibliothèque parallèle de tâches (TPL) s’appuie sur le concept de tâches (« Tasks »), qui représente une opération asynchrone sur le modèle des « Delegates Tasks». On peut parler de parallélisme car cette bibliothèque permet de lancer plusieurs tâches indépendantes qui s’exécutent simultanément pour effectuer la même opération sur les différents objets d’une collection.

Pour mieux comprendre ce concept, voici un exemple d’une action séquentielle et son équivalent parallèle de cette même opération sur une liste d’objet :

Ici la TPL va découper la collection en plusieurs parties de charge de travail équivalentes. Ce qui va permettre de paralléliser les actions à effectuer. Comprendre, les exécuter en même temps, et donc réduire le temps d’exécution de notre méthode.

Explications

La TPL repose sur le concept de « Task », pour effectuer des opérations asynchrones, mais il ne faut pas chercher à comparer les « Tasks » à un « thread ». En effet, on est à un niveau d’abstraction supérieur ce qui nous permet d’en tirer plusieurs avantages :

  • Elles sont plus efficaces au niveau des ressources système.
  • Nous avons un meilleur contrôle au niveau de l’attente, de l’annulation et des exceptions.

Mais il faut faire attention à ne pas utiliser ce concept à tout va, car il ne faut pas croire que c’est gratuit, la parallélisation ajoute de la complexité et surcharge le code CIL. C’est pourquoi dans certains cas, cela peut entraîner une exécution plus lente du code et il faut garder à l’esprit que tout ne peut pas être parallélisé dans une application.

Pour résumer, cette bibliothèque fournie des classes et des outils de diagnostic pour faciliter et développer du code parallèle efficace. Elle simplifie le partitionnement du travail et la planification des « Threads » au niveau du « ThreadPool ». Je ne vais pas m’étendre sur le sujet, mais, le pool de threads (« ThreadPool ») permet d’utiliser les « Threads » de manière plus efficace en fournissant à votre application des threads de travail gérés par le système.

Comme cet article a pour but de fournir des bases solides et éviter les pièges courant pour utiliser efficacement la bibliothèque TPL, nous allons voir dans un premier temps la création de « Tasks ».

Travailler avec les Tasks.

« Task » est un objet qui représente une opération asynchrone, il est possible de retourner un résultat ou non en fonction des besoins. C’est pourquoi on peut voir aussi « Task<TResult> » qui représente une opération unique retournant une valeur de type « TResult ».

Le travail effectué par un « Task » s’exécute généralement sur le thread de pool de threads au lieu du thread de l’application. Le but est d’éviter les goulots d’étranglement au niveau des performances et améliorer la réactivité globale du code. Du coup il n’y a pas de problème à exécuter 100 voire même 1000 tâches en même temps, car le « ThreadPool » gère la concurrence et l’exécution des tâches pour nous.

Il faut tout de même savoir initialiser une tâche, et bien sûr il y a plusieurs façons de procéder, nous allons voir les méthodes les plus courantes.

Initialiser des Tasks.

Pour initialiser une tâche, il est possible d’utiliser les expressions lambdas, des méthodes anonymes ou bien des déclarations de type déléguées.

Prenons l’exemple d’une expression lambda. Ici, on initialise une nouvelle tâche, puis on la démarre. Ce qui reviens à demander au planificateur de tâches courantes (TaskScheduler), par défaut le pool de Threads, de mettre notre action dans sa fille d’attente :

Cela peut se simplifier :

Depuis le .NET Framework 4.5 les méthodes « Run » sont une des manières préférées de créer et de démarrer des tâches lorsqu’un contrôle supplémentaire sur la création et la planification de la tâche n’est pas nécessaire. Attention cela ne rend pas la méthode « Task.Factory.StartNew » obsolète pour autant, c’est plutôt un raccourci qui permet de s’affranchir de plusieurs paramètres d’initialisation.

Lorsque vous passez une action à « Task.Run » :

Cela équivaut exactement à :

Ce qui couvre les cas les plus courants. Pour les autres cas, il est possible d’utiliser « Task.Factory.StartNew » explicitement lorsque l’on souhaite modifier le comportement de création et / ou d’exécution de la tâche, par exemple pour effectuer des opération de longue durée :

En effet, une tâche de plus d’une seconde est considérée comme longue, il est préférable d’empêcher cette tâche d’être mise en file d’attente locale. Cela permet de ne pas bloquer les autres éléments de travail sur la file d’attente locale.

Il est aussi possible de fournir un objet d’état lors de la création de la tâche. Souvent, on utilise des expressions lambdas pour créer un « delegate » lors de la création d’un « Task », cela facilite l’utilisation de variables disponibles dans le scope de la déclaration. Le problème avec ce type d’expressions est qu’elles utilisent des références lors de la déclaration des variables, et donc la valeur des variables ne seront lues/utilisées qu’à l’exécution. Voici un exemple :

Comme vous pouvais le voir, l’identifiant de chaque tâches est le même, à savoir la dernière valeur de i.

C’est un piège courant que l’on peut retrouver avec l’utilisation de LINQ et PLNQ.

Pour remédier à cela, dans notre exemple, on va déporter la responsabilité de la création de notre « CustomData » au niveau de la déclaration de notre objet d’état. :

Cette modification nous a permis de fournir un objet « CustomData » par tâches créées et non plus le même int i pour toutes les tâches. Il est possible d’accéder à notre objet via la variable « AsyncState » du « task » créé.

Maintenant que l’on sait créer et exécuter une tâche, nous allons voir comment obtenir le résultat lorsque l’on en a besoin.

Obtenir le résultat

Que ce soit pour vérifier que le traitement s’est déroulé sans erreur ou pour récupérer le résultat de notre tâche, il y a de bonnes pratiques à prendre en compte.

Dans un premier temps, pour obtenir la valeur du résultat d’une tâche, il faut obligatoirement utiliser un « Task<TResult> » pour pouvoir disposer de la propriété « Result ».

On accède à cette propriété lorsque l’on en a besoin, à savoir que la lecture bloque le Thread appelant si le résultat n’est pas encore disponible, ce qui revient à attendre la fin de la tâche.

En effet, « Wait() » et « Result » bloquent de façon synchrone le thread appelant jusqu’à ce que le résultat soit disponible. Et ce n’est pas une bonne pratique car ils peuvent créer des « deadlocks » et surtout il n’y a plus d’intérêt à faire de tâches.

Il est aussi possible de récupérer le résultat en utilisant GetAwaiter().GetResult() depuis le Framework 4.5 pour obtenir le résultat de notre tâche. Le membre « GetAwaiter » est arrivé en même temps que le couple async/await, c’est pourquoi il n’est pas souvent utilisé de cette façon, en fait « GetAwaiter » est utilisé par « await ».

Vous l’aurez compris, la méthode la plus efficace pour attendre/obtenir le résultat d’une tâche est d’utiliser le couple «async / await » qui permet d’attendre de façon asynchrone le résultat ou l’exception. Nous verrons comment utiliser ce couple dans un autre article, mais avant il y a d’autres notions à voir, comme par exemple savoir annuler une tâche.

Annuler une Task.

Dans nos applications modernes, pouvoir annuler une tâche est indispensable, il se peut que l’utilisateur ait lancé une action par erreur ou tout simplement qu’il ait changé de choix, du coup, l’attente du résultat est inutile. Pour ce faire, on va utiliser un « cancellationToken », comme son nom l’indique c’est un jeton d’annulation, il permet de propager une notification indiquant que l’opération doit être annulé.

Mais il y a deux subtilités, la première est qu’un « cancellationToken », elle permet d’interdire le démarrage de la tâche au niveau du « TaskScheduler » mais n’annule pas une tâche en cours d’exécution. Cela provoque un « TaskCanceledException ».

La seconde est, que si l’annulation est possible cela lève une exception de type « OperationCanceledException » et bien sûr cette dernière est propagée au thread appelant.

Voici un exemple d’utilisation des « cancellationToken » :

Comme vous pouvais le voir il y une façon bien spécifique de gérer les exceptions, ce qui nous permet de faire la transition vers leurs gestions.

Gérer les exceptions

Les exceptions non gérées sont propagées dans le « Thread » appelant, sachant que l’on peut attendre plusieurs tâches dans un même « try/catch » toutes les exceptions sont encapsulées dans un « AggregateException ». Il y a donc une façon bien spécifique de les gérer :

Comme on vient de le dire, les exceptions sont propagées au niveau du « Thread » appelant, alors que si l’on utilise la méthode « Wait() » et/ou si l’on utilise la propriété « Result ». Il est tout de même possible de récupérer l’exception à partir de la propriété « Exception ». Mais contrairement à « Wait() » et « Result » le thread appelant n’est pas bloqué et cette propriété retourne « null » tant qu’il n’y a pas eu d’exception. De plus, cela peut prêter à confusion, il faut savoir que s’il y a eu une erreur lors de l’exécution de notre tâche, la propriété « IsCompleted » sera à « true ».

Il est tout de même possible de récupérer/attendre le résultat sans agréger les exceptions en utilisant « GetAwaiter().GetResult() » de notre objet « Task ».

Conditions de course

Les tâches peuvent être connectées hiérarchiquement et séquentiellement.

Séquentiellement, chaque tâche commence après que la précédente soit finie, ce que nous verrons dans la prochaine partie.

Hiérarchiquement, on peut parler de tâches enfants et parents. Une tâche dite enfant est créée dans une autre tâche appelée parent. Par défaut, une tâche enfant est détachée de son parent, on entend par là qu’elles s’exécutent indépendamment l’une de l’autre. Pour synchroniser des deux parties, il faut utiliser l’option « AttachedToParent » comme ceci :

Sans cette option le résultat serait plutôt :

Notez que si une tâche parente est configurée avec l’option « DenyChildAttach », l’option « AttachedToParent » de la tâche enfant n’a aucun effet, et la tâche enfant s’exécute en tant que tâche enfant détachée.

Voici un tableau qui récapitule ce que nous venons de voir.

Ce qu’il faut retenir c’est que le thread courant n’attend pas que les threads enfants soient terminés pour se terminer, on parle de thread d’arrière-plan. Il faut bien comprendre que par défaut les threads contenus dans le pool de thread sont en arrière-plan.

Création de tâches continuelles

En programmation asynchrone il est courant d’appeler une opération après l’exécution d’une autre.

Si l’on souhaite enchaîner une suite d’action, la TPL fournie des méthodes qui s’appellent « ContinueWith ». Cela permet d’exécuter de nouvelles actions lorsque la tâche précédente est terminée tout en fournissant à la tâche terminée l’objet d’état de la tâche précédente. Cela apporte un avantage au niveau de la lisibilité du code, en effet, cela permet de connaître l’intention du développeur, nul doute qu’il veut attendre la fin de la première tâche avant de lancer la suivante.

Attention les tâches suivantes seront exécutées sur « TaskScheduler.Current » ce qui peut poser des problèmes, mais il est possible de forcer la valeur sur « Taskscheduler.Default » ou bien d’utiliser la méthode « TaskScheduler.FromCurrentSynchronizationContext » pour spécifier qu’une tâche doit être planifiée pour fonctionner sur un thread particulier. Cela s’avère utile dans des projets WPF pour utiliser le même « Thread » que celui de l’interface utilisateur. Sinon, il est impossible de modifier l’interface utilisateur.

Malgré quelques contraintes, cette notion apporte un autre niveau de souplesse à la programmation asynchrone, il est entre autre possible de :

  • Passer des données de l’antécédent à la continuation ;
  • Spécifier les conditions précises sous lesquelles la continuation doit être ou non appelée ;
  • Annuler une continuation avant qu’elle ne soit lancée ou pendant son exécution de manière coopérative ;
  • Appeler plusieurs continuations depuis le même antécédent ;
  • Appeler une continuation quand certains ou tous les antécédents se terminent ;
  • Chaîner des continuations les unes à la suite des autres à une longueur arbitraire ;
  • Utiliser une continuation pour gérer des exceptions levées par l’antécédent.

Conclusion

Pour conclure, il existe plusieurs Framework d’asynchronisme en .NET dont PLINQ ET TPL.

Il faut savoir que depuis le .NET Framework 4, la bibliothèque TPL est le moyen privilégié pour produire du code multithread et parallèle. Cette dernière simplifie l’utilisation des tâches au premier abord, mais nous pouvons très vite nous retrouver avec une application moins réactive, voir pire, instable si l’on ne comprend pas un minimum le mécanisme.

Cet article ne couvre que la notion de « delegate Task », pour l’implémentation du modèle « Promise Task », on utilisera le couple Async/Await dans mon prochain article.

A propos de l’auteur :

Thibault Herviou : Architect logiciel / développeur d’application, je suis avant tout technophile / bidouilleur en tous genres, du raspberry pi aux imprimantes 3D en passant par le brassage de bières amateur. De nature curieuse, je suis adepte du « DIY », en effet, j’aime avoir la satisfaction de faire les choses par moi-même, mais surtout, pouvoir comprendre le fonctionnement des objets/technologies qui m’entourent.

--

--

Ividata Life Sciences
Ividata LINK

Ividata Life Sciences est un cabinet de conseil dédié aux acteurs de la santé : industrie pharmaceutique, biotechnologies, dispositifs médicaux, cosmétologie…