MSDN Magazine > Accueil > Tous les numéros > 2007 > October >  Parallel LINQ: Exécution de requê...
Parallel LINQ
Exécution de requêtes sur les processeurs multicœur
Joe Duffy and Ed Essey

Cet article se base sur la bibliothèque Parallel FX, qui est actuellement en phase de développement. Toutes les informations contenues dans le présent document peuvent faire l’objet de modifications.
Cet article aborde les sujets suivants:
  • Principes fondamentaux du langage PLINQ
  • Modèle de programmation PLINQ
  • Exceptions simultanées
  • Ordre de sortie
Cet article utilise les technologies suivantes:
Bibliothèque Parallel FX
Les processeurs multicœur sont omniprésents. Autrefois essentiellement présents sur les serveurs et les ordinateurs de bureau, les processeurs multicœur sont désormais utilisés dans les téléphones mobiles et les assistants numériques personnels, offrant ainsi des avantages considérables en matière de consommation électrique. En réponse à la disponibilité accrue des plates-formes multiprocesseur, le langage PLINQ permet de tirer facilement parti du matériel parallèle, notamment les ordinateurs multiprocesseur traditionnels et la nouvelle vague de processeurs multicœur.
PLINQ est un moteur d'exécution de requête qui accepte toutes les requêtes LINQ-to-Objects ou LINQ-to-XML, et utilise automatiquement plusieurs processeurs ou noyaux pour l'exécution lorsqu'ils sont disponibles. Les modifications du modèle de programmation sont minimes, ce qui signifie qu'il n'est pas nécessaire d'être un grand manitou de la simultanéité pour l'utiliser. En fait, les threads et les verrous ne sont même pas visibles, sauf si vous souhaitez vraiment mettre les mains dans le cambouis pour comprendre comment cela fonctionne. PLINQ est un composant clé de Parallel FX, la nouvelle génération de prise en charge de la simultanéité de Microsoft® .NET Framework.
L'utilisation de technologies telles que PLINQ va devenir de plus en plus cruciale pour assurer l'évolutivité des logiciels sur les futures architectures multiprocesseur parallèles. En utilisant dès maintenant LINQ à certains endroits précis de vos applications (par exemple là où vous effectuez des opérations recourant de façon intensive aux données ou au calcul qui peuvent être exprimées sous forme de requêtes), vous vous assurez que ces fragments de vos programmes continueront d'offrir des performances optimales lorsque PLINQ sera disponible et que les ordinateurs qui exécutent votre application passeront de 2 à 4, puis à 32 processeurs et plus. Même si vous n'exécutez ce code que sur un ordinateur mono-processeur, le coût de PLINQ en termes de performances est généralement si réduit que vous ne remarquerez pas la différence. De plus, la nature parallèle des données de PLINQ garantit que vos programmes continueront d'évoluer à mesure que la taille de vos jeux de données augmente.
Dans cet article, nous examinerons les objectifs de la technologie PLINQ, comment elle s'inscrit dans .NET Framework et les autres offres concurrentes, et à quoi elle ressemble du point de vue des développeurs LINQ. Nous conclurons par des exemples de scénarios dans lesquels PLINQ a déjà démontré ses remarquables qualités.
Notez que la bibliothèque Parallel FX, qui inclut PLINQ, est actuellement encore en phase de développement, mais la première version CTP (Community Tech Preview) sera disponible sur MSDN® à l'automne 2007. Pour plus d'informations, consultez le blog à l'adresse blog.msdn.com/somasegar.

