Share via


Introduction aux Extensions parallèles à .NET (PFX)

Par Eric Vernié, en charge des relations avec les développeurs

 

Introduction

Comme vous avez sans doute pu le constater, les micro-ordinateurs d’aujourd’hui, du PC de bureau au portable (et sans doute très bientôt les PDA), sont munis de puces dites multi-cœurs. Les raisons pour lesquelles les fondeurs ont travaillé sur le sujet, sont multiples, mais les deux principales tiennent en deux mots : Chaleur et Consommation.

Chaleur : Ces mêmes fondeurs prédisaient qu’en restant aux architectures mono-cœur, la dissipation thermique en 2015-2020 équivaudrait à la chaleur dégagée à la surface du soleil !!! J’imagine que c’était une boutade, mais elle révèle bien l’ampleur du problème.

Consommation : La course au MHz entraine inévitablement une course à la consommation. Plus vous allez vite sur autoroute, plus vous consommez de l’essence. Pour votre processeur c’est pareil, et les adeptes de l’overclocking ne me contrediront pas.

De source Intel © 2006, si vous augmentez la fréquence de votre processeur de 20%, vous augmentez les performances (en moyenne) de 13% ; par contre la consommation augmente de 73%.

Si vous diminuez la fréquence de 20%, vous diminuez la performance de 13%, mais vous diminuez également la consommation d’environ 49%.

En partant de ce constat, si en gardant une fréquence de 20% en deçà de la fréquence d’origine, on ajoute un second cœur (les deux cœurs tournant chacun à 80%), on augmente les performances de 73%, par contre, la consommation n’augmente que de 2%. CQFD

Au même titre que la course à la puissance, si aujourd’hui nous disposons de PC de bureau qui en standard possède des dual-cœurs, nous pouvons sans risque prédire que demain, nos pc auront 4, 8, 16, 32 voir plus.

Plusieurs questions se posent alors.

Est-ce que nos applications actuelles profiteront automatiquement de cette profusion de puissance comme dans le passé ?

Est-ce que nous développeurs, sommes prêts à exploiter cette puissance ?

La réponse dans les deux cas est simple, c’est non !!

Non, car un algorithme de calcul qui n’a pas été « pensé » pour profiter de plusieurs cœurs, consommera au plus 50% de la CPU sur un dual-coeurs, 25% sur un quad-coeurs, 12% sur un huit-cœurs et ainsi de suite (à Vérifier sur Mandelbrot).

Non, la programmation parallèle n’est pas un concept nouveau, et est au cœur des préoccupations des scientifiques depuis des décennies. Mais réservée à des experts et à une minorité d’applications qui demandent des calculs intensifs sur une masse de données très importante, elle n’est pas abordable rapidement au plus grand nombre des développeurs.

D’autre part, sur un système tel que Microsoft Windows, il est bien évidemment possible de faire de la programmation parallèle ou plus précisément dans le jargon Microsoft, de la programmation multi-threads, mais les concepts inhérent à ce mode de développement restent difficiles à maitriser, même avec la plate-forme .NET.

Cette plate-forme a sans aucun doute apporté de la productivité aux développeurs, il n’en reste pas moins que développer en parallèle aujourd’hui avec .NET et les langages associés type VB et C# reste difficile. Savez-vous par exemple, comment éclater l’exécution d’une boucle sur plusieurs processeurs à la fois ?

C’est bien évidemment possible en utilisant la librairie de thread de .NET (System.Threading), mais cela reste compliqué à mettre au point, à déboguer et à maintenir (dans le monde natif, plusieurs bibliothèques telles que OpenMP, permettent de le faire).

Dans l’optique de réduire les concepts de la programmation parallèle et la complexité de développement d’application parallèle, Microsoft travaille sur une extension parallèle à .NET nommée de son acronyme anglo-saxon PFX.

 

Haut de pageHaut de page

Extension parallèle à .NET (PFX)

Disponible en version CTP (Juin 2008) depuis peu :

https://msdn2.microsoft.com/fr-fr/concurrency/default.aspx

