Share via


Empfohlene Vorgehensweisen in der Parallel Patterns Library

In diesem Dokument wird beschrieben, wie sich die Parallel Patterns Library (PPL) am effizientesten verwenden lässt. Die PPL stellt allgemeine Container, Objekte und Algorithmen für die differenzierte parallele Verarbeitung bereit.

Weitere Informationen zur PPL finden Sie unter Parallel Patterns Library (PPL).

Abschnitte

Dieses Dokument enthält folgende Abschnitte:

  • Parallelisieren Sie nicht kleine Schleifentexte

  • Legen Sie Parallelität auf der höchstmöglichen Ebene fest

  • Lösen Sie Divide-and-Conquer-Probleme mit parallel_invoke

  • Verwenden Sie zum Verlassen einer parallelen Schleife Abbruch oder Ausnahmebehandlung

  • Informieren Sie sich über die Auswirkungen von Abbruch und Ausnahmebehandlung auf die Zerstörung von Objekten

  • Führen Sie in einer parallelen Schleife keine mehrfache Blockierung aus

  • Führen Sie keine Blockierungsvorgänge aus, wenn Sie die parallele Verarbeitung abbrechen

  • Schreiben Sie nicht in einer parallelen Schleife in freigegebene Daten

  • Vermeiden Sie nach Möglichkeit falsche Freigaben

  • Stellen Sie sicher, dass Variablen während der gesamten Lebensdauer einer Aufgabe gültig sind

Parallelisieren Sie nicht kleine Schleifentexte

Die Parallelisierung von relativ kleinen Schleifentexten kann dazu führen, dass der entsprechende Planungsaufwand die Vorteile der parallelen Verarbeitung zunichte macht. Betrachten Sie das folgende Beispiel, in dem jedes Paar von Elementen in zwei Arrays hinzugefügt wird.

// 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.
}

Die Arbeitsauslastung für jede parallele Schleifeniteration müsste höher sein, damit sich der Aufwand für die parallele Verarbeitung lohnt. Sie können die Leistung dieser Schleife verbessern, indem Sie im Schleifentext mehr Arbeit ausführen lassen oder indem Sie die Schleife seriell ausführen.

[Nach oben]

Legen Sie Parallelität auf der höchstmöglichen Ebene fest

Wenn Sie Code nur auf einer niedrigen Ebene parallelisieren, können Sie ein Fork-Join-Konstrukt verwenden, bei dem keine Skalierung erfolgt, wenn die Anzahl der Prozessoren zunimmt. Bei einem Fork-Join-Konstrukt wird die Arbeit einer Aufgabe in kleinere parallele Unteraufgaben aufgeteilt und auf den Abschluss der Unteraufgaben gewartet. Jede Unteraufgabe kann rekursiv in weitere Unteraufgaben aufgeteilt werden.

Das Fork-Join-Modell eignet sich zwar zum Lösen vielfältiger Probleme, es gibt jedoch Situationen, in denen die Skalierbarkeit durch den Synchronisierungsaufwand beeinträchtigt wird. Betrachten Sie beispielsweise den folgenden seriellen Code zum Verarbeiten von Bilddaten.

// 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);
}

Da jede Schleifeniteration eigenständig ist, können Sie einen großen Teil der Aufgaben parallelisieren, wie im folgenden Beispiel gezeigt. In diesem Beispiel wird zum Parallelisieren der äußeren Schleife der Concurrency::parallel_for-Algorithmus verwendet.

// 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);
}

Das folgende Beispiel veranschaulicht ein Fork-Join-Konstrukt durch Aufruf der ProcessImage-Funktion in einer Schleife. Jeder Aufruf von ProcessImage wird erst zurückgegeben, wenn die einzelnen Unteraufgaben beendet wurden.

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

