Task Parallel Library – Introducción

Una de las nuevas novedades que .NET Framework 4.0 incluye es el la Task Parallel Library una serie de APIS nuevas para la programación multihilo. La idea principal de esta librería, que viene incluida en el propio framework, es que cuando tengamos que añadir paralelismo y concurrencia a nuestras aplicaciones sea de lo más sencillo.

Actualmente los procesadores ya no incrementan la velocidad en Gigahercios sino que lo que hacen es replicar el hardware haciendo que nos encontremos dentro del mismo encapsulado FPGA dos procesadores exactamente iguales con sus caches de segundo y primer nivel. Esto cambia la manera de desarrollar software porque ya no nos encontramos con procesadores más rápidos sino con procesadores con más cores, 2,4,6,8 ect.

Con este nuevo escenario tenemos los desarrolladores tenemos que empezar a paralelizar nuestro software para explotar toda la potencia de los procesadores.

Una de las cosas buenas de la TPL, es que si la maquina donde se va a ejecutar tiene 2, 4 o 8 procesadores, la TPL es capaz de escalar sin necesidad de recompilar ni configurar, es decir es capaz de usar todos los cores disponibles.

Eso quiere decir que cuanto más cores utilicemos más velocidad ganaremos, aunque esto no es siempre así, porque no todas las tareas son sensibles de ser paralelizadas. Además hay que tener en cuenta que usar la TPL añade complejidad en la ejecución de la aplicación y esto en algunas ocasiones puede hacer que se degrade el rendimiento y no lo aumente. Hablaremos de eso más adelante.

Aquí tenemos la primera toma de contacto con la TPL:

Parallel.For(startIndex, endIndex, (currentIndex) => DoSomeWork(currentIndex));

Parallel.For nos permite ejecutar de manera concurrente el cuerpo de un bucle for, haciendo que la ejecución se propague por todos los cores disponibles en el sistema. Así de sencillo.

Pero como hemos comentado antes no esto a veces aumenta el rendimiento y en otros lo degrada. Veamos porque.

Cuando realizamos un bucle normal todo el código se ejecuta de manera secuencial, es decir una instrucción detrás de otra, pero cuando estamos realizando una paralelizacion de nuestro código tenemos varios threads que están ejecutando código en el mismo instante de tiempo. Si nos ponemos a pensar cómo se podría implementar a mano un Parallel.For, lo primero que tendríamos que hacer es realizar una partición de las iteraciones para dependiendo de los procesadores que tengamos repartir el trabajo.

Si tenemos por ejemplo que recorrer una lista de 50000 elementos y tenemos 2 procesadores, podemos partir la lista de dos sublistas de 25000 y generar dos threas que se encarguen de recorrer esos elementos. Ponemos los threads a ejecutar y tenemos que sincronizar cuando los dos threads terminan.

Básicamente esto es de lo que se encarga el Parallel.For de hacer por nosotros, de una manera cómoda y elegante.

Ahora bien, ¿Cuándo no es recomendable realizar un Parallel.For?, hay una regla que funciona en la mayoría de los casos, cuando el tiempo de ejecución del cuerpo del for sea mayor o igual que el tiempo de creación de los threas y de la sincronización, también es lo mismo si tenemos colecciones pequeñas. ¿Qué quieres decir esto?, veamos un ejemplo:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;

namespace ParalleFor
{
    class Program
    {
        static void Main(string[] args)
        {
            new Program();
        }

        private List<int> GetRandomList()
        {
            List<int> list = new List<int>();
            for (int i = 0; i < 90000000; i++)
            {
                list.Add(i);
            }
            return list;
        }

        public Program()
        {
            List<int> list = GetRandomList();

            Stopwatch st = new Stopwatch();

            // sum all
            decimal value = 2;
            st.Start();
            for (int i = 0; i < list.Count; i++)
            {
                decimal final = value * list[i];
            }
            st.Stop();
            Console.WriteLine(st.Elapsed);
            st.Reset();

            st.Start();
            Parallel.For(0, list.Count, index =>
            {
                decimal final = value * list[(int)index];
            });
            st.Stop();
            Console.WriteLine(st.Elapsed);
        }
    }
}

Si nos fijamos la lista de con la que trabajamos es realmente grande, eso significa que si paralelizamos el bucle for ganaremos en rendimiento como muestra la salida de la ejecución. Pero si tenemos una lista pequeña no ganaremos tiempo sino que perderemos porque tenemos que sincronizar los n threas para esperar a que todos terminen, además del tiempo necesario para inicializarlos.