L’idée de base est de pouvoir fournir aux développeurs .NET une bibliothèque qui pourra exploiter naturellement tous les processeurs disponibles, sans que cela devienne une tâche insurmontable.

Une fois installée, vous retrouverez la documentation ainsi que des exemples dans le répertoire C:\Program Files\Microsoft Parallel Extensions Jun08 CTP

Note :

Pour tous les exemples qui suivent, la bibliothèque C:\Program Files\ Microsoft Parallel Extensions Jun08 CTP \System.Threading.dll a été ajouté en tant que référence.

Nous allons donc dés à présent créer notre premier algorithme parallèle.

Imaginons que nous souhaitons paralléliser la boucle suivante, où nous alimentons un tableau d’une chaîne de caractères, constituée du résultat d’une multiplication, ainsi que du numéro de thread qui a traité la demande.

//En C#

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

{

vecteurs[i] = "Carré : " + (i * i).ToString() + " ThreadID :" + Thread.CurrentThread.ManagedThreadId.ToString();

}

//En VB

For i As Integer = 0 To MAX

 vecteurs(i) = "Carré : " + (i * i).ToString() + " ThreadID :" + Thread.CurrentThread.ManagedThreadId.ToString()

Next

Chaque itération, étant indépendante, il est possible de tenter de paralléliser cette boucle en employant notre nouvelle bibliothèque.

//En C#

string[] vecteurs = new string[MAX];

Parallel.For(0, MAX, (int i)=>

{

vecteurs[i] = "Carré : " + (i * i).ToString() + " ThreadID :" + Thread.CurrentThread.ManagedThreadId.ToString();

});



//En VB

Dim vecteurs(MAX) As String

Parallel.For(0, MAX + 1, Function(i) CalculCarre(i, vecteurs))

Function CalculCarre(ByVal i As Integer, ByVal vecteurs() As String) As Boolean

vecteurs(i) = "Carré : " + (i * i).ToString() + " ThreadID :" + Thread.CurrentThread.ManagedThreadId.ToString()

        Return True

End Function

Dans cet exemple, nous utilisons la méthode statique (shared en vb) Parallel.For pour parallèliser notre algorithme, qui est capturé dans le 3ème argument de la méthode, par un delegate (delegate, exprimé ici sous forme de lambda expression, nouveauté disponible avec C#3 et VB 9).

Notre algorithme de départ reste inchangé, et la bibliothèque tentera de le parallèliser sur plusieurs cœurs.

A ce stade, plusieurs remarques sont à faire.

1)     Si vous ne disposez pas de multi-cœur, cette bibliotheque fonctionnera quand même sur des architectures mono-cœur, vous pouvez donc dès à présent l’utiliser et profiter plus tard de la puissance des multi-coeurs

2)     Il est probable que vous ne constatiez pas (tout de suite) d’amélioration significative de performance, car cela dépend beaucoup de l’algorithme et surtout du volume de données que vous utilisez. Dans notreexemple si MAX est très faible, l’initialisation et la finalisation de la bibliothèque peut prendre plus de temps que la boucle elle-même. Un seuil est donc à trouver.

3)     Bien qu’il soit simple de paralléliser une boucle, il n’en reste pas moins que des mécanismes de verrouillages de la mémoire soient quand même à prévoir pour certains algorithmes. En effet imaginons qu’à la place d’utiliser un tableau de chaine fixe dans notre algorithme, nous utilisions une liste de type générique List<T>. Comme ceci

List<Client> clients = new List<Client>();          

          Parallel.For(0, 2000, (int i) =>

           {

                 Client client = new Client();

                 client.Age = i;

                 client.Nom = "Nom" + i.ToString();

                 client.Prenom = "Prénom" + i.ToString();

                 clients.Add(client);

            });

          foreach (Client c in clients)

           {

             Console.WriteLine("Nom : {0}, Prenom :  {1},  Age : {2}", c.Nom, c.Prenom, c.Age);

           }

