Plan du site  
pixel
pixel

Articles - Étudiants SUPINFO

La parallélisation en .NET

Par Pierre MARCEL Publié le 02/02/2017 à 11:38:16 Noter cet article:
(0 votes)
Avis favorable du comité de lecture

1. Introduction

Aujourd'hui, les développeurs d'applications sont confrontés à un changement de comportement de la part des utilisateurs : peu importe l'application, elle doit s'executer le plus rapidement possible. Heureusement, pour répondre à ces attentes, des processeurs de plus en plus puissants équipent nos machines. Ceux-ci sont aujourd'hui rarement composés de moins de 2 coeurs physiques et permettent ainsi d'exécuter plusieurs traitements simultanément.

L'objectif de cet article est de faire le point sur certaines techniques en .NET qui vous permettront de profiter de la parallélisation de tâches que permet nos processeurs.

Afin de pouvoir suivre cet article efficacement, il vous faut des connaissances de base en programmation C# ainsi que Visual Studio d'installé sur votre poste. Vous pouvez bien entendu utiliser l' IDE de votre choix si vous le maitrisez. Je vous propose de travailler dans une application de type "Console" durant toute la durée du tutoriel.

2. Threads

Pour commencer, la classe Thread permet de créer des Threads au niveau de l'OS. Chaque Thread possède sa propre zone mémoire et ses propres resources.

Voici les billes pour en créer un :

- Créez un nouveau projet de type console : (J'ai nommé le mien "Parallel")

- Remplacez le contenu du fichier Program.cs par le code suivant :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Remoting.Metadata.W3cXsd2001;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //Creation d'un nouveau thread
            Thread monThread = new Thread(() =>
            {
                for(int i = 0 ; i < 10 ; i++)
                {
                    Console.WriteLine("Hello from our second thread {0}", i);
                    Thread.Sleep((50));
                }
            });

            //Variante : 
            //Thread monThread = new Thread(ThreadMethod);

            //Lancement du thread
            monThread.Start();

            Console.WriteLine("Main thread awaiting to finish");

            while (true)
            {
                //J'attends la fin du thread monThread pendant 100 ms
                if (monThread.Join(100))
                    break;
                Console.WriteLine("Still waiting");
            }

            Console.WriteLine("Finished");
            Console.ReadKey();
        }

        private void ThreadMethod()
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("Hello from our second thread {0}", i);
                Thread.Sleep((200));
            }
        }
    }
}

La fonction Main(...) contient le nécessaire pour :

  • Créer un nouveau thread dans la variable monThread

  • Lancer ce nouveau thread via monThread.Start()

  • Attendre la fin de l'exécution du thread via monThread.Join(...)

Si l'on exécute le programme, l'on obtient la sortie suivante :

On voit bien que nous avons deux Threads qui fonctionnent simultanément, l'un attendant la fin de l’exécution de l'autre.

Je ne vais pas aller plus loin en ce qui concerne les Threads dans cet article car il existe une méthode plus performante pour exécuter des opérations en parallèle : les Tasks.

3. Tasks

Les Tasks permettent d'apporter une réponse au problème de performances des Threads :

Lorsque l'on veut exécuter un traitement en parallèle dans un autre Thread, new Thread(...), l'OS doit attribuer une zone mémoire à ce dernier. Cette étape prend du temps. Ensuite, plus il y a de threads qui tournent sur une machine, plus le processeur perdra du temps à jongler de l'un à l'autre s'il y a plus de threads que de coeurs dans votre processeur.

