MSDN Magazine > Home > Issues > 2007 > October >  LINQ paralelo: Ejecución de consultas en p...
LINQ paralelo
Ejecución de consultas en procesadores multinúcleo
Joe Duffy and Ed Essey

Este artículo se basa en la biblioteca de Parallel FX, la cual está actualmente en desarrollo. Por tanto, es necesario tener en cuenta que toda la información de este documento está sujeta a cambios.
En este artículo se analizan los siguientes temas:
  • Elementos básicos de PLINQ
  • Modelo de programación de PLINQ
  • Excepciones simultáneas
  • Ordenación de los resultados
En este artículo se utilizan las siguientes tecnologías:
Biblioteca de Parallel FX
Los procesadores multinúcleo ya están aquí. Los procesadores multinúcleo solían encontrarse exclusivamente en servidores y PCs de escritorio. Pero ahora, ya se están usando en teléfonos móviles y PDA, lo cual genera grandes ventajas en relación con el consumo de energía. En respuesta al aumento de disponibilidad de plataformas con procesadores multinúcleo, Parallel Language Integrated Query (PLINQ) ofrece una manera fácil de sacar partido del uso de hardware paralelo, incluidos equipos tradicionales con varios procesadores y la última ola de procesadores multinúcleo.
PLINQ es un motor de ejecución de consultas que acepta cualquier consulta LINQ to Objects o LINQ to XML y usa automáticamente varios procesadores o núcleos para su ejecución cuando estos están disponibles. El cambio en el modelo de programación es minúsculo, lo cual significa que no es necesario ser un gurú de la simultaneidad para poder usarlo. De hecho, los subprocesos y bloqueos ni siquiera se plantearán salvo que desee realmente profundizar para entender cómo funciona todo. PLINQ es un componente clave de Parallel FX, la próxima generación en compatibilidad con la simultaneidad de Microsoft® .NET Framework.
Con tecnologías como PLINQ, cada vez será más importante garantizar la escalabilidad del software en futuras arquitecturas paralelas de microprocesador. Al emplear hoy mismo LINQ en lugares selectos de sus aplicaciones (por ejemplo, en el lugar de almacenamiento de los datos o bien en las operaciones de cálculo intensas que pueden expresarse como consultas), podrá asegurarse de que esos fragmentos de programas funcionen mejor aún cuando PLINQ pase a estar disponible y los equipos que ejecuten su aplicación aumenten de 2 ó 4 y a 32 procesadores, e incluso y más. Incluso si sólo ejecuta ese código en un equipo con un único procesador, la sobrecarga de PLINQ suele ser tan pequeña que no notará ninguna diferencia. Además, la naturaleza de datos paralela de PLINQ garantiza que sus programas seguirán ampliándose a medida que aumente el tamaño de sus conjuntos de datos.
En este artículo, se examinan los objetivos de la tecnología PLINQ, su lugar dentro del ámbito más amplio de .NET Framework y otras ofertas de simultaneidad. También se verá su aspecto, desde la perspectiva de los desarrolladores de LINQ. Se concluirá con algunos escenarios de ejemplo donde PLINQ ya ha demostrado su enorme valor.
Tenga en cuenta que la biblioteca de Parallel FX, que incluye PLINQ, aún está en desarrollo, pero la primera versión preliminar para la comunidad tecnológica (CTP) estará disponible en MSDN® en otoño de 2007. Para obtener más información acerca de este tema, consulte blogs.msdn.com/somasegar.

