Partager via


Meilleures pratiques de la Bibliothèque de modèles parallèles

Ce document décrit comment tirer le meilleur parti de la bibliothèque de modèles parallèles (PPL). La bibliothèque de modèles parallèles fournit des conteneurs, des objets et des algorithmes à usage général afin d'effectuer un parallélisme affiné.

Pour plus d'informations sur la bibliothèque de modèles parallèles, consultez Bibliothèque de modèles parallèles.

Sections

Ce document contient les sections suivantes :

  • Ne pas paralléliser les petits corps de boucles

  • Exprimer le parallélisme au niveau le plus élevé possible

  • Utiliser parallel_invoke pour résoudre des problèmes de division-conquête

  • Utiliser l'annulation ou la gestion des exceptions pour quitter une boucle parallèle

  • Comprendre comment l'annulation et la gestion des exceptions affectent la destruction d'objet

  • Éviter les blocages répétés dans une boucle parallèle

  • Ne pas exécuter d'opérations de blocage lors de l'annulation d'un travail parallèle

  • Ne pas écrire sur des données partagées dans une boucle parallèle

  • Éviter le faux partage, dans la mesure du possible

  • S'assurer que les variables sont valides pendant toute la durée de vie d'une tâche

Ne pas paralléliser les petits corps de boucles

La parallélisation des corps de la boucle relativement petits peut engendrer la supériorité de la charge de planification associée par rapport aux avantages que procure le traitement parallèle. Prenons l'exemple suivant, qui ajoute chaque paire d'éléments dans deux tableaux.

// small-loops.cpp 
// compile with: /EHsc
#include <ppl.h>
#include <iostream>

using namespace concurrency;
using namespace std;

int wmain()
{
   // Create three arrays that each have the same size. 
   const size_t size = 100000;
   int a[size], b[size], c[size];

   // Initialize the arrays a and b. 
   for (size_t i = 0; i < size; ++i)
   {
      a[i] = i;
      b[i] = i * 2;
   }

   // Add each pair of elements in arrays a and b in parallel  
   // and store the result in array c.
   parallel_for<size_t>(0, size, [&a,&b,&c](size_t i) {
      c[i] = a[i] + b[i];
   });

   // TODO: Do something with array c.
}

La charge de travail pour chaque itération d'une boucle parallèle est trop petite pour tirer parti de la charge pour le traitement en parallèle. Vous pouvez améliorer les performances de cette boucle en effectuant plus de travail dans le corps de la boucle ou en effectuant une boucle de façon séquentielle.

[Premières]

Exprimer le parallélisme au niveau le plus élevé possible

Lorsque vous parallélisez du code uniquement au niveau inférieur, vous pouvez introduire un élément bifurcation-jointure qui n'évolue pas à mesure que le nombre de processeurs augmente. Un élément bifurcation-jointure est un élément où une tâche scinde son travail en sous-tâches parallèles plus petites et attend que ces sous-tâches se terminent. Chaque sous-tâche peut se diviser de manière récursive en sous-tâches supplémentaires.

Bien que le modèle bifurcation-jointure puisse s'avérer utile pour résoudre de nombreux problèmes, la charge de synchronisation peut réduire l'extensibilité dans certaines situations. Prenons l'exemple du code série suivant, qui traite les données d'image.

// Calls the provided function for each pixel in a Bitmap object. 
void ProcessImage(Bitmap* bmp, const function<void (DWORD&)>& f)
{
   int width = bmp->GetWidth();
   int height = bmp->GetHeight();

   // Lock the bitmap.
   BitmapData bitmapData;
   Rect rect(0, 0, bmp->GetWidth(), bmp->GetHeight());
   bmp->LockBits(&rect, ImageLockModeWrite, PixelFormat32bppRGB, &bitmapData);

   // Get a pointer to the bitmap data.
   DWORD* image_bits = (DWORD*)bitmapData.Scan0;

   // Call the function for each pixel in the image. 
   for (int y = 0; y < height; ++y)
   {      
      for (int x = 0; x < width; ++x)
      {
         // Get the current pixel value.
         DWORD* curr_pixel = image_bits + (y * width) + x;

         // Call the function.
         f(*curr_pixel);
      }
   }

   // Unlock the bitmap.
   bmp->UnlockBits(&bitmapData);
}

