Programmation parallèle avec C# 4.0 - Offre parallèle orientée Tâches Part 2

Programmation orientée Tâches

image

Dans le billet précédent, j’ai démontré que l'utilisation des threads devait être réservée à des usages très spécifiques, car leurs coûts sont prohibitifs. Cependant, de nombreux systèmes d'exploitation, langages de programmation, exposent une API Thread. De ce fait, les threads ont gagné une « certaine popularité » auprès des développeurs. Les concepteurs de la librairie Task ont pris en compte ce paramètre en nous proposant un modèle de programmation beaucoup plus puissant, mais conceptuellement similaire. Avec l’API Task, nous disposons d'une alternative à l'API Thread qui devrait nous permettre de transcrire nos programmes orientés Threads très facilement tout en bénéficiant de meilleures performances.

Créer une tâche simple

Précédemment, nous avons utilisé l'API Task, mais sans nous attarder sur la syntaxe. La création d'une tâche est extrêmement simple. Dans un premier temps, assurez-vous que l'espace de nom System.Threading.Tasks est présent dans votre fichier. Dans le code ci-dessous, la méthode RunOneTask contient la création d'une tâche simple.

private static void RunOneTask()

{

    Task task = new Task(new Action(ComputeSomething));

    task.Start();

    task.Wait();

}

private static void ComputeSomething()

{

    int taskId = (int)Task.CurrentId;

    Console.WriteLine("Task {0} running ", taskId);

    Thread.Sleep(5000);

    Console.WriteLine("Task {0} completed", taskId);

}

Le constructeur le plus simple de la classe Task accepte une instance du type délégué Action. En d'autres mots, nous pouvons passer en paramètre du constructeur du type délégué Action, une méthode qui ne prend pas de paramètre et qui ne retourne pas de valeur, comme ici la méthode ComputeSomething. Cette méthode n'offre pas d'intérêt si ce n'est qu'elle nous permet d'illustrer la propriété CurrentId, c'est-à-dire l'identifiant de la tâche courante. À l'instar de l'API Thread, les tâches possèdent un identifiant unique permettant de faciliter notre compréhension d'exécution dans un contexte où plusieurs tâches s'exécutent simultanément. Après avoir construit une instance de la classe Task, notre tâche ne s'exécute pas encore, il nous faut appeler la méthode Start qui permet de réclamer le lancement de la tâche. Enfin, nous appelons la méthode Wait afin d'attendre la fin de l'exécution de notre tâche. Pour un habitué de l'API Thread, ce code est à la fois simple et sans surprise. Pour un néophyte, le code reste simple et facile à comprendre.

Créer une tâche simple qui retourne une valeur

Nos exemples se limitent pour l’instant à lancer des traitements très similaires (*) à ceux que nous pourrions lancer avec l’API ThreadPool. En effet, tous nos traitements ne retournent pas de valeur. Une des nouveautés qu’introduit L’API Task est la notion de futur. Une tâche future engendre un résultat une fois terminée. En d’autres mots, il est possible de lancer une tâche qui retourne une valeur lorsque le corps de la tâche est exécuté. Pour introduire cette notion de futur, voici une version du code précédent, mais cette fois, avec une valeur de retour.

private static void RunOneFuture()

{

    Task<int> task = new Task<int>(new Func<int>(ComputeAndReturnSomething));

    task.Start();

    Console.WriteLine("Task: {0} Result",task.Result);

}

public static int ComputeAndReturnSomething()

{

    int taskId = (int)Task.CurrentId;

    Console.WriteLine("Task {0} running ", taskId);

    Thread.Sleep(5000);

    Console.WriteLine("Task {0} completed", taskId);

    return taskId;

}

Le code ci-dessous est légèrement différent du code précédent sur les points suivants : l’instanciation de la tâche, l’attente de la tâche et le corps de la tâche.