De LINQ a PLINQ
Cuando la gente oye hablar del proyecto PLINQ por primera vez, suelen preguntar: ¿por qué poner LINQ en paralelo? La respuesta sencilla es que el modelo de programación de hora específica para expresar los lugares de cálculo de LINQ hace hincapié en la especificación del qué en lugar del cómo. Sin LINQ, la parte del cómo sería de otro modo expresada a través de bucles y estructuras de datos intermediarias, pero al codificar tanta cantidad de información específica, el compilador y el tiempo de ejecución no pueden ponerse en paralelo con facilidad. Por otro lado, la naturaleza declarativa de LINQ permite una flexibilidad para una implementación inteligente tal como PLINQ para usar la puesta en paralelo y obtener los mismos resultados.
La pregunta que viene tras estos argumentos es inevitablemente: si se van a consultar bastantes datos para que PLINQ tenga sentido, ¿por qué no usar simplemente una base de datos? La respuesta a esta pregunta adopta la forma de una pregunta retórica: ¿qué código habría escrito si no hubiera usado LINQ? Es probable que estuviera realizando el mismo volumen de trabajo con la gran cantidad de datos y cálculos, pero se habría visto atrapado dentro de una serie de bucles For sin estructura alguna, llamadas a funciones, etcétera. Por lo tanto, lo que esta pregunta implica es que todos los programas deben residir en la base de datos, cosa de la que estoy seguro muy pocas personas estarían de acuerdo.
Con PLINQ, no es necesario trasladar toda la lógica de procesamiento del servidor de base de datos para realizar consultas LINQ to Objects en memoria en el cliente. En cambio, PLINQ ofrece una manera incremental de sacar partido de la función de paralelismo para las soluciones y los problemas ya existentes. Si el problema se da básicamente con grandes cantidades de datos que normalmente requeriría la búsqueda de una solución de base de datos, aún debería seguir esa estrategia. PLINQ no cambia mucho ese hecho. Sin embargo, si dispone de varios orígenes de datos que le gustaría consultar conjuntamente (quizás bases de datos heterogéneas y expansivas, archivos XML, etc.), PLINQ puede tomar el relevo y ponerlos en paralelo una vez que estos datos se encuentren en el cliente.
Naturalmente, uno entonces se pregunta: ¿por qué LINQ to Objects no ejecuta él mismo consultas en paralelo? El paralelismo se daría de forma completamente oculta y los desarrolladores no necesitarían cambiar ni una sola línea de código para sacar partido de todas sus ventajas. Desafortunadamente, hay algunas artimañas sutiles que pueden surgir con el traslado de LINQ a PLINQ que no nos permiten realizar esta visión, por lo menos en lo que se refiere a PLINQ 1.0. Estos temas se tratarán más adelante en este artículo.

Modelo de programación de PLINQ
Usar PLINQ es casi exactamente lo mismo que usar LINQ to Objects y LINQ to XML. Puede usar cualquiera de los operadores disponibles a través de la sintaxis de una comprensión de consulta en C# 3.0 y Visual Basic® 9.0 o de la clase System.Linq.Enumerable, que incluye OrderBy, Join, Select, Where, entre otras. PLINQ admite todos los operadores de LINQ, y no sólo los disponible en comprensiones de lenguaje. Asimismo, se pueden realizar consultas en cualquier recopilación en memoria como, por ejemplo, T [], List<T> o cualquier otra clase de IEnumerable<T> además de a documentos XML cargados mediante las API de System.Xml.Linq. Si ya dispone de un montón de consultas LINQ, PLINQ está preparado para ejecutarlas.
Las consultas LINQ to SQL y LINQ to Entities se ejecutarán a través de las respectivas bases de datos y proveedores de consultas, por lo que PLINQ no proporciona ninguna manera de poner en paralelo esas consultas. Si desea procesar los resultados de esas consultas en memoria, incluida la unión de resultados de las muchas consultas heterogéneas, entonces PLINQ puede ser bastante útil.
Aparte de escribir consultas LINQ de la misma manera habitual, hay dos pasos adicionales que deben realizarse con PLINQ:
  1. Establecer una referencia al ensamblado System.Concurrency.dll durante la compilación.
  2. Ajustar el origen de los datos en un IParallelEnumerable<T> mediante una llamada al método de extensión System.Linq.ParallelEnumerable.AsParallel.