De LINQ à PLINQ
Quand les gens entendent parler du projet PLINQ pour la première fois, ils demandent généralement : mais pourquoi paralléliser LINQ ? La réponse simple est que le modèle de programmation LINQ défini à un moment pour exprimer des calculs met l'accent sur la spécification de ce qui doit être effectué et non sur la façon dont cela doit être effectué. Sans LINQ, la façon de procéder serait généralement exprimée à l'aide de boucles et de structures de données intermédiaires, mais en encodant autant d'informations spécifiques, le compilateur et le composant d'exécution ne peuvent pas paralléliser aussi facilement. La nature déclarative de la technologie LINQ, d'autre part, maintient la flexibilité qui permet à une implémentation ingénieuse telle que PLINQ d'utiliser la parallélisation pour obtenir les mêmes résultats.
La question qui suit est inévitablement : si vous effectuez des requêtes portant sur suffisamment de données pour que l'utilisation de PLINQ soit justifiée, pourquoi ne pas utiliser simplement une base de données ? La réponse à cette question prend la forme d'une question rhétorique : quel code auriez-vous écrit si vous n'utilisiez pas LINQ ? Vous effectueriez probablement la même quantité d'opérations recourant de façon intensive aux données ou au calcul, mais elles seraient capturées dans une série déstructurée de boucles For, d'appels de fonction, etc. Ainsi, cette question implique que tous programmes devraient résider dans la base de données, ce qu'à mon avis peu de personnes accepteraient.
Avec PLINQ, vous n'avez pas besoin de déplacer toute la logique de traitement du serveur de base de données vers des requêtes LINQ-to-Objects en mémoire sur le client. Au lieu de cela, PLINQ offre une façon incrémentielle de tirer parti du parallélisme pour les solutions existantes à des problèmes existants. Si votre problème est suffisamment grand consommateur de données qu'il impliquerait généralement que vous ayez recours à une solution de base de données, vous devez continuer d'utiliser ce type de solution. PLINQ y change peu de choses. En revanche, si vous possédez plusieurs sources de données que vous aimeriez interroger conjointement (bases de données hétérogènes ou fractionnées, fichiers XML, etc.), PLINQ peut intervenir et effectuer une parallélisation une fois que les données se trouvent sur le client.
Naturellement, les gens demandent ensuite : pourquoi LINQ-to-Objects n'exécute pas lui-même les requêtes en parallèle ? Le parallélisme serait complètement masqué et les développeurs ne devraient pas modifier une seule ligne de code pour bénéficier des avantages du parallélisme. Malheureusement, il existe certains petits pièges qui peuvent survenir lors du passage de LINQ à PLINQ et qui empêchent de concrétiser cette vision, de moins pour ce qui est de PLINQ 1.0. Ces pièges seront abordés dans la suite de cet article.

Modèle de programmation PLINQ
L'utilisation de PLINQ est presque identique à l'utilisation de LINQ-to-Objects et LINQ-to-XML. Vous pouvez utiliser tous les opérateurs disponibles dans la syntaxe d'inclusion des requêtes C# 3.0 et Visual Basic® 9.0 ou la classe System.Linq.Enumerable, notamment OrderBy, Join, Select, Where, etc. PLINQ prend en charge tous les opérateurs LINQ, pas seulement les opérateurs disponibles dans les prises en charge du langage. Vous pouvez en outre interroger les collections en mémoire telles que T[], List<T> ou tout autre type d'élément IEnumerable<T> en plus des documents XML chargés à l'aide des API System.Xml.Linq. Si vous avez déjà une série de requêtes LINQ, PLINQ est en mesure de les exécuter.
Les requêtes LINQ-to-SQL et LINQ-to-Entities seront toujours exécutées par les bases de données et les fournisseurs de requête correspondants, ce qui signifie que PLINQ ne permet pas de paralléliser ces requêtes. Si vous souhaitez traiter les résultats de ces requêtes en mémoire, notamment joindre la sortie de nombreuses requêtes hétérogènes, PLINQ peut s'avérer très utile.
Outre l'écriture des requêtes LINQ de la même façon que vous le feriez LINQ, il y a deux étapes supplémentaires nécessaires à l'utilisation de PLINQ :
  1. Référencer l'assembly System.Concurrency.dll pendant la compilation.
  2. Encapsuler votre source de données dans un élément IParallelEnumerable<T> avec un appel de la méthode d'extension System.Linq.ParallelEnumerable.AsParallel.
