Programmation parallèle avec C# 4.0 - Part 3

Le modèle d’abandon - Part 1

image

Introduction

   Dans un article précédent, j’avais présenté les bases de la programmation orientée multitâche avec la librairie TPL. Dans celui-ci nous allons étudier un sujet que la programmation multitâche connait bien. En effet, tout développeur ayant l’habitude de construire des applications multitâches a souvent été confronté au problème d’abandon d’un ou de plusieurs traitements multitâches. Lorsqu’un thread exécute une opération qui peut s’avérer longue vis-à-vis d’un utilisateur, il est raisonnable de chercher un moyen de l’interrompre si nécessaire. Une application graphique devrait toujours offrir un moyen de stopper un traitement long, si l’utilisateur le souhaite. De même, un service engagé dans un traitement long doit aussi offrir un moyen de s’arrêter proprement et rapidement, si besoin. Cependant, il est souvent delicate de stopper un traitement long engagé dans un thread, car les threads et le pool de threads ne supportent pas par défaut ce modèle. Le problème le plus connu est sans doute le cas d’un arrêt brutal d’un thread (Thread.Abort). Ce type d’arrêt peut entrainer des comportements aléatoires pouvant aboutir à un crash de logiciel. Pour stopper un thread, il est fortement conseillé d’implémenter une logique applicative qui se chargera d’arrêter le ou les traitements proprement.

   Pour les équipes Microsoft engagées dans l'introduction de librairies parallèles dans le Framework .NET 4.0, il n’était pas envisageable de proposer une offre parallèle complète sans proposer un moyen d’abandonner ses traitements parallèles proprement. En général, pour éviter des déconvenues d’un arrêt brutal, les développeurs définissent leurs propres systèmes pour abandonner leurs tâches proprement. Afin d’éviter que chacun redéveloppe son propre système d’abandon, le Framework .NET 4.0 propose un modèle d’abandon universel à la fois simple et léger reposant sur une organisation coopérative partageable par toutes les techniques parallèles. En d’autres mots, ce modèle vous permet d’implémenter explicitement le support de l’abandon d’un ou de plusieurs traitements parallèles dans différents contextes : TPL, PLINQ mais aussi ThreadPool, Threads et BackgroundWorker. Dans cette première partie, nous aborderons ce modèle d’abandon dans le contexte des tâches TPL.

Abandonner une tâche

   Dans l’espace de nom System.Threading, vous trouverez la classe CancellationTokenSource et la structure CancellationToken qui vous offriront respectivement le moyen de coordonner la signalisation d’une demande d’abandon et de prendre en compte une demande d’abandon via un simple booléen. Pour introduire l’utilisation de ce modèle, je vous propose de commencer avec une méthode simple que j’ai nommée TaskCancellation. Nous réviserons successivement le code de cette méthode, pour découvrir progressivement l’essentiel du modèle d’abandon. Nous aborderons aussi la gestion des exceptions et l’attente des tâches qui sont souvent associées à ce modèle.

static public void TaskCancellation()
{
CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task task = Task.Factory.StartNew(() =>
{
for (int j = 0; j < 10; j++)
{
if (token.IsCancellationRequested)
{
throw new OperationCanceledException();
}
Thread.Sleep(1000);
}
}, token);
Console.WriteLine("Press <Enter> to cancel the operations.");
Console.ReadLine();
cts.Cancel();
try
{
task.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine("{0}", task.Status);
foreach (var e in ex.InnerExceptions)
{
Console.WriteLine("{0}", e.Message);
}
}
}

   La méthode commence par l’initialisation du modèle d’abandon avec la création d’une instance de type CancellationTokenSource. Pour obtenir un jeton (token en anglais) d’abandon, la classe CancellationTokenSource se comporte comme une fabrique en une retournant une structure CancellationToken via la propriété Token.

CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

   Pour illustrer la mise en œuvre du modèle d’abandon, nous instancions une tâche, de type Task, en passant par la classe fabrique statique Factory, puis en appelant la méthode StartNew, qui nous retourne une nouvelle instance d’une tâche de type Task, déjà démarrée.

Task task = Task.Factory.StartNew(() =>
{

}, token);

   On note que le jeton d’abandon est passé en argument de la méthode StartNew. Le corps de la tâche est composé d’une boucle pour 10 itérations. Le corps de la boucle contient juste un test sur la propriété IsCancellationRequested du jeton. Cette propriété permet de prendre connaissance si une demande d’abandon est réclamée.