La llamada al método de extensión AsParallel en el paso 2 asegura que el compilador de C# o de Visual Basic se enlace a la versión System.Linq.ParallelEnumerable de los operadores de consultas estándar en lugar de System.Linq.Enumerable. Esto proporciona a PLINQ una oportunidad para tomar el control y ejecutar la consulta en paralelo. AsParallel se define como el que toma cualquier IEnumerable<T:>
public static class System.Linq.ParallelEnumerable {
    public static IParallelEnumerable<T> AsParallel<T>(
        this IEnumerable<T> source);
    ... the other standard query operators ...
}
IParallelEnumerable<T> procede de IEnumerable<T> y no se diferencia mucho de éste. Sin embargo, su existencia se justifica por el hecho de que facilita el enlace al proveedor de consultas ParallelEnumerable de PLINQ, y así saca partido de la nueva compatibilidad del método de extensión en C# 3.0 y Visual Basic .NET 9.0. La interfaz procede de IEnumerable<T>, por lo tanto, todavía puede usar el método foreach en instancias y pasar éstas a otras API que esperan IEnumerable<T>. Los operadores estándar de consultas definidos en ParallelEnumerable reflejan los de Enumerable con la única diferencia de que cada uno toma un IParallelEnumerable<T> como el argumento de origen de extensión en lugar de IEnumerable<T>, devuelve un IParallelEnumerable<T> en lugar de un IEnumerable<T> (salvo en el caso de las agregaciones, que sólo devuelven tipos sencillos) y, por supuesto, usa el paralelismo internamente para la evaluación de las consultas.
Por ejemplo, tome una consulta sencilla de LINQ definida en C#:
IEnumerable<T> data = ...;
var q = data.Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
Todo lo que en este caso se necesita para usar PLINQ es la adición de una llamada a AsParallel sobre los datos:
IEnumerable<T> data = ...;
var q = data.AsParallel().Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
Esto se puede escribir de forma más concisa con la sintaxis de comprensión de la consulta en C#, en cuyo caso la versión de PLINQ tiene el aspecto siguiente:
IEnumerable<T> data = ...;
var q = from x in data.AsParallel() where p(x) orderby k(x) select f(x);
foreach (var e in q) a(e);
Una vez que haya realizado este cambio, PLINQ ejecutará con toda transparencia Where, OrderBy y Select en todos los procesadores disponibles mediante técnicas tradicionales de evaluación de datos paralelos. PLINQ usa la ejecución diferida igual que LINQ, lo que significa que la consulta no empieza a ejecutarse hasta que realice el método foreach sobre ello, llama a GetEnumerator directamente o aplica los resultados en una lista a través de alguna otra API, tal como ToList o ToDictionary. Cuando se ejecuta la consulta, PLINQ hará que partes de ella se ejecutarán en los procesadores disponibles mediante el uso oculto de varios subprocesos. Ni siquiera hay necesidad de entender el funcionamiento de todo esto; simplemente verá que mejora el rendimiento y el uso de los procesadores disponibles.
Aunque no es algo que sea evidente, el tipo inferido de q difiere entre la consulta habitual de LINQ to Objects y PLINQ que se mostraron anteriormente. En el primer ejemplo, q tiene el tipo IEnumerable<U>, en donde U es cualquier tipo que el método f pasó a las devoluciones del operador Select. Sin embargo, en el segundo ejemplo, q tiene el tipo IParallelEnumerable<U>. Esto normalmente no tiene importancia: si declaró q para tenga explícitamente el tipo IEnumerable<U>, por ejemplo, el cambio a AsParallel todavía funcionará, dado que IParallelEnumerable<U> procede de IEnumerable<U>. Pero significa que cualquier uso posterior de q se tratará como un IParallelEnumerable<U>. Por ejemplo, si posteriormente realiza una consulta en él, PLINQ se elegirá como el proveedor de consultas.
Tenga en cuenta que algunos operadores de LINQ son binarios; es decir, toman dos IEnumerable<T> como entrada. Join es un ejemplo perfecto de operador binario. En estos casos, el tipo de origen de datos del extremo izquierdo determina si se usa LINQ o PLINQ. Por tanto, sólo hace falta llamar a AsParallel en el primer origen de datos para que la consulta se ejecute en paralelo:
IEnumerable<T> leftData = ..., rightData = ...;
var q = from x in leftData.AsParallel()
        join y in rightData on x.a == y.b
        select f(x, y);