L'appel de la méthode d'extension AsParallel à l'étape 2 s'assure que le compilateur C# ou Visual Basic est relié à la version System.Linq.ParallelEnumerable des opérateurs de requête standard au lieu de System.Linq.Enumerable. Cela permet à PLINQ de prendre le contrôle et d'exécuter la requête en parallèle. AsParallel est défini comme utilisant n'importe quel IEnumerable<T> :
public static class System.Linq.ParallelEnumerable {
    public static IParallelEnumerable<T> AsParallel<T>(
        this IEnumerable<T> source);
    ... the other standard query operators ...
}
IParallelEnumerable<T> dérive de IEnumerable<T> et n'y ajoute pas grand chose, étant simplement destiné à faciliter la liaison au fournisseur de requêtes ParallelEnumerable de PLINQ en tirant parti de la nouvelle prise en charge de la méthode d'extension dans C# 3.0 et Visual Basic .NET 9.0. L'interface dérive de l'élément IEnumerable<T> si bien que vous pouvez toujours utiliser ForEach dans les instances et les transmettre aux autres API qui attendent IEnumerable<T.> Les opérateurs de requête standard définis dans ParallelEnumerable reflètent les opérateurs de Enumerable à la différence près que chacun utilise un IParallelEnumerable<T> comme son argument de source d'extension au lieu d'un IEnumerable<T>, renvoie un IParallelEnumerable<T> au lieu d'un IEnumerable<T> (à l'exception des regroupements qui ne renvoient que des types simples) et utilise évidemment le parallélisme en interne pour l'évaluation de chaque requête.
Par exemple, prenez une requête LINQ simple définie en C# :
IEnumerable<T> data = ...;
var q = data.Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
Pour utiliser PLINQ dans ce cas, il est faut simplement ajouter un appel à AsParallel dans les données :
IEnumerable<T> data = ...;
var q = data.AsParallel().Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
Ceci peut être écrit de façon plus concise à l'aide de la syntaxe d'inclusion des requêtes de C#, auquel cas la version de PLINQ a la forme suivante :
IEnumerable<T> data = ...;
var q = from x in data.AsParallel() where p(x) orderby k(x) select f(x);
foreach (var e in q) a(e);
Une fois que vous avez apporté cette modification, PLINQ exécute les clauses Where, OrderBy et Select de façon transparente sur tous les processeurs disponibles à l'aide des techniques d'évaluation parallèles des données classiques. PLINQ utilise l'exécution différée comme LINQ, ce qui signifie que l'exécution de la requête ne commence pas tant que vous n'avez pas utilisé la clause ForEach, appelé directement GetEnumerator ou forcé les résultats dans une liste à l'aide d'une autre API comme ToList ou ToDictionary. Lorsque la requête est exécutée, PLINQ organise l’exécution des différentes parties de la requête sur les processeurs disponibles par le biais de l'utilisation masquée de plusieurs threads. Il n'est même pas nécessaire de comprendre comment tout ceci est effectué. Vous observez simplement de meilleures performances et une meilleure utilisation des processeurs disponibles.
Même si cela n'est pas évident, le type déduit pour « q » diffère entre les requêtes ordinaires LINQ-to-Objects et PLINQ mentionnées précédemment. Dans le premier exemple, « q » est de type IEnumerable<U>, où U est le type renvoyé par la méthode « f » transmise à l'opérateur Select. Dans le deuxième exemple, en revanche, « q » est de type IParallelEnumerable<U>. Cela n'a généralement pas d'importance : Si vous avez déclaré explicitement « q » de sorte qu'il soit de type IEnumerable<U>, par exemple, la modification de AsParallel fonctionne toujours car IParallelEnumerable<U> dérive de IEnumerable<U>. Mais cela signifie que toute utilisation consécutive de « q » est traitée comme un IParallelEnumerable<U>. Par exemple, si vous interrogez « q » par la suite, PLINQ est sélectionné comme fournisseur de requêtes.
Notez que certains opérateurs LINQ sont binaires : ils utilisent deux éléments IEnumerable<T> en entrée. Join est un exemple parfait de ce type d'opérateur. En pareils cas, le type de la source de données la plus à gauche détermine si c'est LINQ ou PLINQ qui est utilisé. Ainsi, vous avez devez seulement appeler AsParallel dans la première source de données pour que votre requête soit exécutée en parallèle :
IEnumerable<T> leftData = ..., rightData = ...;
var q = from x in leftData.AsParallel()
        join y in rightData on x.a == y.b
        select f(x, y);