Pour améliorer les performances, le Framework .Net utilise un pool de Threads. Ce pool de Threads est géré par le CLR (Common language Runtime, il s'agit de l'environnement dans lequel votre programme .NET s'execute). Ainsi, il est possible de soumettre des traitements à ce pool de Thread et d'éviter la création de nouveaux Threads, qui est coûteuse.

Voyons un cas concret avec la classe Task :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Remoting.Metadata.W3cXsd2001;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        public static void Main(string[] args)
        {
            Task task = Task.Factory.StartNew(Operation)
                            .ContinueWith((t) => Console.WriteLine("Operation finished"));

            //Boucle pour attendre la fin de la tâche
            while (!task.IsCompleted)
            {
            }

            Console.ReadKey();
        }

        public static void Operation()
        {
            //Simule un traitement long
            for(int i =0;i<10;i++)
            {
                Console.WriteLine("Working");
                Thread.Sleep(150);
            }
        }

    }
}

Voici les grandes étapes :

  • J'utilise la TaskFactory afin de créer et lancer directement une nouvelle Task.Factory.StartNew(...).

  • Cette fonction reçoit en paramètre soit une méthode de type Action (Methode void et sans paramètres), soit une lambda Expression qui va être exécutée dans le pool de Threads du CLR.

  • En réalité je soumet 2 tâches : une première qui exécute la méthode Operation() puis une seconde qui s'exécutera dès que la première sera terminée afin d'afficher un message dans la console.

  • J'attends la fin de la tâche : "task.IsCompleted == true".

Voici ce qu'affiche la console après exécution :

Il est possible d'arrêter l'exécution d'une tâche prématurément, comme dans l'exemple ci-dessous :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.Remoting.Metadata.W3cXsd2001;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var tokenSource = new CancellationTokenSource();
            CancellationToken token = tokenSource.Token;

            Task task = Task.Factory.StartNew(() =>
                                    {
                                        while (true)
                                        {
                                            Thread.Sleep(150);

                                            try
                                            {
                                                token.ThrowIfCancellationRequested();
                                            }
                                            catch (Exception e)
                                            {
                                                // Pour finir une Task proprement en cas d'annulation on peut intercepter l'exception avant de la relancer
                                                // Par exemple, pour annuler une transaction en base de données
                                                throw e;
                                            }

                                            Console.WriteLine("Working...");
                                        }
                                    },token)
                                    .ContinueWith((t) => Console.WriteLine("Operation finished"),token);

            //On fait patienter le thread principal pendant une seconde
            Thread.Sleep(1000);

            try
            {
                //On ordonne l'annulation de la tâchea
                tokenSource.Cancel();
                //On attend que la tâche soit finie
                task.Wait();
            }
            catch (AggregateException)
            {
                Console.WriteLine("Task interrupted with sucess");
            }

            Console.ReadKey();
        }
    }
}

Remarque : Si vous exécutez ce code dans Visual Studio, le debugger s'arrêtera lorsque l'exception lancée pour arrêter l’exécution de la Task sera detectée : c'est normal. Il suffit d'appuyer sur "F5" pour continuer exécution du programme.

4. Les méthodes asynchrones

Autre technique qui peut vous être utile dans la cadre de la parallélisation en .NET : les méthodes asynchrones. Il s'agit d'une autre façon de manipuler des Tasks.

Les méthodes définies comme "async" seront exécutées de manière asynchrone afin d'en récupérer le résultat plus tard.

Il y a deux mots clés à noter :

  • async : Qui permet de définir une méthode en tant que méthode asynchrone

  • await : Qui permet d'attendre la fin de l'execution d'une tâche

Voyons tout de suite un exemple :

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var input = "abcdefg";

            //Lancement du calcul de la longueur de la chaine "input" en parallèle
            var task = AsyncStringLength(input);

            Console.WriteLine("Veuillez patentiez, en attendant, faisons autre chose d'intéressant");
            for (var i = 0; i < 101; i += 10)
            {
                Thread.Sleep(100);
                Console.WriteLine("Autre chose terminé à {0} %", i);
            }

            Console.WriteLine("Recherchons les résultats de notre appel de méthode");

            //Attente de la fin de l'execution de la méthode asynchrone, dans le cas ou elle n'est pas terminée
            task.Wait();

            Console.WriteLine("Resultat de la fonction asynchrone : {0}", task.Result);

            Console.ReadKey();
        }

        //Convention de nommage : les méthodes asynchrones doivent commencer par "Async"
        public static async Task<int> AsyncStringLength(string input)
        {
            var task = new Task<int>(() =>
            {
                for (var i = 0; i < 10; i++)
                {
                    Thread.Sleep(150);
                    Console.WriteLine("Fonction asynchrone en cours d'execution");
                }

                Console.WriteLine("Fonction asynchrone terminée");
                return input.Length;
            });

            task.Start();
            var result = await task;
            return result;
        }
    }
}