En todas estas explicaciones se da por hecho que se están usando métodos de extensión para escribir las consultas. Si, en lugar de eso, optó por llamar a los métodos de la clase Enumerable directamente, entonces tendrá un poco más de trabajo para trasladarlos a PLINQ. Además de la llamada a AsParallel, también deberá establecerse una referencia al tipo ParallelEnumerable. Por ejemplo, imagine que la consulta anterior fuese escrita mediante la llamada directa a Enumerable:
IEnumerable<T> data = ...;
var q = Enumerable.Select(
            Enumerable.OrderBy(
                Enumerable.Where(data, (x) => p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);
Para usar PLINQ, la consulta tendría que volver a escribirse del modo siguiente:
IEnumerable<T> data = ...;
var q = ParallelEnumerable.Select(
            ParallelEnumerable.OrderBy(
                ParallelEnumerable.Where(data.AsParallel(), (x) =>    p(x)),
            (x) => k(x)),
        (x) => f(x));
foreach (var e in q) a(e);
Por razones obvias, el uso de métodos de comprensión y extensión es una manera más cómoda de escribir consultas y conlleva la ventaja agregada que facilita su traslado a PLINQ.
Y eso es todo. Éstos son los únicos cambios que se requieren para usar PLINQ en lugar de LINQ to Objects. Debido a que LINQ to XML expone documentos XML como estructuras de datos IEnumerable<T>, todo lo dicho hasta ahora se aplica también a las consultas de contenido XML.

Procesamiento del resultado de las consultas
Como ya hemos visto, gracias a la evaluación diferida, el paralelismo no consigue insertarse hasta que se empieza a procesar el resultado de la consulta. Si ya conoce IEnumerable<T>, esto equivale a llamar al método GetEnumerator. Hay tres maneras básicas de procesar las consultas de PLINQ y cada una de ellas lleva a un modelo ligeramente diferente de paralelismo.
El primero es el procesamiento canalizado, en cuyo caso el subproceso que realiza la enumeración se encuentra separado de los subprocesos dedicados a ejecutar la consulta. PLINQ usará muchos subprocesos para la ejecución de consultas, pero reducirá el grado de paralelismo en uno para impedir que se interfiera con el subproceso de enumeración. Por ejemplo, si tiene ocho procesadores disponibles, siete de ellos ejecutarán la consulta PLINQ mientras que el procesador restante ejecuta el bucle foreach sobre el resultado de la consulta PLINQ a medida que los elementos pasan a estar disponibles. Esto comporta la ventaja de permitir que se dé un procesamiento más incremental de los resultados y, de esta forma, se reducen los requisitos de memoria necesarios para retenerlos. Sin embargo, tener muchos subprocesos de productor y sólo un único subproceso de consumidor a menudo puede llevar a una distribución desigual del trabajo, lo cual puede tener como consecuencia la ineficacia del procesador.
El segundo modelo es el procesamiento de detención e inicio. En este modelo, el subproceso que inicia la enumeración se une a todos los otros subprocesos para ejecutar la consulta. Una vez que todos los subprocesos hayan terminado de generar el conjunto completo de resultados, el subproceso a continuación procede a su enumeración. Esto tiene la ventaja de que toda la eficacia del procesamiento está destinada a crear resultados lo más rápidamente posible. Es también un poco más eficaz que el procesamiento canalizado porque existe menos sincronización incremental en la implementación: PLINQ puede ser más inteligente con estas cosas, porque sabe exactamente dónde van a parar los datos de los resultados y cómo se va a tener acceso a ellos. Si la distribución del trabajo es desigual entre los consumidores y el productor, este método de consumo será generalmente el más adecuado.
Por último, hay enumeración invertida. A PLINQ se proporciona una función lambda que se ejecuta en paralelo, una vez por cada elemento del resultado. Esto es el mecanismo más eficaz, ya que hay mucho más paralelismo expuesto para PLINQ, y la implementación evita las operaciones costosas como, por ejemplo, la unión de resultados procedentes de varios subprocesos. Sin embargo, tiene los inconvenientes de que no se puede usar simplemente un bucle foreach, sino que se debe usar una API especial ForAll y hay que tener cuidado de que las funciones lambda no dependan del estado compartido. De otro modo, insertar paralelismo hará que las consultas sean incorrectas y puede provocar bloqueos imprevisibles o daños en los datos. Sin embargo, si puede expresar el problema mediante esta API, este método es siempre el preferible.
Cuál de estos tres modelos se usará depende de lo que se hace con los resultados de la consulta. El modelo predeterminado es el primero, el procesamiento canalizado. En cuanto se invoca MoveNext en el enumerador de consultas resultante, un conjunto de subprocesos de trabajo adicionales ejecutará la consulta y los resultados se devuelven desde ésta y todas las llamadas a MoveNext posteriores a medida que pasan a estar disponibles. Si se realiza una llamada a MoveNext y no hay ningún resultado listo procedente de los subprocesos de consulta de productor, el subproceso de la llamada se bloqueará hasta que haya un elemento disponible. Si sólo usa el método foreach para procesar el resultado de una consulta PLINQ, esto es lo que obtendrá:
var q = ... some query ...;
foreach (var e in q) {
    a(e); // this runs in parallel with the execution of 'q'
}
En realidad, la interfaz de IParallelEnumerable<T> ofrece una sobrecarga de GetEnumerator que toma un argumento bool denominado pipelines, lo cual permite elegir el procesamiento de detención e inicio en su lugar (true significa canalizado y false significa detención e inicio). En la primera llamada posterior a MoveNext, se ejecutará la consulta completa y la llamada sólo se devolverá cuando todos los resultados estén disponibles. Las llamadas posteriores a MoveNext sólo enumeran un búfer que contiene los resultados:
var q = ... some query ...;
using (var e = q.GetEnumerator(false)) {
    while (e.MoveNext()) {
        // after the 1st call, the query is finished executing
        // we then just enumerate the results from an in-memory list
        a(e.Current);
    }
}
Existen algunos casos especiales en los que el procesamiento de detención e inicio se usa como el procesamiento predeterminado: si usa los métodos ToArray o ToList, estos operadores aplicarán internamente una operación de detención e inicio. Si tiene una ordenación en la consulta, se usará el procesamiento de detención e inicio, ya que canalizar el resultado de una ordenación es poco rentable. Una ordenación exhibe una latencia muy alta (ya que generalmente necesita ordenar todo los resultados antes de producir un solo elemento de salida) y, por tanto, PLINQ prefiere dedicar toda la eficacia del procesamiento a completar la ordenación lo más rápido posible.
Para usar la enumeración invertida, se deberá usar una API diferente y específica para PLINQ:
public static class System.Linq.ParallelEnumerable {
    public static void ForAll<T>(
        this IParallelEnumerable<T> source, Action<T> action);
    ... the other standard query operators ...
}
El uso de la API de ForAll se parece bastante al uso de un bucle foreach, tal como acabamos de ver:
var q = ... some query ...;
q.ForAll(e => a(e));

Excepciones simultáneas
Escenarios atractivos
Al leer este artículo, probablemente ya habrá comenzado a imaginarse alguna manera de usar PLINQ en las aplicaciones. Es posible que ya lo esté usando hoy mismo y desea mejorar la escalabilidad de la aplicación en equipos con varios procesadores o varios núcleos. Por supuesto, PLINQ puede hacer que los programas actuales se ejecuten más rápido, pero también permite hacer más trabajo de cálculo y operar sobre tamaños de datos más grandes empleando la misma cantidad de tiempo y mientras se procesan secuencias de datos más rápidamente. Con todo esto, la nueva tecnología de PLINQ podría abrir nuevas posibilidades para las aplicaciones que antes no podía ni probar.
Veamos algunos ejemplos de escenarios en los que los multinúcleos y PLINQ abren las puertas a nuevas posibilidades. Imagínese a un productor de música en un estudio de sonido que desea aplicar una serie de efectos en sonidos de instrumento sin mezclar para producir una pista principal más bonita y de mayor calidad. La compañía que le suministra el software de mezclas podría aplicar estos efectos con PLINQ. Estos efectos se componen generalmente de filtros y proyecciones sobre grandes secuencias de datos (la música sin mezclas). PLINQ podría acelerar mucho el tiempo de producción y usar componentes de hardware más eficaces a medida que estén disponibles. Este método podría incluso permitir transformaciones musicales a casi tiempo real, en lugar de tener que realizar todo un procesamiento de post-producción.
De igual manera, imagínese a un corredor de divisas extranjeras que busca condiciones de arbitraje (ineficacias en el mercado) para poder sacar beneficios. Estos cambios son mínimos y desaparecen, ya que el mercado busca constantemente alcanzar un equilibrio y requiere transacciones muy rápidas. Consultar información relativa a la transacción de acciones con la función de paralelismo mediante PLINQ puede estar cerca de facilitar la toma de decisiones casi en tiempo real, con información proveniente de grandes cantidades de datos, análisis y cálculos complicados.
Estos son sólo unos pocos ejemplos de cómo la aceleración que proporciona PLINQ en hardware multinúcleo puede proporcionar ventajas en los negocios. Otros ámbitos ofrecen oportunidades semejantes, como la asistencia sanitaria, la economía, la modelación geológica, la computación científica, el control y las simulaciones de tráfico, los juegos, la inteligencia artificial, el aprendizaje informático, el análisis lingüístico, y mucho más.

Aunque las declaraciones anteriores acerca de la transparencia completa del proceso de puesta en paralelo de PLINQ eran en su mayoría verdad, hay un número pequeño de lugares donde el uso de esta función de paralelismo puede presentar una pérdida a través de las abstracciones sencillas presentadas más arriba. Éstas son las artimañas a las que hicimos referencia hace un rato. Afortunadamente, la mayoría son de poca importancia, pero aún así deben tenerse en cuenta.
Cualquier función lambda u operador de consultas que genera una excepción detiene inmediatamente la ejecución de las consultas secuenciales LINQ. Eso es porque cuando sólo se usa un único procesador para ejecutar la consulta, los elementos se procesan uno tras otro, de manera secuencial: Si un operador sufre un error en uno de ellos, la excepción se genera inmediatamente y los elementos posteriores ni siquiera se tendrán en cuenta. Esto no es así con PLINQ.
A modo de ilustración, veamos esta consulta (elaborada):
object[] data = new object[] { "foo", null, null, null };
var q = data.Select(x => x.ToString());
foreach (var e in q) Console.WriteLine(e);
Cada vez que se ejecute con LINQ, conseguirá ejecutar ToString correctamente en el primer elemento de la matriz pero, a continuación, dará error con el intento de llamada de NullReferenceException a ToString en el segundo elemento. Nunca se llega al tercer ni al cuarto elemento. Sin embargo, cuando hay varios procesadores implicados, como es el caso en PLINQ, existe la posibilidad de que puedan generarse varias excepciones en paralelo. Según cómo PLINQ decida subdividir el problema, es posible que se vean errores para 1, 2 y 3, todos simultáneamente, o cualquier combinación de estos, incluido posiblemente 3, pero no 1 ó 2.
Para tratar con esto, PLINQ usa un modelo de excepción ligeramente diferente del de LINQ para comunicar los errores. Cuando se da una excepción en uno de los subprocesos de PLINQ, el sistema primero intenta detener la ejecución de todos los demás subprocesos lo antes posible. Este proceso tiene lugar de un modo completamente transparente. No obstante, esto puede o no cumplirse a tiempo para impedir que otras excepciones tengan lugar simultáneamente y, en realidad, ya pueden haberse dado para cuando PLINQ se implique. Una vez desactivados todos los subprocesos, el conjunto completo de excepciones generadas se agregará a un nuevo objeto System.Concurrency.MultipleFailuresException y ese objeto de excepción agregado se volverá a lanzar. Es posible tener acceso posteriormente a cada excepción generada a través de la propiedad InnerExceptions, del tipo Excepción[], incluidos los seguimientos imperturbables de la pila.
De hecho, PLINQ siempre genera una única MultipleFailuresException cuando una excepción no controlada finaliza la ejecución de una consulta, incluso si sólo se genera realmente una excepción. En el ejemplo anterior, eso significa que PLINQ siempre ajusta NullReferenceExceptions en MultipleFailuresException. Si no lo hizo y desea atrapar una excepción de un tipo determinado, tendrá que escribir varias cláusulas catch. Está claro que generalmente no se atrapan ciertas clases de excepciones pero, si lo hubiese deseado, debería haber escrito lo siguiente y duplicar un montón de lógica:
try 
{ 
    // query... 
} 
catch (NullReferenceException) 
{ ... } 
catch (MultipleFailuresException) 
{ ... }
Esto no sólo se trata de algo complicado, sino que los desarrolladores tenderían a olvidarse de una cosa u otra, lo que provocaría errores que suceden sólo bajo algunas circunstancias y configuraciones.
Esto, lamentablemente, puede hacer que la depuración sea más difícil. Si una excepción se genera de forma no controlada y se adjunta un depurador, se interrumpirá la llamada a GetEnumerator (si se está llamando a foreach debido a los resultados de la consulta) en lugar del origen de la excepción con la que se empezó. Esto es parecido a lo que sucede con el modelo de programación asincrónico (Beginxx/Endxx) en .NET Framework hoy en día. Por fortuna, PLINQ conserva los seguimientos de la pila original, por lo que si expande el objeto MultipleFailuresException y observa la propiedad InnerExceptions, encontrará el conjunto completo de excepciones con todo el seguimiento de la pila original disponible.

Ordenación de los resultados de salida
Supongamos que ha escrito el código siguiente en LINQ:
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data select x * 2).ToArray();
¿Puede predecir el contenido de data2? La pregunta parece tan sencilla que sería una tontería incluso planteársela. Todos dirían: { 0, 2, 4, 6 }. Pero si sólo cambia el código, tal como se muestra aquí, el contenido posible de data2, de hecho, es diferente:
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel() select x * 2).ToArray();
En este caso, {0, 2, 4, 6} es seguramente posible, pero también lo es {6, 0, 2, 4} o cualquier otra permutación de estos cuatro números.
Esto sucede así porque PLINQ ejecuta la consulta en paralelo y los resultados se ponen a disposición en cuanto pasan a estar disponibles, independientemente de si se está iterando sobre la consulta con foreach o si se está efectuando el cálculo de referencias de los resultados en una matriz con ToArray. La ordenación de LINQ es simplemente un subproducto del hecho de que su implementación procesa las entradas de manera secuencial. Por lo contrario, la ordenación de PLINQ está determinada por la programación no determinista de unidades paralelas de trabajo, que tiende a cambiar extremadamente de una ejecución a la siguiente.
Esto fue una decisión explícita sobre el diseño tomada por el equipo de PLINQ. Históricamente, las consultas nunca han garantizado nada en cuanto a la ordenación. Por ejemplo, si considera SQL Server™, a menos que haya especificado un orden por cláusulas en el texto de la consulta, la ordenación dependerá de muchas cosas: si se usa un índice en la consulta, la disposición de los registros en el disco, etc. De hecho, también puede ser no determinista, ya que SQL Server puede usar la función de paralelismo también en la evaluación de las consultas.
Dado que los usuarios necesitan con frecuencia conservar el orden, y también para minimizar algunos retos secundarios para los que intenten migrar desde LINQ, PLINQ ofrece una manera de optar a la conservación del orden. Conservar el orden asegura simplemente que, a menos que no haya operaciones que intervengan en él, la ordenación relativa entre los elementos de salida está fuertemente vinculada al orden relativo entre los elementos de entrada. Si quiso asegurarse de que el resultado de la consulta anterior fuese siempre {0, 2, 4, 6}, puede, en cambio, usar la consulta siguiente:
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = (from x in data.AsParallel(QueryOptions.PreserveOrdering)
               select x * 2).ToArray();