Toute cette discussion supposait que vous utilisez des méthodes d'extension pour écrire vos requêtes. Si vous avez plutôt choisi d'appeler directement des méthodes de la classe Enumerable, vous aurez un peu plus de travail pour passer à PLINQ. Outre l'appel à AsParallel, vous devez également référencer le type ParallelEnumerable. Par exemple, imaginez que la requête mentionnée ci-dessus a été écrite en appelant directement Enumerable :
IEnumerable<T> data = ...;
var q = Enumerable.Select(
            Enumerable.OrderBy(
                Enumerable.Where(data, (x) => p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);
Pour utiliser PLINQ, la requête doit être réécrite de la manière suivante :
IEnumerable<T> data = ...;
var q = ParallelEnumerable.Select(
            ParallelEnumerable.OrderBy(
                ParallelEnumerable.Where(data.AsParallel(), (x) =>    p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);
Pour des raisons évidentes, l'utilisation des inclusions et des méthodes d'extension est plus pratique pour l'écriture de vos requêtes et présente l'avantage de faciliter le passage à PLINQ.
Et voilà. Ce sont les seules modifications nécessaires pour utiliser PLINQ à la place de LINQ-to-Objects. Comme LINQ-to-XML expose des documents XML en tant que structures de données IEnumerable<T>, tout ce qui vient d'être dit concerne également l'interrogation de contenu XML.

Traitement de la sortie des requêtes
Comme nous l'avons déjà dit, en raison de l'évaluation différée, le parallélisme n'est pas introduit tant que vous n'avez pas commencé à traiter la sortie de la requête. Si vous êtes familier de IEnumerable<T>, cela revient à appeler la méthode GetEnumerator. Il existe trois modes de traitement de base des requêtes PLINQ, chacun conduisant à un modèle de parallélisme légèrement différent.
Le premier est le traitement en pipeline, auquel cas le thread qui effectue l'énumération est séparé des threads consacrés à l'exécution de la requête. PLINQ utilise de nombreux threads pour l'exécution des requêtes mais réduit le degré de parallélisme d'une unité afin d'éviter toute interférence avec le thread d'énumération. Par exemple, si huit processeurs sont disponibles, sept d'entre eux exécuteront la requête PLINQ, tandis que le dernier processeur exécutera la boucle ForEach sur la sortie de la requête PLINQ à mesure que les éléments sont disponibles. Cela présente l'avantage de permettre un traitement plus incrémentiel de la sortie, ce qui réduit les exigences de mémoire nécessaires à la conservation des résultats. Cependant, le fait d'avoir un grand nombre de threads producteurs et un seul thread consommateur peut souvent entraîner une répartition inégale des tâches, ce qui se traduit par une efficacité réduite des processeurs.
Le deuxième modèle est le traitement de type « arrêt/reprise ». Dans ce modèle, le thread qui lance l'énumération rejoint tous les autres threads pour exécuter la requête. Une fois que tous les threads ont fini de produire l'ensemble de sortie, le thread d'énumération commence à énumérer la sortie. Ainsi, toute la puissance de traitement est consacrée à créer la sortie aussi rapidement que possible. Ce modèle est également légèrement plus efficace que le traitement en pipeline car la surcharge de la synchronisation incrémentielle dans l'implémentation est moindre : PLINQ peut ces éléments plus intelligemment car il connaît exactement la destination des données de sortie et leur mode d'accès. Si la répartition des tâches est inégale entre les consommateurs et le producteur, ce modèle constitue généralement une méthode de consommation plus appropriée.
Le dernier modèle est le modèle d'énumération inversée. Une fonction lambda est fournie à PLINQ, laquelle est exécutée en parallèle, une fois pour chaque élément de la sortie. C'est le mécanisme le plus efficace parce qu'il y a une plus grande part de parallélisme exposée à PLINQ, et que l'implémentation évite les opérations coûteuses comme la fusion de la sortie de plusieurs threads. Mais il présente les inconvénients que vous ne pouvez pas utiliser simplement une boucle ForEach, vous devez utiliser une API spéciale ForAll, et que vous devez veiller à ce que les lambdas ne s'appuient pas sur l'état partagé. Dans le cas contraire, l'introduction du parallélisme rendra les requêtes inexactes et peut causer des incidents ou une altération des données imprévisibles. Si vous pouvez exprimer votre problème dans les termes de cette API, en revanche, elle reste la méthode préférée.
Le modèle utilisé parmi ces trois modèles dépend de ce que vous faites avec les résultats de la requête. Le modèle par défaut est le premier : le traitement en pipeline. Dès que MoveNext est appelé sur l'énumérateur de requête résultant, un jeu de threads de travail supplémentaires exécute la requête et les résultats sont renvoyés à partir de cet appel de MoveNext et de tous les appels de MoveNext suivants qui deviennent disponibles. Si un appel de MoveNext est lancé et qu'aucune sortie n'est prête au niveau des threads producteurs de la requête, le thread à l'origine de l'appel est bloqué jusqu'à ce qu'un élément soit disponible. Si vous utilisez simplement une boucle ForEach pour traiter la sortie d'une requête PLINQ, voici ce que vous obtiendrez :
var q = ... some query ...;
foreach (var e in q) {
    a(e); // this runs in parallel with the execution of 'q'
}
L'interface IParallelEnumerable<T> offre en fait une surcharge de GetEnumerator qui utilise un argument booléen appelé « pipelined », ce qui permet de sélectionner le traitement de type « arrêt/reprise » à la place (la valeur True correspond à pipelined et la valeur False à arrêt/reprise). Lors du premier appel de MoveNext suivant, la requête est exécutée entièrement et l'appel ne renvoie une valeur que lorsque la sortie est disponible. Les appels de MoveNext suivants énumèrent simplement une mémoire tampon qui contient la sortie :
var q = ... some query ...;
using (var e = q.GetEnumerator(false)) {
    while (e.MoveNext()) {
        // after the 1st call, the query is finished executing
        // we then just enumerate the results from an in-memory list
        a(e.Current);
    }
}
Il existe quelques cas spéciaux dans lesquels le traitement arrêt/reprise est utilisé par défaut : si vous utilisez les méthodes ToArray ou ToList, ces opérateurs forcent en interne une opération arrêt/reprise. Si vous utilisez un tri dans votre requête, l'arrêt/reprise est utilisé à la place car le traitement en pipeline de la sortie d'un tri est peu rentable. Un tri présente une latence extrêmement élevée (car il doit généralement trier entièrement l'entrée avant de générer un seul élément de sortie), si bien que PLINQ préfère consacrer toute la puissance de traitement à la réalisation du tri aussi rapidement que possible.
Pour utiliser l'énumération inversée, vous devez utiliser une autre API spécifique à PLINQ :
public static class System.Linq.ParallelEnumerable {
    public static void ForAll<T>(
        this IParallelEnumerable<T> source, Action<T> action);
    ... the other standard query operators ...
}
L'utilisation de l'API ForAll s'apparente un peu à l'utilisation d'une boucle ForEach, comme vous venez de la constater :
var q = ... some query ...;
q.ForAll(e => a(e));

Exceptions simultanées
Scénarios probants
En lisant cet article, vous avez probablement déjà commencé à imaginer des façons d'utiliser PLINQ dans vos propres applications. Peut-être utilisez-vous déjà LINQ actuellement et souhaitez-vous améliorer l'évolutivité de vos applications sur des ordinateurs multiprocesseur ou multicœur. Évidemment, PLINQ peut accélérer l'exécution de vos programmes actuels, mais il permet également d'effectuer davantage de tâches de calcul et d'utiliser de plus gros volumes de données dans le même temps, tout en traitant les flux de données plus rapidement. Pour ces opérations, la nouvelle technologie PLINQ peut déboucher sur de nouvelles applications qui n'étaient pas envisageables auparavant.
Examinons quelques illustrations de scénarios dans lesquels les technologies multicœur et PLINQ ouvrent de nouveaux horizons. Imaginez un producteur de musique, qui travaille dans un studio d'enregistrement et souhaite appliquer une série d'effets sur des sons d'instrument bruts afin de produire une piste de master plus esthétiquement plaisante et de qualité de production. L'entreprise qui fournit son logiciel de mixage peut appliquer ces effets en utilisant PLINQ. Ces effets sont généralement composés de filtres et de projections sur des flux de données volumineux (musique brute). PLINQ peut considérablement accélérer le temps de production et tirer parti du matériel plus puissant à mesure qu'il est disponible. Cette approche peut même permettre de convertir de la musique pratiquement en temps réel au lieu d'effectuer un traitement de post-production complet.
De même, imaginez un courtier en devises qui recherche des conditions d'arbitrage (inefficacités du marché) afin de réaliser un bénéfice. Ces variations étant infimes et disparaissant rapidement vu que le marché s'équilibre constamment, les transactions doivent être extrêmement rapides. L'interrogation des informations boursière via le parallélisme à l'aide de PLINQ pourrait permettre une prise de décision quasiment en temps réel, éclairée par de grandes quantités de données, ainsi qu'une analyse et des calculs complexes.
Ce ne sont que quelques exemples dans lesquels l'accélération offerte par PLINQ sur du matériel multicœur peut fournir un avantage commercial. D'autres domaines offrent des opportunités semblables, comme les soins de santé, l'économie, la modélisation géologique, l'informatique scientifique, le contrôle et les simulations de la circulation, les jeux, l'intelligence artificielle, l'apprentissage artificiel, l'analyse linguistique, et la liste continue.

Même si les affirmations précédentes au sujet de la transparence totale du processus de parallélisation de PLINQ sont essentiellement vraies, il existe un petit nombre de cas dans lesquels le recours au parallélisme peut s'éparpiller par les abstractions simples présentées ci-dessus. Ce sont les pièges que je mentionnais précédemment. Heureusement, la plupart de ces pièges sont mineurs mais vous devez en être conscient.
Les opérateurs lambda ou de requête qui génèrent une exception empêchent l'exécution immédiate des requêtes LINQ séquentielles. Cela est dû au fait qu'un seul processeur est utilisé pour exécuter la requête et que les éléments sont traités l'un après l'autre, séquentiellement : si un opérateur échoue sur l'un d'eux, l'exception est générée immédiatement et les éléments suivants ne sont même pas pris en compte. Cela n'est pas vrai avec PLINQ.
À titre d'illustration, examinez cette requête (fictive) :
object[] data = new object[] { "foo", null, null, null };
var q = data.Select(x => x.ToString());
foreach (var e in q) Console.WriteLine(e);
Chaque fois que vous l'exécutez avec LINQ, elle parvient à exécuter ToString dans le premier élément du tableau, et échoue ensuite avec une exception NullReferenceException lors de l'appel de ToString sur le second élément. La requête n'arrive jamais au troisième ou au quatrième élément. Lorsque plusieurs processeurs sont impliqués, en revanche, comme est le cas avec PLINQ, plusieurs exceptions peuvent être générées en parallèle. En fonction de la manière dont PLINQ décide de sous-diviser le problème, vous pouvez rencontrer des échecs pour le processeur 1, 2 ou 3, tous les processeurs simultanément ou une combinaison de ces processeurs, par exemple le processeur 3 mais pas le processeur 1 ou 2.
Pour traiter cette situation, PLINQ utilise un modèle d'exception légèrement différent de LINQ pour communiquer les échecs. Lorsqu'une exception survient sur un des threads PLINQ, le système tente d'abord d'arrêter aussi rapidement que possible l'exécution de tous les autres threads. Ce processus se produit de façon totalement transparente. Mais cela peut ou ne peut pas être effectué à temps pour empêcher d'autres exceptions de se produire simultanément et, en effet, elles se sont peut-être déjà produites lorsque PLINQ entre en scène. Une fois que tous les threads sont arrêtés, l'ensemble des exceptions qui sont survenues sont regroupées en un nouvel objet System.Concurrency.MultipleFailuresException et ce nouvel objet d'exceptions cumulées est renvoyé. Chaque exception qui est survenue est ensuite accessible par la propriété InnerExceptions, du type Exception[], avec une arborescence des appels de procédure non perturbée.
PLINQ génère en fait toujours une exception MultipleFailuresException simple lorsqu'une exception non traitée met fin à l'exécution d'une requête, même si une seule exception est générée. Dans l'exemple précédent, cela signifie que PLINQ encapsule toujours l'exception NullReferenceExceptions dans une exception MultipleFailuresException. Si ce n'était pas le cas et que vous souhaitiez intercepter une exception d'un type particulier, vous devriez écrire plusieurs clauses d'interception. Il y a évidemment certains types d'exception vous n'interceptez généralement pas, mais si vous le souhaitiez, vous devriez écrire le code ci-dessous et dupliquer un fragment de la logique :
try 
{ 
    // query... 
} 
catch (NullReferenceException) 
{ ... } 
catch (MultipleFailuresException) 
{ ... }
Ce code est non seulement maladroit, mais les développeurs oublieraient facilement l'une ou l'autre exception, ce qui entraîne des erreurs qui ne se produisent que dans certaines circonstances et certaines configurations.
Cela peut malheureusement compliquer le débogage. Si une exception n'est pas traitée et que vous associez un débogueur, vous vous introduirez dans l'appel de GetEnumerator (si vous appelez ForEach dans les résultats de requête) au lieu du niveau d'où provenait l'exception au départ. Cela s'apparente à ce qui se produit avec le modèle de programmation asynchrone (BeginXX/EndXX) actuel dans .NET Framework. Heureusement, PLINQ conserve les arborescences des appels de procédure originales, donc si vous développez l'objet MultipleFailuresException et observez sa propriété InnerExceptions, vous trouvez l'ensemble de toutes les exceptions avec toutes les arborescences des appels de procédure disponibles.

Tri des résultats de sortie
Imaginez que vous avez écrit le code ci-dessous en LINQ :
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data select x * 2).ToArray();
Pouvez-vous prévoir le contenu de data2 ? La question paraît si simple qu'il semble presque idiot de s'en soucier. Tout le monde dirait : { 0, 2, 4, 6 }. Mais si vous modifiez simplement le code comme indiqué ici, le contenu possible de data2 diffère :
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel() select x * 2).ToArray();
Dans ce cas ci, {0, 2, 4, 6} est sûrement possible, mais {6, 0, 2, 4} l'est également tout comme toute autre permutation de ces quatre numéros.
Cela est dû au fait que PLINQ exécute la requête en parallèle et les résultats sont mis à disposition dès qu'ils sont disponibles, que vous effectuiez une itération sur la requête avec ForEach ou regroupiez les résultats dans un tableau avec ToArray. Le tri de LINQ est simplement une possibilité dérivée du fait que ses processus d'implémentation sont entrés de manière séquentielle. À l'inverse, le tri de PLINQ est déterminé par la programmation non déterministe des unités de travail parallèles, qui est susceptible de varier du tout au tout d'une exécution à l'autre.
C'est une décision de conception explicite prise délibérément par l'équipe PLINQ. Historiquement, les requêtes n'apportent aucune garantie en matière de tri. Si vous examinez SQL Server™, par exemple, sauf si vous avez spécifié une clause order by dans le texte de la requête, le tri dépend de nombreux éléments : utilisation d'un index dans la requête, disposition des enregistrements sur le disque, etc. En fait, il peut être également non déterministe car SQL Server peut également utiliser le parallélisme lors de l'évaluation des requêtes.
Comme les utilisateurs doivent fréquemment conserver le tri, et afin d'éliminer certains difficultés mineures aux personnes qui essaient de migrer à partir de LINQ, PLINQ permet d'opter pour la conservation du tri. La conservation du tri garantit simplement que, sous réserve qu'il n'y ait pas d'opération de tri qui intervienne, l'ordre relatif des éléments de sortie est étroitement lié à l'ordre relatif des éléments d'entrée. Si vous souhaitez garantir que la sortie de la requête ci-dessus est toujours {0, 2, 4, 6}, vous pouvez utiliser la requête suivante à la place :
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel(QueryOptions.PreserveOrdering)
               select x * 2).ToArray();
La conservation de l'ordre n'est pas gratuite. En fait, elle peut avoir un impact considérable sur les performances et l'évolutivité de vos requêtes. Cela est dû au fait que PLINQ insère logiquement une opération de tri à la fin, et que le tri est un opérateur qui n'est pas très bien adapté à une augmentation du nombre de processeurs. Pour vous faire une idée de ce que cela signifie, la requête précédente est équivalente en termes de logique à la requête suivante :
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = data.AsParallel().
                  Select((x, i) => new { x, i }). // remember indices
                  Select((x) => new { x * 2, i }). // (in original)
                  OrderBy((x) => x.i). // ensure order is preserved
                  Select((x) => x.x). // get rid of indices from output
                  ToArray(); // (in original)
Les étapes introduites par PLINQ pour prendre en charge la conservation du tri sont indiquées en rouge. Comme vous pouvez le constater, cela représente une certaine quantité de travail. Évidemment, PLINQ implémente cette fonctionnalité beaucoup plus efficacement que si vous aviez juste écrit cette version de la requête, mais les étapes sont équivalentes en termes de logique.

Effets secondaires
PLINQ s'appuie sur la pureté statistique : le plus souvent, les requêtes LINQ ne transforment pas les structures de données et n'exécutent pas d'opérations impures. En d'autres termes, la plupart des requêtes LINQ sont strictement fonctionnelles. Elles utilisent certaines données comme entrée, effectuent des calculs et créent une copie des données totalement séparée, avec des modifications, comme sortie. Mais cette utilisation recommandée n'est absolument pas appliquée par les compilateurs ou le composant d'exécution. Si des utilisateurs ont écrit des requêtes qui rompent avec ce modèle d'utilisation courant, le passage à PLINQ peut s'avérer un peu plus compliqué.
À titre d'exemple, considérez cette requête LINQ :
var q = from x in data where (x.f-- > 0) select x;
Remarquez que le prédicat de la clause Where modifie effectivement un champ d'un objet. Cela signifie qu'il n'est peut-être pas sûr d'exécuter la requête en parallèle sans exposer les conditions de concurrence et les erreurs de simultanéité. Par défaut, vous devez considérer que les requêtes comme celles-ci ne sont pas sûres et qu'elles ne doivent pas être exécutées avec PLINQ. Mais dans quels cas est-ce sûr ? Les concurrences n'ont lieu que si le prédicat est exécuté sur plusieurs threads pour le même objet, et cela ne peut se produire que si les données contiennent des objets en double. Donc si les données forment un jeu de données, il n'y a aucun problème.
Il existe d'autres cas, comme lorsque des variables statiques sont utilisées, qui ne sont jamais sûrs :
class C { internal static int s_cCounter; }
// elsewhere...
var q = from x in data where (x.f == C.s_cCounter++) select x;
Si vous exécutez cette requête en parallèle, vous risquez d'être déçu. Vous pouvez vous retrouver avec de nombreux objets dont les valeurs de champ sont en double, ce qui ne se produit qu'en raison de la condition de concurrence. Faites attention : votre premier réflexe pour résoudre ce problème peut consister à remplacer C.s_cCounter++ par Interlocked.Increment(ref C.s_cCounter). Bien que cette approche mène à une solution correcte, tout type de synchronisation réduit largement l'évolutivité de vos requêtes. Comme premier principe, vous devez vous efforcer d'éliminer de vos requêtes toute dépendance à la mutabilité et à l'état partagé.
La plate-forme Windows® est très centrée sur les threads, et de nombreux états système peuvent se retrouver associés au thread qui exécute une partie du code proprement dit. Cela peut prendre la forme d'une mémoire locale pour les threads, d'informations de sécurité comme l'emprunt d'identité, de compartiments COM (en particulier des compartiments de thread simple) et d'infrastructures d'interface utilisateur comme Windows Forms et Windows Presentation Foundation. Cela peut poser des problèmes lors du passage de LINQ à PLINQ.
Tandis que dans le modèle LINQ, tout le code de la requête est exécuté sur le thread qui a établi la requête, avec PLINQ, le code de la requête est distribué sur de nombreux threads. Malheureusement, ce n'est pas toujours évident lorsque vous avez établi une dépendance à l'affinité des threads dans la mesure où de nombreux services de .NET Framework s'appuient, de manière intrinsèque et transparente, sur eux. Le meilleur conseil que je puisse donner est de rester sur ses gardes quant aux sources de dangers de simultanéité susmentionnées et de les éviter le plus possible dans vos requêtes.

Mise en situation de PLINQ
PLINQ permet aux développeurs LINQ de tirer partir du matériel parallèle, notamment de bénéficier de meilleures performances et de créer des logiciels plus intrinsèquement évolutifs, sans avoir à comprendre le parallélisme de la simultanéité ou des données. Le modèle de programmation est simple et permet à un nombre plus important de développeurs de tirer parti de davantage de matériel parallèle. Pour plus d'informations, l'encadré « Ressources PLINQ » contient des références.
Pour les développeurs qui utilisent déjà LINQ-to-Objects, LINQ-o-XML ou LINQ pour lier des sources de données hétérogènes, il s'agit d'un processus simple pour obliger vos requêtes à utiliser PLINQ. Pour plus d'informations sur les utilisations possibles de cette technologie, reportez-vous à l'encadré « Scénarios probants ». Pour ceux qui n'ont pas encore adopté LINQ, les gains de temps du parallélisme constituent une autre raison d'envisager de l'utiliser. Même si PLINQ n'est pas encore disponible, l'utilisation de LINQ dans vos programmes est un investissement pour l'avenir.

Joe Duffy est responsable du développement au sein de l'équipe Parallel FX de Microsoft. Il poste régulièrement des billets dans le blog accessible à l'adresse www.bluebytesoftware.com/blog. Il rédige actuellement un livre intitulé Concurrent Programming on Windows, à paraître chez Addison-Wesley.

Ed Esseyresponsable de programme senior depuis 5 ans chez Microsoft, travaille sur les modèles informatiques et les infrastructures parallèles

Page view tracker