Wenn jede Iteration der parallelen Schleife fast keine Arbeitsvorgänge ausführt oder wenn die von der parallelen Schleife ausgeführten Arbeitsvorgänge ein Ungleichgewicht aufweisen, weil die Ausführung einiger Schleifeniterationen länger als die Ausführung anderer Schleifeniterationen dauert, kann der Planungsaufwand für die häufige Verzweigung und Verknüpfung von Aufgaben gemäß dem Fork-Join-Konstrukt die Vorteile der parallelen Ausführung zunichte machen. Mit einer zunehmenden Anzahl von Prozessoren wird dieser Aufwand erhöht.

Um den Planungsaufwand in diesem Beispiel zu verringern, können Sie äußere Schleifen parallelisieren, bevor Sie innere Schleifen parallelisieren, oder ein anderes paralleles Konstrukt, z. B. Pipelines, verwenden. Im folgenden Beispiel wird die ProcessImages-Funktion geändert, sodass zum Parallelisieren der äußeren Schleife der Concurrency::parallel_for_each-Algorithmus verwendet wird.

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

Ein ähnliches Beispiel, in dem für die parallele Bildverarbeitung eine Pipeline verwendet wird, finden Sie unter Exemplarische Vorgehensweise: Erstellen eines Bildverarbeitungsnetzwerks.

[Nach oben]

Lösen Sie Divide-and-Conquer-Probleme mit parallel_invoke

Ein Divide-and-Conquer-Problem ist eine Form des Fork-Join-Konstrukts, bei der eine Aufgabe rekursiv in Unteraufgaben unterteilt wird. Sie können Divide-and-Conquer-Probleme nicht nur mit der Concurrency::task_group-Klasse und der Concurrency::structured_task_group-Klasse, sondern auch mit dem Concurrency::parallel_invoke-Algorithmus lösen. Die Syntax des parallel_invoke-Algorithmus ist kürzer als die Syntax von Aufgabengruppenobjekten und empfiehlt sich, wenn eine feste Anzahl paralleler Aufgaben vorhanden ist.

Im folgenden Beispiel wird die Verwendung des parallel_invoke-Algorithmus zum Implementieren des bitonischen Sortieralgorithmus veranschaulicht.

// 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);
   }
}

Um den Aufwand zu reduzieren, führt der parallel_invoke-Algorithmus die letzte Reihe von Aufgaben im aufrufenden Kontext aus.

Die vollständige Version dieses Beispiels finden Sie unter Gewusst wie: Verwenden von parallel_invoke zum Schreiben einer Runtime für paralleles Sortieren. Weitere Informationen über en parallel_invoke-Algorithmus finden Sie unter Parallele Algorithmen.

[Nach oben]

Verwenden Sie zum Verlassen einer parallelen Schleife Abbruch oder Ausnahmebehandlung

Die PPL bietet zwei Möglichkeiten, die parallele Verarbeitung, die von einer Aufgabengruppe oder einem parallelen Algorithmus ausgeführt wird, abzubrechen. Eine Möglichkeit ist die Verwendung des von der Concurrency::task_group-Klasse und der Concurrency::structured_task_group-Klasse bereitgestellten Abbruchmechanismus. Oder Sie lösen im Text einer Arbeitsfunktion eine Ausnahme aus. Für den Abbruch einer Struktur paralleler Arbeitsvorgänge ist der Abbruchmechanismus effizienter als die Ausnahmebehandlung. Eine parallele Arbeitsstruktur ist eine Gruppe verwandter Aufgabengruppen, in der einige Aufgabengruppen andere Aufgabengruppen enthalten. Der Abbruchmechanismus bricht eine Aufgabengruppe und ihre untergeordneten Aufgabengruppen von oben nach unten ab. Bei der Ausnahmebehandlung wird dagegen die umgekehrte Reihenfolge verwendet (Bottom-Up-Ansatz), sodass jede untergeordnete Aufgabengruppe einzeln abgebrochen werden muss.

