Pièges potentiels avec PLINQ

Dans de nombreux cas, PLINQ permet d’améliorer les performances de manière significative par rapport à des requêtes LINQ to Objects séquentielles. Toutefois, le travail de parallélisation de l’exécution de la requête présente une certaine complexité pouvant entraîner des problèmes qui, dans du code séquentiel, ne sont pas si courants ou ne surviennent pas du tout. Cette rubrique répertorie des pratiques à éviter lorsque vous écrivez des requêtes PLINQ.

Ne pas partir du principe qu’une boucle parallèle est toujours plus rapide

Parfois, la parallélisation entraîne l’exécution plus lente d’une requête PLINQ que son LINQ to Objects équivalent. La règle empirique de base veut que les requêtes ayant peu d’éléments source et des délégués utilisateurs rapides ne sont pas susceptibles d’apporter une grande accélération. Toutefois, étant donné que de nombreux facteurs sont impliqués dans les performances, nous vous recommandons de mesurer les résultats réels avant de décider si vous utiliserez PLINW. Pour plus d’informations, consultez Fonctionnement de l’accélération dans PLINQ.

Éviter d’écrire dans des emplacements de mémoire partagés

Dans du code séquentiel, il n’est pas rare de lire des variables statiques ou d’écrire dans ces dernières ou dans des champs de classe. Toutefois, l’accès simultané de plusieurs threads à de telles variables entraîne un fort risque d’engorgement. Bien que vous puissiez utiliser des verrous pour synchroniser l’accès à la variable, le coût de synchronisation peut nuire aux performances. Par conséquent, nous vous recommandons d’éviter, ou au moins de limiter autant que possible l’accès à un état partagé dans une requête PLINQ.

Éviter la surparallélisation

L’utilisation de la méthode AsParallel entraîne des frais généraux liés au partitionnement de la collection source et à la synchronisation des threads de travail. Les avantages de la parallélisation sont également limités par le nombre de processeurs de l’ordinateur. L’exécution de plusieurs threads liés au calcul sur un seul processeur ne permet aucune accélération. Par conséquent, vous devez veiller à ne pas surparalléliser une requête.

Les requêtes imbriquées sont le scénario le plus courant dans lequel une surparallélisation peut se produire, comme le montre l’extrait suivant.

var q = from cust in customers.AsParallel()
        from order in cust.Orders.AsParallel()
        where order.OrderDate > date
        select new { cust, order };
Dim q = From cust In customers.AsParallel()
        From order In cust.Orders.AsParallel()
        Where order.OrderDate > aDate
        Select New With {cust, order}

Dans ce cas, il est préférable de paralléliser uniquement la source de données externe (clients), sauf si une ou plusieurs conditions suivantes s’appliquent :

  • La source de données interne (cust.Orders) est connue pour être très longue.

  • Vous effectuez un calcul coûteux sur chaque commande. (l’opération montrée dans l’exemple n’est pas coûteuse)

  • Le système cible est connu pour avoir suffisamment de processeurs pour gérer le nombre de threads produits en parallélisant la requête sur cust.Orders.

Dans tous les cas, le test et la mesure sont la meilleure façon de déterminer la forme de requête optimale. Pour plus d’informations, consultez Comment : mesurer les performances des requêtes PLINQ.

Éviter les appels aux méthodes qui ne sont pas thread-safe

L’écriture dans des méthodes d’instance qui ne sont pas thread-safe à partir d’une requête PLINQ peut entraîner une corruption des données qui peut être détectée ou non dans votre programme. Cela peut également entraîner des exceptions. Dans l’exemple suivant, plusieurs threads tenteraient d’appeler simultanément la méthode FileStream.Write, qui n’est pas prise en charge par la classe.

Dim fs As FileStream = File.OpenWrite(…)
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(Sub(x) fs.Write(x))
FileStream fs = File.OpenWrite(...);
a.AsParallel().Where(...).OrderBy(...).Select(...).ForAll(x => fs.Write(x));

Limiter les appels aux méthodes thread-safe

La plupart des méthodes statiques dans .NET sont thread-safe et peuvent être appelées à partir de plusieurs threads simultanément. Toutefois, même dans ces cas, la synchronisation impliquée peut entraîner un ralentissement significatif de la requête.

Notes

Vous pouvez le tester vous-même en insérant des appels à WriteLine dans vos requêtes. Bien que cette méthode soit utilisée dans les exemples de documentation destinés à la démonstration, ne l’utilisez pas dans les requêtes PLINQ.