Dans cette exemple nous partageons l’objet liste clients entre plusieurs tâches et sa méthode Add(), ne garantit pas qu’un client soit ajouté correctement. Il est fort probable que la boucle suivante foreach plante aléatoirement ne trouvant pas dans la liste d’objet client approprié (client=null) Bug plus incidieux, la liste risque également de ne pas avoir le nombre correct d’élèments. Dans notre cas, il sera préférable d’utiliser la collection System.Threading.Collections.BlockingCollection<T> à la place, qui utilise un mécanisme interne de sémaphore léger pour la synchronisation de la méthode Add().

Remplacez

List<Client> clients= new List<Client>()

par

BlockingCollection<Client> clients = new BlockingCollection<Client>();

Dans la version de Juin vous retrouverez également les collections dites « Thread Safe » suivantes :

System.Threading.collections.ConcurrentQueue<T> System.Threading.collections.ConcurrentStack<T>

Il est également possible d’utiliser la méthode statique Parallel.ForEach pour itérer en parallèle sur une collection d’objets. Une bonne candidate est la liste générique List<T>.

//en C#

List<long> vecteurs = new List<long>();

ReaderWriterLockSlim lck = new ReaderWriterLockSlim();

long cumule=0;

Parallel.ForEach(vecteurs, delegate(long j)

{

    try

    {

        lck.EnterWriteLock();

        cumule += j;

    }

    finally

    {

        lck.ExitWriteLock(); 

    }

});

//En VB

Dim lck As New ReaderWriterLockSlim

Parallel.ForEach(vecteurs, Function(z) Somme(z, cumule, lck))



Function Somme(ByVal j As Long, ByRef cumule As Long, ByRef lck As ReaderWriterLockSlim) As Boolean

        Try

            lck.EnterWriteLock()

            cumule += j



        Finally

            lck.ExitWriteLock()

        End Try

        Return True

    End Function

Je vous l’accorde cet algorithme est assez sot, car l’objet « List » possède déjà une méthode d’instance Sum(). Mais il me permet d’introduire une notion importante, la gestion des états.

Dans cet exemple, nous partageons entre chaque thread, la variable « cumule », qui est incrémentée de « j ». Si nous ne posons pas un verrou en écriture de type ReaderWriterLockSlim par exemple (nouveau avec .NET 3.5), le résultat risque d’être incertain. J’attire ici votre attention sur le fait que la gestion de verrou concept ô combien important dans un environnement parallèle, reste à la charge du développeur. Cette bibliothèque n’apporte rien de nouveau dans ce sens. Les problèmes de concurrences d’accès, d’étreinte fatale, et autres conflits restent bien évidement une réalité.

D’ailleurs les verrous ne sont pas toujours les plus performants, et d’après certains experts, il est dans certaine situation, préférable d’utiliser des alternatives. Je ne peux que vous conseiller d’ailleurs la lecture des articles suivants sur le sujet :

Reader/Writer Locks and the ResourceLock Library et Performance-Conscious Thread Synchronization

Une des alternatives avec la bibliothèque cette CTP de Juin 2008 est l’introduction de la classe System.Threading.SpinLock qui ne pose pas de verrous à proprement parlé, mais qui comme son nom l’indique rentre dans une boucle répétitive et évite les passages dans le mode kernel de Windows.

Dans la version de Juin vous retrouverez également les objets de synchronisation suivants :

System.Threading.CountdownEvent

System.Threading.LazyInit<T>

System.Threading.ManualResetEventSlim

System.Threading.SemaphoreSlim

System.Threading.SpinLock

System.Threading.SpinWait

System.Threading.WriteOnce<T>

 

Haut de pageHaut de page

Exécution de tâches asynchrones

Avec la plate-forme .NET, lorsqu’on souhaite exécuter une ou plusieurs tâches asynchrones, nous disposons de divers moyens. Le thread brut, le gestionnaire de pool de threads, ou le modèle de programmation asynchrone fourni avec les délégués (delegate).

Dans cet exemple, nous allons utiliser la méthode statique Parallel.Invoke, qui nous permet d’exécuter facilement des tâches indépendantes les unes des autres.