Conservar el orden tiene consecuencias. De hecho, puede tener repercusiones substanciales en el rendimiento y la escala de las consultas. Esta es la razón por la cual PLINQ insertará de forma lógica una operación de ordenación al final, y el orden es un operador que no escala muy bien con un aumento en el número de procesadores. Para hacerse una idea de lo que esto significa, la consulta anterior es lógicamente equivalente a la consulta siguiente:
int[] data  = new int[] { 0, 1, 2, 3 };
int[] data2 = data.AsParallel().
                  Select((x, i) => new { x, i }). // remember indices
                  Select((x) => new { x * 2, i }). // (in original)
                  OrderBy((x) => x.i). // ensure order is preserved
                  Select((x) => x.x). // get rid of indices from output
                  ToArray(); // (in original)
Los pasos presentados por PLINQ para admitir la conservación del orden están en rojo. Como puede ver, es algo bastante laborioso. Obviamente PLINQ implementa esta funcionalidad de una manera mucho más eficaz que si tuviese que escribir esta versión de la consulta, pero los pasos son lógicamente equivalentes.

Efectos secundarios
PLINQ depende de un elemento denominado la pureza estadística: la mayor parte del tiempo, la mayoría de las consultas LINQ no mutan estructuras de datos ni realizan operaciones impuras. En otras palabras, la mayoría de las consultas LINQ es estrictamente funcional; es decir, toman algunos datos como entradas, realizan algunos cálculos y crean como resultado una copia totalmente independiente de los datos, con algunos cambios. No obstante, los compiladores o el tiempo de ejecución no exigen de manera alguna esta práctica recomendada. Si se escribieron consultas que interrumpen este patrón de uso común, entonces el traslado a PLINQ puede llegar a ser algo bastante más complicado.
A modo de ejemplo, consideremos esta consulta LINQ:
var q = from x in data where (x.f-- > 0) select x;
Observe que el predicado en la cláusula where en realidad modifica un campo de un objeto. Esto significa que la ejecución en paralelo de la consulta puede no ser segura sin que se expongan las condiciones de anticipación y errores de simultaneidad. De forma predeterminada, debe suponerse que consultas como ésta no son seguras y no deben ejecutarse con PLINQ. Pero, ¿cuándo serían realmente seguras? Las anticipaciones suceden sólo si el predicado se ejecuta en varios subprocesos en el mismo objeto, lo que sólo puede suceder si los datos contienen objetos duplicados. Por lo tanto, si los datos son un conjunto, no habrá ningún problema.
Hay otros casos, como por ejemplo, los lugares en los que se usan variables estáticas, que son siempre arriesgados:
class C { internal static int s_cCounter; }
// elsewhere...
var q = from x in data where (x.f == C.s_cCounter++) select x;
Si ejecuta esta consulta en paralelo, es probable que se decepcione bastante. Podría acabar por tener muchos objetos con valores de campo f duplicados, lo cual sólo sucede a causa de la condición de anticipación. Tenga cuidado: su reacción y método iniciales para resolver esto podrían ser reemplazar C.s_cCounter++ por Interlocked.Increment(ref C.s_cCounter). Aunque este método lleva a una solución correcta, cualquier tipo de sincronización reducirá bastamente la escalabilidad de las consultas. Debe esforzarse por eliminar toda dependencia de mutabilidad y estado compartido de las consultas como principio prioritario.
La plataforma Windows® se centra mucho en los subprocesos, por lo que gran parte del estado del sistema puede acabar adjunto al subproceso que está ejecutando algún fragmento de código. Esto puede adoptar la forma de almacenamiento de subprocesos locales, información de seguridad tal como la suplantación, apartamentos COM (concretamente contenedores uniproceso) y los marcos de GUI tales como Windows Forms y Windows Presentation Foundation. Todos estos elementos pueden causar problemas al trasladarlos de LINQ a PLINQ.
Mientras que en el modelo LINQ todo el código de la consulta se ejecuta en el mismo subproceso que la inició, con PLINQ el código de la consulta se distribuye entre varios subprocesos. Lamentablemente, no siempre se hace evidente cuando se ha adoptado una dependencia en la afinidad del subproceso, ya que muchos servicios en .NET Framework dependen de ellos internamente de manera intrínseca y transparente. El mejor consejo que puedo dar es ser cauteloso ante los orígenes de los peligros de simultaneidad mencionados anteriormente, y evitarlos tanto como sea posible desde dentro de las consultas.

