Procedimientos recomendados en la biblioteca de modelos paralelos
En este documento se describe la mejor manera de hacer un uso eficaz de la biblioteca de modelos de procesamiento paralelo (PPL). La PPL proporciona contenedores, objetos y algoritmos de uso general para realizar el paralelismo específico.
Para obtener más información sobre PPL, vea Parallel Patterns Library (PPL).
Secciones
Este documento contiene las siguientes secciones:
No paralelizar pequeños cuerpos de bucle
Expresar el paralelismo en el nivel más alto posible
Usar parallel_invoke para solucionar problemas de divide y vencerás
Usar la cancelación o el control de excepciones para interrumpir un bucle paralelo
Comprender cómo afectan la cancelación y el control de excepciones a la destrucción de objetos
No bloquear repetidamente en un bucle paralelo
No realizar operaciones de bloqueo al cancelar el trabajo paralelo
No escribir en los datos compartidos en un bucle paralelo
Evitar el uso compartido falso cuando sea posible
Asegurarse de que las variables son válidas en la duración de una tarea
No paralelizar pequeños cuerpos de bucle
La paralelización de los cuerpos de bucle relativamente pequeños puede hacer que la sobrecarga de programación asociada sobrepase las ventajas del procesamiento paralelo. Considere el ejemplo siguiente, que agrega cada par de elementos a dos matrices.
// 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 carga de trabajo de cada iteración del bucle paralelo es demasiado pequeña para beneficiarse de la sobrecarga del procesamiento paralelo. Puede mejorar el rendimiento de este bucle si realiza más trabajo en el cuerpo del bucle o si ejecuta el bucle en serie.
[Arriba]
Expresar el paralelismo en el nivel más alto posible
Cuando se paraleliza el código solo en el nivel inferior, se puede presentar una construcción de bifurcación-unión que no se escale a medida que aumenta el número de procesadores. Una construcción de bifurcación-unión es una construcción donde una tarea divide el trabajo en subtareas paralelas más pequeñas y espera a que finalicen esas subtareas. Cada subtarea puede dividirse en subtareas adicionales de forma recursiva.
Aunque el modelo de bifurcación-unión puede ser útil para resolver diversos problemas, hay situaciones en las que la sobrecarga de sincronización puede reducir la escalabilidad. Por ejemplo, considere el siguiente código serie que procesa los datos de la imagen.
// 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);
}
Dado que cada iteración del bucle es independiente, puede paralelizar gran parte del trabajo, tal y como se muestra en el ejemplo siguiente. Este ejemplo utiliza el algoritmo de concurrency::parallel_for para paralelizar el bucle exterior.
// 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);
}
En el ejemplo siguiente se muestra una construcción de bifurcación-unión que llama a la función ProcessImage en un bucle. Cada llamada a ProcessImage no devuelve un resultado hasta que finaliza cada subtarea.
// 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 cada iteración del bucle paralelo no realiza casi ningún trabajo o el trabajo que realiza el bucle paralelo está desequilibrado, es decir, algunas iteraciones del bucle duran más que otras, la sobrecarga de programación que se requiere para el trabajo frecuente de bifurcación y unión pueden sobrepasar las ventajas de la ejecución en paralelo. Esta sobrecarga aumenta a medida que se incrementa el número de procesadores.
Para reducir la cantidad de sobrecarga de programación de este ejemplo, puede paralelizar los bucles externos antes de paralelizar los internos o usar otra construcción paralela como, por ejemplo, la canalización. El ejemplo siguiente se modifica la función de ProcessImages para utilizar el algoritmo de concurrency::parallel_for_each para paralelizar el bucle exterior.
// 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);
});
}
Para obtener un ejemplo similar en el que usa una canalización para realizar el procesamiento de imágenes en paralelo, vea Tutorial: Crear una red de procesamiento de imagen.
[Arriba]
Usar parallel_invoke para solucionar problemas de divide y vencerás
Un problema de divide y vencerás es una forma de la construcción de bifurcación-unión que usa la recursividad para dividir una tarea en subtareas. Además de las clases de concurrency::task_group y de concurrency::structured_task_group , también puede utilizar el algoritmo de concurrency::parallel_invoke para resolver divisoria-y- conquista problemas. El algoritmo parallel_invoke tiene una sintaxis más concisa que los objetos de grupo de tareas y resulta útil cuando hay un número fijo de tareas paralelas.
En el ejemplo siguiente se muestra el uso del algoritmo parallel_invoke para implementar el algoritmo de ordenación bitónica.
// 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);
}
}
Para reducir la sobrecarga, el algoritmo parallel_invoke realiza la última de las series de tareas en el contexto de la llamada.
Para obtener la versión completa de este ejemplo, vea Cómo: Usar parallel.invoke para escribir una rutina de ordenación en paralelo. Para obtener más información sobre el algoritmo parallel_invoke, vea Algoritmos paralelos.
[Arriba]
Usar la cancelación o el control de excepciones para interrumpir un bucle paralelo
PPL proporciona dos mecanismos para cancelar el trabajo paralelo que un grupo de tareas o un algoritmo paralelo realizan. Una consiste en usar el mecanismo de cancelación proporcionado por las clases de concurrency::task_group y de concurrency::structured_task_group . El otro consiste en iniciar una excepción en el cuerpo de una función de trabajo de una tarea. El mecanismo de cancelación es más eficaz que el control de excepciones en la cancelación de un árbol de trabajo paralelo. Un árbol de trabajo paralelo es uno de los grupos de tareas relacionadas que contienen otros grupos de tareas. El mecanismo de cancelación cancela un grupo de tareas y los grupos de tareas secundarias de forma descendente. Por el contrario, el control de excepciones funciona de manera ascendente y debe cancelar cada grupo de tareas secundario por separado a medida que la excepción se propaga hacia arriba.
Cuando trabaje directamente con un objeto de grupo de tareas, use los métodos de concurrency::task_group::cancel o de concurrency::structured_task_group::cancel para cancelar el trabajo que pertenece a ese grupo de tareas. Para cancelar un algoritmo paralelo, por ejemplo, parallel_for, cree un grupo de tareas primarias y cancele ese grupo de tareas. Por ejemplo, considere la siguiente función, parallel_find_any, que busca un valor en una matriz en paralelo.
// 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;
}
Dado que los algoritmos paralelos usan los grupos de tareas, cuando una de las iteraciones paralelas cancela el grupo de tareas primario, la tarea total se cancela. Para obtener la versión completa de este ejemplo, vea Cómo: Usar la cancelación para interrumpir un bucle Parallel.
Aunque el control de excepciones es una manera menos eficaz de cancelar el trabajo paralelo que el mecanismo de cancelación, hay casos en los que es apropiado el control de excepciones. Por ejemplo, el método siguiente, for_all realiza una función de trabajo en cada nodo de una estructura tree de forma recursiva. En este ejemplo, el miembro de datos _children es un objeto std::list que contiene objetos 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);
}
El llamador del método tree::for_all puede producir una excepción si no requiere que se llame a la función de trabajo en cada elemento del árbol. En el siguiente ejemplo se muestra la función search_for_value, que busca un valor en el objeto tree proporcionado. La función search_for_value usa una función de trabajo que inicia una excepción cuando el elemento actual del árbol coincide con el valor proporcionado. La función search_for_value usa un bloque try-catch para capturar la excepción e imprimir el resultado en la consola.
// 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();
}
Para obtener la versión completa de este ejemplo, vea Cómo: Usar el control de excepciones para interrumpir un bucle Parallel.
Para obtener más información general sobre los mecanismos de cancelación y control de excepciones proporcionados por la biblioteca PPL, vea Cancelación en la biblioteca PPL y Control de excepciones en el runtime de simultaneidad.
[Arriba]
Comprender cómo afectan la cancelación y el control de excepciones a la destrucción de objetos
En un árbol de trabajo paralelo, una tarea que se cancela evita que se ejecuten las tareas secundarias. Esto puede producir problemas si una de las tareas secundarias realiza una operación que es importante para la aplicación, como liberar un recurso. Además, la cancelación de tareas puede hacer que una excepción se propague por un objeto destructor y provoque un comportamiento no definido en la aplicación.
En el ejemplo siguiente, la clase Resource describe un recurso y la clase Container describe un contenedor que contiene los recursos. En su destructor, la clase Container llama al método cleanup en dos de sus miembros de Resource en paralelo y, a continuación, llama al método cleanup en el tercer miembro de 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;
};
Aunque este modelo no presenta ningún problema por sí solo, considere el siguiente código que ejecuta dos tareas en paralelo. La primera tarea crea un objeto Container y la segunda tarea cancela la tarea completa. En la ilustración, el ejemplo utiliza dos objetos de concurrency::event para asegurarse de que la cancelación se produce después de que se cree el objeto de Container y que el objeto de Container se destruirá después de que la operación de cancelación aparece.
// 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;
}
Este ejemplo produce el siguiente resultado:
Este ejemplo de código contiene los siguientes problemas que pueden hacer que se comporte de forma distinta a la esperada:
La cancelación de la tarea primaria hace que la tarea secundaria, la llamada a concurrency::parallel_invoke, también de cancelarse. Por consiguiente, no se liberan estos dos recursos.
La cancelación de la tarea primaria hace que la tarea secundaria inicie una excepción interna. Dado que el destructor Container no controla esta excepción, la excepción se propaga hacia arriba y no se libera el tercer recurso.
La excepción que produce la tarea secundaria se propaga por el destructor Container. Al iniciarse en el destructor, la aplicación queda en un estado sin definir.
Se recomienda no realizar las operaciones críticas, como la liberación de recursos, en las tareas a menos que puede garantizar que no se cancelarán estas tareas. También se recomienda no usar la funcionalidad del runtime que puede producirse en el destructor de los tipos.
[Arriba]
No bloquear repetidamente en un bucle paralelo
Un bucle paralelo como concurrency::parallel_for o concurrency::parallel_for_each dominado por operaciones que puede hacer que el runtime cree muchos subprocesos durante un breve.
El runtime de simultaneidad realiza un trabajo adicional cuando una tarea finaliza, o se bloquea o cede de forma cooperativa. Cuando se bloquea una iteración del bucle paralelo, el runtime podría iniciar otra iteración. Cuando no hay subprocesos inactivos disponibles, el runtime crea un nuevo subproceso.
Cuando se bloquea ocasionalmente el cuerpo de un bucle paralelo, este mecanismo ayuda a maximizar el rendimiento general de la tarea. Sin embargo, cuando se bloquean muchas iteraciones, el runtime puede crear muchos subprocesos para ejecutar el trabajo adicional. Esto podría provocar problemas de memoria insuficiente o una utilización deficiente de los recursos de hardware.
Considere el ejemplo siguiente que llama a la función de concurrency::send en cada iteración de un bucle de parallel_for . Dado que send se bloquea de forma cooperativa, el runtime crea un nuevo subproceso para ejecutar el trabajo adicional cada vez que se llama a 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);
});
}
Se recomienda refactorizar el código para evitar este modelo. En este ejemplo, se puede evitar la creación de subprocesos adicionales mediante una llamada a send en un bucle for en serie.
[Arriba]
No realizar operaciones de bloqueo al cancelar el trabajo paralelo
Cuando sea posible, no realice operaciones de bloqueo antes de llamar al método de concurrency::task_group::cancel o de concurrency::structured_task_group::cancel para cancelar el trabajo paralelo.
Cuando una tarea realiza una operación de bloqueo cooperativa, el runtime puede realizar otra tarea mientras la primera tarea espera los datos. El runtime reprograma la tarea en espera cuando se desbloquea. Normalmente, el runtime reprograma las tareas que se desbloquearon más recientemente antes de reprogramar las tareas que se desbloquearon menos recientemente. Por consiguiente, el runtime puede programar el trabajo innecesario durante la operación de bloqueo, lo que lleva a una degradación del rendimiento. En consecuencia, al realizar una operación de bloqueo antes de cancelar el trabajo paralelo, la operación de bloqueo puede retrasar la llamada a cancel. Esto hace que otras tareas realicen trabajo innecesario.
Considere el ejemplo siguiente que define la función parallel_find_answer, que busca un elemento de la matriz especificada que satisface la función de predicado especificada. Cuando la función de predicado devuelve true, la función de trabajo paralela crea un objeto Answer y cancela la tarea completa.
// 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;
}
El operador new realiza una asignación del montón, que podría bloquearse. El runtime realiza otro trabajo únicamente cuando la tarea realiza una llamada de bloqueo cooperativo, como una llamada a concurrency::critical_section::lock.
En el ejemplo siguiente se muestra cómo evitar el trabajo innecesario, y, por tanto, mejorar el rendimiento. En este ejemplo se cancela el grupo de tareas antes de que asigne el almacenamiento del objeto 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;
}
[Arriba]
No escribir en los datos compartidos en un bucle paralelo
El runtime de simultaneidad proporciona varias estructuras de datos, por ejemplo, concurrency::critical_section, que sincronizan el acceso simultáneo a los datos compartidos. Estas estructuras de datos son útiles en muchos casos, por ejemplo, cuando varias tareas no requieren apenas el acceso compartido a un recurso.
Considere el ejemplo siguiente que utiliza el algoritmo de concurrency::parallel_for_each y un objeto de critical_section para calcular el contador de números primos en un objeto de std::array . Este ejemplo no se escala porque cada subproceso debe esperar para obtener acceso a la variable compartida 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();
});
Este ejemplo también puede provocar un rendimiento deficiente porque la operación de bloqueo frecuente serializa eficazmente el bucle. Además, cuando un objeto del runtime de simultaneidad realiza una operación de bloqueo, el programador puede crear un subproceso adicional para realizar otro trabajo mientras el primer subproceso espera los datos. Si el runtime crea muchos subprocesos porque muchas tareas están esperando datos compartidos, la aplicación puede ejecutarse mal o entrar en un estado de escasez de recursos.
PPL define la clase de concurrency::combinable , que ayuda a eliminar el estado compartido ya que proporciona acceso a recursos compartidos de una manera bloqueo- libre. La clase combinable proporciona un almacenamiento local de subprocesos que permite realizar cálculos específicos y, a continuación, combinar esos cálculos en un resultado final. Puede considerar un objeto combinable como una variable de reducción.
En el ejemplo siguiente se modifica el anterior mediante un objeto combinable, en lugar de usar un objeto critical_section para calcular la suma. Este ejemplo se escala porque cada subproceso contiene su propia copia local de la suma. Este ejemplo utiliza el método de concurrency::combinable::combine para combinar los cálculos locales en el resultado 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>());
Para obtener la versión completa de este ejemplo, vea Cómo: Usar la clase combinable para mejorar el rendimiento. Para obtener más información sobre la clase combinable, vea Contenedores y objetos paralelos.
[Arriba]
Evitar el uso compartido falso cuando sea posible
El uso compartido falso tiene lugar cuando varias tareas simultáneas que se están ejecutando en procesadores independientes escriben en las variables que se encuentran en la misma línea de la memoria caché. Cuando una tarea escribe en una de las variables, se invalida la línea de la memoria caché para ambas variables. Cada procesador debe recargar la línea de caché cada vez que esta se invalida. Por consiguiente, el uso compartido falso puede provocar una degradación del rendimiento de la aplicación.
En el siguiente ejemplo básico se muestran dos tareas simultáneas, cada una de las cuales incrementa una variable de contador compartida.
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);
}
);
Para eliminar el uso compartido de datos entre las dos tareas, se puede modificar el ejemplo para usar dos variables de contador. En este ejemplo se calcula el valor de un contador final una vez que finalizan las tareas. Sin embargo, este ejemplo muestra el uso compartido falso porque las variables count1 y count2 probablemente se encuentran en la misma línea de la memoria caché.
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;
Una manera de eliminar el uso compartido falso es asegurarse de que las variables de contador están en líneas de la memoria caché independientes. En el ejemplo siguiente se alinean las variables count1 y count2 en límites de 64 bytes.
__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;
En este ejemplo se supone que el tamaño de la memoria caché es de 64 bytes o menos.
Se recomienda usar la clase de concurrency::combinable cuando debe compartir datos entre tareas. La clase combinable crea variables locales de subproceso de tal forma que el uso compartido falso es menos probable. Para obtener más información sobre la clase combinable, vea Contenedores y objetos paralelos.
[Arriba]
Asegurarse de que las variables son válidas en la duración de una tarea
Cuando se proporciona una expresión lambda a un grupo de tareas o un algoritmo paralelo, la cláusula capture especifica si el cuerpo de la expresión lambda obtiene acceso a las variables en el ámbito de inclusión por valor o por referencia. Al pasar las variables a una expresión lambda por referencia, se debe garantizar que la duración de esa variable persista hasta que finalice la tarea.
Considere el ejemplo siguiente que define la clase object y la función perform_action. La función perform_action crea una variable object y realiza alguna acción en esa variable de forma asincrónica. Dado que no se garantiza que la tarea finalice antes de que la función perform_action devuelva un resultado, el programa se bloqueará o mostrará un comportamiento no especificado si se destruye la variable object cuando la tarea se está ejecutando.
// 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 función de los requisitos de la aplicación, puede usar una de las técnicas siguientes para garantizar que las variables sigan siendo válidas a lo largo de la duración de cada tarea.
En el ejemplo siguiente se pasa la variable object por valor a la tarea. Por consiguiente, la tarea actúa en su propia copia 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();
});
}
Dado que la variable object se pasa por valor, los cambios de estado que tienen lugar en esta variable no aparecen en la copia original.
El ejemplo siguiente se usa el método de concurrency::task_group::wait para asegurarse de que la tarea finaliza antes de que la función de perform_action vuelva.
// 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();
}
Dado que, ahora, la tarea finaliza antes de que la función devuelva un resultado, la función perform_action ya no se comporta de forma asincrónica.
En el ejemplo siguiente se modifica la función perform_action para tomar una referencia a la variable object. El llamador debe garantizar que la duración de la variable object sea válida hasta que finalice la tarea.
// Performs an action asynchronously.
void perform_action(object& obj, task_group& tasks)
{
// Perform some action on the object variable.
tasks.run([&obj] {
obj.action();
});
}
También puede usar un puntero para controlar la duración de un objeto que se pasa a un grupo de tareas o a un algoritmo paralelo.
Para obtener más información sobre las expresiones lambda, vea Expresiones lambda en C++.
[Arriba]
Vea también
Tareas
Tutorial: Crear una red de procesamiento de imagen
Cómo: Usar parallel.invoke para escribir una rutina de ordenación en paralelo
Cómo: Usar la cancelación para interrumpir un bucle Parallel
Cómo: Usar la clase combinable para mejorar el rendimiento
Conceptos
Parallel Patterns Library (PPL)
Contenedores y objetos paralelos
Cancelación en la biblioteca PPL
Control de excepciones en el runtime de simultaneidad
Procedimientos recomendados en la biblioteca de agentes asincrónicos
Procedimientos recomendados generales con el Runtime de simultaneidad