//En C#

try

  {

      Parallel.Invoke( ()  =>  AfficherMessage("Bonjour"),

                   () => AfficherMessage("et bienvenue"),

                   () => AfficherMessage("au"),

                   () => AfficherMessage("techdays 2008"));

  }

  catch (AggregateException agrEx)

  {

      foreach (Exception ex in agrEx.InnerExceptions)

      {

          Console.WriteLine(ex.InnerException.Message); 

      }

  }



static void AfficherMessage(string message)

        {

            Console.WriteLine("Thread ID :" + Thread.CurrentThread.ManagedThreadId.ToString());

            Console.WriteLine(message);

           //Simule une activité

            System.Threading.Thread.Sleep(1000);

           //Les erreurs sont encapsulées dans une erreur de niveau supèrieure AggregateException

            throw new ArithmeticException("Erreur ThreadID" + Thread.CurrentThread.ManagedThreadId.ToString());

        }



//En VB

Try

            Parallel.Invoke(Function() AfficherMessage("Bonjour"), _

                        Function() AfficherMessage("et bienvenue"), _

                        Function() AfficherMessage("au"), _

                        Function() AfficherMessage("techdays 2008"))

        Catch exAgr As AggregateException

            For Each ex As Exception In exAgr.InnerExceptions

                Console.WriteLine(ex.InnerException.Message)

            Next

        End Try



Function AfficherMessage(ByVal message As String) As Boolean

        Console.WriteLine("Thread ID :" + Thread.CurrentThread.ManagedThreadId.ToString())

        Console.WriteLine(message)

        ‘Simule une activité

        System.Threading.Thread.Sleep(1000)

        'Les erreurs sont encapsulées dans une erreur de niveau supèrieure AggregateException

        Throw New ArithmeticException("Erreur ThreadID" + Thread.CurrentThread.ManagedThreadId.ToString())

        Return True

    End Function

Encore une fois cet exemple est assez idiot et loin de la réalité du terrain. Mais il met en exergue la facilité avec laquelle il est possible d’exécuter des tâches asynchrones, ainsi que la facilité à gérer les exceptions.

En effet, une des notions difficiles à maitriser en développement parallèle et celle de la gestion des erreurs. Dans notre exemple, au lieu d’arrêter l’exécution à la 1ère exception levée, la bibliothèque les agrège dans l’exception « AggregateException » de plus haut niveau. Puis lorsque toutes les tâches sont terminées elle lève alors l’exception. Il suffit ensuite de parcourir la collection « InnerExceptions », pour retrouver les réelles erreurs qui se sont produites dans les threads.

 

Haut de pageHaut de page

Notion de Tâches (Task) et de Future.

Parfois, vous aurez sans doute besoin de maitriser plus finement la gestion de vos tâches asynchrones, car vos algorithmes seront plus complexes. Il faudra alors faire appel aux classes « Task » et « Future » (Cette dernière dérivant elle-même de la classe Task).

Note :

Toutes les méthodes statiques de la classe « Parallel » que nous venons de voir utilisent d’une manière ou d’une autre l’objet « Task ».

Imaginons par exemple le besoin simple d’exécuter des tâches en parallèle qui soient indépendantes les unes des autres, mais dont on a besoin d’agréger leurs résultats. Il est donc indispensable de mettre une barrière, pour attendre que tous les threads aient achevé leur travail.

//En C#

Task[] tasks = new Task[2];

int z = 0;

int w = 0;

 tasks[0] = Task.Create(delegate

{

     z = Carre(42);

 });

 tasks[1] = Task.Create(delegate

 {

     w = Carre(42);

 });

 //attendre que toutes les tâches est finies leur travail

 Task.WaitAll(tasks);

 Console.WriteLine("La somme des carrés {0}", w + z);

static int Carre(int a)

        {

            return a * a;

        }



//En VB

  Dim Tasks(1) As Task

Dim w As Integer = 0

Dim z As Integer = 0

Tasks(0) = Task.Create(Function() Carre(42, z))