Étant donné que chaque itération de boucle est indépendante, vous pouvez paralléliser une grande partie du travail, comme indiqué dans l'exemple suivant. Cet exemple utilise l'algorithme concurrency::parallel_for pour paralléliser la boucle externe.

// Calls the provided function for each pixel in a Bitmap object. 
void ProcessImage(Bitmap* bmp, const function<void (DWORD&)>& f)
{
   int width = bmp->GetWidth();
   int height = bmp->GetHeight();

   // Lock the bitmap.
   BitmapData bitmapData;
   Rect rect(0, 0, bmp->GetWidth(), bmp->GetHeight());
   bmp->LockBits(&rect, ImageLockModeWrite, PixelFormat32bppRGB, &bitmapData);

   // Get a pointer to the bitmap data.
   DWORD* image_bits = (DWORD*)bitmapData.Scan0;

   // Call the function for each pixel in the image.
   parallel_for (0, height, [&, width](int y)
   {      
      for (int x = 0; x < width; ++x)
      {
         // Get the current pixel value.
         DWORD* curr_pixel = image_bits + (y * width) + x;

         // Call the function.
         f(*curr_pixel);
      }
   });

   // Unlock the bitmap.
   bmp->UnlockBits(&bitmapData);
}

L'exemple suivant illustre un élément bifurcation-jointure en appelant la fonction ProcessImage dans une boucle. Chaque appel à ProcessImage ne retourne pas de valeur jusqu'à ce que chaque sous-tâche soit terminée.

// Processes each bitmap in the provided vector. 
void ProcessImages(vector<Bitmap*> bitmaps, const function<void (DWORD&)>& f)
{
   for_each(begin(bitmaps), end(bitmaps), [&f](Bitmap* bmp) {
      ProcessImage(bmp, f);
   });
}

Si chaque itération de la boucle parallèle n'exécute quasiment aucun travail ou que le travail exécuté par la boucle parallèle est déséquilibré, c'est-à-dire que des itérations de la boucle sont plus longues que d'autres, la charge de planification qui est requise pour les opérations fréquentes de bifurcation et de jointure peut l'emporter sur les avantages que procure l'exécution parallèle. Cette charge augmente à mesure que le nombre de processeurs augmente.

Pour réduire la quantité de charge de planification dans cet exemple, vous pouvez paralléliser les boucles externes avant de paralléliser les boucles internes ou utiliser un autre élément parallèle tel que le traitement pipeline. L'exemple suivant modifie la fonction ProcessImages pour utiliser l'algorithme concurrency::parallel_for_each pour paralléliser la boucle externe.

// Processes each bitmap in the provided vector. 
void ProcessImages(vector<Bitmap*> bitmaps, const function<void (DWORD&)>& f)
{
   parallel_for_each(begin(bitmaps), end(bitmaps), [&f](Bitmap* bmp) {
      ProcessImage(bmp, f);
   });
}

Pour obtenir un exemple similaire qui utilise un pipeline pour effectuer le traitement d'image en parallèle, consultez Procédure pas à pas : création d'un réseau de traitement d'image.

[Premières]

Utiliser parallel_invoke pour résoudre des problèmes de division-conquête

Un problème de division-conquête est une forme de l'élément bifurcation-jointure qui utilise la récursivité pour scinder une tâche en sous-tâches. En plus des classes concurrency::task_group et concurrency::structured_task_group, vous pouvez utiliser l'algorithme concurrency::parallel_invoke pour résoudre des problèmes de division-conquête. L'algorithme parallel_invoke a une syntaxe plus succincte que les objets de groupe de tâches. Il est utile lorsque vous disposez d'un nombre fixe de tâches parallèles.

L'exemple suivant illustre l'utilisation de l'algorithme parallel_invoke pour implémenter l'algorithme de tri bitonique.

// Sorts the given sequence in the specified order. 
template <class T>
void parallel_bitonic_sort(T* items, int lo, int n, bool dir)
{   
   if (n > 1)
   {
      // Divide the array into two partitions and then sort  
      // the partitions in different directions. 
      int m = n / 2;

      parallel_invoke(
         [&] { parallel_bitonic_sort(items, lo, m, INCREASING); },
         [&] { parallel_bitonic_sort(items, lo + m, m, DECREASING); }
      );

      // Merge the results.
      parallel_bitonic_merge(items, lo, n, dir);
   }
}