Wenn Sie direkt mit einem Aufgabengruppenobjekt arbeiten, verwenden Sie für den Abbruch der Arbeit, die zu dieser Aufgabengruppe gehört, die Concurrency::task_group::cancel-Methode oder die Concurrency::structured_task_group::cancel-Methode. Erstellen Sie zum Abbrechen eines parallelen Algorithmus, z. B. parallel_for, eine übergeordnete Aufgabengruppe, und brechen Sie diese Aufgabengruppe ab. Betrachten Sie beispielsweise die folgende Funktion parallel_find_any, die parallel einen Wert in einem Array sucht.

// 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;

   // Use parallel_for to search for the element. 
   // The task group enables a work function to cancel the overall 
   // operation when it finds the result.

   structured_task_group tasks;
   tasks.run_and_wait([&]
   {
      parallel_for(std::size_t(0), count, [&](int n) {
         if (a[n] == what)
         {
            // Set the return value and cancel the remaining tasks. 
            position = n;            
            tasks.cancel();
         }
      });
   });

   return position;
}

Da in parallelen Algorithmen Aufgabengruppen verwendet werden, wird die gesamte Aufgabe abgebrochen, wenn eine der parallelen Iterationen die übergeordnete Aufgabengruppe abbricht. Die vollständige Version dieses Beispiels finden Sie unter Gewusst wie: Verwenden eines Abbruchs zum Verlassen einer Parallel-Schleife.

Der Abbruch paralleler Arbeitsvorgänge durch die Ausnahmebehandlung ist zwar weniger effizient als der Abbruch durch den Abbruchmechanismus, es gibt jedoch Fälle, in denen die Ausnahmebehandlung sinnvoll ist. Zum Beispiel führt die folgende Methode for_all rekursiv für jeden Knoten einer tree-Struktur eine Arbeitsfunktion aus. In diesem Beispiel ist der _children-Datenmember eine std::list, die tree-Objekte enthält.

// 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(_children.begin(), _children.end(), [&](tree& child) {
      child.for_all(action);
   });

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

Der Aufrufer der tree::for_all-Methode kann eine Ausnahme auslösen, wenn die Arbeitsfunktion nicht für jedes Element der Struktur aufgerufen werden muss. Im folgenden Beispiel wird die search_for_value-Funktion veranschaulicht, die nach einem Wert im bereitgestellten tree-Objekt sucht. Die search_for_value-Funktion verwendet eine Arbeitsfunktion, die eine Ausnahme auslöst, wenn das aktuelle Element der Struktur mit dem bereitgestellten Wert übereinstimmt. Die search_for_value-Funktion verwendet einen try-catch-Block, um die Ausnahme zu erfassen und das Ergebnis in der Konsole auszugeben.

// 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();   
}

Die vollständige Version dieses Beispiels finden Sie unter Gewusst wie: Verwenden der Ausnahmebehandlung zum Verlassen einer Parallel-Schleife.

Weitere allgemeine Informationen zu Abbruch- und Ausnahmebehandlungsmechanismen, die von der PPL bereitgestellt werden, finden Sie unter Abbruch in der PPL und Ausnahmebehandlung in der Concurrency Runtime.

[Nach oben]

Informieren Sie sich über die Auswirkungen von Abbruch und Ausnahmebehandlung auf die Zerstörung von Objekten

Wenn eine Aufgabe in einer Struktur paralleler Arbeitsvorgänge abgebrochen wird, können untergeordnete Aufgaben nicht ausgeführt werden. Dies kann Probleme verursachen, wenn eine der untergeordneten Aufgaben einen Vorgang ausführen soll, der für die Anwendung von Bedeutung ist, beispielsweise das Freigeben einer Ressource. Darüber hinaus kann der Aufgabenabbruch dazu führen, dass eine Ausnahme über einen Objektdestruktor weitergegeben wird, und nicht definiertes Verhalten in der Anwendung verursachen.

Im folgenden Beispiel beschreibt die Resource-Klasse eine Ressource und die Container-Klasse einen Container, der Ressourcen enthält. Die Container-Klasse ruft in ihrem Destruktor für zwei ihrer Resource-Member parallel die cleanup-Methode auf, und sie ruft dann für ihren dritten Resource-Member die cleanup-Methode auf.