for (int j = 0; j < 10; j++)
{
if (token.IsCancellationRequested)
{
throw new OperationCanceledException();
}
Thread.Sleep(1000);

}

   Si une demande d’abandon est réclamé par l’objet cts de type CancellationTokenSource, la tâche lève une exception, ici de type OperationCanceledException. Cette exception est particulièrement bien adaptée à la situation, car il s’agit bien d’un abandon de l’opération courante. Pour simuler une activité, le code s’endort une seconde via l’appel à la méthode Thread.Sleep.

Console.WriteLine("Press <Enter> to cancel the operations.");
Console.ReadLine();

cts.Cancel();

   Pour réclamer à l’utilisateur de notre petit programme, la demande d’abandonner l’opération en cours, on affiche un prompt. Si l’utilisateur frappe la touche entrée, il déclanche l’appel à la méthode Cancel de l’objet cts. Enfin on attend la fin de la tâche tout en mettant en place une gestion d’exception autour de la méthode Wait de l’objet task.

try
{
task.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine("{0}", task.Status);
foreach(var e in ex.InnerExceptions)
{
Console.WriteLine("{0}", e.Message);
}
}

   Les exceptions lancées dans l’offre parallèle .NET 4.0 sont de type AggregateException car en programmation parallèle, il possible que plusieurs tâches lèvent des exceptions simultanément. Pour découvrir les exceptions jetées, nous itérons la collection InnerExceptions. Si vous exécutez ce code, vous obtiendrez un résultat similaire.

image

Figure 1 : Abandonner une tâche en levant en une exception de type OperationCanceledException

   On obtient le statut de la tâche, via l’appel task.Status qui affiche ici Faulted. Le message de l’exception affichée est bien de type OperationCancelException.

Tâches TPL et la gestion des exceptions

C’est une bonne pratique de placer systématiquement une gestion d’exception autour de la méthode Wait ( la méthode WaitAll pour attendre plusieurs tâches, ou la propriété Result d’une tâche dite « future » : Task<TResult>), car les exceptions jetées depuis le corps d’une tâche ne sont filtrables qu’à travers sur les méthodes d’attentes précédemment citées. Si vous n’effectuez pas une attente sur vos tâches , vous risquez de rater des exceptions, mais il existe une technique permettant de les récupérer (nous aborderons le sujet à la fin de cet article).

Améliorer la méthode TaskCancellation

Passer le jeton à l’exception OperationCanceledException

   Nous pouvons apporter une légère modification à ce petit exemple, en passant en paramètre du constructeur de la classe OperationCancelException, le jeton d’abandon.

if (token.IsCancellationRequested)
{
throw new OperationCanceledException(token);
}

   Si vous exécutez ce code, vous obtiendrez un résultat légèrement différent.

image

Figure 2 : Abandonner une tâche en levant en une exception de type OperationCanceledException avec le jeton d’abandon

   Cette fois l’appeltask.Status affiche Canceled et le message de l’exception affichée est bien de type OperationCancelException. Donc si vous souhaitez que l’exception et le statut affichent un résultat cohérent, vous devez passer le jeton au constructeur de l’exception. L’exemple précédent peut-être amélioré en substituant le code ci-dessous.

if (token.IsCancellationRequested)
{
throw new OperationCanceledException(token);
}

   Par la méthode ThrowIfCancellationRequested, car si on l’observe cette méthode avec Reflector, on obtient ce code.

public void ThrowIfCancellationRequested()

{

    if (this.IsCancellationRequested)

    {

        throw new OperationCanceledException(Environment.GetResourceString("OperationCanceled"), this);

    }

}

 

 On peut donc remplacer notre code précédent par cette ligne.

token.ThrowIfCancellationRequested();

   Donc, si vous souhaitez lever une exception d’abandon (OperationCancelException) si un abandon est réclamé, utiliser la méthode ThrowIfCancellationRequested. C’est à la fois plus simple et plus clair vis-à-vis du code.