Pour réduire la charge, l'algorithme parallel_invoke exécute la dernière des séries de tâches sur le contexte appelant.

Pour obtenir la version complète de cet exemple, consultez Comment : utiliser parallel_invoke pour écrire une routine de tri parallèle. Pour plus d'informations sur l'algorithme parallel_invoke, consultez Algorithmes parallèles.

[Premières]

Utiliser l'annulation ou la gestion des exceptions pour quitter une boucle parallèle

La bibliothèque de modèles parallèles offre deux façons d'annuler un travail parallèle qui est exécuté par un groupe de tâches ou un algorithme parallèle. L'une des méthodes consiste à utiliser le mécanisme d'annulation fourni par les classes concurrency::task_group et concurrency::structured_task_group. L'autre consiste à lever une exception dans le corps d'une fonction de travail de tâche. Le mécanisme d'annulation est plus efficace que la gestion des exceptions pour annuler une arborescence de travail parallèle. Une arborescence de travail parallèle est un groupe de groupes de tâches connexes dans lesquels certains groupes de tâches contiennent d'autres groupes de tâches. Le mécanisme d'annulation annule un groupe de tâches et ses groupes de tâches enfants de haut en bas. Inversement, la gestion des exceptions opère de bas en haut et doit annuler chaque groupe de tâches enfant indépendamment à mesure que l'exception se propage de bas en haut.

Lorsque vous travaillez directement avec un objet groupe de tâches, utilisez les méthodes concurrency::task_group::cancel ou concurrency::structured_task_group::cancel pour annuler le travail qui appartient à ce groupe de tâches. Pour annuler un algorithme parallèle, par exemple parallel_for, créez un groupe de tâches parent et annulez ce groupe de tâches. Prenons l'exemple de la fonction suivante, parallel_find_any, qui recherche une valeur en parallèle dans un tableau.

// Returns the position in the provided array that contains the given value,  
// or -1 if the value is not in the array. 
template<typename T>
int parallel_find_any(const T a[], size_t count, const T& what)
{
   // The position of the element in the array.  
   // The default value, -1, indicates that the element is not in the array. 
   int position = -1;

   // Call parallel_for in the context of a cancellation token to search for the element.
   cancellation_token_source cts;
   run_with_cancellation_token([count, what, &a, &position, &cts]()
   {
      parallel_for(std::size_t(0), count, [what, &a, &position, &cts](int n) {
         if (a[n] == what)
         {
            // Set the return value and cancel the remaining tasks.
            position = n;
            cts.cancel();
         }
      });
   }, cts.get_token());

   return position;
}

Étant donné que les algorithmes parallèles utilisent des groupes de tâches, lorsque l'une des itérations parallèles annule le groupe de tâches parent, la tâche globale est annulée. Pour obtenir la version complète de cet exemple, consultez Comment : utiliser l'annulation pour rompre une boucle parallèle.

Bien que la gestion des exceptions soit moins efficace que le mécanisme d'annulation pour annuler un travail parallèle, la gestion des exceptions est appropriée dans certaines situations. Par exemple, la méthode suivante, for_all, effectue de manière récursive une fonction de travail sur chaque nœud d'une structure tree. Dans cet exemple, la donnée membre _children est un std::list qui contient des objets tree.

// Performs the given work function on the data element of the tree and 
// on each child. 
template<class Function>
void tree::for_all(Function& action)
{
   // Perform the action on each child.
   parallel_for_each(begin(_children), end(_children), [&](tree& child) {
      child.for_all(action);
   });

   // Perform the action on this node.
   action(*this);
}

L'appelant de la méthode tree::for_all peut lever une exception si la fonction de travail ne doit pas être appelée sur chaque élément de l'arborescence. L'exemple suivant illustre la fonction search_for_value, qui recherche une valeur dans l'objet tree fourni. La fonction search_for_value utilise une fonction de travail qui lève une exception lorsque l'élément actuel de l'arborescence correspond à la valeur fournie. La fonction search_for_value utilise un bloc try-catch pour capturer l'exception et imprimer le résultat sur la console.