Tasks(1) = Task.Create(Function() Carre(42, w))

''attendre que toutes les tâches est finies leur travail

Task.WaitAll(Tasks)

Console.WriteLine("La somme des carrés {0}", w + z)

Function Carre(ByVal a As Integer, ByRef ret As Integer) As Boolean

   ret = a * a

   Return True

End Function

Dans cet exemple, nous instancions 2 tâches qui vont exécuter en parallèle la méthode « Carre() ». Ensuite, nous utilisons la méthode statique (shared en VB) WaitAll de la classe Task, afin d’attendre que toutes les tâches soient finies. Une fois cette garantie obtenue, nous pouvons en toute tranquillité calculer la somme des deux carrés.

Néanmoins, en tant que développeur, nous souhaiterions exprimer notre somme de manière plus naturelle ; exemple Somme=A+B ; sans nous soucier des mécanismes additionnels de synchronisation. L’objet « Future », va nous aider dans ce sens.

//EN C#

Future<int> z=Future.Create (()=>Carre(42));

Future<int> w = Future.Create(() => Carre(42));

Console.WriteLine("La somme des carrés {0}", w.Value  + z.Value);



//En VB

Dim z As Future(Of Integer) = Future.Create(Function() Carre(42))

Dim w As Future(Of Integer) = Future.Create(Function() Carre(42))

Console.WriteLine("La somme des carrés {0}", w.Value + z.Value)

Plus besoin de créer un tableau de tâches, et de mettre en place un mécanisme d’attente, c’est l’objet « Future » et sa propriété « Value() » qui se met en attente de résultat. En d’autres termes, tant que les deux threads exécutant en parallèle la méthode « Carre() » n’ont pas fini leur travail, l’addition de w+z est en attente.

Les Futures sont une manière élégante de programmer en asynchrone, en réduisant considérablement le nombre de lignes de code. Plus on réduit le nombre de lignes en programmation parallèle, plus nous avons de chance de réduire également les erreurs. Voici par exemple, comment nous aurions pu faire le même calcul avec le Thread de base de .NET.

static private int[] values;

struct UserState

{

    public WaitHandle waitHandle;

    public int index;

    public int value;

}

static void TestAPM()

{

    WaitHandle[] waitHandles=new WaitHandle[2];

    values = new int[2];

    waitHandles[0] = new AutoResetEvent(false);

    waitHandles[1] = new AutoResetEvent(false);

    UserState param1=new UserState ();

    UserState param2 = new UserState();

    param1.waitHandle = waitHandles[0];

    param1.index = 0;

    param1.value = 42;

    Thread t1 = new Thread(new ParameterizedThreadStart(Carre));

    t1.Name = "Thread1";

    t1.IsBackground = true;

    param2.waitHandle = waitHandles[1];

    param2.value = 42; 

    param2.index = 1;

    Thread t2 = new Thread (new ParameterizedThreadStart (Carre));

    t2.Name = "Thread2";

    t2.IsBackground = true;

    t1.Start(param1);

    t2.Start(param2);

    WaitHandle.WaitAll(waitHandles); 

    Console.WriteLine("La somme des carrés {0}", values[0] + values[1]);

}



static void Carre(object state)

{

        UserState parametres = (UserState)state;

        values[parametres.index] = parametres.value * parametres.value;

        AutoResetEvent mre =(AutoResetEvent)parametres.waitHandle;

        mre.Set();  

 

}

Je ne vais pas détailler l’algorithme, mais sa longueur parle de lui-même. Vous noterez qu’à la différence de la méthode Carre() utilisée avec la classe Future, chaque instance de la méthode Carre() doit signifier (par l’intermédiaire de l’objet AutoResetEvent) au thread appelant qu’elle a fini son travail, afin que ce même thread principal puisse continuer.

 

Haut de pageHaut de page

PLINQ

Nous venons de voir comment à l’aide de programmation impérative nous pouvons développer des applications concurrentielles. Voyons maintenant, comment le faire de manière plus déclarative à l’aide de PLINQ.

