Dot.Blog

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

Task, qui es-tu ? partie 10

Lancer des Task de type Delegate peut prendre différents chemins, nombreux sont ceux qui sont obsolètes ou qui n’ont que peu d’intérêt, finalement le choix se réduit à peu de possibilités essentielles à connaitre.

Liens rapides vers la série complète

Les constructeurs

Comme nous avons pu le voir au cours de cette série l’utilisation des constructeurs de Task est exceptionnelle et sans véritable intérêt pour la vaste majorité des développements. La raison est que les constructeurs de Task retournent une tâche non planifiée (associée à aucun scheduler) et que cela n’apporte rien dans la pratique.

Que reste-t-il alors pour créer des Task de type Delegate et les exécuter ?

(la différence entre Delegate Task et Promise Task est traité dans les parties précédentes de cette série)

TaskFactory.StartNew

StartNew autorise la création et le lancement d’une tâche à partir d’un delegate sans valeur de retour (un Action) ou avec (Func<Result>). Cette méthode retourne une Task typée correctement basée sur le delegate et sa valeur de retour éventuelle.

Il faut noter qu’aucun des delegate traités par StartNew ne sont de type “async aware” ce qui occasionne des complications quand le développeur veut utiliser StartNew pour démarrer une tâche asynchrone. Alors que Task.Run sait gérer des tâches adaptées à un contexte asynchrone.

De fait StartNew est trop souvent utilisé au détriment de Task.Run qui pourtant représente une solution mieux adaptée à la majorité du code moderne.

StartNew existe en plusieurs versions proposant des paramètres plus ou moins nombreux, environ 16, pour les Action ou les Func<Result>. Si on simplifie ces différents constructeurs il reste fonctionnellement deux variantes (simple jeu de l’esprit, ces variantes n’existent pas dans le Framework) :

Task StartNew(Action, CancellationToken, TaskCreationOptions, TaskScheduler);
Task<TResult> StartNew<TResult>(Func<TResult>, CancellationToken, TaskCreationOptions, TaskScheduler);

 

Dans les variantes “réelles” les paramètres non présents sont remplacés par des valeurs par défaut. Et ces dernières proviennent de l’instance de TaskFactory. CancellationToken est par exemple initialisé à TaskFactory.CancellationToken s’il n’est pas spécifié dans l’un des constructeurs utilisé. Idem pour les TaskCreationOptions ou le Scheduler.

CancellationToken

Savoir lancer une tâche est une chose, savoir l’arrêter à tout moment en est une autre. Cet aspect ne doit pas être négligé car dans une programmation moderne et fluide l’utilisateur ne doit jamais être bloqué à attendre quelque chose dont il n’a pas ou plus besoin (erreur de manipulation, changement de choix…).

Mais le paramètre CancellationToken est souvent mal compris. Il est vrai que son rôle n’est pas tout à fait intuitif.

Beaucoup de développeurs même expérimentés pensent qu’un CancellationToken leur permettra d’arrêter le delegate exécuté par la tâche à tout moment. Il est vrai que c’est à quoi on pourrait s’attendre. Mais ce n’est pas ce qui arrive… Le CancellationToken qu’on passe à un StartNew n’a de pouvoir d’annulation uniquement avant que la tâche exécute le delegate ! Dit autrement, CancellationToken dans un StartNew ne permet que de stopper le démarrage du delegate, pas celui-ici une fois lancé.

Une fois le delegate lancé CancellationToken ne sert plus à rien. En réalité il peut servir, mais ce n’est pas magique, si on veut que la tâche puisse être arrêtée par CancellationToken le delegate doit lui-même observer la valeur du token par exemple en utilisant un CancellationToken.ThrowifCancellationRequested. A partir de ce moment là CancellationToken va fonctionner tel qu’on le pense au départ. Mais uniquement à partir de ce moment…

Comme vu plus haut, toutes les variantes de StarNew ne possèdent pas un paramètre CancellationToken qui est alors remplacé par la valeur par défaut de TaskFactory. Ce qui signifie, et c’est ce que montre les deux variantes imaginaires, que dans tous les cas un CancellationToken est passé à tâche. Soit celui qu’on indique soit celui par défaut donc.

De fait un delegate lancé par StartNew sans utiliser de variantes avec CancellationToken peut tout de même manipuler ce dernier et éventuellement annuler l’opération en cours en levant l’exception de Cancellation Requested.

Quelle est la différence entre un delegate qui agit de la sorte au sein d’un StartNew sans précision du token et un autre autre qui serait démarrer par un StartNew précisant le token ?

La différence est subtile mais elle mérite d’être connue !

Dans le cas où le delegate observe le token et qu’il annule la tâche il lèvera OperationCancelledException. Si StartNew est utilisé sans précision du token la tâche sera retournée comme Faulted avec l’exception indiquée. Mais i le delegate lève une OperationCancelledException depuis le même CancellationToken passé à StartNew alors la tâche sera retournée non plus Faulted mais Canceled. Et l’exception est remplacée par une autre : TaskCanceledException.