Otro de los grandes problemas de la paralelizacion es que necesitamos estructuras para sincronizar nuestro código y tener claro los conceptos de: locks, deadlocks, race condition (data and flow), etc.

En el siguiente artículo veremos el soporte para Task, las unidades básicas de ejecución dentro de TPL.

Aquí tenéis el código fuente del ejemplo.

http://www.luisguerrero.net/downloads/parallefor.zip

 

Saludos. Luis.

7 comentarios en “Task Parallel Library – Introducción”

  1. Es un tema interesante, sin embargo erras cuando haces mención de los threads y sus problemas subsecuentes de sincronización. Los Task, que es en donde se apoyan las instrucciones en Parrallel no son realmente threads por cuanto no hay temas de sincronización asociados. Un thread es interrumpido para permitir la ejeción de otro thread dentro del mismo proceso, mientras que los task se ejecutan independientemente sin sincronizar entre si , cada uno como si fuera un proceso independiente dependiendo unincamente de la calendarizacion del sistema operativo. Esa es una de sus mayores ventajas, que al no tener tareas sincronizacion ni cambios de contexto dentro del propio proceso, hacen la ejecución mucho más eficiente.

  2. Estimado Juan Carlos, me gustaría puntualizar tu comentario porque me parece que no he entendido lo que quieres explicar, según tú las Task no son realmente threads y no tienen temas de sincronización asociadas. Bueno eso es cierto a medias, porque como comento en mi siguiente artículo, los Task se pueden ejecutar de manera síncrona y asíncrona lo que quiere decir que se puede utilizar el TaskScheduler predeterminado, el del ThreadPool que implica el uso de Threads para su ejecución, así que esto no es cierto siempre.

    A partir de este punto estoy muy confuso porque me parece que no has entendido que son realmente las Task. No son ningún objeto nuevo del SO operativo, para que se “ejecuten independientemente dependiendo únicamente de la calendarización del sistema operativo”, a que te refieres con eso que el SO tiene otra unidad mínima de ejecución que no son los Threads?, desde cuando el kernel de Windows puede ejecutar Task como tales?, de hecho ni siquiera .NET tiene noción de lo que son los Task, simplemente ejecuta código en un thread. Lo único que los Task una manera muy sencilla de abstraer esa complejidad necesaria para paralelizar la ejecución de todo ese código. Así que no veo porque las Task tiene una ventana que no tienen cambios de contexto cuando son cosas que están en diferentes niveles de concepto, un cambio de contexto se realiza cuando un thread agota el cuantum de tiempo o espera por algún evento externo haciendo que Windows guarde el estado de la pila y los registros en el objeto contexto asociado al TEB del thread que es completamente nativo.

    Te invito a que te leas el siguiente artículo de la TPL de mi blog y que veas este video para que entiendas un poco mejor con funciona por dentro.
    http://channel9.msdn.com/pdc2008/TL26/

    Saludos. Luis.

  3. Hola Luis, desde luego no hablo de Task como otra unidad de ejecución, pues desde luego por debajo todos son threads del sistema operativo , el punto es que tu puedes abrir un task y este hara uso de un Taskpool que es parecido al threadpool pero no es lo mismo, el taskpool controla que tareas se ejecutan en cual procesador y solo una vez la tarea termine su ejecucion este utilizara ese procesador ‘libre’ para enviar la siguiente tarea en espera a ejecucion.
    Un Thread es libre de ejecutarse en diferentes procesadores segun la calendarizacion que el sistema operativo haga para el proceso en donde corre el thread. En tanto un Task no sera calendarizado para usar sino un solo procesador hasta que este acabe.
    Segun recuerdo, un Task no cambia al estado suspend y luego a resume como sucede con un Thread cada vez que es asignado tiempo de ejecucion a otro hilo de un mismo proceso. En un Task eso no existe, porque el Task no entra en competencia con la aplicacion actual por el tiempo procesador, ya que de hecho el task y el hilo principal dela aplicación se ejecutan en procesadores diferentes de manera simultanea.

    Recuerda que mientras un hilo es solo un truco para simular paralelismo a traves de hebras que intercambian tiempo de ejecución. El Task realmente SI se esta ejecutando de manera paralela en un procesador diferente, lo cual le evita pasar por los cambios de contexto para que se ejecute el hilo principal de la aplicación.

    En el video que me pasas incluso puedes apreciar como el mismo algoritmo implemantado haciendo uso de Threads y luego haciendo uso de Task se ejecuta muchisimas veces mas rapido gracias al impresionante ahorro de tiempo al evitar los cambios de contexto durante la ejecución.

    Un Task, si bien en un nivel mas bajo es un hilo, no es realmente un Thread desde el punto de vista del CLR, ya que estan hechos para propositos diferentes.

    En verdad vi ese video hace ya como 6 meses y puede que est confundido, asi que lo revisare, pero creo que deberias pegarle una revisada tambien.

  4. “Un Thread es libre de ejecutarse en diferentes procesadores segun la calendarizacion que el sistema operativo haga para el proceso en donde corre el thread. En tanto un Task no sera calendarizado para usar sino un solo procesador hasta que este acabe.”

    Tengo que decirte que en la primera parte llevas razón, eso se llama Thread Affinity. Pero con respecto al segundo punto, estamos hablando del mismo concepto, que diferencia a un thread de un Task?, pues conceptualmente nada, porque los dos intentan ejecutar una función, un delegado en un thread diferente, el caso es que Task utiliza el ThreadPool de Windows que esta optimizado para reutilizar threads pero en esencia es lo mismo, los dos son thread un creado a mano y el otro creado de una manera más sofisticada. Es lo que explico en mi segundo artículo de TPL.

    “Segun recuerdo, un Task no cambia al estado suspend y luego a resume como sucede con un Thread cada vez que es asignado tiempo de ejecucion a otro hilo de un mismo proceso. En un Task eso no existe, porque el Task no entra en competencia con la aplicacion actual por el tiempo procesador, ya que de hecho el task y el hilo principal dela aplicación se ejecutan en procesadores diferentes de manera simultanea.”

    Cuando he dicho yo que un Task cambia a estado Suspended y luego se resume, al ser código normal, puedes seguir usando tus primitivas de sincronización como lock, (Auto/Manual)ResetEvent, Interlocked, Semaphore, ect. ¿Cómo que un Task no entra en competencia con la aplicación actual por el tiempo de procesador?, hasta lo que yo se aplicación es un contenedor lógico que asigna Windows a un conjunto de Threads, así que sí entra en competencia por supuesto porque un thread normal y un Task son lo mismo.

    “Recuerda que mientras un hilo es solo un truco para simular paralelismo a traves de hebras que intercambian tiempo de ejecución. El Task realmente SI se esta ejecutando de manera paralela en un procesador diferente, lo cual le evita pasar por los cambios de contexto para que se ejecute el hilo principal de la aplicación.”

    Un truco? pues vaya tela, y antes de los Task que se usaba? No había paralelismo?, y un pequeño detale Thread y Fiber (hilo y hebra) son dos conceptos diferentes. Los threads utilizan el scheduler del kernel mientras que las fibers utilizan un scheduler de usuario.

    “En el video que me pasas incluso puedes apreciar como el mismo algoritmo implemantado haciendo uso de Threads y luego haciendo uso de Task se ejecuta muchisimas veces mas rapido gracias al impresionante ahorro de tiempo al evitar los cambios de contexto durante la ejecución.”

    Esto es mezclar conceptos que son completamente diferentes, se ejecuta más rápido en comparación a qué? Y que tiene que ver los cambios de contexto para eso? Lo que ves en el video se llama Work-stealing y es un mecanismo para optimizar el uso de procesador, de hecho se le puede notificar el TaskScheduler que lo deshabilite con esta flag a la hora de crear una Task con TaskCreationOptions. PreferFairness.

    “Un Task, si bien en un nivel mas bajo es un hilo, no es realmente un Thread desde el punto de vista del CLR, ya que estan hechos para propositos diferentes.”

    Error, un Task es un nivel más alto, un thread es de bajo nivel el CLR (maquina que ejecuta el código de framework) no entiende de Task, entiende de thread. De hecho Task envuelve a Thread porque un Task contiene un Thread (semánticamente hablando).

    Te pasteo de la web de TPL de Microsoft la definición de Task:

    http://msdn.microsoft.com/en-us/library/dd460717(VS.100).aspx

    In the TPL, the basic abstraction is the Task, not the thread. A task is an instance of the Task class. A task can be canceled and waited on, and can return a value, and can invoke another task when it completes. And when you use Parallel.For and Parallel.ForEach, even the Task object itself is implicit. In your code, you simply supply the delegate that performs the desired work. The TPL handles the rest. By default, the TPL uses its own task scheduler, which is integrated with the .NET ThreadPool. However, you can also provide a custom task scheduler that uses some other thread-scheduling mechanism. There is no fixed relationship between a task and a thread. A thread may run several tasks in succession for any given block of parallel code. A task may define a child task that runs on the same thread, or a different thread. A task may also invoke another task that has been defined elsewhere.

    Saludos. Luis.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *