
Siguiendo con la serie de artículos referente a programación asíncrona, hoy toca hablar de una clase muy conocida y usada a partir del .NET Framework 2.0 (aunque existía desde la versión 1). Nos referimos a programación asíncrona con hilos, usados a través de la clase System.Threading.Thread.
Puedes seguir el primer artículo de esta serie aquí: Programación asíncrona 1: Delegados asíncronos.
Creación y ejecución de un hilo
Para crear el hilo, hacemos uso de la clase Thread, del espacio de nombres System.Threading.
En el siguiente ejemplo, crearemos 2 hilos para explicar los primeros conceptos. En cada hilo se imprimirá un enunciado sobre (¡oh, sorpresa!) el universo Star Wars.
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la clase de apoyo 'Soldado' var objSoldado = new Soldado(); // Se declaran los 2 hilos, estableciendo que la función a ejecutar será 'Soldado.ImprimeEnunciado' Thread hilo1 = new Thread(objSoldado.ImprimeEnunciado); Thread hilo2 = new Thread(objSoldado.ImprimeEnunciado); // Se muestra el mensaje, desde el programa principal, que se llamarán a la ejecución de los hilos // En la impresión de mensaje se especifica el identificador del hilo que se asigna internamente Console.WriteLine("ID Hilo: {0} => Se llama a la ejecución de los hilos desde el programa principal", Thread.CurrentThread.ManagedThreadId.ToString()); // Se llaman a ejecución los 2 hilos, y se pasa como parámetro el mensaje un hecho para imprimir hilo1.Start("Primer enunciado : El 'Stormtrooper' es un soldado del Imperio"); hilo2.Start("Segundo enunciado: El 'Clonetrooper' es un soldado de la República"); // Se espera a que termine la ejecución de ambos hilos while ((hilo1.IsAlive) || (hilo2.IsAlive)) { } // Una vez terminados, se muestra el mensaje de finalización Console.WriteLine("ID Hilo: {0} => Finalizó la ejecución de los hilos", Thread.CurrentThread.ManagedThreadId.ToString()); Console.ReadLine(); } } // Clase de apoyo que contiene la función que ejecutarán los hilos public class Soldado { public void ImprimeEnunciado(object texto) { // Se muestra el mensaje de ejecución de la función // En la impresión de mensaje se especifica el identificador del hilo que se asigna internamente Console.WriteLine("ID Hilo: {0} => {1}.", Thread.CurrentThread.ManagedThreadId.ToString(), texto.ToString()); } } }
Si ejecutamos el programa, el resultado será:
Internamente se asigna un identificador a cada uno de los 3 procesos: el del programa principal y de sus 2 hilos creados (subprocesos). Además podemos notar que a pesar que las llamadas a los hilos se ejecutaron en un orden específico (primer enunciado, segundo enunciado), la ejecución no se dio en ese orden (tuve que ejecutar varias veces el programa para conseguir este resultado). Esto sucede porque internamente se pone en una cola de ejecución las llamadas a los hilos, pero es el procesador el que finalmente decide cuál ejecutar primero.
Paso de parámetros
En el primer ejemplo pasamos un parámetro (el texto del enunciado) al hilo a través de una variable tipo objeto (object), que luego convertimos a cadena (string) cuando lo recibimos en el método. Esto sirve para ejemplos simples en los que sólo necesitemos un parámetro, pero si queremos pasar más de uno o queremos una alternativa más elegante, tenemos varias opciones a elegir.
A continuación detallo 3 de estas alternativas. En cada ejemplo desde el programa principal se pasarán 2 parámetros al hilo para imprimirlos. Éstos serán:
- Nombre de planeta (Cadena)
- Número de soles (Entero)
1. Agrupando variables en una estructura
Creamos una estructura de apoyo (struct) para establecer los parámetros:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la primera clase de apoyo para la función: Proceso var objProceso = new Proceso(); // Se instancia una variable con la segunda clase de apoyo para los parámetros: Parametro var objParametro = new Parametro(); // Se declara el hilo a usar Thread hilo1 = new Thread(objProceso.ImprimeConsola); objParametro.NombrePlaneta = "Coruscant"; objParametro.NumeroSoles = 1; // Se ejecuta el hilo hilo1.Start(objParametro); Console.ReadLine(); } } public class Proceso { public void ImprimeConsola(object objParametro) { // Se reciben y convierten los parámetros string NombrePlaneta = ((Parametro)objParametro).NombrePlaneta; int NumeroSoles = ((Parametro)objParametro).NumeroSoles; // Se muestra el mensaje de ejecución de la función Console.WriteLine("Id Hilo: {0} => Nombre de Planeta: {1}, Número de soles: {2}.", Thread.CurrentThread.ManagedThreadId.ToString(), NombrePlaneta, NumeroSoles.ToString()); } } public struct Parametro { public string NombrePlaneta; public int NumeroSoles; } }
Al ejecutar el programa se verá lo siguiente:
2. Usando las variables de la clase de apoyo
Otra manera de pasar parámetros a la ejecución del hilo es usando la misma variable de apoyo para establecer los parámetros a usar. Tendríamos:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la primera clase de apoyo para la función: Proceso var objProceso = new Proceso(); // Se declara el hilo a usar Thread hilo1 = new Thread(objProceso.ImprimeConsola); objProceso.NombrePlaneta = "Tatooine"; objProceso.NumeroSoles = 2; // Se ejecuta el hilo hilo1.Start(); Console.ReadLine(); } } public class Proceso { public string NombrePlaneta { get; set; } public int NumeroSoles { get; set; } public void ImprimeConsola() { // Se muestra el mensaje de ejecución de la función Console.WriteLine("ID Hilo: {0} => Nombre de Planeta: {1}, Número de soles: {2}.", Thread.CurrentThread.ManagedThreadId.ToString(), NombrePlaneta, NumeroSoles.ToString()); } } }
Al ejecutar el programa se verá lo siguiente:
3. Usando un método anónimo como Inicializador
Por último, otra alternativa es usando la clase ThreadStart, que es un inicializador del hilo. A este nuevo objeto le pasamos un método anónimo haciendo uso de la palabra clave delegate. El código quedaría así:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la primera clase de apoyo para la función: Proceso var objProceso = new Proceso(); // Se usa como inicializador un método anónimo ThreadStart _ts = delegate { objProceso.ImprimeConsola("Naboo", 1); }; // Se declara el hilo a usar Thread hilo1 = new Thread(_ts); // Se ejecuta el hilo hilo1.Start(); Console.ReadLine(); } } public class Proceso { public void ImprimeConsola(string NombrePlaneta, int NumeroSoles) { // Se muestra el mensaje de ejecución de la función Console.WriteLine("Id Hilo: {0} => Nombre de Planeta: {1}, Número de soles: {2}.", Thread.CurrentThread.ManagedThreadId.ToString(), NombrePlaneta, NumeroSoles.ToString()); } } }
Al ejecutar el programa se verá lo siguiente:
La ventaja de este método es que es más compacto con respecto a los anteriores, además la comprobación de los tipos de datos se hace en tiempo de compilación a diferencia de usar una estructura de apoyo, en donde se comprueba el tipo de dato en tiempo de ejecución.
Acceso y bloqueo de recursos compartidos
También es importante verificar el funcionamiento de los hilos en el caso de recursos compartidos (variables que tienen referencia común a la misma instancia o variables estáticas). Pongamos el siguiente ejemplo: creamos 2 hilos, y en cada uno de ellos se accede a un mismo recurso para ejecutar una secuencia de tareas.
Para este ejemplo tendremos 2 soldados (Stormtrooper y Clonetrooper) que accederán a un mismo recurso compartido (Vehículo espacial). Dentro del hilo, la secuencia de tareas será:
- Abordar el vehículo espacial
- Conducir el vehículo espacial
- Estacionar el vehículo espacial
El código quedaría así:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { // Variable que contiene el recurso compartido a usar: static string RecursoCompartido = "Vehículo espacial"; static void Main(string[] args) { // Se instancia 2 inicializadores de hilo, con sus respectivos métodos anónimos ThreadStart _ts1 = delegate { EjecutaSecuenciaTareas("Stormtrooper"); }; ThreadStart _ts2 = delegate { EjecutaSecuenciaTareas("Clonetrooper"); }; // Se declara los hilos Thread hilo1 = new Thread(_ts1); Thread hilo2 = new Thread(_ts2); // Se ejecutan los hilos hilo1.Start(); hilo2.Start(); Console.ReadLine(); } static void EjecutaSecuenciaTareas(string NombreSoldado) { // Se muestra las tareas del hilo para el soldado Console.WriteLine("Id Hilo: {0} => El {1} es abordado por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); // Se hace una pequeña pausa para la siguiente tarea System.Threading.Thread.Sleep(1); Console.WriteLine("Id Hilo: {0} => El {1} es conducido por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); // Se hace una pequeña pausa para la siguiente tarea System.Threading.Thread.Sleep(1); Console.WriteLine("Id Hilo: {0} => El {1} es estacionado por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); } } }
Si ejecutamos el programa nos mostraría lo siguiente:
Como vemos, las tareas de ambos soldados sobre el recurso compartido (Vehículo espacial) se superponen, lo cual es incorrecto; ya que no es posible que los 2 soldados aborden, conduzcan y estacionen el mismo vehículo espacial a la vez. Este ejemplo es conceptual, pero en un caso real se pueden hacer lecturas, cálculos y actualizaciones sobre una variable compartida; y si no se tiene conciencia de esto se pueden duplicar o ejecutar tareas sobre variables de forma errónea, generando datos incorrectos.
Para solucionar este problema usaremos la instrucción lock (con ayuda de la variable adicional monitor), para que las tareas que se ejecutan en un hilo no sean interrumpidas por otros subprocesos (hilos). Entonces, el segundo hilo tendrá que esperar a que se termine de ejecutar toda la secuencia de tareas del primer hilo.
El código final quedaría así:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { // Variable que contiene el recurso compartido a usar: static string RecursoCompartido = "Vehículo espacial"; // Variable que servirá para bloquear la ejecución de un hilo static object monitor = new object(); static void Main(string[] args) { // Se instancia 2 inicializadores de hilo, con sus respectivos métodos anónimos ThreadStart _ts1 = delegate { EjecutaSecuenciaTareas("Stormtrooper"); }; ThreadStart _ts2 = delegate { EjecutaSecuenciaTareas("Clonetrooper"); }; // Se declara los hilos Thread hilo1 = new Thread(_ts1); Thread hilo2 = new Thread(_ts2); // Se ejecutan los hilos hilo1.Start(); hilo2.Start(); Console.ReadLine(); } static void EjecutaSecuenciaTareas(string NombreSoldado) { lock (monitor) { // Se muestra las tareas del hilo para el soldado Console.WriteLine("Id Hilo: {0} => El {1} es abordado por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); // Se hace una pequeña pausa para la siguiente tarea System.Threading.Thread.Sleep(1); Console.WriteLine("Id Hilo: {0} => El {1} es conducido por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); // Se hace una pequeña pausa para la siguiente tarea System.Threading.Thread.Sleep(1); Console.WriteLine("Id Hilo: {0} => El {1} es estacionado por el {2}", Thread.CurrentThread.ManagedThreadId.ToString(), RecursoCompartido, NombreSoldado); } } } }
Y su ejecución mostraría lo siguiente:
Ahora sí se aprecia que el segundo hilo se ejecuta una vez que se terminen todas las tareas del primer hilo.
Manejo de excepciones
Cuando se trabaja con hilos hay que tener cuidado con el manejo de excepciones. Podríamos pensar que en el siguiente código se captura correctamente la excepción:
using System; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la primera clase de apoyo para la función: Proceso var objProceso = new Proceso(); // Se declara el hilo a usar Thread hilo1 = new Thread(objProceso.Ejecutar); // Se ejecuta el hilo try { hilo1.Start(); } catch (Exception ex) { Console.WriteLine("Se capturó la excepción: {0}", ex.Message); } Console.ReadLine(); } } public class Proceso { public void Ejecutar() { // Se lanza la excepción del hilo throw new Exception("Excepción de subproceso"); } } }
Cuando ejecutemos el programa obtendremos el siguiente error:
Como vemos, desde el programa principal no se puede controlar una excepción generada en un hilo. En general, cada subproceso (programa principal, hilo) debe tener su propio manejo de excepciones. El código que sí funciona es el siguiente:
using System; using System.Threading; namespace ProgramacionAsincrona.Hilos { class Program { static void Main(string[] args) { // Se instancia una variable con la primera clase de apoyo para la función: Proceso var objProceso = new Proceso(); // Se declara el hilo a usar Thread hilo1 = new Thread(objProceso.Ejecutar); // Se ejecuta el hilo hilo1.Start(); Console.ReadLine(); } } public class Proceso { public void Ejecutar() { // Se lanza la excepción del hilo try { throw new Exception("Excepción de subproceso"); } catch (Exception ex) { Console.WriteLine("Se capturó la excepción: {0}", ex.Message); } } } }
En este caso hemos trasladado la captura de la excepción desde el programa principal hacia el hilo. Esto a veces puede resultar engorroso, mas aún si estamos acostumbrados a capturar todas las excepciones desde el programa principal, pero es algo necesario.
Para el caso de aplicaciones de escritorio Windows Forms, es muy conocida la técnica de centralizar el manejo de excepciones con el evento Application.ThreadException. Pero cuidado, con este evento no se capturarían las excepciones producidas en los hilos. Una técnica con mayor alcance es usar el evento AppDomain.UnhandledException, que se ejecuta cuando existe una excepción no controlada a nivel de dominio de aplicación (se incluyen los hilos claro). Ojo que esta técnica también tiene sus consideraciones. En este artículo puedes profundizar un poco en el tema.
Conclusiones
La clase Thread es muy utilizada actualmente, a pesar que el tema ha evolucionado en las versiones más recientes del Framework (lo veremos en los siguientes artículos), por lo que hay que tener presente estas técnicas para una correcta gestión de esta clase. Además, es importante también un buen diseño del programa para usar la cantidad correcta de hilos, ya que si bien es cierto dividir una tarea pesada en hilos puede mejorar la performance, si se abusa de ellos o no se gestiona la complejidad de usarlos de forma correcta, el resultado puede ser contraproducente.
Saludo especial
Quiero aprovechar el post para agradecerte por estar del otro lado y leer los artículos del año 2014 que se acaba de ir (si fuera el caso; si no, te invito a leerlos). Además, saludarte por este año nuevo y desearte los mejores deseos para este 2015, esperando que se cumpla todo lo que has planeado. Personalmente, dentro de mis objetivos primordiales está aumentar la frecuencia de publicación de artículos, además de tocar algunos temas (nuevos para mí) de las tecnologías .NET más recientes. Para esto ya estoy encaminado en la práctica y preparación de artículos.
¡Gracias, un saludo afectuoso y lo mejor para este 2015!