Améliorer la mise en sommeil de la tâche

   Dans notre petit exemple, nous utilisons l’appel à la méthode Thread.Sleep pour endormir notre tâche. Cependant, si nous réclamons un abandon alors que la tâche dort, celle-ci en tiendra compte à la prochaine itération, une fois le temps de sommeil écoulé. Imaginons la frustration de l'utilisateur à l’origine de la demande et qui attend quelques secondes et parfois beaucoup plus avant que sa demande soit traitée. Pour éviter ce problème dans notre cas, nous pouvons utiliser la méthode WaitOne de la classe WaitHandle accessible depuis notre jeton. Dans ce cas, notre traitement sera immédiatement interrompu si une demande d’abandon est réclamée. Si dans votre code, la méthode WaitOne est représentée par une méthode couteuse en temps d’exécution, vous devez sans doute revoir cette méthode en lui passant en paramètre notre jeton, et modifier le code de manière à tester régulièrement s’il n’y a pas une demande d’abandon. Mais nous reviendrons sur ce point dans le cadre de la seconde partie de cet article.

Task task = Task.Factory.StartNew(() =>
{
for (int j = 0; j < 10; j++)
{
token.ThrowIfCancellationRequested();
token.WaitHandle.WaitOne(1000) ;
}
}, token);

   Ainsi, notre programme sera plus réactif en cas de demande d’abandon. Donc si vous développez de nouvelles méthodes susceptibles de durer longtemps, pensez à implémenter le système d’abandon via un paramètre de type CancellationToken. Cependant, en appelant la propriété WaitHandle nous faisons une allocation d’une ressource native Win32, qui devra être libérée explicitement en appelant de la méthode Dispose.

static public void TasksCancellation(int item)
{
CancellationTokenSource cts = new CancellationTokenSource();

Task task = Task.Factory.StartNew(() =>
{
for (int j = 0; j < 10; j++)
{
cts.Token.ThrowIfCancellationRequested();
cts.Token.WaitHandle.WaitOne(1000);
}
}, cts.Token);

Console.WriteLine("Press <Enter> to cancel the operations.");
Console.ReadLine();
cts.Cancel();

try
{
task.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine("{0}", task.Status);
foreach (var e in ex.InnerExceptions)
{
Console.WriteLine("{0}", e.Message);
}
}
finally
{
task.Dispose();
cts.Dispose();
}
}

 

Tâches TPL et le pattern Dispose