// 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;
};

Dieses Muster weist zwar an sich keine Probleme auf, betrachten Sie jedoch den folgenden Code für die parallele Ausführung von zwei Aufgaben. Die erste Aufgabe erstellt ein Container-Objekt, und die zweite Aufgabe bricht die gesamte Aufgabe ab. Im Beispiel werden zur Veranschaulichung zwei Concurrency::event-Objekte verwendet, um sicherzustellen, dass der Abbruch nach Erstellung des Container-Objekts erfolgt und dass das Container-Objekt nach dem Abbruch zerstört wird.

// 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;
}

Dieses Beispiel erzeugt folgende Ausgabe:

Container 1: Freeing resources...
Exiting program...

Dieses Codebeispiel enthält die folgenden Probleme, die möglicherweise dazu führen, dass das Verhalten nicht den Erwartungen entspricht:

  • Durch den Abbruch der übergeordneten Aufgabe wird auch die untergeordnete Aufgabe, der Aufruf von Concurrency::parallel_invoke, abgebrochen. Deshalb werden diese beiden Ressourcen nicht freigegeben.

  • Der Abbruch der übergeordneten Aufgabe bewirkt, dass die untergeordnete Aufgabe eine interne Ausnahme auslöst. Da der Container-Destruktor diese Ausnahme nicht behandelt, wird die Ausnahme aufwärts weitergegeben, und die dritte Ressource wird nicht freigegeben.

  • Die von der untergeordneten Aufgabe ausgelöste Ausnahme wird über den Container-Destruktor weitergegeben. Durch das Auslösen einer Ausnahme über einen Destruktor gerät die Anwendung in einen nicht definierten Zustand.

Es wird empfohlen, in Aufgaben keine wichtigen Vorgänge, z. B. das Freigeben von Ressourcen, auszuführen, es sei denn, Sie können sicherstellen, dass diese Aufgaben nicht abgebrochen werden. Es wird außerdem empfohlen, keine Laufzeitfunktionen zu verwenden, die im Destruktor der Typen Ausnahmen auslösen können.

[Nach oben]

Führen Sie in einer parallelen Schleife keine mehrfache Blockierung aus

Eine parallele Schleife, z. B. Concurrency::parallel_for oder Concurrency::parallel_for_each, die von Blockierungsvorgängen dominiert wird, kann bewirken, dass die Laufzeit innerhalb einer kurzen Zeitspanne viele Threads erzeugt.

Die Concurrency Runtime führt zusätzliche Arbeit aus, wenn eine Aufgabe beendet, kooperativ blockiert oder zurückgehalten wird. Wenn die Iteration einer parallelen Schleife blockiert wird, kann die Laufzeit eine andere Iteration starten. Wenn keine Threads im Leerlauf verfügbar sind, erstellt die Laufzeit einen neuen Thread.

Wenn der Text einer parallelen Schleife gelegentlich blockiert wird, trägt dies zum Maximieren des Durchsatzes der gesamten Aufgabe bei. Wenn jedoch viele Iterationen blockiert werden, erstellt die Laufzeit möglicherweise viele Threads, um die zusätzlichen Arbeitsvorgänge auszuführen. Dies kann zu Arbeitsspeichermangel oder unzureichender Nutzung von Hardwareressourcen führen.

Betrachten Sie das folgende Beispiel, in dem in jeder Iteration einer parallel_for-Schleife die Concurrency::send-Funktion aufgerufen wird. Da send kooperativ blockiert wird, erstellt die Laufzeit bei jedem Aufruf von send einen neuen Thread, um zusätzliche Arbeit auszuführen.

// 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);
   });
}

Es wird empfohlen, den Code umzugestalten, um dieses Muster zu vermeiden. In diesem Beispiel können Sie durch den Aufruf von send in einer seriellen for-Schleife die Erstellung zusätzlicher Threads vermeiden.