// Searches for a value in the provided tree object. 
template <typename T>
void search_for_value(tree<T>& t, int value)
{
   try
   {
      // Call the for_all method to search for a value. The work function 
      // throws an exception when it finds the value.
      t.for_all([value](const tree<T>& node) {
         if (node.get_data() == value)
         {
            throw &node;
         }
      });
   }
   catch (const tree<T>* node)
   {
      // A matching node was found. Print a message to the console.
      wstringstream ss;
      ss << L"Found a node with value " << value << L'.' << endl;
      wcout << ss.str();
      return;
   }

   // A matching node was not found. Print a message to the console.
   wstringstream ss;
   ss << L"Did not find node with value " << value << L'.' << endl;
   wcout << ss.str();   
}

Pour obtenir la version complète de cet exemple, consultez Comment : utiliser la gestion des exceptions pour rompre une boucle parallèle.

Pour obtenir des informations plus générales sur les mécanismes d'annulation et de gestion des exceptions fournis par la bibliothèque de modèles parallèles, consultez Annulation dans la bibliothèque de modèles parallèles et Gestion des exceptions dans le runtime d'accès concurrentiel.

[Premières]

Comprendre comment l'annulation et la gestion des exceptions affectent la destruction d'objet

Dans une arborescence de travail parallèle, une tâche qui est annulée empêche l'exécution des tâches enfants. Cela peut entraîner des problèmes si l'une des tâches enfants effectue une opération importante pour votre application, telle que la libération d'une ressource. En outre, l'annulation de tâche peut provoquer la propagation d'une exception à travers un destructeur d'objet et provoquer un comportement non défini dans votre application.

Dans l'exemple suivant, la classe Resource décrit une ressource et la classe Container décrit un conteneur qui conserve les ressources. Dans son destructeur, la classe Container appelle la méthode cleanup sur deux de ses membres Resource en parallèle, puis elle appelle la méthode cleanup sur son troisième membre Resource.

// parallel-resource-destruction.h
#pragma once
#include <ppl.h>
#include <sstream>
#include <iostream>

// Represents a resource.
class Resource
{
public:
   Resource(const std::wstring& name)
      : _name(name)
   {
   }

   // Frees the resource.
   void cleanup()
   {
      // Print a message as a placeholder.
      std::wstringstream ss;
      ss << _name << L": Freeing..." << std::endl;
      std::wcout << ss.str();
   }
private:
   // The name of the resource.
   std::wstring _name;
};

// Represents a container that holds resources.
class Container
{
public:
   Container(const std::wstring& name)
      : _name(name)
      , _resource1(L"Resource 1")
      , _resource2(L"Resource 2")
      , _resource3(L"Resource 3")
   {
   }

   ~Container()
   {
      std::wstringstream ss;
      ss << _name << L": Freeing resources..." << std::endl;
      std::wcout << ss.str();

      // For illustration, assume that cleanup for _resource1
      // and _resource2 can happen concurrently, and that 
      // _resource3 must be freed after _resource1 and _resource2.

      concurrency::parallel_invoke(
         [this]() { _resource1.cleanup(); },
         [this]() { _resource2.cleanup(); }
      );

      _resource3.cleanup();
   }

private:
   // The name of the container.
   std::wstring _name;

   // Resources.
   Resource _resource1;
   Resource _resource2;
   Resource _resource3;
};

Bien que ce modèle ne présente pas de problème, examinons le code suivant qui exécute deux tâches en parallèle. La première tâche crée un objet Container et la deuxième tâche annule la tâche globale. À titre d'illustration, l'exemple utilise deux objets concurrency::event pour vérifier que l'annulation se produit après la création de l'objet Container et que l'objet Container est détruit après l'opération d'annulation.

// parallel-resource-destruction.cpp 
// compile with: /EHsc
#include "parallel-resource-destruction.h" 

using namespace concurrency;
using namespace std;

static_assert(false, "This example illustrates a non-recommended practice.");

