Eduardo Barrios
Posted on December 20, 2020
Esta publicación, forma parte del 3er. Calendario Adviento C# 2020, una iniciativa liderada por Benjamín Camacho.
A continuación les traigo mi aporte sencillo pero no menos importante en el desarrollo de software y el uso de características geniales del lenguaje de programación C# en este caso el procesamiento multithreading y asíncrono mediante TPL.
Para comprender TPL es necesario comprender algunos fundamentos respecto a como funcionan los hilos, el grupo de hilos, características del sistema operativo como el scheduler, procesos, registros, etc.
Es lógico pensar que a medida que las aplicaciones se vuelven indispensables en cualquier entorno también se vuelven más complejas y las expectativas del usuario aumentan, ante esta problemática los desarrolladores de software podemos tomar ventaja de sistemas multinúcleo y capacidades de respuesta optimas para crear aplicaciones que utilicen múltiples threads (hilos) y lograr el famoso paralelismo. Antiguamente las computadoras arquitectónicamente hablando fueron creadas siguiendo el diseño lógico denominado Arquitectura de Von Newmann, la cual contaba con una unidad de procesamiento, una unidad de control, entrada y salida, y la unidad de procesamiento y control formaban la unidad de procesamiento central CPU, pero este diseño contaba con una sola unidad de procesamiento por lo que los programas debían ser escritos para funcionar bajo este diseño y eso implicaba escribir código que se ejecutara de manera secuencial.
Con base en el anterior fundamento pensemos en la siguiente premisa, una computadora con una sola CPU es capaz de ejecutar una sola operación a la vez, pero que sucede si esa operación es muy trabajosa y lleva mucho tiempo de ejecución para esa CPU. Mientras se ejecuta ese proceso las demás operaciones quedarían en pausa, obviamente esto significa que toda la computadora se congelaría y no respondería aparentemente, y todo empeoraría más sí ese proceso contiene un error por lo que la computadora quedaría inutilizable y lo único que puedes hacer es reiniciar. Para solucionar esto se utiliza el concepto de Thread (Hilo).
Entendiendo los Threads
En las versiones actuales del Sistema Operativo Windows, cada aplicación se ejecuta en su propio proceso, un proceso se encarga de separar aplicaciones de otras aplicaciones asignándoles su propia memoria virtual y asegurado que los diferentes procesos no puedan interferir entre sí, cada proceso se ejecuta en su propio hilo. Un Thread (Hilo) es algo así como una CPU virtualizada que permite a una aplicación realizar varias tareas a la vez en paralelo, es parecido al concepto multitarea a nivel del sistema operativo pero los Threads se enfocan en subprocesos que pertenecen a un mismo proceso y la diferencia es que los Threads comparten espacio de memoria y los procesos no.
Para el caso del Sistema Operativo Windows, este administra todos los subprocesos para garantizar que puedan ejecutarse y realizar su trabajo, el SO se encarga de esa administración, de darle tiempo de ejecución en la CPU y cuando este tiempo de ejecución termina, el subproceso se detiene y Windows cambia a otro Thread, esto es a lo que se le conoce como cambio de contexto.
Para poder utilizar Threads en nuestras apliaciones .NET podemos recurrir al espacio de nombres System.Threading en donde encontraremos la clase Thread con la que podremos crear nuevos Threads, gestionar su prioridad y obtener su estado.
Veamos un ejemplo con código C# de como podemos ejecutar un segundo Thread al tiempo que realizamos una operación en el Thread principal de una aplicación de consola.
Crearemos 3 métodos
- ReadDataFromIO => este método simula lectura y escritura de archivos, aunque en realidad lo que hacemos es poner el hilo actual en suspensión para simular esa operación de IO.
static double ReadDataFromIO()
{
// Estamos simulando una E/S poniendo el hilo actual en suspensión.
Thread.Sleep(5000);
return 10d;
}
- DoIntensiveCalculations => este método simula cálculos matemáticos intensivos, aunque en realidad lo que haremos será hacer divisiones sin sentido solo para efectos de simulación.
static double DoIntensiveCalculations()
{
// Estamos simulando cálculos intensivos
// haciendo divisiones sin sentido
double result = 100000000d;
var maxValue = Int32.MaxValue;
for (int i = 1; i < maxValue; i++)
{
result /= i;
}
return result;
}
- RunWithThreads => este método contendrá la ejecución de ambas operaciones y será invocado en el método main de la clase Program.
static void RunWithThreads()
{
double result = 0d;
// Crear el hilo para leer desde E/S
var thread = new Thread(() => result = ReadDataFromIO());
// Iniciar el hilo
thread.Start();
// Guardar el resultado de el calculo en otra variable
double result2 = DoIntensiveCalculations();
// Esperar a que el hilo termine
thread.Join();
// Calcular el resultado final
result += result2;
// Imprimir el resultado
Console.WriteLine("El resultado es {0}", result);
}
Ahora en el método Main invocaremos RunWithThreads().
static void Main(string[] args)
{
RunWithThreads();
}
Independientemente del resultado podemos ver que ambos hilos se ejecutan, inicialmente pasamos un delegado como parámetro en el método constructor de la instancia de la clase Thread, el hilo no se inicia cuando se crea, debemos iniciarlo llamando al método Start(), esto pone en cola este nuevo hilo para su ejecución mientras continúa ejecutando el código en el método actual, seguidamente al ejecutar el método DoIntensiveCalculations se realiza el cálculo intensivo y debe esperar a que el hilo creado anteriormente termine de ejecutarse, esto sucede al invocar al método Join, thread.Join bloquea el hilo actual hasta que el otro hilo termine de ejecutarse y cuando finalice el otro hilo, Join volverá y el hilo actual se desbloqueará. A grandes rasgos de esta manera podemos gestionar los Threads en C#.
Entendiendo el Thread Pool (Grupo de Hilos)
Cuando trabajamos directamente con la clase Thread, creamos un nuevo hilo cada vez y ese hilo que creamos cuando termina su ejecución este muere al finalizar, no obstante esto para el manejador de procesos del sistema operativo representa un golpe al rendimiento al costarle algo de tiempo y recursos. Afortunadamente en C# tenemos la clase static ThreadPool que representa un grupo de hilos que nos permite reutilizar hilos, en lugar de dejar morir un hilo cuando finalice su ejecución se envía de vuelta al Thread Pool donde puede estar para ser reutilizado cada vez que llega una petición. Para utilizarla lo único que debemos hacer es pasar nuestra operación al método QueueUserWorkItem que se encargará de colocar el elemento recibido en una cola administrada por el Thread Pool para que cuando un Thread del Thread Pool este disponible este recogerá el elemento y lo ejecutará hasta su finalización, aunque tenemos un problema debido a que no se sabe cuando el subproceso terminará su trabajo y no hay ningún tipo de Join para bloquear y forzar una espera. Para resolver esto debemos recurrir a la sincronización de recursos pero es algo que no veremos en este Post y debido a que no se recomienda ni tampoco utilizaremos la clase ThreadPool a menudo ya que en su lugar se debe utilizar otras tecnologías como el Task Parallel Library (TPL) que veremos más adelante.
Veamos el ejemplo anterior con código C# pero esta vez utilizando la clase ThreadPool.
static void RunInThreadPool()
{
double result = 0d;
// Crear un elemento de trabajo para leer desde E/S
ThreadPool.QueueUserWorkItem((x) => result += ReadDataFromIO());
// Guardar el resultado del calculo en otra variable
double result2 = DoIntensiveCalculations();
// Esperar a que el Thread termine
// HACER: Necesitaremos una manera de indicar
// cuando el Thread del ThreadPool finalizó la ejecución
// Calcular el resultado final
result += result2;
Console.WriteLine("El resultado es {0}", result);
}
Ahora en el método Main invocaremos RunInThreadPool().
static void Main(string[] args)
{
RunInThreadPool();
}
Independientemente del resultado de esta manera utilizamos el ThreadPool que viene siendo como una versión 2 de los Hilos.
Algo que debemos saber del Thead Pool es que este limita el número disponible de Threads, esto significa que obtendremos un grado menor de paralelismo que usando la clase Thread a secas. Pero el Thread Pool también tiene muchas ventajas por ejemplo pensemos en un servidor web que atiende peticiones entrantes. Todas esas peticiones llegan en un tiempo y frecuencia que no conocemos. El Thread Pool garantiza que cada petición se agregue a una cola y que cuando exista un Thread disponible este procese la petición. Esto nos asegura que el servidor no se no bloqueará por la cantidad de solicitudes, en cambio si usamos la clase Thread y gestionamos los hilos manualmente podemos derribarlo fácilmente si este recibiera infinidad de peticiones.
Task Parallel Library (TPL)
Una de las deficiencias del uso de subprocesos, hilos, múltiples hilos es que consumen muchos recursos. Cuando se inicia un subproceso este compite con más subprocesos por tiempo de ejecución en la CPU, y gestionar todo esto para el Desarrollador de Software es sumamente complejo, afortunadamente .NET cuenta con el Task Parallel Library (TPL) un conjunto de clases contenidas en el espacio de nombres System.Threading y System.Threading.Task que tienen como finalidad hacer que los Desarrolladores de Software sean más productivos reutilizando clases que optimizan el trabajo de agregar paralelismo y simultaneidad en aplicaciones, TPL se encarga por nosotros de escalar el grado de simultaneidad dinámicamente para usar eficazmente todos los procesadores disponibles en el host, TPL controla la partición del trabajo, la programación de subprocesos en el ThreadPool, permite cancelaciones, administración de estado y otros detalles que preocupan a bajo nivel.
La clase Task (Tarea)
La clase Task fue introducida por Microsoft a partir de .NET Framework 4. Una Task (Tarea) es un objeto que representa un trabajo que debe hacerse, Task puede decir si el trabajo se ha completado, si la operación devuelve un resultado o no, en caso de que si devuelva un resultado Task te permite hacer lo que sea con ese resultado, el Task Scheduler (Planificador de Tareas) es el responsable de iniciar Tasks y administrarlas, por defecto el Task Scheduler utiliza Threads (Hilos) del Thread Pool (Grupo de Hilos) para ejecutar Tasks.
Veamos un ejemplo con Código C#.
const int NUMBER_OF_ITERATIONS = 32;
static void RunTasksCorrected()
{
double result = 0d;
Task<double>[] tasks = new Task<double>[NUMBER_OF_ITERATIONS];
// Creamos una tarea por iteración.
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++)
{
tasks[i] = Task.Run(() => DoIntensiveCalculations());
}
// Esperar a que terminen todas las Tareas
Task.WaitAll(tasks);
// Recopilar los resultados
foreach (var task in tasks)
{
result += task.Result;
}
// Imprimir el resultado
Console.WriteLine("El resultado es {0}", result);
}
static void Main(string[] args)
{
RunTasksCorrected();
}
Observemos que podemos lanzar múltiples Tareas a ejecución y esto hará que nuestra aplicación sea más receptiva, ya que sí el Thread que maneja la interfaz de usuario descarga el trabajo a realizar a otro Thread del Thread Pool puede seguir procesando eventos de usuario y garantizar que la aplicación aún se puede usar.
Task se encarga por nosotros de gestionar las condiciones de carrera que ocurren cuando dos o más procesos acceden a un recurso compartido sin control, si esto no se administra o no se toma en cuenta el resultado es erróneo y depende del orden de llegada de la Tarea.
Consejo de expertos y especialistas en C#: No es necesario llamar a WaitAll ya que task.Result bloqueara al autor de la llamada si la Task aún no finalizo de realizar el cálculo. Entonces, si alguna de las tasks no se realiza cuando ingresa al bucle foreach, la petición que llama se bloqueará y esperará a que termine la tarea.
Task Scheduler (Manejador de Tareas)
El Task Scheduler realiza el trabajo de poner en cola las Tareas en subprocesos, la clase TaskScheduler se encarga de esta responsabilidad. Cada vez que se inicia una Task si no se especifica un Scheduler se inicia uno por defecto. Los desarrolladores de software debemos tener en cuenta un aspecto muy importante cuando se crea software que utiliza interfaz gráfica como en proyectos Windows Forms, WPF o Xamarin.Forms, recodemos que la interfaz de usuario solo se puede actualizar mediante el Thread principal que administra la interfaz de usuario, por lo que si una Task necesita actualizar la interfaz de usuario debe hacerlo mediante el Thread que gestiona la interfaz de usuario. Para lograr este objetivo se debe llamar a una de las sobrecargas de StartNew o ContinueWith que toman como parámetro un TaskScheduler y pasarle TaskScheduler
.FromCurrentSynchronizationContext() como valor. Por ejemplo supongamos que tenemos una aplicación Xamarin.Forms que contiene un método llamado UpdateProgressBar() que actualiza un ProgressBar en el Thread de la interfaz de usuario, usaríamos lo siguiente.
Task.Factory.StartNew(UpdateProgressBar,
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext());
De esta manera el Thread de la interfaz de usuario actualizará la interfaz de usuario tan pronto como pueda procesarlo.
Parallel Class
El espacio de nombres System.Threading.Tasks también contiene otra clase que podemos utilizar para el procesamiento paralelo. La clase Paralela tiene un par de métodos estáticos: For, ForEach e Invoke: que podemos usar para paralelizar el trabajo.
El paralelismo implica tomar una determinada tarea y dividirla en un conjunto de Tasks relacionadas que pueden ser ejecutadas simultáneamente, esto tampoco significa tengamos que revisar todo nuestro código y reemplazar todos los bucles que tengamos por bucles paralelos. Es recomendable utilizar la clase paralela solo cuando nuestro código no tiene que ejecutarse secuencialmente.
A continuación utilizaremos el método For de la clase Parallel el cuál está definido por la siguiente firma.
public static ParallelLoopResult For<TLocal>(
int fromInclusive,
int toExclusive,
Func<TLocal> localInit,
Func<int, ParallelLoopState, TLocal, TLocal> body,
Action<TLocal> localFinally
)
Haremos una implementación siguiendo el ejemplo anterior pero esta vez con la clase Parallel.
static void RunParallelFor()
{
double result = 0d;
// Aquí llamamos al mismo metodo varias veces
//for (int i = 0; i < NUMBER_OF_ITERATIONS; i++)
Parallel.For(0, NUMBER_OF_ITERATIONS,
// Func<TLocal> localInt,
() => 0d,
// Func<int, ParallelLoopState, TLocal, TLocal> body,
(i, state, interimResult) => interimResult + DoIntensiveCalculations(),
// Paso final despues de los calculos
// Agregaremos el resultado al resultado final
// Action<TLocal> localFinally
(lastInterimResult) => result += lastInterimResult);
Console.WriteLine("El resultado es: {0}", result);
}
static void Main(string[] args)
{
RunParallelFor();
}
El tema del rendimiento es algo muy importante a tomar en cuenta en nuestras aplicaciones, y especificamente con el procesamiento en paralelo y multithreading este aumenta cuando tenemos mucho trabajo que realizar que se pueda ejecutar en paralelo. Para cargas de trabajo pequeñas o trabajos que tienen que sincronizar el acceso a los recursos el uso de la clase Parallel puede afectar el rendimiento, la manera de saber si funcionará en nuestros escenarios es medir los resultados con la siguiente y muy sencilla implementación.
static void Main(string[] args)
{
Parallel.For(0, 10, i =>
{
Thread.Sleep(1000);
Console.WriteLine("Parallel.For: {0} , Task Id: {1}", i, Task.CurrentId);
});
var numbers = Enumerable.Range(0, 10);
Parallel.ForEach(numbers, (i) =>
{
Thread.Sleep(1000);
Console.WriteLine("Parallel.Foreach: {0}", i);
});
}
Conclusiones
- Escribir código que maneja múltiples Threads es altamente díficil debido a que hay muchas cosas a tomar en cuenta como el stack, el heap, datos, memoria, condiciones de carrera y otros aspectos que ocurren a bajo nivel cuando de multithreading se trata.
- Aunque existe la clase Thread y ThreadPool es recomendable no utilizarlas directamente, en su lugar Microsoft y expertos en C# recomiendan TPL.
- Antiguamente tener un procesador significaba que sólo un subproceso se podía ejecutar a la vez, actualmente con la llegada de los nuevos procesadores multinúcleo y de muchos núcleos, las aplicaciones que se escriben de forma multihilo o asíncrona se benefician intrínsecamente de esas mejoras, mientras que las aplicaciones escritas secuencialmente ignoran los recursos disponibles y hacen que el usuario espere innecesariamente lo que nos lleva a una mala experiencia de usuario.
- Un Thread puede verse como una CPU virtualizada.
- El uso de varios threads puede mejorar la capacidad de respuesta y permite utilizar múltiples procesadores.
- Un objeto Task encapsula un trabajo que debe ejecutarse. Las tareas son la forma recomendada de crear código multithread.
- Con TPL podemos debuguear código asíncrono como si fuera síncrono en Visual Studio.
- Task Parallel Library (TPL), básicamente proporciona un mayor nivel de abstracción.
- El objetivo principal TPL es dar a los desarrolladores la oportunidad de agregar paralelismo y/o simultaneidad a sus aplicaciones.
- Una Task es una manera más fácil de ejecutar algo de forma asincrónica y en paralelo en comparación con un subproceso.
- TPL es una biblioteca muy extensa por lo que considero que sería oportuno extender este Post en una segunda parte y hablar acerca de async y await palabras clave en el uso de TPL, el procesamiento multithreading y programación asincrona.
- TPL también nos proporciona la clase Parallel para paralelizar cargas de trabajo.
Referencias:
- https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-parallel-library-tpl
- https://dl.acm.org/doi/abs/10.1145/1639949.1640106
- https://books.google.es/books?hl=es&lr=&id=nBsGVjwzBJEC&oi=fnd&pg=PT7&dq=task+parallel+library+c%23&ots=V5yE1nyR6e&sig=PPo0YGPBXbSFl3Xuw_ZUW5Y1PjY#v=onepage&q=task%20parallel%20library%20c%23&f=false
Posted on December 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024