[Nach oben]

Führen Sie keine Blockierungsvorgänge aus, wenn Sie die parallele Verarbeitung abbrechen

Führen Sie nach Möglichkeit keine Blockierungsvorgänge aus, bevor Sie die Concurrency::task_group::cancel-Methode oder die Concurrency::structured_task_group::cancel-Methode zum Abbrechen der parallelen Verarbeitung aufrufen.

Wenn eine Aufgabe einen Blockierungsvorgang ausführt, kann die Laufzeit andere Aufgaben ausführen, während die erste Aufgabe auf Daten wartet. Wenn die Benutzermodusplanung (User-Mode Scheduling, UMS) aktiviert ist und eine Aufgabe einen kooperativen Blockierungsvorgang oder einen Blockierungsvorgang mit Kernelübergang ausführt, führt die Laufzeit andere Arbeit aus. Bei normaler Threadplanung, die standardmäßig aktiviert ist, führt die Laufzeit nur andere Arbeit aus, wenn eine Aufgabe einen kooperativen Blockierungsvorgang ausführt. Wenn die Blockierung aufgehoben wird, plant die Laufzeit die wartende Aufgabe neu. Aufgaben, deren Blockierung vor längerer Zeit aufgehoben wurde, werden von der Laufzeit i. d. R. später neu geplant als Aufgaben, deren Blockierung vor kürzerer Zeit aufgehoben wurde. Daher plant die Laufzeit während des Blockierungsvorgangs möglicherweise unnötige Aufgaben neu, und dies verringert die Leistung. Wenn Sie einen Blockierungsvorgang ausführen, bevor Sie parallele Arbeit abbrechen, kann daher durch den Blockierungsvorgang der Aufruf von cancel verzögert werden. Dies führt dazu, dass andere Aufgaben unnötige Arbeit ausführen.

Betrachten Sie das folgende Beispiel, das die parallel_find_answer-Funktion definiert, die ein Element des bereitgestellten Arrays sucht, das der angegebenen Prädikatfunktion entspricht. Wenn die Prädikatfunktion true zurückgibt, erstellt die parallele Arbeitsfunktion ein Answer-Objekt und bricht die gesamte Aufgabe ab.

// 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;
}

Der new-Operator führt eine Heapreservierung aus, die möglicherweise die Ausführung blockiert. Bei aktivierter Benutzermodusplanung (User-Mode Scheduling, UMS) führt die Laufzeit während des Blockierungsvorgangs andere Arbeit aus. Wenn die normale Threadplanung aktiviert ist, führt die Laufzeit nur andere Arbeit aus, wenn die Aufgabe einen kooperativ blockierenden Aufruf ausführt, z. B. einen Aufruf von Concurrency::critical_section::lock.

Das folgende Beispiel zeigt, wie Sie unnötige Arbeit verhindern und somit die Leistung verbessern. In diesem Beispiel wird die Aufgabengruppe abgebrochen, bevor der Speicher für das Answer-Objekt reserviert wird.

// 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;
}

[Nach oben]

Schreiben Sie nicht in einer parallelen Schleife in freigegebene Daten

Die Concurrency Runtime bietet verschiedene Datenstrukturen, z. B. Concurrency::critical_section, die den gleichzeitigen Zugriff auf freigegebene Daten synchronisieren. Diese Datenstrukturen sind in vielen Fällen hilfreich, z. B. wenn mehrere Aufgaben nur selten gemeinsamen Zugriff auf eine Ressource erfordern.

Betrachten Sie das folgende Beispiel, in dem der Concurrency::parallel_for_each-Algorithmus und ein critical_section-Objekt verwendet werden, um die Anzahl der Primzahlen in einem std::array-Objekt zu berechnen. In diesem Beispiel erfolgt keine Skalierung, da jeder Thread warten muss, bis er auf die freigegebene Variable prime_sum zuzugreifen kann.

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