int main()
{  
   // Create a task_group that will run two tasks.
   task_group tasks;

   // Used to synchronize the tasks. 
   event e1, e2;

   // Run two tasks. The first task creates a Container object. The second task 
   // cancels the overall task group. To illustrate the scenario where a child  
   // task is not run because its parent task is cancelled, the event objects  
   // ensure that the Container object is created before the overall task is  
   // cancelled and that the Container object is destroyed after the overall  
   // task is cancelled.

   tasks.run([&tasks,&e1,&e2] {
      // Create a Container object.
      Container c(L"Container 1");

      // Allow the second task to continue.
      e2.set();

      // Wait for the task to be cancelled.
      e1.wait();
   });

   tasks.run([&tasks,&e1,&e2] {
      // Wait for the first task to create the Container object.
      e2.wait();

      // Cancel the overall task.
      tasks.cancel();      

      // Allow the first task to continue.
      e1.set();
   });

   // Wait for the tasks to complete.
   tasks.wait();

   wcout << L"Exiting program..." << endl;
}

Cet exemple génère la sortie suivante :

  

Cet exemple de code contient les problèmes suivants, qui peuvent entraîner un comportement du code différent de ce que vous attendez :

  • L'annulation de la tâche parente provoque également l'annulation de la tâche enfant, à savoir l'appel à concurrency::parallel_invoke. Par conséquent, ces deux ressources ne sont pas libérées.

  • L'annulation de la tâche parente entraîne une exception interne levée par la tâche enfant. Étant donné que le destructeur Container ne gère pas cette exception, l'exception est propagée vers le haut et la troisième ressource n'est pas libérée.

  • L'exception qui est levée par la tâche enfant se propage à travers le destructeur Container. Une exception levée par un destructeur entraîne l'état indéfini de l'application.

Nous vous conseillons de ne pas exécuter d'opération critique, telle que la libération des ressources, dans les tâches, sauf si vous pouvez garantir que ces tâches ne seront pas annulées. Nous vous conseillons également de ne pas utiliser la fonctionnalité du runtime qui permet de lever une exception dans le destructeur de vos types.

[Premières]

Éviter les blocages répétés dans une boucle parallèle

Une boucle parallèle, telle que concurrency::parallel_for ou concurrency::parallel_for_each, qui est dominée par des opérations de blocage peut entraîner la création, par le runtime, d'un grand nombre de threads en peu de temps.

Le runtime d'accès concurrentiel effectue un travail supplémentaire lorsqu'une tâche prend fin ou qu'elle se bloque ou cède de manière coopérative. Lorsqu'une itération de boucle parallèle se bloque, le runtime peut démarrer une autre itération. Lorsqu'aucun thread inactif n'est disponible, le runtime crée un thread.

Lorsque le corps d'une boucle parallèle bloque occasionnellement, ce mécanisme permet d'accroître le débit global de la tâche. Toutefois, lorsqu'un grand nombre d'itérations bloquent, le runtime peut créer un grand nombre de threads pour effectuer le travail supplémentaire. Cela peut entraîner des conditions de mémoire insuffisante ou une mauvaise utilisation des ressources en matériel.

Prenons l'exemple suivant, qui appelle la fonction concurrency::send dans chaque itération d'une boucle parallel_for. Étant donné que send effectue un blocage coopératif, le runtime crée un nouveau thread pour exécuter le travail supplémentaire chaque fois que send est appelé.

// repeated-blocking.cpp 
// compile with: /EHsc
#include <ppl.h>
#include <agents.h>

using namespace concurrency;

static_assert(false, "This example illustrates a non-recommended practice.");

int main()
{
   // Create a message buffer.
   overwrite_buffer<int> buffer;

   // Repeatedly send data to the buffer in a parallel loop.
   parallel_for(0, 1000, [&buffer](int i) {

      // The send function blocks cooperatively.  
      // We discourage the use of repeated blocking in a parallel 
      // loop because it can cause the runtime to create  
      // a large number of threads over a short period of time.
      send(buffer, i);
   });
}

Nous vous conseillons de refactoriser le code pour éviter ce modèle. Dans cet exemple, vous pouvez éviter de créer des threads supplémentaires en appelant send dans une boucle série for.

[Premières]

Ne pas exécuter d'opérations de blocage lors de l'annulation d'un travail parallèle

