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
- Asincronía frente a paralelismo: dos problemas distintos
Thread: el hilo básico de .NETTask.Run: paralelismo con la API de tareasParallel.ForyParallel.ForEach: paralelizar un bucle- Condiciones de carrera: el problema de compartir estado entre hilos
lock: proteger una sección crítica- Colecciones concurrentes: mención a
ConcurrentDictionary - Ejemplo: validar en paralelo el catálogo de BiblioTech
- Cierre del Módulo 6 y enlace con el Módulo 7
- 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.
Thread: el hilo básico de .NET
Thread: el hilo básico de .NETUn 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.
Task.Run: paralelismo con la API de tareas
Task.Run: paralelismo con la API de tareasTask.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.
Parallel.For y Parallel.ForEach: paralelizar un bucle
Parallel.For y Parallel.ForEach: paralelizar un bucleCuando 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) |
- 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.
lock: proteger una sección crítica
lock: proteger una sección críticalock (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 |
- Colecciones concurrentes: mención a
ConcurrentDictionary
ConcurrentDictionaryComo 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.
- 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"]
- 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. UsarTask.Runpara "hacer asíncrona" una operación que ya tiene una versiónasyncnativa (comoHttpClient.GetAsync) desperdicia un hilo del thread pool sin necesidad. - Modificar una colección compartida desde
Parallel.ForEachsin protección:List<T>.Addy 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
lockmás código del necesario: incluir el trabajo de CPU (VerificarChecksumIsbn) dentro dellock, 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 otrolocksobre 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 unforeachsecuencial simple.
Ejercicios
-
Escribe un método
long SumarSecuencial(int cantidad)que sume los números de0acantidad - 1en un buclefornormal, y compáralo (conStopwatch, ya usado en el curso para medir tiempos) frente a repartir la suma conParallel.Forprotegiendo el acumulador compartido conlock. Comenta, en un comentario, qué versión esperarías que fuera más rápida y por qué. -
Retoma
ValidarCatalogoEnParalelode esta lección, pero sustituye laList<ResultadoValidacion>protegida conlockpor unConcurrentBag<ResultadoValidacion>(deSystem.Collections.Concurrent), sin necesidad de ningúnlockexplícito. -
Provoca deliberadamente una condición de carrera: usa
Parallel.ForEachsobre una lista de 1000 números para incrementar una variable compartidaint contadorconcontador++(sinlockniInterlocked), y ejecuta el programa varias veces comprobando que el resultado final no siempre es1000. Después, corrígelo conlock.
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#
- Introducción a C#
- Configuración del Entorno de Desarrollo
- Programa Hola Mundo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Arrays y Cadenas de Texto
Módulo 2: Estructuras de Control
Módulo 3: Programación Orientada a Objetos
- Clases y Objetos
- Métodos
- Constructores y Destructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- Structs y Records: Tipos por Valor y por Referencia
Módulo 4: Conceptos Avanzados de C#
- Interfaces
- Delegados y Eventos
- Pattern Matching y Características Modernas de C#
- Genéricos
- Colecciones
- LINQ (Consulta Integrada en el Lenguaje)
- Programación Asíncrona
Módulo 5: Trabajando con Datos
- Entrada/Salida de Archivos
- Serialización
- Conectividad con Bases de Datos
- Entity Framework
- Trabajo con JSON y Consumo de APIs REST
Módulo 6: Temas Avanzados
- Reflexión
- Atributos
- Programación Dinámica
- Gestión de Memoria y Recolección de Basura
- Multihilo y Programación Paralela
Módulo 7: Construcción de Aplicaciones
- Formularios de Windows
- WPF (Windows Presentation Foundation)
- ASP.NET Core
- Blazor
- Xamarin y .NET MAUI
Módulo 8: Mejores Prácticas y Patrones de Diseño
- Estándares de Codificación y Mejores Prácticas
- Patrones de Diseño
- Inyección de Dependencias e Inversión de Control
- Pruebas Unitarias
- Revisión y Refactorización de Código
