Pièges potentiels dans le parallélisme des données et des tâches
Dans de nombreux cas, Parallel.For et Parallel.ForEach permettent une amélioration significative des performances par rapport à des boucles séquentielles ordinaires. Toutefois, le travail de la parallélisation de la boucle 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 les pratiques à éviter lorsque vous écrivez des boucles parallèles.
Ne partez pas du principe qu’une boucle parallèle est toujours plus rapide
Dans certains cas, elle peut s’exécuter plus lentement qu’une boucle séquentielle équivalente. La règle empirique de base veut que les boucles parallèles ayant peu d’itérations et des délégués utilisateurs rapides ne soient 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 toujours mesurer les résultats réels.
Éviter d’écrire à 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 boucle parallèle. La meilleure façon de procéder consiste à utiliser les surcharges de Parallel.For et Parallel.ForEach qui utilisent une variable System.Threading.ThreadLocal<T> pour stocker l’état local du thread pendant l’exécution de la boucle. Pour plus d’informations, consultez Guide pratique pour écrire une boucle Parallel.For avec des variables locales de thread et Guide pratique pour écrire une boucle Parallel.ForEach avec des variables locales de partition.
Éviter la surparallélisation
L’utilisation de boucles parallèles entraîne des coûts de surcharge 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 boucle.
Les boucles imbriquées sont le scénario le plus courant dans lequel une surparallélisation peut se produire. Dans la plupart des cas, il est préférable de paralléliser uniquement la boucle externe, sauf si une ou plusieurs conditions suivantes s’appliquent :
La boucle interne est réputée ê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 qui seront générés en parallélisant le traitement.
Dans tous les cas, le test et la mesure sont la meilleure façon de déterminer la forme de requête optimale.
Éviter de faire appel 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 boucle parallèle 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.WriteByte, qui n’est pas prise en charge par la classe.
FileStream fs = File.OpenWrite(path);
byte[] bytes = new Byte[10000000];
// ...
Parallel.For(0, bytes.Length, (i) => fs.WriteByte(bytes[i]));
Dim fs As FileStream = File.OpenWrite(filepath)
Dim bytes() As Byte
ReDim bytes(1000000)
' ...init byte array
Parallel.For(0, bytes.Length, Sub(n) fs.WriteByte(bytes(n)))
Limiter les appels aux méthodes qui ne sont pas 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 boucles parallèles, sauf si nécessaire.
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éé. Cela signifie, par exemple, que vous ne pouvez pas mettre à jour un contrôle de liste à partir d’une boucle parallèle, sauf si vous configurez le planificateur de threads de sorte qu’il planifie le travail uniquement sur le thread d’interface utilisateur. Pour plus d’informations, consultez Spécification d’un contexte de synchronisation.
Être vigilant lors de l’attente dans des délégués appelés par Parallel.Invoke
Dans certaines circonstances, la bibliothèque parallèle de tâches intègre une tâche, ce qui signifie qu’elle s’exécute sur la tâche du thread en cours d’exécution. (Pour plus d’informations, consultez Planificateurs de tâches.) Cette optimisation des performances peut entraîner un blocage dans certains cas. Par exemple, deux tâches peuvent exécuter le même code de délégué, qui signale la survenue d’un événement et attend l’autre tâche à signaler. Si la seconde tâche est incluse sur le même thread que la première et que cette dernière passe à l’état En attente, la seconde tâche ne pourra jamais signaler son événement. Pour éviter une telle situation, vous pouvez spécifier un délai d’expiration sur l’opération d’attente, ou utiliser les constructeurs de thread explicites pour s’assurer qu’une tâche ne peut pas bloquer l’autre.
Ne pas supposer 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 For, 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 :
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
Dim mres = 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)
mres.Set()
Else
Console.WriteLine("Waiting on {0} with value of {1}",
Thread.CurrentThread.ManagedThreadId, j)
mres.Wait()
End If
End Sub) ' 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.
Éviter d’exécuter des boucles parallèles sur le thread d’interface utilisateur
Il est important de maintenir la réactivité de l’interface utilisateur de l’application (IU). Si une opération contient suffisamment de travail pour assurer la parallélisation, elle ne doit pas être exécutée sur le thread d’interface utilisateur. Au lieu de cela, elle doit décharger cette opération de sorte qu’elle s’exécute sur un thread d’arrière-plan. Par exemple, si vous souhaitez utiliser une boucle parallèle pour calculer des données qui doivent ensuite être restituées dans un contrôle d’interface utilisateur, vous devez envisager l’exécution de la boucle dans une instance de tâche plutôt que directement dans un gestionnaire d’événements de l’interface utilisateur. Vous ne devez marshaler la mise à jour de l’interface utilisateur vers le thread de l’interface utilisateur qu’une fois le calcul principal terminé.
Si vous exécutez des boucles parallèles sur le thread d’interface utilisateur, veillez à éviter la mise à jour des contrôles d’interface utilisateur à partir de la boucle. Toute tentative de mise à jour des contrôles d’interface utilisateur à partir d’une boucle parallèle qui s’exécute sur le thread d’interface utilisateur peut entraîner une altération de l’état, des exceptions, des reports de mise à jour et même des interblocages, selon la manière dont la mise à jour de l’interface utilisateur est appelée. Dans l’exemple suivant, la boucle parallèle bloque le thread d’interface utilisateur sur lequel elle s’exécute jusqu’à ce que toutes les itérations soient terminées. Toutefois, si une itération de la boucle s’exécute sur un thread d’arrière-plan (comme For peut le faire), l’appel à Invoke entraîne l’envoi d’un message au thread d’interface utilisateur et bloque en attendant que ce message soit traité. Étant donné que le thread d’interface utilisateur est bloqué lors de l’exécution de For, le message ne peut jamais être traité et le thread d’interface utilisateur est soumis à des interblocages.
private void button1_Click(object sender, EventArgs e)
{
Parallel.For(0, N, i =>
{
// do work for i
button1.Invoke((Action)delegate { DisplayProgress(i); });
});
}
Private Sub Button1_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click
Dim iterations As Integer = 20
Parallel.For(0, iterations, Sub(x)
Button1.Invoke(Sub()
DisplayProgress(x)
End Sub)
End Sub)
End Sub
L’exemple suivant montre comment éviter l’interblocage, en exécutant la boucle à l’intérieur d’une instance de tâche. Le thread d’interface utilisateur n’est pas bloqué par la boucle, et le message peut être traité.
private void button1_Click(object sender, EventArgs e)
{
Task.Factory.StartNew(() =>
Parallel.For(0, N, i =>
{
// do work for i
button1.Invoke((Action)delegate { DisplayProgress(i); });
})
);
}
Private Sub Button2_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Button1.Click
Dim iterations As Integer = 20
Task.Factory.StartNew(Sub() Parallel.For(0, iterations, Sub(x)
Button1.Invoke(Sub()
DisplayProgress(x)
End Sub)
End Sub))
End Sub