Si nous n’appelons pas la méthode Dispose sur la tâche, comme tous les objets supportant l’interface IDisposable, notre tâche terminera sa vie dans la file des objets à finaliser. Si le corps de la tâche lève une exception sans être attendue par la méthode Wait (WaitAll pour attendre plusieurs tâches, ou la propriété Result d’une tâche dite « future » : Task<TResult>, l’exception est conservée et noté « non observée ». Lorsque le thread de finalisation traite une telle tâche, il relancera l’exception avant de libérer la tâche. Vous pouvez récupérer cette exception en installant un dispositif d’exception de dernier recours (nous aborderons le sujet à la fin de cet article).

Concernant l’instance CancellationTokenSource, nous devons aussi la libérer, car l’appel à la classe WaitHandle a engendré une ressource native, donc nous devrions appeler la méthode Dispose.

Vous pouvez naturellement négliger les différents appels à la méthode Dispose, mais dans ce cas, la file de finalisation du GC sera un peu plus encombrée.

Être informé lorsque l’abandon est déclenché

   Réclamer l’abandon d’une opération n’est pas toujours anodin pour un traitement fonctionnel. Il peut être avantageux de signaler l’exécution d’une demande d’abandon sans entrer dans l’intimité du traitement.

    Task task = Task.Factory.StartNew(() =>
{
for (int j = 0; j < 10; j++)
{
if (cts.Token.IsCancellationRequested)
{
Console.WriteLine("Token has been cancelled!\n");
throw new OperationCanceledException(token);
}
token.WaitHandle.WaitOne(1000);
}
}, cts.Token);

   Cette méthode n’est pas très élégante ; il est préférable que vos applications utilisent un autre moyen pour informer qu’un abandon a été déclenché.

   Vous trouverez dans la classe CancellationToken une méthode Register, permettant de passer une expression lambda qui s’exécutera lorsqu’une demande d’abandon sera réclamée sur ce jeton.

 static void Main(string[] args)
{
     CancellationTokenSource cts = new CancellationTokenSource();
     CancellationToken token = cts.Token;

     Task task = new Task(() =>
     {
         for (int j = 0; j < 10; j++)
         {
             cts.Token.ThrowIfCancellationRequested();
             cts.Token.WaitHandle.WaitOne(1000);
         }
     }, cts.Token);
 
     cts.Token.Register(() =>
     {
         Console.WriteLine("Token has been cancelled!\n");
     });

     task.Start();
     Console.WriteLine("Press <Enter> to cancel the operations.");
     Console.ReadLine();
     cts.Cancel();
 
     try
     {
         task.Wait();
     }
     catch (AggregateException ex)
     {
         Console.WriteLine("{0}", task.Status);
         foreach (var e in ex.InnerExceptions)
         {
             Console.WriteLine("{0}", e.Message);
         }
     }
     finally
     {
         task.Dispose();
         cts.Dispose();
     }
}

   Dans l’exemple ci-dessus nous avons enregistré une expression lambda qui nous permettra d’être notifiés lorsqu’une demande d’abandon sera déclenchée. Si vous exécutez cette nouvelle version, vous obtiendrez un résultat similaire à la version précédente.

image

Figure 3 : Notifier un abandon de traitement en enregistrant une expression lambda

   Naturellement, notre exemple n’a que peu d’intérêt avec un simple programme console. Mais dans le cadre d’une application, ce système peut offrir un moyen simple de déclencher un ou plusieurs traitements. Vous pouvez si vous le souhaitez, enregistrer plusieurs demandes de notification sur le même jeton.

 static void Main(string[] args)
{
     CancellationTokenSource cts = new CancellationTokenSource();

     Task task = new Task(() =>
     {
         for (int j = 0; j < 10; j++)
         {
             cts.Token.ThrowIfCancellationRequested();
             cts.Token.WaitHandle.WaitOne(1000);
         }
     }, cts.Token);
 
     cts.Token.Register(() =>
     {
         Console.WriteLine("Token has been cancelled!\n");
     });

     cts.Token.Register((state) =>
     {
         Task t = state as Task; 
         Console.WriteLine("Token has been cancelled! {0}", t.Status);
     }, task);
 
     task.Start();
     Console.WriteLine("Press <Enter> to cancel the operations.");
     Console.ReadLine();
     cts.Cancel();
 
     try
     {
         task.Wait();
     }
     catch (AggregateException ex)
     {
         Console.WriteLine("{0}", task.Status);
         foreach (var e in ex.InnerExceptions)
         {
             Console.WriteLine("{0}", e.Message);
         }
     }
     finally
     {
         task.Dispose();
         cts.Dispose();
     }
}

   La méthode Register retourne une instance de CancellationTokenRegistration qui supporte IDisposable qui devrait être libérée afin d’éviter d’encombrer la file d’attente du thread finalisation du Garbage Collector. L’exemple montre que la méthode Register autorise un paramètre « state » qui nous illustrons ici avec le second enregistrement.

image

Figure 4 : Notifier un abandon de traitement en enregistrant une expression lambda

   L’exécution est sans surprise et le second enregistrement affiche bien le statut de la tâche passé en paramètre. Passons maintenant à un autre contexte, celui d’une application WPF, le temps d’illustrer un autre usage de la méthode Register.

public MainWindow()

{

InitializeComponent();

CancellationTokenSource cts = new CancellationTokenSource();

cts.Token.Register(() =>

{

button1.IsEnabled = true;

button1.Content = "Cancelled!";

});
Task t = Task.Factory.StartNew(() =>

{

button1.IsEnabled = false;

button1.Content = "In progress";

},
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext());

t.ContinueWith((Task previous) => {

cts.Cancel();

})

.ContinueWith((Task previous) =>

{

button1.Content = previous.Status;

},

CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted,
TaskScheduler.FromCurrentSynchronizationContext());

}

   Dans l’exemple ci-dessous, nous lançons successivement deux tâches. En préambule, nous enregistrons une expression lambda où nous rafraichissons le bouton button1 par les valeurs suivantes :

button1.IsEnabled = true;

button1.Content = "Cancelled!";

   Dans la tâche suivante, nous appelons la méthode Cancel sur notre instance CancellationTokenSource. Cet appel va appeler tous les délégués enregistrés actuellement, donc notre expression lambda précédente.

t.ContinueWith((Task previous) =>

{

cts.Cancel();

})

   La seconde tâche tourne dans un thread indépendant, non graphique et c’est pourtant depuis cette tâche que nous appelons l'expression lambda enregistrée, via l’appel de la méthode Cancel (la méthode Cancel itère en interne sur tous les enregistrements d'expression lambda, pour les appeler un à un). Pour que l’expression lambda puisse accéder au bouton, il faut synchroniser le contexte courant sur le thread graphique, ce qui n’est pas le cas. Alors, lorsque l’expression lambda tente d’accéder au bouton button1, une expression est jetée. Mais comme nous avons défini, une continuation exécutée uniquement quand la tâche précédente lance une exception (TaskContinuationOptions.OnlyOnFaulted), alors celle-ci s'exécute.