Dans la mesure du possible, n'effectuez pas d'opérations de blocage avant d'appeler les méthodes concurrency::task_group::cancel ou concurrency::structured_task_group::cancel pour annuler un travail parallèle.

Lorsqu'une tâche effectue une opération de blocage coopératif, le runtime peut effectuer d'autres tâches pendant que la première tâche attend des données. Le runtime replanifie la tâche en attente lorsqu'elle se débloque. En général, le runtime replanifie les tâches qui ont été débloquées plus récemment, avant de replanifier les tâches qui ont été débloquées moins récemment. Par conséquent, le runtime peut planifier un travail inutile pendant l'opération de blocage, ce qui entraîne une diminution des performances. Par conséquent, lorsque vous exécutez une opération de blocage avant d'annuler un travail parallèle, l'opération de blocage peut différer l'appel à cancel. D'autres tâches effectuent alors un travail inutile.

Prenons l'exemple suivant, qui définit la fonction parallel_find_answer. Cette fonction recherche un élément du tableau fourni qui satisfait la fonction de prédicat fournie. Lorsque la fonction de prédicat retourne true, la fonction de travail parallèle crée un objet Answer et annule la tâche globale.

// blocking-cancel.cpp 
// compile with: /c /EHsc
#include <windows.h>
#include <ppl.h>

using namespace concurrency;

// Encapsulates the result of a search operation. 
template<typename T>
class Answer
{
public:
   explicit Answer(const T& data)
      : _data(data)
   {
   }

   T get_data() const
   {
      return _data;
   }

   // TODO: Add other methods as needed. 

private:
   T _data;

   // TODO: Add other data members as needed.
};

// Searches for an element of the provided array that satisfies the provided 
// predicate function. 
template<typename T, class Predicate>
Answer<T>* parallel_find_answer(const T a[], size_t count, const Predicate& pred)
{
   // The result of the search.
   Answer<T>* answer = nullptr;
   // Ensures that only one task produces an answer. 
   volatile long first_result = 0;

   // Use parallel_for and a task group to search for the element.
   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      // Declare the type alias for use in the inner lambda function. 
      typedef T T;

      parallel_for<size_t>(0, count, [&](const T& n) {
         if (pred(a[n]) && InterlockedExchange(&first_result, 1) == 0)
         {
            // Create an object that holds the answer.
            answer = new Answer<T>(a[n]);
            // Cancel the overall task.
            tasks.cancel();
         }
      });
   });

   return answer;
}

L'opérateur new effectue une allocation de tas, qui peut bloquer. Le runtime effectue un autre travail uniquement lorsque la tâche effectue un appel de blocage coopératif, tel que concurrency::critical_section::lock.

L'exemple suivant indique comment empêcher un travail inutile et ainsi améliorer les performances. Cet exemple annule le groupe de tâches avant qu'il alloue le stockage pour l'objet Answer.

// Searches for an element of the provided array that satisfies the provided 
// predicate function. 
template<typename T, class Predicate>
Answer<T>* parallel_find_answer(const T a[], size_t count, const Predicate& pred)
{
   // The result of the search.
   Answer<T>* answer = nullptr;
   // Ensures that only one task produces an answer. 
   volatile long first_result = 0;

   // Use parallel_for and a task group to search for the element.
   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      // Declare the type alias for use in the inner lambda function. 
      typedef T T;

      parallel_for<size_t>(0, count, [&](const T& n) {
         if (pred(a[n]) && InterlockedExchange(&first_result, 1) == 0)
         {
            // Cancel the overall task.
            tasks.cancel();
            // Create an object that holds the answer.
            answer = new Answer<T>(a[n]);            
         }
      });
   });

   return answer;
}

[Premières]

Ne pas écrire sur des données partagées dans une boucle parallèle

Le runtime d'accès concurrentiel fournit plusieurs structures de données, par exemple concurrency::critical_section, qui synchronisent l'accès simultané aux données partagées. Ces structures de données sont utiles dans de nombreux cas, par exemple lorsque plusieurs tâches requièrent rarement un accès partagé à une ressource.

Prenons l'exemple suivant, qui utilise l'algorithme concurrency::parallel_for_each et un objet critical_section pour calculer le nombre de nombres premiers dans un objet std::array. Cet exemple ne monte pas en charge, car chaque thread doit attendre pour accéder à la variable partagée prime_sum.

