La lección de Programación Asíncrona (Módulo 4) presentó async/await para esperar operaciones de entrada/salida —una consulta HTTP, una lectura de fichero— sin bloquear el hilo mientras se espera. Esto resuelve un problema muy concreto (esperar), pero no es lo mismo que paralelismo real: varios hilos de CPU ejecutando trabajo genuinamente a la vez, en núcleos distintos del procesador. Esta lección, la última del Módulo 6, distingue ambas ideas, presenta Thread, Task.Run y la librería Parallel para trabajo intensivo en CPU, y expone el problema central de compartir datos entre varios hilos: las condiciones de carrera, resueltas con lock. Cierra así los Temas Avanzados antes de que el Módulo 7 conecte, por fin, toda la lógica de BiblioTech a una interfaz real.

Contenido

  1. Asincronía frente a paralelismo: dos problemas distintos
  2. Thread: el hilo básico de .NET
  3. Task.Run: paralelismo con la API de tareas
  4. Parallel.For y Parallel.ForEach: paralelizar un bucle
  5. Condiciones de carrera: el problema de compartir estado entre hilos
  6. lock: proteger una sección crítica
  7. Colecciones concurrentes: mención a ConcurrentDictionary
  8. Ejemplo: validar en paralelo el catálogo de BiblioTech
  9. Cierre del Módulo 6 y enlace con el Módulo 7

  1. Asincronía frente a paralelismo: dos problemas distintos

Es habitual confundir async/await con "hacer varias cosas a la vez", pero resuelven problemas distintos, aunque relacionados:

Asincronía (async/await, Módulo 4) Paralelismo (esta lección)
Problema que resuelve Esperar una operación de E/S sin bloquear el hilo mientras se espera Ejecutar trabajo de CPU genuinamente a la vez, en varios núcleos
Qué ocurre mientras se espera El hilo queda libre para hacer otro trabajo; no hay ningún hilo "ocupado esperando" Cada hilo paralelo consume activamente un núcleo de CPU
Ejemplo ya visto en el curso await ClienteHttp.GetAsync(...) (Módulo 5): esperar la red Parallel.ForEach sobre miles de Libro (apartado 8): calcular, no esperar
Número de hilos del sistema operativo usados Normalmente uno (o muy pocos), reutilizados eficientemente Varios, potencialmente tantos como núcleos de CPU disponibles

PrestarLibroAsync (Módulo 4) usa await Task.Delay(...) para simular una espera —ningún núcleo de CPU está "trabajando" durante esa espera, solo aguardando—; el ejemplo del apartado 8 de esta lección, en cambio, reparte cálculo real (verificar checksums de ISBN sobre miles de libros) entre varios núcleos de CPU trabajando simultáneamente. Ambas técnicas pueden combinarse en una aplicación real, pero conviene tener claro cuál resuelve cada problema antes de elegir una u otra.

  1. Thread: el hilo básico de .NET

Un Thread (de System.Threading) representa un hilo de ejecución del sistema operativo, gestionado directamente:

using System.Threading;

Thread hilo = new Thread(() =>
{
    Console.WriteLine($"Trabajando en el hilo {Thread.CurrentThread.ManagedThreadId}");
});

hilo.Start();
hilo.Join(); // espera a que el hilo termine antes de continuar

Console.WriteLine("Hilo terminado.");

hilo.Start() lanza el hilo, que se ejecuta de forma independiente al hilo que lo creó; hilo.Join() bloquea el hilo actual hasta que hilo termine —útil cuando el resto del programa necesita esperar ese resultado antes de continuar. Thread es la API de más bajo nivel para trabajar con hilos en .NET; en la práctica, el código de aplicación moderno rara vez crea Thread directamente, prefiriendo las abstracciones de más alto nivel de los siguientes apartados (Task.Run, Parallel), que gestionan por debajo un grupo de hilos compartido (thread pool) de forma mucho más eficiente que crear un Thread nuevo para cada tarea.

  1. Task.Run: paralelismo con la API de tareas

Task.Run (del mismo Task ya conocido desde async/await, Módulo 4) programa una pieza de trabajo para ejecutarse en un hilo del thread pool, sin necesidad de gestionar un Thread manualmente:

using System.Threading.Tasks;

Task<int> tarea = Task.Run(() =>
{
    // trabajo intensivo en CPU, no una espera de E/S
    int resultado = 0;
    for (int i = 0; i < 100_000_000; i++)
    {
        resultado += i % 7;
    }
    return resultado;
});