.ContinueWith((Task previous) =>

{

button1.Content = previous.Status;

},

CancellationToken.None,

TaskContinuationOptions .OnlyOnFaulted,

TaskScheduler.FromCurrentSynchronizationContext());

   Si l'on exécute ce programme, on obtient une confirmation sur l’exécution de cette continuation.

image

Figure 5 : Notre application WPF a un petit souci

   Pour corriger ce petit souci, il nous suffit de passer la valeur true au paramètre useSynchronizationContext, à la méthode Register, ainsi le comportement du programme change et l’expression lambda enregistrée est correctement exécutée.

cts.Token.Register(() =>

{

    button1.IsEnabled = true;

button1.Content = "Cancelled!";

}, true);

Si vous exécutez cette nouvelle version, vous obtiendrez un résultat similaire.

image

Figure 6 : Abandonner une tâche dans une application WPF

Tâches TPL et les interfaces graphiques

Si vous souhaitez utiliser les tâches TPL avec WPF , ASP.NET ou même Windows Forms , vous devez considérer le découplage du code graphique de partie métier. Par exemple, vous pouvez utiliser le pattern MVVM ou MVC , et dans ce cas, il sera préférable de placer le code TPL dans la partie modèle. En revanche, si vous souhaitez mélanger le code graphique et TPL , comme dans le petit exemple précédent, vous devriez éviter l’utilisation de la méthode TaskScheduler.FromCurrentSynchronizationContext , qui permet de plonger tout le corps de la tâche dans le thread graphique, car si vous lancez une tâche, c’est que vous souhaitez améliorer les performances de votre opération. Il est donc préférable d’utiliser une technique de synchronisation partielle. L'exemple ci-dessous illustre le cas d'une application Windows Forms :

    SynchronizationContext ctx = SynchronizationContext.Current;

    Task t = Task.Factory.StartNew(() =>

    {

        ctx.Send((s) =>

        {

            button1.Enabled = false;

            button1.Text = "In progress";

        }, null) ;

 

        DoLongWork(cts.Token);

    }, cts.Token);

 

Les développeurs WPF préfèreront sans doute l’expression suivante :

     Task t = Task.Factory.StartNew(() =>
    {
        Dispatcher.Invoke(DispatcherPriority.Background, (Action)delegate
        {
            button1.IsEnabled = false;
            button1.Content = "In progress";
        });
        DoLongWork(cts.Token);
    }, cts.Token);

 

Ainsi, notre tâche peut mettre à jour des éléments graphiques tout en traitant une opération longue sans bloquer le thread graphique.

Abandonner plusieurs tâches

   Jusqu’à présent, nous avons présenté le modèle d’abandon à travers l’utilisation d’une seule tâche. Dans « la vraie vie », vous serez souvent amené à gérer plusieurs tâches à la fois. Dans le code ci-dessous nous lançons autant de tâches que la valeur items l'indique avec une mise en place du modèle d’abandon.

 private static void TasksCancellation(int items)
{
     CancellationTokenSource cts = new CancellationTokenSource();

     Task[] tasks = new Task[items];

     for (int i = 0; i < tasks.Length; i++)
     {
         tasks[i] = Task.Factory.StartNew(() =>
         {
             cts.Token.WaitHandle.WaitOne();
             Console.WriteLine("Task {0} has been cancelled!", 
             Task.CurrentId);
         }, cts.Token);       
     }    
     Console.WriteLine("Press <Enter> to cancel the operations."); 

     Console.ReadLine();       
     cts.Cancel();  
     try       
     { 

         Task.WaitAll(tasks);       
     }       
     catch (AggregateException ex)       
     {
         foreach (var e in ex.InnerExceptions)       
         {
             Console.WriteLine("{0}", e.Message);      
         }

     }       
}

   Chacune des tâches attend indéfiniment via WaitOne tant qu’une demande d’abandon n’a pas été réclamée. Lorsque l’utilisateur frappe la touche entrée, l’abandon est propagé à toutes les tâches. Si vous exécutez cette nouvelle version, vous obtenez un résultat similaire.