Explications :

  • AsyncStringLength(...) est une méthode qui permet de calculer la longueur d'une chaîne de caractères. Elle est marquée comme async, car elle est exécutée en asynchrone, c'est à dire que lors de son exécution, une Task est lancée et on attend pas la fin de l'exécution de la méthode. Le type de retour est donc Task<int>, une instance de Task qui retourne un int.

  • Dans cette méthode, je crée une task qui va effectuer le traitement voulu puis retourner le résultat. Puis, j'attends que cette tâche soit terminée avec la commande "await task".

  • Dans la fonction Main(...), je commencer par appeler la fonction asynchrone, qui va s'exécuter en arrière-plan. Je peux donc exécuter d'autres traitements sur le Thread principal en attendant.

  • Une fois que j'ai besoin de connaître le résultat de ma méthode asynchrone, il suffit de s'assurer que la Task a bien finie son exécution avec task.Wait(). Ensuite, j'ai accès au résultat via task.Result, que j'affiche dans la console.

Voici une copie d'écran de la console, après exécution du programme, ou l'on voit bien que deux traitements sont en cours simultanément.

5. Les accès concurrentiels

Dès lors que plusieurs Threads manipulent les mêmes variables, se pose le problème des accès concurrentiels.

Dans l'exemple ci-dessous, j'utilise deux tasks qui incrémentent une variable "score" dans la fonction DoWork().

Si le score est égal à 0, je l'incrémente de 10, sinon, je l'incrémente de 1.

Chaque Task effectue 5 incrémentations.

Quand les deux Tasks sont finies (Task.WhenAll(...)), j'affiche le score dans la console.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        private static int score =0;

        public static void Main(string[] args)
        {
            var task1 = new Task(DoWork);
            var task2 = new Task(DoWork);

            task1.Start();
            task2.Start();

            Task.WhenAll(task1, task2).ContinueWith((task) => { Console.WriteLine("Total : {0}", score); });

            Console.ReadKey();
        }

        private static void DoWork()
        {
            for (int i = 0; i < 5; i++)
            {
                if (score == 0)
                {
                    Console.WriteLine("Thread : {0} : Ajout du score initial car le score est 0", Thread.CurrentThread.ManagedThreadId);
                    Console.WriteLine("Thread : {0} : +10", Thread.CurrentThread.ManagedThreadId);
                    score += 10;
                }
                else
                {
                    Console.WriteLine("Thread : {0} : +1",Thread.CurrentThread.ManagedThreadId);
                    score += 1;
                }
            }
        }
    }
}

A quel résultat devons-nous nous attendre ?

Au total on incrémente 10 fois notre variable (5 pour chaque Task)

La première fois que l'on entre dans la fonction le score est 0, donc on va l'incrémenter de 10.

Puis on va ajouter 9 fois 1.

On devrait donc avoir un résultat égal à 19.

Bien entendu, ce n'est pas le cas. Voici le résultat :

On obtient donc 26. Pourquoi ?

On voit que chaque Thread (le numéro 10 et le numéro 12) ajoute 10 au score lors de la première itération car celui-ci était à 0 lorsque les deux Tasks ont commencé leur exécution.

Pour résoudre le problème, nous allons utiliser le mécanisme de "Lock" pour verrouiller la variable score. Ceci va ralentir l'exécution mais nous assurer que les résultats sont cohérents.