int valor = await tarea; // espera el resultado sin bloquear el hilo que hace el await
Console.WriteLine(valor);

Task.Run devuelve un Task<T>, el mismo tipo que ya conoces de async/await; la diferencia de intención es importante: await Task.Delay(...) (Módulo 4) libera el hilo mientras se espera algo externo (no hay trabajo de CPU que hacer), mientras que Task.Run(...) reserva activamente un hilo del thread pool para ejecutar cálculo real. Usar Task.Run para "envolver" una espera de E/S (como una llamada a HttpClient) sería un error de diseño: ya existen versiones asíncronas nativas (GetAsync, GetFromJsonAsync) que no consumen un hilo completo solo para esperar.

  1. Parallel.For y Parallel.ForEach: paralelizar un bucle

Cuando el trabajo consiste en repetir la misma operación, independiente entre sí, sobre muchos elementos de una colección, Parallel.ForEach (y su equivalente Parallel.For para rangos numéricos) reparte automáticamente las iteraciones entre varios hilos del thread pool, sin que el programador tenga que crear ni coordinar los hilos manualmente:

using System.Threading.Tasks;

List<Libro> catalogo = ObtenerCatalogoCompleto(); // miles de Libro

Parallel.ForEach(catalogo, libro =>
{
    bool esValido = VerificarChecksumIsbn(libro.Isbn); // trabajo de CPU por cada libro
    Console.WriteLine($"{libro.Isbn}: {(esValido ? "valido" : "invalido")}");
});

Parallel.ForEach decide internamente cuántos hilos usar (normalmente, aproximadamente uno por núcleo de CPU disponible) y en qué orden repartir los elementos —el orden de ejecución no está garantizado, a diferencia de un foreach normal—. Es la herramienta correcta cuando cada iteración es independiente de las demás (no depende del resultado de la iteración anterior); si las iteraciones dependen entre sí, paralelizarlas con Parallel.ForEach produciría resultados incorrectos o inconsistentes.

foreach (secuencial) Parallel.ForEach
Orden de ejecución Garantizado, uno tras otro No garantizado
Núcleos de CPU usados Uno Varios, en paralelo
Cuándo conviene Pocas iteraciones, o iteraciones dependientes entre sí Muchas iteraciones independientes, con trabajo de CPU significativo cada una
Riesgo si se comparte estado mutable Ninguno (una sola ejecución a la vez) Condiciones de carrera si no se protege (apartado 5)

  1. Condiciones de carrera: el problema de compartir estado entre hilos

Una condición de carrera (race condition) ocurre cuando varios hilos leen y escriben la misma variable compartida al mismo tiempo, sin coordinación, y el resultado final depende del orden —no determinista— en que el sistema operativo intercala sus operaciones:

List<string> resultadosCompartidos = new List<string>();

Parallel.ForEach(catalogo, libro =>
{
    bool esValido = VerificarChecksumIsbn(libro.Isbn);
    resultadosCompartidos.Add($"{libro.Isbn}: {esValido}"); // PELIGRO: List<T> no es segura para varios hilos a la vez
});

List<T>.Add(...) no está diseñado para que varios hilos lo invoquen simultáneamente: internamente gestiona un array y un contador de elementos, y si dos hilos ejecutan Add exactamente al mismo tiempo, pueden pisarse el uno al otro —perdiendo un elemento, corrompiendo el array interno, o lanzando una excepción en tiempo de ejecución de forma intermitente y difícil de reproducir—. Esta clase de error es especialmente traicionera porque no siempre se manifiesta: puede funcionar correctamente en la mayoría de ejecuciones y fallar solo ocasionalmente, cuando la sincronización exacta del sistema operativo hace coincidir dos hilos en el momento justo.

  1. lock: proteger una sección crítica