Dieses Beispiel kann auch zu Leistungseinbußen führen, da die häufige Blockierung die Schleife im Endeffekt serialisiert. Wenn ein Concurrency Runtime-Objekt einen Blockierungsvorgang ausführt, erstellt der Planer eventuell einen zusätzlichen Thread, um andere Arbeit auszuführen, während der erste Thread auf Daten wartet. Wenn die Laufzeit viele Threads erstellt, da viele Aufgaben auf freigegebene Daten warten, kann die Leistung der Anwendung abfallen, oder es tritt Ressourcenmangel auf.

Die PPL definiert die Concurrency::combinable-Klasse, mit der Sie den Freigabezustand verhindern können, indem Sie den Zugriff auf freigegebene Ressourcen ohne Blockierung ermöglichen. Die combinable-Klasse stellt lokalen Threadspeicher bereit, der Ihnen die Durchführung differenzierter Berechnungen und die Zusammenführung dieser Berechnungen in einem Endergebnis ermöglicht. Sie können sich ein combinable-Objekt wie eine reduction-Variable vorstellen.

Das folgende Beispiel unterscheidet sich vom vorherigen Beispiel darin, dass die Summe mit einem combinable-Objekt statt einem critical_section-Objekt berechnet wird. In diesem Beispiel erfolgt Skalierung, da jeder Thread eine eigene lokale Kopie der Summe enthält. In dem Beispiel wird die Concurrency::combinable::combine-Methode verwendet, um die lokalen Berechnungen zum Endergebnis zusammenzuführen.

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

Die vollständige Version dieses Beispiels finden Sie unter Gewusst wie: Verbessern der Leistung mithilfe von combinable. Weitere Informationen zur combinable-Klasse finden Sie unter Parallele Container und Objekte.

[Nach oben]

Vermeiden Sie nach Möglichkeit falsche Freigaben

Eine falsche Freigabe erfolgt, wenn mehrere gleichzeitige Aufgaben, die mit jeweils eigenen Prozessoren ausgeführt werden, in Variablen schreiben, die sich in derselben Cachezeile befinden. Wenn eine Aufgabe in eine der Variablen schreibt, wird die Cachezeile für beide Variablen ungültig. Jeder Prozessor muss die Cachezeile immer neu laden, wenn die Cachezeile ungültig wird. Daher kann eine falsche Freigabe die Leistung der Anwendung beeinträchtigen.

Im folgenden einfachen Beispiel werden zwei gleichzeitige Aufgaben gezeigt, die eine gemeinsame Zählervariable erhöhen.

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);
   }
);

Um die gemeinsame Nutzung von Daten durch die beiden Aufgaben zu verhindern, können Sie das Beispiel so ändern, dass zwei Zählervariablen verwendet werden. In diesem Beispiel wird der endgültige Zählerwert berechnet, nachdem die Aufgaben beendet wurden. In diesem Beispiel wird jedoch die falsche Freigabe veranschaulicht, da sich die count1-Variable und die count2-Variable wahrscheinlich in derselben Cachezeile befinden.

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;

Eine Möglichkeit zum Verhindern einer falschen Freigabe besteht darin, sicherzustellen, dass sich die Zählervariablen in unterschiedlichen Cachezeilen befinden. Im folgenden Beispiel werden die Variablen count1 und count2 an 64-Byte-Grenzen ausgerichtet.

__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;

In diesem Beispiel wird davon ausgegangen, dass der Arbeitsspeichercache nicht größer als 64 Bytes ist.

Es wird empfohlen, die Concurrency::combinable-Klasse zu verwenden, wenn Sie Daten für Aufgaben freigeben müssen. Die combinable-Klasse erstellt lokale Threadvariablen auf eine Weise, die die Wahrscheinlichkeit einer falschen Freigabe verringert. Weitere Informationen zur combinable-Klasse finden Sie unter Parallele Container und Objekte.

[Nach oben]

