Procedure consigliate nella libreria PPL (Parallel Patterns Library)
In questo documento viene descritto come ottimizzare l'utilizzo della libreria PPL (Parallel Patterns Library). In tale libreria sono disponibili contenitori, oggetti e algoritmi di utilizzo generale che consentono di eseguire un parallelismo accurato.
Per ulteriori informazioni sulla libreria PPL, vedere PPL (Parallel Patterns Library).
Il documento include le sezioni seguenti:
Non parallelizzare corpi di ciclo di dimensioni ridotte
Parallelismo rapido al massimo livello possibile
Utilizzo di parallel_invoke per la risoluzione dei problemi di tipo divide et impera
Utilizzo dell'annullamento o della gestione delle eccezioni per l'interruzione di un ciclo parallelo
Come l'annullamento e la gestione delle eccezioni influiscono sull'eliminazione degli oggetti
Non eseguire ripetutamente blocchi in un ciclo parallelo
Non eseguire operazioni di blocco quando si annulla il lavoro parallelo
Non scrivere nei dati condivisi in un ciclo parallelo
Quando possibile, evitare la falsa condivisione
Assicurarsi che le variabili siano valide per l'intera durata di un'attività
La parallelizzazione di corpi di ciclo di dimensioni relativamente ridotte può determinare un sovraccarico della pianificazione associata che annulla i vantaggi derivanti dall'elaborazione in parallelo. Si consideri l'esempio seguente, in cui ogni coppia di elementi viene aggiunta in due matrici.
// 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.
}
Il volume del carico di lavoro per ogni iterazione del ciclo parallelo non è tale da trarre vantaggio dal sovraccarico dell'elaborazione in parallelo. È possibile migliorare le prestazioni di questo ciclo eseguendo un volume di lavoro maggiore nel corpo del ciclo oppure eseguendo il ciclo in modalità seriale.
[Top]
Quando il codice viene parallelizzato solo a un livello basso, è possibile introdurre un costrutto fork-join che non viene ridimensionato con l'aumento del numero di processori. Un costrutto fork-join prevede la suddivisione del lavoro di un'attività in sottoattività parallele più piccole di cui si attende il completamento. Ciascuna sottoattività può a sua volta essere suddivisa in modalità ricorsiva in ulteriori sottoattività.
Sebbene il modello fork-join possa risultare utile per la soluzione di vari problemi, esistono situazioni in cui il sovraccarico della sincronizzazione può ridurre la scalabilità. Si consideri ad esempio il codice seriale seguente che elabora i dati di immagine.
// 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);
}
Poiché ogni iterazione del ciclo è indipendente, è possibile parallelizzare gran parte del lavoro, come illustrato nell'esempio seguente. In questo esempio viene utilizzato l'algoritmo concurrency::parallel_for per parallelizzare il ciclo esterno.
// 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);
}
Nell'esempio seguente viene illustrato un costrutto fork-join mediante la chiamata alla funzione ProcessImage in un ciclo. Ogni chiamata a ProcessImage non comporta alcuna risposta fino al completamento di ciascuna sottoattività.
// 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);
});
}
Se ogni iterazione del ciclo parallelo non esegue quasi alcun lavoro oppure il lavoro eseguito dal ciclo parallelo non è bilanciato, ovvero alcune iterazioni del ciclo richiedono più tempo di altre, il sovraccarico di pianificazione richiesto per eseguire con frequenza le operazioni di fork e join può annullare il vantaggio dell'esecuzione parallela. Questo sovraccarico aumenta con l'aumentare del numero di processori.
Per ridurre la quantità di sovraccarico di pianificazione in questo esempio, è possibile parallelizzare i cicli esterni prima di quelli interni oppure utilizzare un altro costrutto parallelo come pipelining. Nell'esempio riportato di seguito la funzione ProcessImages viene modificata in modo che venga utilizzato l'algoritmo concurrency::parallel_for_each per parallelizzare il ciclo esterno.
// 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);
});
}
Per un esempio analogo, in cui viene utilizzata una pipeline per eseguire l'elaborazione di immagini in parallelo, vedere Procedura dettagliata: creazione di una rete per l'elaborazione di immagini.
[Top]
Un problema di tipo divide et impera è una forma di costrutto fork-join che utilizza la ricorsione per suddividere un'attività in sottoattività. Oltre alle classi concurrency::task_group e concurrency::structured_task_group, è possibile utilizzare l'algoritmo concurrency::parallel_invoke per risolvere i problemi di tipo divide et impera. L'algoritmo parallel_invoke presenta una sintassi più concisa rispetto agli oggetti gruppo di attività e risulta utile quando è presente un numero fisso di attività parallele.
Nell'esempio riportato di seguito viene illustrato l'utilizzo dell'algoritmo parallel_invoke per implementare l'algoritmo di ordinamento bitonico.
// 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);
}
}
Per ridurre il sovraccarico, l'algoritmo parallel_invoke esegue l'ultima delle serie di attività nel contesto di chiamata.
Per la versione completa di questo esempio, vedere Procedura: utilizzare parallel_invoke per scrivere una routine di ordinamento in parallelo. Per ulteriori informazioni sull'algoritmo parallel_invoke, vedere Algoritmi paralleli.
[Top]
Utilizzo dell'annullamento o della gestione delle eccezioni per l'interruzione di un ciclo parallelo
La libreria PPL fornisce due modi per annullare il lavoro parallelo che viene eseguito da un gruppo di attività o da un algoritmo parallelo. Un modo consiste nell'utilizzare il meccanismo di annullamento fornito dalle classi concurrency::task_group e concurrency::structured_task_group. L'altro consiste nel generare un'eccezione nel corpo di una funzione lavoro dell'attività. Il meccanismo di annullamento è più efficace della gestione delle eccezioni per annullare una struttura ad albero di lavoro parallelo. Una struttura ad albero di lavoro parallelo è un insieme di gruppi di attività correlate in cui alcuni gruppi di attività sono inclusi in altri. Il meccanismo di annullamento annulla un gruppo di attività e i rispettivi gruppi di attività figlio dall'alto verso il basso. La gestione delle eccezioni funziona invece in ordine sequenziale dal basso verso l'alto e deve annullare ogni gruppo di attività figlio in modo indipendente poiché l'eccezione si propaga verso l'alto.
Quando si lavora direttamente con un oggetto gruppo di attività, utilizzare il metodo concurrency::task_group::cancel oppure concurrency::structured_task_group::cancel per annullare il lavoro che appartiene a tale gruppo di attività. Per annullare un algoritmo parallelo, ad esempio parallel_for, creare un gruppo di attività padre e annullarlo. Si consideri ad esempio la funzione seguente, parallel_find_any, che esegue la ricerca di un valore in una matrice in parallelo.
// 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;
}
Poiché gli algoritmi paralleli utilizzano i gruppi di attività, quando una delle iterazioni parallele annulla il gruppo di attività padre, viene annullata l'intera attività. Per la versione completa di questo esempio, vedere Procedura: utilizzare l'annullamento per interrompere un ciclo Parallel.
Sebbene rispetto al meccanismo di annullamento la gestione delle eccezioni risulti meno efficace per annullare il lavoro parallelo, esistono situazioni in cui questo sistema è più appropriato. Ad esempio il metodo riportato di seguito, for_all, esegue in modo ricorsivo una funzione lavoro in ciascun nodo di una struttura tree. In questo esempio il membro dati _children è un oggetto std::list contenente oggetti 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);
}
Il chiamante del metodo tree::for_all può generare un'eccezione se non richiede che la funzione lavoro venga chiamata per ciascun elemento della struttura ad albero. Nell'esempio seguente viene illustrata la funzione search_for_value che cerca un valore nell'oggetto tree fornito. La funzione search_for_value utilizza una funzione lavoro che genera un'eccezione quando l'elemento corrente della struttura ad albero corrisponde al valore fornito. La funzione search_for_value utilizza un blocco try-catch per acquisire l'eccezione e stampare il risultato nella 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();
}
Per la versione completa di questo esempio, vedere Procedura: utilizzare la gestione delle eccezion per interrompere un ciclo Parallel.
Per ulteriori informazioni generali sui meccanismi di annullamento e di gestione delle eccezioni forniti dalla libreria PPL, vedere Annullamento nella libreria PPL e Gestione delle eccezioni nel runtime di concorrenza.
[Top]
In una struttura ad albero di lavoro parallelo l'annullamento di un'attività impedisce l'esecuzione delle attività figlio. Ciò può comportare problemi se una delle attività figlio esegue un'operazione importante per l'applicazione, ad esempio liberare una risorsa. L'annullamento delle attività può inoltre provocare la propagazione di un'eccezione tramite un distruttore di oggetti e causare un comportamento non definito nell'applicazione.
Nell'esempio riportato di seguito la classe Resource descrive una risorsa e la classe Container descrive un contenitore che include le risorse. Nel rispettivo distruttore la classe Container chiama il metodo cleanup per due dei rispettivi membri Resource in parallelo, quindi chiama il metodo cleanup per il rispettivo terzo membro 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;
};
Questo modello non presenta problemi di per sé, tuttavia si consideri il codice seguente, che esegue due attività in parallelo. La prima attività crea un oggetto Container, mentre la seconda annulla l'intera attività. Nell'esempio vengono utilizzati a titolo esemplificativo due oggetti concurrency::event per garantire che venga eseguito l'annullamento dopo la creazione dell'oggetto Container e che l'oggetto Container venga eliminato dopo l'operazione di annullamento.
// 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;
}
Questo esempio produce il seguente output:
Nell'esempio di codice sono presenti i problemi indicati di seguito, che possono determinare un comportamento diverso da quello previsto.
L'annullamento dell'attività padre comporta l'annullamento anche dell'attività figlio e della chiamata a concurrency::parallel_invoke. Di conseguenza, queste due risorse non vengono liberate.
L'annullamento dell'attività padre comporta la generazione di un'eccezione interna da parte dell'attività figlio. Poiché il distruttore Container non gestisce questa eccezione, l'eccezione viene propagata verso l'alto e la terza risorsa non viene liberata.
L'eccezione che viene generata dall'attività figlio si propaga mediante il distruttore Container. La generazione da un distruttore imposta l'applicazione su uno stato non definito.
Si consiglia di non eseguire operazioni critiche nelle attività, ad esempio liberare risorse, a meno che non sia possibile garantire che queste attività non verranno annullate. Si consiglia inoltre di non utilizzare la funzionalità di runtime che può generare un'eccezione nel distruttore dei tipi.
[Top]
Un ciclo parallelo, come concurrency::parallel_for oppure concurrency::parallel_for_each, dominato da operazioni di blocco, può comportare la creazione di un numero elevato di thread in breve tempo da parte del runtime.
Il runtime di concorrenza esegue lavoro aggiuntivo quando un'attività termina oppure si blocca o cede volontariamente il controllo. Quando un'iterazione del ciclo parallelo si blocca, è possibile che il runtime avvii un'altra iterazione. Quando non sono disponibili thread inattivi, il runtime crea un nuovo thread.
Se il corpo di un ciclo parallelo si blocca occasionalmente, questo meccanismo contribuisce a ottimizzare la velocità dell'intera attività. Quando invece si blocca un numero elevato di iterazioni, il runtime potrebbe creare un numero elevato di thread per eseguire il lavoro aggiuntivo. Ciò può determinare condizioni di memoria insufficiente o di utilizzo non appropriato delle risorse hardware.
Si consideri l'esempio seguente, in cui viene chiamata la funzione concurrency::send in ciascuna iterazione di un ciclo parallel_for. Poiché send si blocca in modo cooperativo, il runtime crea un nuovo thread per eseguire il lavoro aggiuntivo ogni volta che viene chiamato il metodo send.
// 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);
});
}
È consigliabile effettuare il refactoring del codice per evitare questo modello. In questo esempio è possibile evitare la creazione di thread aggiuntivi chiamando il metodo send in un ciclo seriale di for.
[Top]
Quando possibile, non eseguire operazioni di blocco prima di chiamare il metodo concurrency::task_group::cancel oppure concurrency::structured_task_group::cancel per annullare lavoro parallelo.
Quando un'attività effettua un'operazione di blocco cooperativo, il runtime può eseguire altro lavoro mentre la prima attività resta in attesa dei dati. Quando si sblocca, il runtime ripianifica l'attività di attesa. Il runtime generalmente ripianifica prima le ultime attività sbloccate e poi quelle sbloccate meno di recente. Pertanto, il runtime potrebbe pianificare lavoro non necessario durante l'operazione di blocco, comportando una riduzione delle prestazioni. Di conseguenza, quando si esegue un'operazione di blocco prima di annullare lavoro parallelo, l'operazione di blocco può ritardare la chiamata a cancel. Ciò comporta l'intervento di altre attività per l'esecuzione del lavoro non necessario.
Si consideri l'esempio seguente che definisce la funzione parallel_find_answer. Tale funzione esegue la ricerca di un elemento della matrice fornita che soddisfa la funzione predicato specificata. Quando la funzione predicato restituisce true, la funzione lavoro parallelo crea un oggetto Answer e annulla l'intera attività.
// 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'operatore new esegue un'allocazione per l'heap, che potrebbe bloccarsi. Il runtime esegue altro lavoro solo quando l'attività esegue una chiamata di blocco cooperativo, come una chiamata a concurrency::critical_section::lock.
Nell'esempio riportato di seguito viene mostrato come evitare il lavoro non necessario migliorando le prestazioni. Questo esempio annulla il gruppo di attività prima di allocare lo spazio di archiviazione per l'oggetto 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;
}
[Top]
Il runtime di concorrenza fornisce diverse strutture di dati, ad esempio concurrency::critical_section, che consentono di sincronizzare l'accesso concorrente ai dati condivisi. Queste strutture di dati sono utili in molti casi, ad esempio quando più attività richiedono raramente l'accesso condiviso a una risorsa.
Si consideri l'esempio riportato di seguito, in cui si utilizza l'algoritmo concurrency::parallel_for_each e un oggetto critical_section per elaborare il conteggio dei numeri primi in un oggetto std::array. Questo esempio non è scalabile poiché ogni thread deve attendere per accedere alla variabile condivisa 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();
});
L'esempio può inoltre comportare una riduzione delle prestazioni in quanto l'operazione di blocco frequente serializza il ciclo in modo efficace. Inoltre, quando un oggetto runtime di concorrenza esegue un'operazione di blocco, l'utilità di pianificazione potrebbe creare un thread aggiuntivo per eseguire altro lavoro mentre il primo thread rimane in attesa dei dati. Se il runtime crea numerosi thread perché molte attività sono in attesa dei dati condivisi, è possibile che le prestazioni dell'applicazione si riducano notevolmente o che si passi a uno stato di risorse insufficienti.
La libreria PPL definisce la classe concurrency::combinable, che consente di eliminare lo stato condiviso fornendo accesso alle risorse condivise in modalità senza blocchi. La classe combinable fornisce l'archiviazione locale dei thread che consente di eseguire calcoli accurati, quindi di unire i calcoli in un risultato finale. È possibile considerare un oggetto combinable come una variabile di riduzione.
Nell'esempio riportato di seguito si modifica l'esempio precedente mediante l'utilizzo di un oggetto combinable anziché un oggetto critical_section per calcolare la somma. Questo esempio è scalabile, in quanto ogni thread è responsabile della propria copia locale della somma. In questo esempio viene utilizzato il metodo concurrency::combinable::combine per unire i calcoli locali nel risultato finale.
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>());
Per la versione completa di questo esempio, vedere Procedura: utilizzare la classe combinable per migliorare le prestazioni. Per ulteriori informazioni sulla classe combinable, vedere Contenitori e oggetti paralleli.
[Top]
La falsa condivisione si verifica quando più attività simultanee in esecuzione su processori separati scrivono nelle variabili che si trovano nella stessa riga della cache. Quando una sola attività scrive in una delle variabili, viene invalidata la riga della cache per entrambe le variabili. Ogni processore deve ricaricare la riga della cache ogni volta che questa viene invalidata. Pertanto, la falsa condivisione può compromettere le prestazioni nell'applicazione.
Nell'esempio di base seguente vengono illustrate due attività simultanee, ognuna delle quali incrementa una variabile di contatore condivisa.
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);
}
);
Per eliminare la condivisione dei dati tra le due attività, è possibile modificare l'esempio in modo che vengano utilizzate due variabili di contatore. Questo esempio calcola il valore di contatore finale dopo che le attività sono state completate. L'esempio tuttavia rappresenta un caso di falsa condivisione poiché le variabili count1 e count2 possono essere collocate nella stessa riga della 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;
Un modo per eliminare la falsa condivisione consiste nell'assicurarsi che le variabili di contatore si trovino su righe della cache separate. Nell'esempio seguente le variabili count1 e count2 vengono allineate su limiti di 64 byte.
__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 questo esempio si presuppone che la dimensione della cache in memoria sia pari o inferiore a 64 byte.
Si consiglia di utilizzare la classe concurrency::combinable quando si ha necessità di condividere i dati tra le attività. La classe combinable consente di creare le variabili di thread locali in modo da ridurre la probabilità che si verifichi la falsa condivisione. Per ulteriori informazioni sulla classe combinable, vedere Contenitori e oggetti paralleli.
[Top]
Quando si fornisce un'espressione lambda per un gruppo di attività o un algoritmo parallelo, la clausola di acquisizione specifica se il corpo dell'espressione lambda accede alle variabili nell'ambito di inclusione in base al valore o al riferimento. Quando si passano le variabili a un'espressione lambda in base al riferimento, è necessario garantire che tale variabile duri fino al completamento dell'attività.
Si consideri l'esempio riportato di seguito, che definisce la classe object e la funzione perform_action. La funzione perform_action crea una variabile object ed esegue una determinata azione su tale variabile in modo asincrono. Dal momento che non è sicuro che l'attività venga completata prima che la funzione perform_action restituisca un risultato, il programma si arresterà in modo anomalo o seguirà un comportamento non specificato se la variabile object viene eliminata durante l'esecuzione dell'attività.
// 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.
}
A seconda dei requisiti dell'applicazione, è possibile utilizzare una delle tecniche indicate di seguito per garantire che le variabili rimangano valide per l'intera durata di ogni attività.
Nell'esempio riportato di seguito la variabile object viene passata all'attività in base al valore. Pertanto, l'attività agisce sulla propria copia della variabile.
// 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();
});
}
Poiché la variabile object viene passata in base al valore, eventuali modifiche di stato apportate a questa variabile non vengono visualizzate nella copia originale.
Nell'esempio seguente viene utilizzato il metodo concurrency::task_group::wait per garantire che l'attività termini prima che la funzione perform_action restituisca un risultato.
// 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();
}
Poiché ora l'attività viene completata prima che la funzione restituisca un risultato, la funzione perform_action non si comporta più in modo asincrono.
Nell'esempio riportato di seguito la funzione perform_action viene modificata in modo da accettare un riferimento alla variabile object. Il chiamante deve garantire che la durata della variabile object resti valida fino al termine dell'attività.
// Performs an action asynchronously.
void perform_action(object& obj, task_group& tasks)
{
// Perform some action on the object variable.
tasks.run([&obj] {
obj.action();
});
}
È inoltre possibile utilizzare un puntatore per controllare la durata di un oggetto che viene passato a un gruppo di attività o a un algoritmo parallelo.
Per ulteriori informazioni sulle espressioni lambda, vedere Espressioni lambda in C++.
[Top]
Procedura dettagliata: creazione di una rete per l'elaborazione di immagini
Procedura: utilizzare parallel_invoke per scrivere una routine di ordinamento in parallelo
Procedura: utilizzare l'annullamento per interrompere un ciclo Parallel
Procedura: utilizzare la classe combinable per migliorare le prestazioni
PPL (Parallel Patterns Library)
Contenitori e oggetti paralleli
Annullamento nella libreria PPL
Gestione delle eccezioni nel runtime di concorrenza
Procedure consigliate nella libreria di agenti asincroni