Voici le code amélioré :

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace Parallel
{
    public class Program
    {
        private static int score =0;

        //Ajout d'une variable qui va servire de "verrou"
        private static object verrou = new object();
        public static void Main(string[] args)
        {
            var task1 = new Task(DoWork);
            var task2 = new Task(DoWork);

            //Lancement des deux tasks
            task1.Start();
            task2.Start();

            //Lorsque les deux tasks ont terminées, j'affiche le résultat dans la console
            Task.WhenAll(task1, task2).ContinueWith((task) => { Console.WriteLine("Total : {0}", score); });

            Console.ReadKey();
        }

        private static void DoWork()
        {
            for (int i = 0; i < 5; i++)
            {
                //Verouillage
                lock (verrou)
                {
                    if (score == 0)
                    {
                        Console.WriteLine("Thread : {0} : Ajout du score initial car le score est 0",
                            Thread.CurrentThread.ManagedThreadId);
                        Console.WriteLine("Thread : {0} : +10", Thread.CurrentThread.ManagedThreadId);
                        Operation();
                        score += 10;
                    }
                    else
                    {
                        Console.WriteLine("Thread : {0} : +1", Thread.CurrentThread.ManagedThreadId);
                        Operation();
                        score += 1;
                    }
                }
            }
        }

        private static void Operation()
        {
            //Simulation d'un traitement long
            Thread.Sleep(100);
        }
    }
}

Pour résoudre le problème, j'ai ajouté une variable de type Object nommée "verrou". J'ai ensuite utilisé la clause "lock(verrou)" pour verrouiller le traitement qui incrémente la variable score.

Concrètement : si un thread verrouille la variable "verrou", n'importe quel autre thread qui essaye de le faire sera bloqué le temps que le verrou ne sera pas levé.

On obtient donc le résultat attendu après exécution programme :

6. Bilan

Il est donc assez simple de paralléliser des traitements dans une application .NET car le CLR fournit un système de "pool de threads" très intéressant. C'est aux développeurs que revient la tâche de penser "multi-thread" afin d'optimiser leurs applications en tirant profit de la puissance de nos processeurs actuels.

Il existe d'autres systèmes qui permettent de paralléliser des traitements en .NET, je pense notamment aux requêtes PLINQ, ou encore à la classe "Parallel" qui permet d'écrire des boucles "Foreach" ou des boucles "For" qui s'exécutent dans plusieurs Threads. Si ce sujet vous intéresse, il y a des articles bien fournis dans la documentation MSDN.

Classe Parallel : https://msdn.microsoft.com/fr-fr/library/dd460720(v=vs.110).aspx

PLINQ : https://msdn.microsoft.com/fr-fr/library/dd460688(v=vs.110).aspx

Bibliographie

  • Threads : https://msdn.microsoft.com/en-us/library/e1dx6b2h(v=vs.110).aspx

  • Tasks : https://msdn.microsoft.com/fr-fr/library/dd537609(v=vs.110).aspx

  • Méthodes asynchrones : https://msdn.microsoft.com/fr-fr/library/mt674882.aspx

  • Locks : https://msdn.microsoft.com/fr-fr/library/c5kehkcz.aspx

A propos de SUPINFO | Contacts & adresses | Enseigner à SUPINFO | Presse | Conditions d'utilisation & Copyright | Respect de la vie privée | Investir
Logo de la société Cisco, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société IBM, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Sun-Oracle, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Apple, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Sybase, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Novell, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Intel, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Accenture, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société SAP, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Prometric, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo de la société Toeic, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management Logo du IT Academy Program par Microsoft, partenaire pédagogique de SUPINFO, la Grande École de l'informatique, du numérique et du management

SUPINFO International University
Ecole d'Informatique - IT School
École Supérieure d'Informatique de Paris, leader en France
La Grande Ecole de l'informatique, du numérique et du management
Fondée en 1965, reconnue par l'État. Titre Bac+5 certifié au niveau I.
SUPINFO International University is globally operated by EDUCINVEST Belgium - Avenue Louise, 534 - 1050 Brussels