Fundamentos de Concurrencia y Asincronía en C#

Conceptos Básicos de Hilos (Threads)

Un hilo representa una ruta de ejecución independiente dentro de un proceso del sistema operativo. Mientras que un proceso proporciona un entorno aislado para la aplicación, los hilos permiten la ejecución concurrente compartiendo recursos como la memoria.

  • En aplicaciones monohilo, un único hilo tiene control exclusivo del proceso.
  • En aplicaciones multihilo, varios hilos comparten el entorno de ejecución, lo que permite, por ejemplo, procesar datos en segundo plano mientras se actualiza la interfaz de usuario.

El siguiente ejemplo demuestra la creación de hilos independientes que ejecutan tareas en paralelo:


using System;
using System.Threading;

class EjemploConcurrencia
{
    static void Main()
    {
        Thread hiloSecundario = new Thread(EjecutarTareaB);
        hiloSecundario.Name = "HiloTrabajador";
        hiloSecundario.Start();

        for (int i = 0; i < 500; i++)
        {
            Console.Write("A");
        }
        Console.ReadKey();
    }

    static void EjecutarTareaB()
    {
        for (int i = 0; i < 500; i++)
        {
            Console.Write("B");
        }
    }
}

En sistemas de un solo núcleo, el sistema operativo alterna entre hilos mediante "quantums" de tiempo, simulando concurrencia. En sistemas multinúcleo, la ejecución es verdaderamente paralela.

Propiedades y Control de Hilos

  • IsAlive: Indica si el hilo está en ejecución.
  • Name: Útil para depuración. Solo se puede asignar una vez.
  • Thread.CurrentThread: Obtiene una referencia al hilo que se está ejecutando actualmente.

Sincronización y Bloqueo de Hilos

Los métodos Join y Sleep son fundamentales para controlar el flujo de ejecución.

Espera con Join

El método Join bloquea el hilo actual hasta que el hilo objetivo finalice su ejecución.


using System;
using System.Threading;

class SincronizacionHilos
{
    static void Main()
    {
        Thread trabajador = new Thread(ProcesarDatos);
        trabajador.Start();
        
        // Bloquea el hilo principal hasta que 'trabajador' termine
        trabajador.Join(); 
        Console.WriteLine("\nProcesamiento finalizado.");
    }

    static void ProcesarDatos()
    {
        for (int i = 0; i < 100; i++)
        {
            Console.Write(".");
            Thread.Sleep(10);
        }
    }
}

También es posible especificar un tiempo de espera (timeout). Si el hilo no termina en el tiempo indicado, Join retornará false.

Pausas y Liberación de CPU

Thread.Sleep(0) o Thread.Yield() ceden el turno de ejecución a otros hilos listos para correr, lo cual es útil en escenarios de alto rendimiento para evitar la inanición de otros procesos.

Estados del Hilo y Bloqueo

Un hilo se considera bloqueado cuando su ejecución se pausa (por ejemplo, mediante Sleep o Join). Durante este estado, no consume tiempo de CPU.

La propiedad ThreadState es un enum con atributos [Flags] que permite conocer el estado actual. Los estados más relevantes son Unstarted, Running, WaitSleepJoin y Stopped.

I/O-Bound vs Compute-Bound

  • I/O-Bound: Operaciones que pasan la mayor parte del tiempo esperando eventos externos (lectura de discos, redes, Sleep).
  • Compute-Bound: Operaciones intensivas en CPU que consumen ciclos de procesamiento continuamente.

Seguridad en los Hilos (Thread Safety)

Cada hilo posee su propia pila de memoria para variables locales. Sin embargo, las variables estáticas o las instancias de objetos compartidos entre hilos pueden generar condiciones de carrera.