critical_section cs;
prime_sum = 0;
parallel_for_each(begin(a), end(a), [&](int i) {
   cs.lock();
   prime_sum += (is_prime(i) ? i : 0);
   cs.unlock();
});

Cet exemple peut également entraîner des performances médiocres, étant donné que l'opération fréquente de verrouillage sérialise efficacement la boucle. En outre, lorsqu'un objet du runtime d'accès concurrentiel effectue une opération de blocage, le planificateur peut créer un thread supplémentaire pour effectuer d'autres tâches pendant que le premier thread attend des données. Si le runtime crée plusieurs threads, car un grand nombre de tâches attendent des données partagées, les performances de l'application peuvent être médiocres, ou l'application se trouve en situation de ressources limitées.

La bibliothèque de modèles parallèles définit la classe concurrency::combinable, qui vous aide à éliminer l'état partagé en fournissant l'accès aux ressources partagées sans verrou. La classe combinable fournit un stockage local des threads qui vous permet d'effectuer des calculs affinés, puis de fusionner ces calculs dans un résultat final. On peut considérer un objet combinable comme une variable de réduction.

L'exemple suivant modifie le précédent. Pour calculer la somme, un objet combinable est utilisé à la place d'un objet critical_section. Cet exemple monte en charge, étant donné que chaque thread contient sa propre copie locale de la somme. Cet exemple utilise la méthode concurrency::combinable::combine pour fusionner les calculs locaux dans le résultat final.

combinable<int> sum;
parallel_for_each(begin(a), end(a), [&](int i) {
   sum.local() += (is_prime(i) ? i : 0);
});
prime_sum = sum.combine(plus<int>());

Pour obtenir la version complète de cet exemple, consultez Comment : utiliser la classe combinable pour améliorer les performances. Pour plus d'informations sur la classe combinable, consultez Conteneurs et objets parallèles.

[Premières]

Éviter le faux partage, dans la mesure du possible

Le faux partage se produit lorsque plusieurs tâches simultanées, qui s'exécutent sur des processeurs distincts, écrivent sur des variables situées sur la même ligne de cache. Lorsqu'une tâche écrit dans l'une des variables, la ligne de cache des deux variables est invalidée. Chaque processeur doit recharger la ligne de cache chaque fois que la ligne de cache est invalidée. Par conséquent, le faux partage peut provoquer une diminution des performances de votre application.

L'exemple de base suivant illustre deux tâches simultanées, qui incrémentent toutes les deux une variable de compteur partagée.

volatile long count = 0L;
concurrency::parallel_invoke(
   [&count] {
      for(int i = 0; i < 100000000; ++i)
         InterlockedIncrement(&count);
   },
   [&count] {
      for(int i = 0; i < 100000000; ++i)
         InterlockedIncrement(&count);
   }
);

Pour supprimer le partage des données entre les deux tâches, vous pouvez modifier l'exemple pour utiliser deux variables de compteur. Cet exemple calcule la valeur de compteur finale, une fois que la tâche est terminée. Toutefois, cet exemple illustre le faux partage, étant donné que les variables count1 et count2 sont susceptibles d'être situées sur la même ligne de cache.

long count1 = 0L;
long count2 = 0L;
concurrency::parallel_invoke(
   [&count1] {
      for(int i = 0; i < 100000000; ++i)
         ++count1;
   },
   [&count2] {
      for(int i = 0; i < 100000000; ++i)
         ++count2;
   }
);
long count = count1 + count2;

Pour supprimer le faux partage, vous pouvez notamment vérifier que les variables de compteur sont situées sur des lignes de cache distinctes. L'exemple suivant aligne les count1 et count2 des variables sur des limites de 64 octets.

__declspec(align(64)) long count1 = 0L;      
__declspec(align(64)) long count2 = 0L;      
concurrency::parallel_invoke(
   [&count1] {
      for(int i = 0; i < 100000000; ++i)
         ++count1;
   },
   [&count2] {
      for(int i = 0; i < 100000000; ++i)
         ++count2;
   }
);
long count = count1 + count2;

Cet exemple suppose que la taille du cache mémoire est inférieure ou égale à 64 octets.