Stellen Sie sicher, dass Variablen während der gesamten Lebensdauer einer Aufgabe gültig sind

Wenn Sie für eine Aufgabengruppe oder einen parallelen Algorithmus einen Lambda-Ausdruck bereitstellen, gibt die Erfassungsklausel an, ob der Text des Lambda-Ausdrucks auf die Variablen im einschließenden Bereich als Wert oder als Verweis zugreift. Wenn Sie Variablen als Verweis an einen Lambda-Ausdruck übergeben, müssen Sie sicherstellen, dass die Lebensdauer dieser Variablen bis zum Beenden der Aufgabe erhalten bleibt.

Betrachten Sie das folgende Beispiel, in dem die object-Klasse und die perform_action-Funktion definiert werden. Die perform_action-Funktion erstellt eine object-Variable und führt asynchron eine Aktion für diese Variable aus. Da nicht sichergestellt ist, dass die Aufgabe vor Rückgabe der perform_action beendet wird, stürzt das Progamm ab oder weist nicht definiertes Verhalten auf, wenn die object-Variable während der Ausführung der Aufgabe zerstört wird.

// 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.
}

Abhängig von den Anforderungen der Anwendung können Sie eines der folgenden Verfahren verwenden, um sicherzustellen, dass Variablen während der Lebensdauer der einzelnen Aufgaben gültig bleiben.

Im folgenden Beispiel wird die object-Variable als Wert an die Aufgabe übergeben. Daher wird die Aufgabe mit einer eigenen Kopie der Variablen ausgeführt.

// 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();
   });
}

Da die object-Variable als Wert übergeben wird, werden Zustandsänderungen an dieser Variablen in der Originalversion der Variablen nicht übernommen.

Im folgenden Beispiel wird mit der Concurrency::task_group::wait-Methode sichergestellt, dass die Aufgabe vor Rückgabe der perform_action-Funktion beendet wird.

// 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();
}

Da jetzt die Aufgabe vor der Rückgabe der Funktion beendet wird, ist das Verhalten der perform_action-Funktion nicht mehr asynchron.

Im folgenden Beispiel wird die perform_action-Funktion geändert, sodass sie einen Verweis auf die object-Variable akzeptiert. Der Aufrufer muss sicherstellen, dass die Lebensdauer der object-Variablen gültig bleibt, bis die Aufgabe beendet wurde.

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

Sie können auch einen Zeiger verwenden, um die Lebensdauer eines Objekts zu steuern, das Sie an eine Aufgabengruppe oder einen parallelen Algorithmus übergeben.

Weitere Informationen zu Lambdaausdrücken finden Sie unter Lambda Expressions in C++.

[Nach oben]

Siehe auch

Aufgaben

Gewusst wie: Verwenden von parallel_invoke zum Schreiben einer Runtime für paralleles Sortieren

Gewusst wie: Verwenden eines Abbruchs zum Verlassen einer Parallel-Schleife

Gewusst wie: Verbessern der Leistung mithilfe von combinable

Konzepte

Empfohlene Vorgehensweisen im Zusammenhang mit der Concurrency Runtime

Parallel Patterns Library (PPL)

Parallele Container und Objekte

Parallele Algorithmen

Abbruch in der PPL

Ausnahmebehandlung in der Concurrency Runtime

Weitere Ressourcen

Exemplarische Vorgehensweise: Erstellen eines Bildverarbeitungsnetzwerks

Empfohlene Vorgehensweisen in der Asynchronous Agents Library

Allgemein empfohlene Vorgehensweisen in der Concurrency Runtime

Änderungsprotokoll

Datum

Versionsgeschichte

Grund

März 2011

Informationen hinzugefügt, die erläutern, wie mehrfaches Blockieren in einer parallelen Schleife verhindert wird und wie sich Abbruch und Ausnahembehandlung auf die Zerstörung von Objekten auswirken.

Informationsergänzung.

Mai 2010

Richtlinien erweitert.

Informationsergänzung.