image

Figure 7 : Abandonner plusieurs tâches

   Pour attendre toutes les tâches, nous avons utilisé un tableau de tâches que nous avons rempli dans la boucle de lancements des tâches. Dans vos programmes, essayez de définir ce tableau le plus proche de son utilisation afin qu’il soit libéré juste après l’appel à la méthode WaitAll, ceci afin de ne pas garder en mémoire tous les objets utilisés par vos tâches.

   Il existe une autre variante de l’attente groupée avec la méthode WaitAny . Cette méthode, comme son nom l’indique, permet d’attendre sur un groupe de tâches, mais l’attente sera relâchée dès que l’une des tâches sera terminée. Cependant, vous devez appeler WaitAll pour indiquer (observer) que toutes les tâches sont effectivement terminées.

static public void TaskCancellation(int items)

{

CancellationTokenSource cts = new CancellationTokenSource();
Task[] tasks = new Task[items];

for (int i = 0; i < tasks.Length; i++)

{

tasks[i] = Task.Factory.StartNew(() =>

{

cts.Token.WaitHandle.WaitOne();

Console.WriteLine("Task {0} has been cancelled!", Task.CurrentId);

}, cts.Token);

}

Console.WriteLine("Press <Enter> to cancel the operations.");

Console.ReadLine();

cts.Cancel();
int idx = 0;
while (tasks.Length > 0)

{

idx = Task.WaitAny(tasks);

Console.WriteLine("Finished task {0}", tasks[idx].Id);

tasks = tasks.Where((t) => t != tasks[idx]).ToArray();

}

try

{

Task.WaitAll(tasks);

}

catch (AggregateException ex)

{

foreach (var e in ex.InnerExceptions)

{

Console.WriteLine("{0}", e.Message);

}

}

}

 

   C’est au regard de vos scénarios fonctionnels que vous profiterez de cette fonctionnalité. Si vous exécutez ce code, vous obtiendrez un résultat similaire.

image

Figure 8 : Abandonner plusieurs tâches avec l’utilisation de la méthode WaitAny

   Sur cette exécution, on note que les prises en compte des demandes abandons des tâches n’est pas en harmonie avec leurs terminaisons, ce qui en matière de programmation parallèle, parfaitement normale.

Créer une composition de CancellationTokenSource

  Si votre application contient plusieurs instances de CancellationTokenSource, il peut être souhaitable de les composer de manière à engendrer la propagation d’une demande d’abandon au sein de la composition. Le code si dessous illustre le fonctionnement d’une composition de CancellationTokenSource.

private static void TasksCancellation(int items)

{

CancellationTokenSource[] cancellationTokenSources =

 new CancellationTokenSource[items];

CancellationToken[] cancellationTokens = new CancellationToken[items];

for (int i = 0; i < cancellationTokenSources.Length; i++)

{

cancellationTokenSources[i] = new CancellationTokenSource();

cancellationTokens[i] = cancellationTokenSources[i].Token;

}

CancellationTokenSource cancellationTokenSourceComposite =

CancellationTokenSource.CreateLinkedTokenSource(cancellationTokens);

Task[] tasks = new Task[items];
for (int i = 0; i < tasks.Length; i++)

{

tasks[i] = Task.Factory.StartNew(() =>

{

cancellationTokenSourceComposite.Token.WaitHandle.WaitOne();

Console.WriteLine("TaskId: {0} - Token has been cancelled!\n",
Task.CurrentId);
}, cancellationTokenSourceComposite.Token);

}
Console.WriteLine("Press <Enter> to cancel the operations.");

Console.ReadLine();

cancellationTokenSources[cancellationTokenSources.Length / 2].Cancel();
try

{

Task.WaitAll(tasks);

}

catch (AggregateException ex)

{

foreach (var e in ex.InnerExceptions)

{

Console.WriteLine("{0}", e.Message);

}

}

Console.WriteLine("CancellationTokenSourceComposite.IsCancellationRequested = {0}",
cancellationTokenSourceComposite.IsCancellationRequested);

for (int i = 0; i < cancellationTokenSources.Length; i++)

{

Console.WriteLine("cancellationTokenSources[{0}].IsCancellationRequested = {1}",
i, cancellationTokenSources[i].IsCancellationRequested);

}

}

   Dans l’exemple ci-dessus, on formule une demande d’abandon à partir d’une des instances CancellationTokenSource afin de stopper toutes les taches en attentent sur la composition. La création de la composition repose sur la méthode CreateLinkedTokenSource issue de la classe CancellationTokenSource. Cette méthode prend en paramètre un tableau de CancellationToken correctement initialisé.