Pour faire simple disons que si on prévoit d’arrêter une tâche il est nécessaire de passer un CancellationToken à StartNew et de l’observer dans le corps du delegate. Cela garantit que la tâche peut bien être arrêtée même une fois démarrée le tout avec un état de sortie correspondant à la situation (état Canceled et non pas Faulted).

Arrêt dans un code asynchrone

Certes la différence que nous venons de voir n’a pas un impact gigantesque, la tâche est arrêtée. Mais l’état de sortie de la tâche et l’exception levée sont différents. Et cela peut poser quelques soucis dès lors qu’on utilise les patterns habituels pour vérifier si une tâche a été arrêtée ou non… Pour du code asynchrone par exemple on await la tâche et on protège le code pour attraper l’exception OperationCanceledException :

try
{
  // "task" démarrée par StartNew, et soit StartNew soit
  // la tâche observent le token de cancellation.
  await task;
}
catch (OperationCanceledException ex)
{
  // ex.CancellationToken contient le token de cancellation,
  // si votre code en a besoin.
}

 

Arrêt dans un code synchrone

Dans le cadre d’un code synchrone await ne sera pas utilisé. A la place on appellera Task.Wait ou Task.Result. Dans ce cas on obtiendra une exception agrégée de type AggregateException dont il faudra inspecter la InnerException pour voir s’il s’agit de OperationCanceledException :

try
{
  // même conditions que le code précédent
  task.Wait();
}
catch (AggregateException exception)
{
  var ex = exception.InnerException as OperationCanceledException;
  if (ex != null)
  {
    // ex.CancellationToken contient aussi le token
    // comme le code précédent
  }
}

 

Token dans le StartNew ?

Au final l’utilisation du token d’annulation dans StartNew ne fait que compliquer les choses sans apporter grand chose puisqu’il n’agit que sur le démarrage. En revanche le token est utile s’il est pris en charge par le delegate lui-même, peut importe s’il a été spécifié dans le StartNew ou non.

Les effets étant légèrement différents, chacun adoptera la solution qui lui semble être la plus claire pour son code mais utiliser un token dans StartNew n’offre rien d’intéressant et dans tous les cas il ne fait pas ce à quoi on s’attend…

TaskCreationOptions

La création d’une tâche est quelque chose de finalement assez simple et direct, alors à quoi peuvent servir les options de créations ?

Tout se joue dans la création donc dans la planification de la tâche. Les options passées ici sont transmises au scheduler. Le mode PreferFairness indique à ce dernier d’utiliser une planification de type FIFO. LongRunning est une indication qui spécifie au planificateur que la tâche en question sera longue dans tous les cas ce qui permet de mieux optimiser le fonctionnement des autres tâches éventuelles. Le scheduler crééra un thread spécifique pour la tâche longue en dehors du thread pool.

Le plus intéressant n’est pas dit : ces options ne sont que des indications données au scheduler et il n’y a aucune garantie qu’il les prenne en compte ni même s’il le fait de quelle façon il le fera…

Dautres options ne concernent pas le TaskScheculer dans son fonctionnement. Par exemple HideScheduler ajouté dans .NET 4.5. La tâche sera planifiée en utilisant le scheduler spécifié mais durant l’exécution de la tâche la librairie indiquera qu’il n’y a pas de scheduler courant… On suppose que cela permet de contourner une erreur assez sournoise sur le scheduler par défaut. On verra plus bas ce qu’il en est en abordant le TaskScheduler.

L’option RunContinuationsAsynchronously ajouté dans .NET 4.6 force toutes les continuations de la tâche à s’exécuter de façon asynchrone. Il s’agit de cas d’utilisation un peu limites et il difficile comme ça de trouver un exemple de l’utilité pratique de cette option, mais je suis certain que ceux qui rencontreront le besoin seront content de s’en servir !

Les options de parenté sont intéressantes car elles modifient la façon dont la tâche est reliée à la tâche en cours d’exécution. Les tâches enfants attachées changent le comportement de leur classe parent. Ce mode de fonctionnement est particulièrement bien adapté à la parallélisation dynamique des tâches. Toutefois en dehors de ce scénario assez limité ces options n’ont que très peu d’intérêt. AttachedToParent attache la tâche comme un enfant de la tâche en cours d’exécution. En réalité ce n’est pas forcément quelque chose de souhaitable, on ne doit pas pouvoir accrocher d’autres tâches à l’une des vôtres. C’est pourquoi l’option DenyChildAttach a été ajoutée dans .NET 4.5. Elle interdit d’autres tâches de devenir enfant de celle qui utilise cette option.

On notera que pour l’instant TaskFactory.StartNew utilise une valeur par défaut inappropriée pour TaskCreationOptions (qui est TaskCreationOptions.None) alors que Task.Run, dont j’ai souvent conseillé l’utilisation dans cette série, utilise une valeur désormais plus conforme aux bonnes pratiques à savoir TaslCreationOptions.DenyChildAttach. Une raison de plus de préférer Task.Run à StartNew ou autres méthodes d’exécution des Task.

TaskScheduler