lock (de C#, sobre un objeto usado como "candado") garantiza que solo un hilo a la vez puede ejecutar el bloque de código protegido, obligando al resto a esperar su turno:

List<string> resultadosCompartidos = new List<string>();
object candado = new object();

Parallel.ForEach(catalogo, libro =>
{
    bool esValido = VerificarChecksumIsbn(libro.Isbn); // trabajo de CPU, fuera del lock: no necesita proteccion

    lock (candado)
    {
        resultadosCompartidos.Add($"{libro.Isbn}: {esValido}"); // seccion critica: un hilo a la vez
    }
});

El objeto candado (una instancia cualquiera, dedicada únicamente a este propósito, sin ninguna otra función) actúa como "testigo": mientras un hilo está dentro del bloque lock (candado), cualquier otro hilo que intente entrar en un bloque lock (candado) —el mismo objeto candado— queda bloqueado, esperando su turno, hasta que el primero termine. Solo hace falta proteger con lock la parte que modifica estado compartido (resultadosCompartidos.Add); el cálculo (VerificarChecksumIsbn) puede seguir ejecutándose en paralelo sin restricción, ya que no toca ningún dato compartido —proteger más código del necesario dentro del lock desperdicia el paralelismo, obligando a los hilos a esperarse entre sí sin motivo real.

Consejo sobre lock Por qué
Usar un objeto dedicado exclusivamente a ser candado (private readonly object _candado = new object();) Evita bloqueos inesperados si otro código externo también hiciera lock sobre el mismo objeto por error
Proteger solo la sección que de verdad modifica estado compartido Maximiza el paralelismo real; un lock demasiado amplio anula buena parte del beneficio de paralelizar
Evitar trabajo lento (E/S, esperas) dentro de un lock Mientras un hilo espera dentro del lock, todos los demás quedan bloqueados sin motivo

  1. Colecciones concurrentes: mención a ConcurrentDictionary

Como alternativa a proteger manualmente una colección normal con lock, System.Collections.Concurrent ofrece colecciones ya preparadas internamente para que varios hilos las usen a la vez sin condiciones de carrera, como ConcurrentDictionary<TKey, TValue> o ConcurrentBag<T>:

using System.Collections.Concurrent;

ConcurrentDictionary<string, bool> resultadosConcurrentes = new ConcurrentDictionary<string, bool>();

Parallel.ForEach(catalogo, libro =>
{
    bool esValido = VerificarChecksumIsbn(libro.Isbn);
    resultadosConcurrentes[libro.Isbn] = esValido; // seguro sin lock explicito: ya gestionado internamente
});

ConcurrentDictionary gestiona su propia sincronización interna, de forma más eficiente que un Dictionary<TKey, TValue> normal protegido con lock (permite, en muchos casos, que varios hilos operen sobre partes distintas de la colección simultáneamente en vez de bloquearse todos entre sí). Es una alternativa a tener presente cuando el estado compartido es, precisamente, una colección; para el resto de casos (una variable simple, una lista con lógica de acumulación más compleja como en el apartado 8), lock sigue siendo la herramienta más directa y explícita.

  1. Ejemplo: validar en paralelo el catálogo de BiblioTech

Uniendo todo lo anterior, se puede paralelizar una operación de validación costosa sobre todo el catálogo de Biblioteca —verificar que el ISBN de cada Libro tiene un formato y checksum válidos—, acumulando los resultados de forma segura en una lista compartida:

using System.Threading.Tasks;

static bool VerificarChecksumIsbn(string isbn)
{
    // Simplificacion con fines didacticos: suma los digitos del ISBN (ignorando guiones)
    // y comprueba que el resultado sea multiplo de 10; una verificacion real de ISBN-13
    // sigue un algoritmo de pesos alternos (1 y 3) fuera del alcance de esta leccion.
    int suma = 0;
    foreach (char caracter in isbn)
    {
        if (char.IsDigit(caracter))
        {
            suma += caracter - '0';
        }
    }
    return suma % 10 == 0;
}
class ResultadoValidacion
{
    public string Isbn { get; }
    public bool EsValido { get; }

    public ResultadoValidacion(string isbn, bool esValido)
    {
        Isbn = isbn;
        EsValido = esValido;
    }
}
static List<ResultadoValidacion> ValidarCatalogoEnParalelo(Biblioteca biblioteca)
{
    List<ResultadoValidacion> resultados = new List<ResultadoValidacion>();
    object candado = new object();

    List<Libro> libros = biblioteca.Catalogo.OfType<Libro>().ToList(); // OfType, de LINQ (Modulo 4)

    Parallel.ForEach(libros, libro =>
    {
        bool esValido = VerificarChecksumIsbn(libro.Isbn); // trabajo de CPU, fuera del lock

        lock (candado)
        {
            resultados.Add(new ResultadoValidacion(libro.Isbn, esValido)); // seccion critica
        }
    });

    return resultados;
}
List<ResultadoValidacion> resultados = ValidarCatalogoEnParalelo(biblioteca);

foreach (ResultadoValidacion resultado in resultados)
{
    string estado = resultado.EsValido ? "valido" : "INVALIDO";
    Console.WriteLine($"{resultado.Isbn}: {estado}");
}

ValidarCatalogoEnParalelo reparte VerificarChecksumIsbn —el trabajo de CPU, independiente para cada Libro— entre varios hilos con Parallel.ForEach, y protege únicamente el momento de añadir cada resultado a la lista compartida resultados con lock (candado). Con un catálogo de miles de libros y un cálculo de verificación más costoso que esta simplificación didáctica, este reparto entre núcleos de CPU puede reducir notablemente el tiempo total frente a validar el catálogo de uno en uno con un foreach secuencial.

flowchart TD
    A["ValidarCatalogoEnParalelo(biblioteca)"] --> B["Parallel.ForEach sobre cada Libro"]
    B --> C1["Hilo 1: VerificarChecksumIsbn"]
    B --> C2["Hilo 2: VerificarChecksumIsbn"]
    B --> C3["Hilo N: VerificarChecksumIsbn"]
    C1 --> D["lock (candado): resultados.Add(...)"]
    C2 --> D
    C3 --> D
    D --> E["List de ResultadoValidacion completa"]

  1. Cierre del Módulo 6 y enlace con el Módulo 7

Con esta lección se cierra el Módulo 6 (Temas Avanzados): reflexión, atributos, dynamic, gestión de memoria y multihilo son herramientas de infraestructura y rendimiento que actúan por debajo de la lógica de negocio de BiblioTech, sin cambiar lo que Libro, Socio o Prestamo representan como dominio. El Módulo 7 (Construcción de Aplicaciones) da el siguiente paso: conectar, por fin, todo ese dominio —incluida la validación en paralelo de este apartado, o las operaciones asíncronas del Módulo 4 y 5— a una interfaz real, ya sea de escritorio (Windows Forms, WPF), web (ASP.NET Core, Blazor) o móvil (Xamarin, .NET MAUI). Ahí, patrones como Parallel.ForEach deberán combinarse con cuidado con las restricciones propias de cada tipo de interfaz (por ejemplo, actualizar un control visual únicamente desde su hilo de interfaz, nunca directamente desde un hilo paralelo), un matiz que se retomará en su momento.

Errores Comunes y Consejos

  • Confundir asincronía con paralelismo: async/await (Módulo 4) libera un hilo mientras se espera E/S; no reparte trabajo entre varios núcleos de CPU. Usar Task.Run para "hacer asíncrona" una operación que ya tiene una versión async nativa (como HttpClient.GetAsync) desperdicia un hilo del thread pool sin necesidad.
  • Modificar una colección compartida desde Parallel.ForEach sin protección: List<T>.Add y estructuras similares no son seguras para llamadas concurrentes desde varios hilos; el resultado es, en el mejor caso, una excepción intermitente y, en el peor, datos corrompidos en silencio.
  • Proteger con lock más código del necesario: incluir el trabajo de CPU (VerificarChecksumIsbn) dentro del lock, en vez de solo la escritura en la lista compartida, serializa de hecho toda la operación y anula buena parte del beneficio de paralelizar.
  • Usar objetos distintos como candado en distintas partes del código que protegen el mismo dato: lock (candado) solo bloquea frente a otro lock sobre el mismo objeto; si dos bloques de código usan candados distintos para proteger la misma lista, no se protegen entre sí en absoluto.
  • Consejo: antes de paralelizar un bucle con Parallel.ForEach, comprueba que el trabajo de cada iteración es realmente costoso en CPU y verdaderamente independiente del resto; para colecciones pequeñas o trabajo trivial, la sobrecarga de coordinar varios hilos puede hacer que la versión paralela sea, de hecho, más lenta que un foreach secuencial simple.

Ejercicios

  1. Escribe un método long SumarSecuencial(int cantidad) que sume los números de 0 a cantidad - 1 en un bucle for normal, y compáralo (con Stopwatch, ya usado en el curso para medir tiempos) frente a repartir la suma con Parallel.For protegiendo el acumulador compartido con lock. Comenta, en un comentario, qué versión esperarías que fuera más rápida y por qué.

  2. Retoma ValidarCatalogoEnParalelo de esta lección, pero sustituye la List<ResultadoValidacion> protegida con lock por un ConcurrentBag<ResultadoValidacion> (de System.Collections.Concurrent), sin necesidad de ningún lock explícito.

  3. Provoca deliberadamente una condición de carrera: usa Parallel.ForEach sobre una lista de 1000 números para incrementar una variable compartida int contador con contador++ (sin lock ni Interlocked), y ejecuta el programa varias veces comprobando que el resultado final no siempre es 1000. Después, corrígelo con lock.

Soluciones

using System.Diagnostics;

static long SumarSecuencial(int cantidad)
{
    long suma = 0;
    for (int i = 0; i < cantidad; i++)
    {
        suma += i;
    }
    return suma;
}

static long SumarParalelo(int cantidad)
{
    long suma = 0;
    object candado = new object();

    Parallel.For(0, cantidad, i =>
    {
        lock (candado)
        {
            suma += i;
        }
    });

    return suma;
}

Stopwatch cronometro = Stopwatch.StartNew();
long resultadoSecuencial = SumarSecuencial(50_000_000);
Console.WriteLine($"Secuencial: {cronometro.ElapsedMilliseconds} ms");

cronometro.Restart();
long resultadoParalelo = SumarParalelo(50_000_000);
Console.WriteLine($"Paralelo con lock: {cronometro.ElapsedMilliseconds} ms");

// En este caso concreto, la version paralela probablemente NO es mas rapida: el trabajo
// dentro de cada iteracion (una simple suma) es demasiado pequeño comparado con el coste
// de sincronizar el lock en cada iteracion; el lock convierte de hecho la suma en secuencial.
using System.Collections.Concurrent;

static ConcurrentBag<ResultadoValidacion> ValidarCatalogoEnParaleloConcurrentBag(Biblioteca biblioteca)
{
    ConcurrentBag<ResultadoValidacion> resultados = new ConcurrentBag<ResultadoValidacion>();
    List<Libro> libros = biblioteca.Catalogo.OfType<Libro>().ToList();

    Parallel.ForEach(libros, libro =>
    {
        bool esValido = VerificarChecksumIsbn(libro.Isbn);
        resultados.Add(new ResultadoValidacion(libro.Isbn, esValido)); // seguro sin lock explicito
    });

    return resultados;
}
List<int> numeros = Enumerable.Range(0, 1000).ToList(); // Enumerable.Range, de LINQ (Modulo 4)
int contador = 0;

Parallel.ForEach(numeros, _ =>
{
    contador++; // SIN proteccion: condicion de carrera
});

Console.WriteLine(contador); // con frecuencia, distinto de 1000 en distintas ejecuciones

// Correccion con lock:
int contadorCorregido = 0;
object candado = new object();

Parallel.ForEach(numeros, _ =>
{
    lock (candado)
    {
        contadorCorregido++;
    }
});

Console.WriteLine(contadorCorregido); // siempre 1000

Conclusión

En esta lección has distinguido asincronía (esperar E/S sin bloquear, Módulo 4) de paralelismo real (varios hilos de CPU trabajando a la vez), y usado Thread, Task.Run y Parallel.For/Parallel.ForEach para repartir trabajo de CPU entre varios núcleos. También has visto el problema de las condiciones de carrera al compartir estado mutable entre hilos, cómo lock protege una sección crítica sin sacrificar el paralelismo del resto del trabajo, y la alternativa de las colecciones concurrentes como ConcurrentDictionary. El ejemplo de validación paralela del catálogo de BiblioTech reúne todas estas piezas sobre datos reales del dominio del curso.

Con esto se cierra el Módulo 6 (Temas Avanzados) al completo: reflexión, atributos, dynamic, gestión de memoria y multihilo. El Módulo 7 (Construcción de Aplicaciones) retoma ahora todo lo construido en los seis módulos anteriores —el dominio de BiblioTech con MaterialBibliotecario, Libro, Revista, Socio, Prestamo y Biblioteca, su persistencia en texto, JSON, SQLite y Entity Framework, su comunicación con servicios externos, y las técnicas avanzadas de este módulo— y lo conecta, por primera vez en el curso, a una interfaz de usuario real: aplicaciones de escritorio con Windows Forms y WPF, aplicaciones web con ASP.NET Core y Blazor, y aplicaciones móviles con Xamarin y .NET MAUI.

Curso de Programación en C#

Módulo 1: Introducción a C#

Módulo 2: Estructuras de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Conceptos Avanzados de C#

Módulo 5: Trabajando con Datos

Módulo 6: Temas Avanzados

Módulo 7: Construcción de Aplicaciones

Módulo 8: Mejores Prácticas y Patrones de Diseño

Módulo 9: Proyecto Final

© Copyright 2026. Todos los derechos reservados