Para garantizar la seguridad, se utiliza el bloqueo mutuo (lock en C#), asegurando que solo un hilo acceda a la sección crítica a la vez.


private static readonly object _candado = new object();
private static int _contadorCompartido = 0;

public static void Incrementar()
{
    lock (_candado)
    {
        _contadorCompartido++;
    }
}

Hilos en Primer Plano vs Segundo Plano

Por defecto, los hilos creados manualmente son en primer plano (Foreground). La aplicación no terminará hasta que todos los hilos en primer plano finalicen. Los hilos en segundo plano (Background) se terminan abruptamente cuando todos los hilos en primer plano terminan. Esto se controla mediante la propiedad IsBackground.

Manejo de Excepciones en Hilos

Las excepciones no controladas dentro de un hilo secundario no pueden ser capturadas por bloques try-catch del hilo que lo originó. En aplicaciones de escritorio, se deben suscribir eventos globales como AppDomain.CurrentDomain.UnhandledException para evitar cierres inesperados.

Contexto de Sincronización y UI

En aplicaciones de interfaz gráfica (WPF, WinForms, UWP), los controles solo pueden ser actualizados desde el hilo que los creó (hilo UI). Para actualizar la UI desde un hilo trabajador, se debe hacer marshaling del contexto.

  • WPF: Dispatcher.Invoke o BeginInvoke.
  • WinForms: Control.Invoke.

La clase SynchronizationContext abstrae este comportamiento, permitiendo capturar el contexto actual y enviar ejecuciones de vuelta al hilo original mediante Post (asíncrono) o Send (síncrono).

El Pool de Hilos (Thread Pool)

Crear y destruir hilos manualmente tiene un alto costo de rendimiento. El ThreadPool mantiene un conjunto de hilos reutilizables, optimizando la ejecución de tareas cortas y operaciones asíncronas.

Consideraciones al usar el ThreadPool:

  • Los hilos del pool son siempre en segundo plano.
  • No se debe bloquear un hilo del pool innecesariamente, ya que reduce el rendimiento global.
  • El CLR ajusta dinámicamente el número de hilos para evitar la sobre-suscripción de CPU.

Introducción a Task Parallel Libray (TPL)

La clase Task representa una operación asíncrona y ofrece una abstracción de mayor nivel que Thread. Permite encadenar continuaciones, propagar valores de retorno y manejar excepciones de forma nativa.

Iniciando Tareas

La forma más común de iniciar una tarea es mediante Task.Run, que delega la ejecución al ThreadPool.


using System;
using System.Threading.Tasks;

class EjemploTask
{
    static async Task Main()
    {
        Task<int> tareaCalculo = Task.Run(() => CalcularSuma(1000000));
        
        Console.WriteLine("Calculando en segundo plano...");
        
        int resultado = await tareaCalculo;
        Console.WriteLine($"El resultado es: {resultado}");
    }

    static int CalcularSuma(int limite)
    {
        int suma = 0;
        for (int i = 0; i <= limite; i++) suma += i;
        return suma;
    }
}

Tareas con Retorno de Valor

Para tareas que devuelven un resultado, se utiliza Task<TResult>. Al acceder a la propiedad Result o hacer await, el hilo se bloquea (o se suspende en el caso de await) hasta que la tarea finalice.

Manejo de Excepciones en Tasks

Si una tarea lanza una excepción, esta se envuelve en un AggregateException cuando se llama a Wait() o se accede a Result. Si se usa await, la excepción se propaga directamente sin el envoltorio.

Las tareas "fire-and-forget" (autónomas) que fallan sin ser observadas pueden generar UnobservedTaskException, por lo que es crucial manejar sus errores.

Continuaciones y Awaiters

Las continuaciones permiten especificar qué hacer cuando una tarea termina, sin bloquear el hilo actual.


Task.Run(() => ObtenerDatosAsync())
    .ContinueWith(t => 
    {
        if (t.IsFaulted) {
            Console.WriteLine("Error: " + t.Exception.InnerException);
        } else {
            Console.WriteLine("Datos obtenidos.");
        }
    });

El patrón await simplifica enormemente este proceso, utilizando el SynchronizationContext para volver al hilo de la UI automáticamente, a menos que se especifique ConfigureAwait(false).

TaskCompletionSource

Cuando se necesita envolver una operación basada en eventos o callbacks en un Task, se utiliza TaskCompletionSource<T>. Esto permite crear tareas que se completan, fallan o cancelan manualmente, integrando perfectamente operaciones asíncronas antiguas con el modelo moderno.


using System;
using System.Threading;
using System.Threading.Tasks;

public Task<string> SimularOperacionAsincronaAsync()
{
    var tcs = new TaskCompletionSource<string>();
    
    Timer timer = new Timer(_ => 
    {
        tcs.SetResult("Operación completada");
    }, null, 1000, Timeout.Infinite);
    
    return tcs.Task;
}

Etiquetas: CSharp async-await task-parallel-library Multithreading thread-pool

Publicado el 9-3 09:18