Éviter les opérations de tri inutiles

Lorsque PLINQ exécute une requête en parallèle, il divise la séquence source en partitions qui peuvent être traitées simultanément sur plusieurs threads. Par défaut, l’ordre dans lequel les partitions sont traitées et les résultats sont remis n’est pas prévisible (à l’exception des opérateurs tels que OrderBy). Vous pouvez demander à PLINQ de conserver le classement de toute séquence source, mais cela a un impact négatif sur les performances. Si possible, la meilleure pratique consiste à structurer les requêtes afin qu’elles ne reposent pas sur la conservation de l’ordre. Pour plus d’informations, consultez Order Preservation in PLINQ (Conservation de l’ordre dans PLINQ).

Préférer ForAll à ForEach si possible

Bien que PLINQ exécute une requête sur plusieurs threads, si vous consommez les résultats dans une boucle foreach (For Each en Visual Basic), les résultats de la requête doivent être fusionnés dans un thread et consultés de façon séquentielle par l’énumérateur. Dans certains cas, c’est inévitable ; toutefois, si possible, utilisez la méthode ForAll pour activer chaque thread afin qu’il sorte ses propres résultats, par exemple, en écrivant dans une collection thread-safe, telle que System.Collections.Concurrent.ConcurrentBag<T>.

Le même problème s’applique à Parallel.ForEach. En d’autres termes, source.AsParallel().Where().ForAll(...) doit être fortement privilégié par rapport à Parallel.ForEach(source.AsParallel().Where(), ...).

Tenir compte des problèmes d’affinité de thread

Certaines technologies, par exemple, les composants STA (Single-Threaded Apartment), Windows Forms et Windows Presentation Foundation (WPF) imposent des restrictions d’affinité de thread qui requièrent l’exécution de code sur un thread spécifique. Par exemple, dans Windows Forms et WPF, un contrôle est uniquement accessible sur le thread sur lequel il a été créé. Si vous essayez d’accéder à l’état partagé d’un contrôle Windows Forms dans une requête PLINQ, une exception est levée si vous exécutez dans le débogueur. (Ce paramètre peut être désactivé.) Cependant, si votre requête est consommée sur le thread d’interface utilisateur, vous pouvez accéder au contrôle à partir de la boucle foreach qui énumère les résultats de la requête, parce que ce code ne s’exécute que sur un thread.

Ne pas partir du principe que les itérations de ForEach, For et ForAll s’exécutent toujours en parallèle

Il est important de garder à l’esprit que les itérations individuelles dans une boucle Parallel.For, Parallel.ForEach ou ForAll peuvent, mais ne doivent pas forcément, s’exécuter en parallèle. Par conséquent, vous devez éviter d’écrire du code dont l’exactitude dépend de l’exécution parallèle d’itérations ou de l’exécution d’itérations dans un ordre particulier.

Par exemple, ce code est susceptible d’interbloquer :

Dim mre = New ManualResetEventSlim()
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll(Sub(j)
   If j = Environment.ProcessorCount Then
       Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Set()
   Else
       Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j)
       mre.Wait()
   End If
End Sub) ' deadlocks
ManualResetEventSlim mre = new ManualResetEventSlim();
Enumerable.Range(0, Environment.ProcessorCount * 100).AsParallel().ForAll((j) =>
{
    if (j == Environment.ProcessorCount)
    {
        Console.WriteLine("Set on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Set();
    }
    else
    {
        Console.WriteLine("Waiting on {0} with value of {1}", Thread.CurrentThread.ManagedThreadId, j);
        mre.Wait();
    }
}); //deadlocks

Dans cet exemple, une itération définit un événement que toutes les autres itérations attendent. Aucune des itérations en attente ne peut s’achever tant que l’itération de définition d’événement n’est pas terminée. Toutefois, il est possible que les itérations en attente bloquent tous les threads utilisés pour exécuter la boucle parallèle, avant que l’itération de définition d’événement ait eu une chance de s’exécuter. Cela provoque un interblocage : l’itération de définition d’événement ne s’exécute jamais et les itérations en attente ne s’activent pas non plus.

En particulier, une itération de boucle parallèle ne doit jamais attendre une autre itération de la boucle pour progresser. Si la boucle parallèle décide de planifier les itérations de manière séquentielle, mais dans l’ordre inverse, un interblocage se produit.

Voir aussi