Avec le framework .NET 3.5, C# 3 et VB 9 arrive une technologie d’interrogation d’objets nommée LINQ (Language Integrated Query), la bibliothèque d’extension parallèle lui emboîte le pas, et propose sa version parallélisée nommée PLINQ (Parallel Linq).

Pour bien comprendre la suite, quelques rudiments de LINQ sont nécessaires.

Imaginons le scénario suivant :

J’ai une entité nommée Client organisée de la manière suivante : Client (ID,Nom,Prenom,Age).

Dans notre scénario, nous remplissons dans le désordre une liste générique List<Client> de clients et ce que nous souhaitons, c’est pouvoir en extraire un sous ensemble qui répond à un certain nombre de critères.

Avec le langage SQL, cette demande pourrait s’exprimer de la manière suivante :

“Select Age, Nom, Prénom from [tableClients] where Age > 50 and Age < 70 Order By Nom”

LINQ va alors nous permettre d’interroger la liste de clients dans une syntaxe qui se rapproche peu ou prou du langage SQL.

Par exemple si je souhaite dans cette liste ne récupérer que les clients ayant un âge compris entre 50 et 70 ans, voici comment cela se traduit.

//En C#

List<Client> Clients = EntiteMetier.Client.CreerClient(1000);

var cs = from c in Clients where c.Age >50 & c.Age < 70   select c;



//En VB

Dim Clients As List(Of Client) = EntiteMetier.Client.CreerClient(1000)

Dim cs = From c In Clients Where c.Age > 50 And c.Age < 70 Select c

Si je souhaite trier par le nom, je peux y ajouter une clause OrderBy comme ceci :

//EN C#

List<Client> Clients = EntiteMetier.Client.CreerClient(1000);

var cs = from c in Clients where c.Age >50 & c.Age < 70 orderby c.Nom  select c;



//EN VB

Dim Clients As List(Of Client) = EntiteMetier.Client.CreerClient(1000)

Dim cs = From c In Clients Where c.Age > 50 And c.Age < 70 Order By c.Nom Select c

Ensuite il est simple de parcourir ce sous ensemble :

//En C#

foreach (Client c in cs)

{

Console.WriteLine("Nom {0}, Prenom {1},  Age : {2}", c.Nom, c.Prenom, c.Age);

}

//En VB

For Each cl As Client In cs

Console.WriteLine("Nom {0}, Prenom {1},  Age : {2}", cl.Nom, cl.Prenom, cl.Age)

Next

Avec PLINQ il sera possible de manière déclarative d’exprimer ces mêmes demandes, mais cette fois-ci en exploitant tous les processeurs disponibles et ceci d’une manière extrêmement simple. Il suffit d’utiliser l’extension AsParallel() comme ceci.

//En C#

List<Client> Clients = EntiteMetier.Client.CreerClient(1000);

var cs = from c in Clients.AsParallel () where c.Age >50 & c.Age < 70   orderby c.Nom  select c;



//En VB

Dim Clients As List(Of Client) = EntiteMetier.Client.CreerClient(1000)

Dim cs = From c In Clients.AsParallel() Where c.Age > 50 And c.Age < 70 Order By c.Nom Select c

 

Haut de pageHaut de page

Conclusion

Comme je le disais en introduction, les concepts et le développement parallèle restent difficiles à maitriser. Nous aimerions, nous développeurs, exprimer nos algorithmes de manière traditionnelle et le système fait le reste. Aujourd’hui ce n’est pas encore le cas, mais avec cette nouvelle bibliothèque d’extension parallèle à .NET, nous nous en approchons à grand pas.

Restez à l’écoute, car dans le prochain article je rentrerai plus dans le détail pour vous expliquer quelques nouveautés disponibles dans cette version de Juin 2008.

Par exemple :

Comment exécuter des tâches dans un ordre prédéfini ?

Comment exécuter plusieurs tâches en parallèle et continuer l’exécution lorsque la tâche la plus rapide a terminé son travail ?

Eric Vernié

Microsoft France

Relation Technique Développeurs

 

Haut de pageHaut de page