Nous vous conseillons d'utiliser la classe concurrency::combinable lorsque vous devez partager des données entre des tâches. La classe combinable crée des variables de thread local de manière à réduire le risque de faux partage. Pour plus d'informations sur la classe combinable, consultez Conteneurs et objets parallèles.

[Premières]

S'assurer que les variables sont valides pendant toute la durée de vie d'une tâche

Lorsque vous fournissez une expression lambda à un groupe de tâches ou un algorithme parallèle, la clause de capture spécifie si le corps de l'expression lambda accède par valeur ou par référence à des variables dans la portée englobante. Lorsque vous passez des variables à une expression lambda par référence, vous devez garantir que la durée de vie de cette variable persiste jusqu'à ce que la tâche se termine.

Prenons l'exemple suivant, qui définit la classe object et la fonction perform_action. La fonction perform_action crée une variable object et exécute une action sur cette variable de façon asynchrone. Étant donné que vous n'êtes pas assuré que la tâche se termine avant que la fonction perform_action retourne une valeur, le programme se bloque ou présente un comportement non spécifié si la variable object est détruite pendant l'exécution de la tâche.

// lambda-lifetime.cpp 
// compile with: /c /EHsc
#include <ppl.h>

using namespace concurrency;

// A type that performs an action. 
class object
{
public:
   void action() const
   {
      // TODO: Details omitted for brevity.
   }
};

// Performs an action asynchronously. 
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on  
   // that variable asynchronously.
   object obj;
   tasks.run([&obj] {
      obj.action();
   });

   // NOTE: The object variable is destroyed here. The program 
   // will crash or exhibit unspecified behavior if the task 
   // is still running when this function returns.
}

En fonction des spécifications de votre application, vous pouvez utiliser l'une des techniques suivantes pour garantir la validité des variables pendant toute la durée de vie de chaque tâche.

L'exemple suivant passe la variable object par valeur à la tâche. Par conséquent, la tâche effectue sa propre copie de la variable.

// Performs an action asynchronously. 
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on  
   // that variable asynchronously.
   object obj;
   tasks.run([obj] {
      obj.action();
   });
}

Étant donné que la variable object est passée par valeur, les modifications d'état de cette variable n'apparaissent pas dans la copie originale.

L'exemple suivant utilise la méthode concurrency::task_group::wait pour vérifier que la tâche se termine avant que la fonction perform_action retourne une valeur.

// Performs an action. 
void perform_action(task_group& tasks)
{
   // Create an object variable and perform some action on  
   // that variable.
   object obj;
   tasks.run([&obj] {
      obj.action();
   });

   // Wait for the task to finish. 
   tasks.wait();
}

Étant donné que la tâche se termine désormais avant que la fonction retourne une valeur, la fonction perform_action ne se comporte plus de façon asynchrone.

L'exemple suivant modifie la fonction perform_action pour prendre une référence à la variable object. L'appelant doit garantir que la durée de vie de la variable object est valide jusqu'à ce que la tâche se termine.

// Performs an action asynchronously. 
void perform_action(object& obj, task_group& tasks)
{
   // Perform some action on the object variable.
   tasks.run([&obj] {
      obj.action();
   });
}

Vous pouvez également utiliser un pointeur pour contrôler la durée de vie d'un objet que vous passez à un groupe de tâches ou à un algorithme parallèle.

Pour plus d'informations sur les expressions lambda, consultez Expressions lambda en C++.

[Premières]

Voir aussi

Tâches

Procédure pas à pas : création d'un réseau de traitement d'image

Comment : utiliser parallel_invoke pour écrire une routine de tri parallèle

Comment : utiliser l'annulation pour rompre une boucle parallèle

Comment : utiliser la classe combinable pour améliorer les performances

Concepts

Bibliothèque de modèles parallèles

Conteneurs et objets parallèles

Algorithmes parallèles

Annulation dans la bibliothèque de modèles parallèles

Gestion des exceptions dans le runtime d'accès concurrentiel

Meilleures pratiques de la Bibliothèque d'agents asynchrones

Meilleures pratiques en général du runtime d'accès concurrentiel

Autres ressources

Meilleures pratiques sur le runtime d'accès concurrentiel