CancellationTokenSource cancellationTokenSourceComposite =
CancellationTokenSource.CreateLinkedTokenSource(cancellationTokens);

 

   Si vous exécutez cette nouvelle version pour 5 items, vous obtenez un résultat similaire.

image

Figure 9 : Abandonner une composition de CancellationTokenSources

   L’analyse des instances CancellationTokenSource montre bien qu’une seule demande d’abandon (ici la 3e instance) permet réclamer une demande d’abandon au niveau de la composition. Ce principe de composition peut s’avérer très pratique dans des scénarios où certaines méthodes doivent tenir compte de plusieurs sources d'abandon. Ce système de composition peut naturellement cohabiter avec un usage non composé. Vous êtes libre de décider où et quand l’utiliser.

Insensibiliser vos traitements vis-à-vis du modèle d’abandon

   Si vous ne souhaitez plus que le modèle d’abandon interfère avec vos traitements, alors que vos tâches implémentent déjà le modèle d’abandon, vous pouvez intervenir facilement. À la création de vos tâches, au lieu de passer un jeton issu d’une instance CancellationTokenSource, vous passerez la valeur CancellationToken.None. Ainsi toutes les méthodes ou propriétés associées au modèle d’abandon ne signaleront pas de demandes d’abandon.

     IgnoreCancellation(CancellationToken.None);

 

private static void IgnoreCancellation(CancellationToken token)

{

Task task = Task.Factory.StartNew(() =>

{

for (int j = 0; j < 10; j++)

{

Console.WriteLine(j);

token.ThrowIfCancellationRequested();

Thread.Sleep(1000);

}

}, token);
task.Wait();

}

   Ainsi, notre fonction n’est pas modifiée, mais toutes les méthodes du jeton ne signaleront pas de demandes d’abandon. Naturellement, la motivation peut sembler étrange, mais le code fonctionnel de vos applications pourrait par exemple, réclamer pour une période donnée, d'insensibiliser les opérations au modèle d’abandon.

Plus loin avec les exceptions et les tâches TPL

Utiliser un handler d’exception itératif

   Dans tous les exemples précédents, nous avons utilisé une gestion d’exception où nous étions obligés d’itérer nous-mêmes la collection InnerExceptions pour découvrir la source de l’exception. La méthode Handle appartient à la classe AggregateException. Elle permet de définir un filtre sur une exception attendue. Si l’exception levée correspond à votre attente, vous retournez la valeur true sinon false, car ce n’est pas l’exception que vous attendiez.

try

{

task.Wait();

}

catch (AggregateException ex)

{

ex.Handle((inner) =>

{

if (inner is OperationCanceledException)

{

OperationCanceledException oce = inner as OperationCanceledException;

Console.WriteLine("OperationCanceledException has been treated!.");

return true;

}

else

{

return false;

}

});

}

   En interne, la méthode Handle itère sur la collection InnerExceptions et vérifie à travers une expression lambda de type Func<Exception, bool> , si l’exception courante est traitée ou pas. À l’issue de cette itération, s’il reste des exceptions non traitées, une nouvelle exception de type AggregationException est lancée avec une collection contenant les exceptions non traitées.

   Si une exception est levée depuis une tâche encapsulée dans une autre, vous pouvez tout de même la traiter avec méthode Flatten de la classe AggregateException.

    private static void CancellationFlattenException()
{
CancellationTokenSource cts = new CancellationTokenSource();
Task task = Task.Factory.StartNew(() => {
Task.Factory.StartNew(() => {
for (int j = 0; j < 10; j++) {
Console.WriteLine(j);
cts.Token.ThrowIfCancellationRequested();
Thread.Sleep(1000);
}
}, cts.Token,
TaskCreationOptions.AttachedToParent,
TaskScheduler.Default);
}, cts.Token);
cts.Cancel();
try { task.Wait(); }
catch (AggregateException ex)
{

ex.Flatten().Handle((inner) =>
{
if (inner is OperationCanceledException)
{
OperationCanceledException oce =
inner as OperationCanceledException;
Console.WriteLine("OperationCanceledException has been treated!.");
return true;
}
else
{
return false;
}
});
}
}

 