On en a entendu parler souvent dans cette série.. Le TaskScheduler est un planificateur qui ordonne l’exécution des tâches lorsqu’il y a continuation. Une TaskFactory peut définir son propre planificateur qui est alors utilisé par défaut.

Attention, le scheduler par défaut de la méthode statique Task.Factory n’est pas TaskScheduler.Default mais TaskScheduler.Current. Depuis des années cela a causé beaucoup de confusion parce que la majorité des développeurs s’attendait avec raison à ce que la valeur soit TaskScheduler.Default. Il vaut mieux le savoir et en tenir compte.

Pour comprendre toute la subtilité de l’impact de ce choix étrange de la part des concepteurs de cette partie du Framework prenons un petit exemple. Le code suivant créée une task factory pour planifier un tâche à exécuter sur le thread d’UI, mais au sein de ce travail planifié une tâche de fond est aussi démarrée :

private void Button_Click(object sender, RoutedEventArgs e)
{
    var ui = new TaskFactory(TaskScheduler.FromCurrentSynchronizationContext());
    ui.StartNew(() =>
    {
        Debug.WriteLine("UI on thread " + Environment.CurrentManagedThreadId);
        Task.Factory.StartNew(() =>
        {
            Debug.WriteLine("Background work on thread " + Environment.CurrentManagedThreadId);
        });
    });
}

 

La sortie console va être :

UI on thread 8
Background work on thread 8

 

Le numéro de thread n’a pas d’importance, ce qui compte ici c’est que la tâche de fond s’exécute sur le même thread que celui de l’UI ! Voilà qui peut causer certains ennuis.. notamment une lenteur de l’UI, son blocage éventuel, alors même que le développeur croit tout faire pour avoir une UI fluide !

Le problème vient du fait que le ui.StartNew tourne sur le thread de l’UI (le clic d’un bouton). De fait TaskScheduler.Current est le thread d’UI ce qui est d’ailleurs parfaitement normal et exact.

Mais en raison de la confusion Default/Current, le second StartNew qui est à l’intérieur du premier ne va pas utiliser la valeur Default mais Current du scheduler… Et par ce petit tour de passe-passe toute la belle construction de ce code réactif tombe à l’eau puisque la tâche de fond sera créée sur le thread de l’UI…

Qu’en penser ?

Task.Fasctory.StartNew ne doit finalement pas être utilisé en dehors de cas assez rares de parallélisation dynamique. A la place il est bien plus intelligent d’utiliser Task.Run qui est “la” méthode pour exécuter une tâche. Si vous avez créer votre propre TaskScheduler (une instance de l’un de ceux proposé dans ConcurrentExclusiveSchedulerPair) il y a alors un intérêt à créer votre propre instance de TaskFactory et d’utiliser StartNew dans ce contexte. Dans les autres cas, préférez systématiquement Task.Run. A moins d’être un expert super pointu vous risquez de faire plus de bêtises qu’autre chose en utilisant StartNew (d’autant qu’il ne support pas directement l’asynchronisme et qu’il faut utiliser un Unwrap de TaskExtensions qui créée un proxy de la tâche pour son utilisation dans un contexte asynchrone).

Bref, c’est bien de savoir ce que fait StartNew, c’est encore mieux d’avoir saisi qu’il vaut mieux l’éviter pour écrire du code qui marche…

Task.Run

Task.Run est finalement l’unique,le seul moyen propre et efficace dans un code moderne d’exécuter une Task qui sera empilée sur le thread pool. Cette méthode ne permet pas d’utiliser un scheduler personnalisé et propose une API plutôt simple et compréhensible n’offrant pas les risques de celle de Task.Factory.StartNew. Et puis Task.Run est async-aware ce qui est très important dans le cadre d’une programmation moderne donc adaptée aux OS récents et aux machines qui les supportent.

Les différentes variantes de Task.Run sont les suivantes :

Task Run(Action);
Task Run(Action, CancellationToken);

Task Run(Func<Task>);
Task Run(Func<Task>, CancellationToken);

Task<TResult> Run<TResult>(Func<TResult>);
Task<TResult> Run<TResult>(Func<TResult>, CancellationToken);

Task<TResult> Run<TResult>(Func<Task<TResult>>);
Task<TResult> Run<TResult>(Func<Task<TResult>>, CancellationToken);

 

Simple et clair comme je le disais. On peut exécuter des delegate avec ou sans valeur de retour, un jeton d’annulation. Le tout en étant async-aware.

On notera que Task.Run ne créée pas forcément une Delegate Task (voir billets précédents de la série) mais qu’il peut dans un contexte asynchrone créer une Promise Task (lorsque le delegate passé est asynchrone lui-même). Mais on peut conserver à l’esprit que Task.Run permet d’exécuter des delegate sur le thread pool.

Bien entendu le jeton d’annulation souffre des mêmes limitations que dans le cadre de StartNew c’est à dire qu’il ne joue que sur le lancement de la tâche. On utilisera le même contournement que pour StartNew afin de d’assurer que la tâche annulée est retournée Canceled et non pas Faulted.

blog comments powered by Disqus