· Dans la méthode RunOneFuture, nous déclarons une tâche future, c'est-à-dire un type Task générique fermé ici par un type entier. Cette version générique de la classe Task est donc dédiée aux traitements de tâches futures dont les valeurs de retour sont déterminées par le type de retour de l’expression passée au constructeur Task<T>. L’expression « future » est encapsulée par type délégué Func<out TResult>, qui retourne la valeur de l’expression, contrairement au type délégué Action qui ne retourne rien. Dans notre exemple, le générique Func<out TResult> est fermé par un entier, car son expression, ici la méthode ComputeAndReturnSomething retourne un entier. Le reste de la méthode est similaire excepté la fin, où nous n’attendons plus explicitement la fin de la tâche, mais nous réclamons une valeur de retour, ce qui revient aussi à attendre la fin de la tâche. Cependant, nous aurions aussi pu écrire le code suivant :

    task.Wait();

    Console.WriteLine("Task: {0} Result", task.Result);

· Dans la méthode ComputeAndReturnSomething, le code est relativement similaire à la version précédente mise à part la fin de la méthode où nous retournons une valeur, ici l’identifiant de la tâche. Naturellement, cet exemple n’est pas réaliste, car il est parfaitement possible de récupérer l’identifiant de la tâche depuis la méthode RunOneFuture, mais l’objectif ici, est de montrer sur la base de l’exemple précédent, la différence entre la version non générique et générique du type Task. Techniquement, les tâches futures sont une généralisation des tâches, vous retrouvez donc les méthodes et propriétés publiques de la classe Task dans sa version générique. Le choix entre les deux versions dépendra de votre besoin.

    public class Task<TResult> : Task

    {

En résumé

Nous avons montré que le lancement d’une tâche de base était à la fois simple, facile à diagnostiquer et peu couteux vis-à-vis des ressources engagées. Le support des tâches futures est sans doute un avantage déterminant vis-à-vis de l’API Thread ou l’API ThreadPool.

(*) Pour retrouver le comportement d’attente de fin de traitement proposé par Task.Wait, il faut dans le cadre du ThreadPool utiliser un outil de synchronisation comme le montre le code ci-dessous :

using (ManualResetEvent mre = new ManualResetEvent(false))

{

    ThreadPool.QueueUserWorkItem(delegate

    {

        Console.WriteLine("ThreadId {0} running ", Thread.CurrentThread.ManagedThreadId);

        Thread.Sleep(5000);

        Console.WriteLine("ThreadId {0} completed", Thread.CurrentThread.ManagedThreadId);

            mre.Set();

    });

    mre.WaitOne();  

}

Comprendre les relations entre les tâches et le Pool de Threads

Comme nous l'avons expliqué dans le billet précédent, les tâches reposent en interne sur le Pool de Threads du Framework .NET 4.0. Dans cette nouvelle version (code ci-dessous), nous avons ajouté la méthode PrintThreadPoolUsage. Cette méthode utilise la méthode statique ThreadPool.GetAvailableThreads qui permet de récupérer le nombre de threads de travail et le nombre de threads d'entrées/sorties (utilisé dans le cadre d'appels asynchrones). Ici, c'est le nombre de threads de travail du Pool de Threads que nous souhaitons observer. Nous avons ajouté une méthode PrintThreadPoolUsage à la fois dans la méthode RunOnTask et la méthode ComputeSomething. On note que le type délégué Action a été retiré, car il est inféré.

public static void PrintThreadPoolUsage()

{

    int workerThreads;

    int ioThreads;

    ThreadPool.GetAvailableThreads(out workerThreads, out ioThreads);

    Console.WriteLine("Thread Pool threads available {0}", workerThreads);

}

private static void RunOneTask()

{

    PrintThreadPoolUsage();      

    Task task = new Task(ComputeSomething);

    task.Start();

    task.Wait();

}

private static void ComputeSomething()

{

    Console.WriteLine("Task {0} running ", Task.CurrentId);

    PrintThreadPoolUsage();

    Thread.Sleep(5000);

    Console.WriteLine("Task {0} completed", Task.CurrentId);

}

L'exécution du code précédent nous permet d'obtenir un résultat similaire à la figure 9

image

Figure 9: Exécution d'une tâche

On constate que l'identifiant de la tâche est initialisé à 1, ce qui semble logique puisque nous avons lancé une seule tâche. Pour être honnête, la motivation de la propriété TaskId est d’offrir un moyen de mieux comprendre l’exécution de son code dans une phase de diagnostic. En effet Visual Studio 2010 contient une nouvelle vue, la fenêtre des Tâches, figure 10, qui permet d’observer les tâches en cours dans votre application. 

image

Figure 10: Fenêtre des tâches dans Visual Studio 2010

Pour afficher cette fenêtre dans Visual Studio 2010, aller dans le menu racine Debug, puis descendre dans menu Windows et sélectionner l’item Parallel Tasks, ou bien utiliser le raccourci clavier, « Ctrl+D,K » . Nous reviendrons sur cette nouvelle fenêtre dans un prochain billet consacré à l'outillage parallèle de Visual Studio 2010. On observe aussi l'impact de notre tâche sur le nombre de tâches disponibles dans le Pool de Threads. Ce nombre est décrémenté de 1, une fois notre tâche lancée. Cependant, ce nombre ne sera pas immédiatement décrémenté lorsque la tâche se terminera, car le Pool de Threads suppose qu'une autre demande peut arriver soudainement et dans ce cas réutiliser le thread de travail précédent est une bonne chose: pas de création couteuse, ce qui ne pénalise pas l'exécution de notre programme. Dans les faits, s'il n'y a plus demandes après quelques instants, le thread correspondant se détruira automatiquement. Enfin, le nombre de threads disponibles peut sembler élevé, mais c'est premièrement, une limite et non le nombre d'instances déjà lancées et deuxièmement la valeur maximum de threads disponibles dépend de la version Framework que vous utilisez.

Framework

Worker threads Max

Worker threads Min

I/O threads Max

I/O threads Min

x86

1023

4

1000

4

x64

32767

4

1000

4

 

Dans les exemples, j'ai fixé dans Visual Studio l'utilisation de la plateforme x86 du Framewok .NET et non la plateforme x64 pour des raisons de tests. Pour modifier la plateforme cible, figure 11, afficher les propriétés du projet et modifier la valeur de "Platform target".

image 

Figure 11: Sélection de la plateforme cible

Les valeurs minimum sont déterminées par le nombre de CPU disponibles, sur ma machine, je dispose de 4 cœurs, d'où la valeur minimum de 4. On note que les threads d'entrées / sorties sont toujours figés à 1000 pour leurs maximums et à 4 pour leurs minimums. Cependant, il est toujours possible de modifier ces valeurs, car nous disposons des propriétés : SetMaxThreads et SetMinThreads. Mais je n'encourage personne à modifier ces valeurs sauf exception parfaitement justifiée.

Remarque: dans les prochaines versions du Framework .NET, il est envisagé que la distinction entre les threads de travail et les threads d'entrées/sorties disparaissent pour ne garder que la notion de threads de travail.

Lancer plusieurs petites tâches

Précédemment, nous avons illustré l'instanciation d'une tâche, accompagné de son exécution. Nous allons maintenant nous intéresser au lancement de plusieurs tâches. Le code ci-dessous est fortement inspiré du billet précédent. Pour un nombre d’items fournit en paramètre, on boucle tout en créant une tâche entretenue dans le tableau, tasks, à chaque tour. Cette fois, nous utilisons une expression lambda pour définir le corps de la méthode qui sera exécuté par toutes les tâches. On note l'utilisation de la méthode statique Task.WaitAll qui permet d'attendre la terminaison de toutes les instances contenues dans le tableau tasks.

private static void RunWaitTasks(int items)

{

    var taskNum = 0;

    var tasks = new Task[items];

    Console.WriteLine("Launch {0} tasks", tasks.Length);

 

    while (taskNum < items)

    {

        tasks[taskNum] = new Task(() => {

            Console.WriteLine("TaskId:{0,2} ThreadId:{1} IsThreadPool:{2}",

                Task.CurrentId,

                Thread.CurrentThread.ManagedThreadId,

                Thread.CurrentThread.IsThreadPoolThread);

        });

        tasks[taskNum].Start();

        ++taskNum;

    }

    Task.WaitAll(tasks);

}

Si vous exécutez cette méthode avec cinquante items, vous obtiendrez un résultat similaire à la figure 12.

image
Figure 12: Exécution de 50 tâches ne consommant que 4 threads

Si vous observez bien la colonne ThreadId, vous constatez que seulement 4 threads sont utilisés, la figure 13 ci-dessous nous le montre encore plus clairement.

image

Figure 13: utilisation de 4 threads pour 50 taches

Lancer plusieurs longues tâches

Dans le paragraphe précédent, la figure 12 illustre la stratégie du pool de threads vis-à-vis des demandes en provenance de l'API Task. Tant que les demandes réclament une exécution relativement courte, le pool de threads n'accorde que la valeur minimum de threads de travail (ici quatre sur un quad core). Naturellement si vous sollicitez de manière plus agressive le pool de threads en augmentant le temps d’exécution des tâches, vous obtiendrez un résultat différent. Tous nos exemples sont pour l'instant extrêmement simples et sans doute loin de la réalité. Mais imaginons que nos tâches exécutent un code moins rapide, par exemple un temps de plus de dix secondes, comme le montre le code ci-dessous.

private static void RunWaitTasks(int items)

{

    var taskNum = 0;

    var tasks = new Task[items];

    Console.WriteLine("Launch {0} tasks", tasks.Length);            

    PrintThreadPoolUsage();

    while (taskNum < items)

    {

        tasks[taskNum] = new Task(() => {

            Console.WriteLine("TaskId:{0,2} ThreadId:{1} IsThreadPool:{2}",

                Task.CurrentId,

                Thread.CurrentThread.ManagedThreadId,

                Thread.CurrentThread.IsThreadPoolThread);

            Thread.Sleep(10000);

            PrintThreadPoolUsage();

                  

        });

        tasks[taskNum].Start();

        ++taskNum;

    }

    Task.WaitAll(tasks);

    PrintThreadPoolUsage();

}

Dans le code ci-dessus, nous affichons quelques informations sur la nature de la tâche en cours d'exécution, comme son identifiant, mais aussi l'identifiant de son thread correspondant et enfin nous affichons si ce thread appartient bien au ThreadPool (ce qui peut sembler inutile). Puis nous endormons la tâche courante pendant 10 secondes pour enfin afficher le nombre de threads disponibles. Si vous compiler ce code, vous obtiendrez sans doute un résultat similaire à la figure 14.

image

Figure 14: le Pool de Threads accorde un nombre de threads supérieur au minimum (MinThreads)

Dans cet exemple, on observe une consommation de 23  Threads, ce qui est bien supérieur à la MinThreads. Au fil des terminaisons des tâches, le nombre de threads disponibles remonte progressivement et retrouve sa valeur nominale à la fin de tous les traitements. Cet exemple nous démontre une nouvelle fois, le caractère adaptatif du Pool de Threads.

En résumé

Nous avons montré comment créer de nombreuses tâches tout en observant le comportement du Pool de Threads. Le caractère adaptatif du Pool de Thread permet à la librairie des tâches de garantir une excellente disponibilité tout en préservant les ressources sous-jacentes. Si vos besoins en tâches sont variables comme par exemple sur le nombre de tâches ou sur le temps des traitements, l’API Task devrait vous satisfaire pleinement.

Dans le prochain billet, je continuerai cette introduction de l’offre parallèle orientée tâches, en abordant les sujets suivants : l’abandon coopératif, les exceptions agrégées et la notion de continuité entre tâches.

A bientôt,

Bruno

boucard.bruno@free.fr