Monter un dispositif d’exception de dernier recours

   Si votre code contient des lancements de tâches non accompagnés d’une attente respective (méthodes Wait, WaitAll ou propriété Result pour les tâches de type futures), ces tâches finiront dans la file attente des objets à finaliser au niveau du Garbage Collector (GC). Lorsque le thread finalisation note que l’objet Task contient une exception non observée, il relance une exception AggregateException contenant l’exception non traitée. Par défaut, lorsque la CLR relance une exception depuis le thread de finalisation, celle-ci n’est pas filtrable par défaut par votre programme. Cependant, il existe une technique type dernier recours, permettant de récupérer les exceptions levées depuis le thread de finalisation. Le code ci-dessous illustre cette technique.

    TaskScheduler.UnobservedTaskException +=
(object sender, UnobservedTaskExceptionEventArgs excArgs) =>

{

Console.WriteLine("Exception.Message: {0}\n",
excArgs.Exception.Message);

Console.WriteLine("Exception.InnerException.Message: {0}\n",
excArgs.Exception.InnerException.Message);

excArgs.SetObserved();

};

   La classe TaskScheduler contient un évènement nommé UnobservedTaskException, qui offre l’opportunité de capturer les exceptions lancées depuis le thread de finalisation. Le code ci-dessous illustre le déclenchement du handler d’exception de dernier recours.

static void Main(string[] args)

{

TaskScheduler.UnobservedTaskException
+= (object sender, UnobservedTaskExceptionEventArgs excArgs) =>

{

Console.WriteLine("Exception.Message: {0}\n",
excArgs.Exception.Message);

Console.WriteLine("Exception.InnerException.Message: {0}\n",
excArgs.Exception.InnerException.Message);

excArgs.SetObserved();

};

CancellationTokenSource tokenSource = new CancellationTokenSource();
LaunchTask(cts.Token);

Thread.Sleep(5000);
tokenSource.Cancel();
GC.Collect();

GC.WaitForPendingFinalizers();

GC.Collect();
Console.WriteLine("Enter to finish");

Console.ReadLine();

}

static void LaunchTask(CancellationToken token)

{

Task t = Task.Factory.StartNew((s) =>

{

for (int i = 0; i < 2048; i++)

{

Task.Factory.StartNew(() =>

{

while (true)

token.WaitHandle.WaitOne(200);

}, token);

}

throw new Exception("Oops!");

}, TaskCreationOptions.None, token);

}

   Si vous exécutez cette nouvelle version, vous obtenez un résultat similaire.

image

Figure 10 : Capturer les exceptions non observées lancées par le thread finalisation du Garbage Collector

   Cette technique est naturellement un dernier recours qui ne devra jamais faire l’objet d’un usage « technico/fonctionnel ». Dans de nombreux cas, il est préférable de sortir du programme après avoir enregistré le type d’exception récupéré.

  Pour conclure le sujet des exceptions, nous pouvons que regretter qu’il n’y ait pas de moyens de corréler l’opération qui a levé l’exception depuis l’objet AggregateException.InnerExceptions, excepté si vous examinez la propriété StackTrace puis analyser le code source correspondant, ce qui peut être gênant dans certains cas.

En conclusion

   Dans cette première partie, nous avons illustré le modèle d’abandon en utilisant le type Task. Dans ce cadre, nous avons à la fois abordé la gestion des exceptions souvent associée au modèle d’abandon ainsi que les différentes techniques d’attente des tâches. Enfin, nous avons compris comment le système de tâches gérait les exceptions non traitées. Cependant, le modèle d’abandon ne se limite pas aux tâches TPL. Il peut être utilisé dans divers contextes comme PLINQ, le ThreadPool, les Threads, les BackgroundWorkers, les nouveaux conteneurs adaptés au parallélisme, les structures de synchronisation. Nous aborderons tous ces sujets dans une seconde partie que je publierai prochainement.

A bientôt,

Bruno

boucard.boucard@free.fr