Poner PLINQ en funcionamiento
PLINQ permite que los desarrolladores de LINQ saquen provecho del hardware en paralelo (entre lo que se incluye una mejora en el rendimiento y la creación de software intrínsecamente más escalable), sin necesidad de tener que entender el concepto de simultaneidad o de paralelismo de datos. El modelo de programación es sencillo y permite que un número mayor de desarrolladores saque partido de más hardware en paralelo. La barra lateral "Recursos de PLINQ" enumera algunas referencias para poder obtener más información.
Para aquellos que ya estén usando LINQ to Objects, LINQ to XML o LINQ para vincular orígenes de datos heterogéneos, les resultará sencillo conseguir que sus consultas ya existentes usen PLINQ. Eche una ojeada a la barra lateral "Escenarios atractivos" para obtener más usos posibles de esta tecnología. Para aquellos que no han empezado todavía a adoptar LINQ, lo que se gana en aceleración en paralelo es una razón más para considerar su uso. Aunque PLINQ no está todavía disponible, usar LINQ en los programas es una inversión para el futuro.

Joe Duffy es responsable de desarrollo del equipo Parallel FX de Microsoft y participa habitualmente en el blog www.bluebytesoftware.com/blog. Está escribiendo un libro, Concurrent Programming on Windows, que va a ser publicado por Addison-Wesley.

Ed Essey tiene cinco años de experiencia como Director de programas de Microsoft y trabaja con modelos e infraestructura de procesamiento paralelo.

Page view tracker