Programación asíncrona con async/await
La programación asíncrona es una de las características más importantes y poderosas de C# moderno. Nos permite escribir aplicaciones que pueden realizar múltiples operaciones simultáneamente sin bloquear el hilo principal de ejecución, mejorando significativamente la capacidad de respuesta y el rendimiento de nuestras aplicaciones. Las palabras clave async
y await
introducidas en C# 5.0 han revolucionado la forma en que manejamos operaciones que consumen tiempo, como acceso a archivos, llamadas a servicios web o consultas a bases de datos.
Entender la programación asíncrona es fundamental para desarrollar aplicaciones modernas eficientes, especialmente en el contexto de interfaces de usuario responsivas y servicios web escalables. En este artículo exploraremos desde los conceptos fundamentales hasta las mejores prácticas para implementar código asíncrono robusto y eficiente.
Conceptos fundamentales de la programación asíncrona
Diferencia entre síncrono y asíncrono
Aspecto | Programación Síncrona | Programación Asíncrona |
---|---|---|
Ejecución | Secuencial, una operación a la vez | Múltiples operaciones pueden ejecutarse concurrentemente |
Bloqueo | El hilo se bloquea hasta completar la operación | El hilo puede continuar con otras tareas |
Uso de recursos | Menos eficiente para operaciones I/O | Más eficiente, mejor utilización de recursos |
Complejidad | Más simple de entender y depurar | Más compleja, requiere manejo de estados |
El modelo asíncrono en .NET
En .NET, la programación asíncrona se basa en el concepto de Task (tarea), que representa una operación que puede completarse en el futuro. Las tareas pueden devolver un valor (Task<T>
) o simplemente indicar finalización (Task
).
Tipo | Descripción | Uso |
---|---|---|
Task |
Representa una operación asíncrona sin valor de retorno | Para operaciones que no devuelven datos |
Task<T> |
Representa una operación asíncrona que devuelve un valor de tipo T | Para operaciones que devuelven datos |
ValueTask<T> |
Versión optimizada de Task para casos específicos | Para mejorar rendimiento en escenarios particulares |
Introducción a async y await
Las palabras clave async
y await
proporcionan una sintaxis natural para trabajar con código asíncrono, permitiendo escribir código que se lee de forma secuencial pero se ejecuta de manera asíncrona.
Sintaxis básica
using System;
using System.Threading.Tasks;
class Program
{
// Método asíncrono que no devuelve valor
static async Task MostrarMensajeAsync()
{
await Task.Delay(1000); // Simula una operación que tarda 1 segundo
Console.WriteLine("¡Mensaje mostrado después de 1 segundo!");
}
// Método asíncrono que devuelve un valor
static async Task<string> ObtenerSaludoAsync(string nombre)
{
await Task.Delay(500); // Simula una operación que tarda 0.5 segundos
return $"¡Hola, {nombre}! Saludo generado asincrónicamente.";
}
// Método asíncrono que calcula un resultado
static async Task<int> CalcularSumaAsync(int a, int b)
{
Console.WriteLine("Iniciando cálculo...");
await Task.Delay(2000); // Simula un cálculo complejo
int resultado = a + b;
Console.WriteLine("Cálculo completado.");
return resultado;
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Ejemplo básico de async/await ===");
// Llamada a método async sin valor de retorno
await MostrarMensajeAsync();
// Llamada a método async con valor de retorno
string saludo = await ObtenerSaludoAsync("Carlos");
Console.WriteLine(saludo);
// Llamada a método async con cálculo
int suma = await CalcularSumaAsync(15, 27);
Console.WriteLine($"La suma es: {suma}");
Console.WriteLine("Programa finalizado.");
}
}
Operaciones asíncronas concurrentes
Una de las ventajas principales de la programación asíncrona es la capacidad de ejecutar múltiples operaciones concurrentemente:
using System;
using System.Threading.Tasks;
using System.Diagnostics;
class Program
{
static async Task<string> DescargarDatosAsync(string fuente, int tiempoMs)
{
Console.WriteLine($"Iniciando descarga desde {fuente}...");
await Task.Delay(tiempoMs); // Simula tiempo de descarga
Console.WriteLine($"Descarga desde {fuente} completada.");
return $"Datos de {fuente}";
}
static async Task<int> ProcesarNumeroAsync(int numero, int tiempoMs)
{
Console.WriteLine($"Procesando número {numero}...");
await Task.Delay(tiempoMs);
int resultado = numero * numero;
Console.WriteLine($"Número {numero} procesado. Resultado: {resultado}");
return resultado;
}
static async Task Main(string[] args)
{
Stopwatch cronometro = Stopwatch.StartNew();
Console.WriteLine("=== Ejecución SECUENCIAL ===");
// Ejecución secuencial - una operación después de otra
string datos1 = await DescargarDatosAsync("Servidor A", 1000);
string datos2 = await DescargarDatosAsync("Servidor B", 1500);
string datos3 = await DescargarDatosAsync("Servidor C", 800);
cronometro.Stop();
Console.WriteLine($"Tiempo secuencial: {cronometro.ElapsedMilliseconds}ms");
Console.WriteLine($"Datos obtenidos: {datos1}, {datos2}, {datos3}");
cronometro.Restart();
Console.WriteLine("\n=== Ejecución CONCURRENTE ===");
// Ejecución concurrente - todas las operaciones inician simultáneamente
Task<string> tarea1 = DescargarDatosAsync("Servidor A", 1000);
Task<string> tarea2 = DescargarDatosAsync("Servidor B", 1500);
Task<string> tarea3 = DescargarDatosAsync("Servidor C", 800);
// Esperar a que todas las tareas se completen
string[] resultados = await Task.WhenAll(tarea1, tarea2, tarea3);
cronometro.Stop();
Console.WriteLine($"Tiempo concurrente: {cronometro.ElapsedMilliseconds}ms");
Console.WriteLine($"Datos obtenidos: {string.Join(", ", resultados)}");
Console.WriteLine("\n=== Procesamiento concurrente de números ===");
cronometro.Restart();
// Crear múltiples tareas de procesamiento
Task<int>[] tareasProcesamiento = new Task<int>[]
{
ProcesarNumeroAsync(5, 800),
ProcesarNumeroAsync(10, 600),
ProcesarNumeroAsync(15, 1000),
ProcesarNumeroAsync(20, 400)
};
int[] resultadosProcesamiento = await Task.WhenAll(tareasProcesamiento);
cronometro.Stop();
Console.WriteLine($"Tiempo de procesamiento concurrente: {cronometro.ElapsedMilliseconds}ms");
Console.WriteLine($"Resultados: [{string.Join(", ", resultadosProcesamiento)}]");
Console.WriteLine($"Suma total: {Array.Sum(resultadosProcesamiento)}");
}
}
Métodos Task.WhenAll y Task.WhenAny
Estos métodos nos permiten trabajar con múltiples tareas de manera eficiente:
using System;
using System.Threading.Tasks;
using System.Linq;
class Program
{
static async Task<string> ConsultarServicioAsync(string nombreServicio, int tiempoRespuesta, bool exito = true)
{
Console.WriteLine($"Consultando {nombreServicio}...");
await Task.Delay(tiempoRespuesta);
if (!exito)
{
throw new Exception($"Error en {nombreServicio}");
}
string resultado = $"Respuesta de {nombreServicio} (tardó {tiempoRespuesta}ms)";
Console.WriteLine(resultado);
return resultado;
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Task.WhenAll - Esperar todas las tareas ===");
try
{
// Crear múltiples tareas
Task<string>[] tareas = new Task<string>[]
{
ConsultarServicioAsync("Servicio de Usuarios", 1000),
ConsultarServicioAsync("Servicio de Productos", 1500),
ConsultarServicioAsync("Servicio de Pedidos", 800),
ConsultarServicioAsync("Servicio de Pagos", 1200)
};
// Esperar a que TODAS se completen
string[] resultados = await Task.WhenAll(tareas);
Console.WriteLine("\nTodas las consultas completadas:");
for (int i = 0; i < resultados.Length; i++)
{
Console.WriteLine($" {i + 1}. {resultados[i]}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error en Task.WhenAll: {ex.Message}");
}
Console.WriteLine("\n=== Task.WhenAny - Esperar la primera tarea ===");
// Crear tareas con diferentes tiempos de respuesta
Task<string>[] tareasCompetencia = new Task<string>[]
{
ConsultarServicioAsync("Servidor Rápido", 500),
ConsultarServicioAsync("Servidor Normal", 1500),
ConsultarServicioAsync("Servidor Lento", 3000)
};
// Esperar a que la PRIMERA se complete
Task<string> primeraTarea = await Task.WhenAny(tareasCompetencia);
string primerResultado = await primeraTarea;
Console.WriteLine($"\nPrimera respuesta recibida: {primerResultado}");
// Opcional: cancelar las tareas restantes (en un escenario real)
Console.WriteLine("Las otras tareas continúan ejecutándose en segundo plano...");
Console.WriteLine("\n=== Manejo de errores con WhenAll ===");
try
{
Task<string>[] tareasConError = new Task<string>[]
{
ConsultarServicioAsync("Servicio OK 1", 800, true),
ConsultarServicioAsync("Servicio Con Error", 1000, false), // Esta fallará
ConsultarServicioAsync("Servicio OK 2", 600, true)
};
string[] resultadosConError = await Task.WhenAll(tareasConError);
}
catch (Exception ex)
{
Console.WriteLine($"Se produjo un error: {ex.Message}");
// Verificar qué tareas se completaron exitosamente
Console.WriteLine("Verificando estado de las tareas individuales:");
var tareasConError = new Task<string>[]
{
ConsultarServicioAsync("Servicio OK 1", 800, true),
ConsultarServicioAsync("Servicio Con Error", 1000, false),
ConsultarServicioAsync("Servicio OK 2", 600, true)
};
await Task.Delay(2000); // Esperar a que las tareas terminen
for (int i = 0; i < tareasConError.Length; i++)
{
var tarea = tareasConError[i];
if (tarea.IsCompletedSuccessfully)
{
Console.WriteLine($" Tarea {i + 1}: Exitosa - {tarea.Result}");
}
else if (tarea.IsFaulted)
{
Console.WriteLine($" Tarea {i + 1}: Falló - {tarea.Exception?.InnerException?.Message}");
}
}
}
}
}
Manejo de excepciones en código asíncrono
El manejo de excepciones en código asíncrono requiere atención especial:
using System;
using System.Threading.Tasks;
class Program
{
static async Task<int> OperacionRiesgosaAsync(int valor, bool deberiaFallar = false)
{
Console.WriteLine($"Iniciando operación con valor: {valor}");
await Task.Delay(1000);
if (deberiaFallar)
{
throw new InvalidOperationException($"Error simulado para valor: {valor}");
}
if (valor < 0)
{
throw new ArgumentException("El valor no puede ser negativo");
}
int resultado = valor * 2;
Console.WriteLine($"Operación completada. Resultado: {resultado}");
return resultado;
}
static async Task<string> LeerArchivoSimuladoAsync(string nombreArchivo)
{
await Task.Delay(500);
if (nombreArchivo == "archivo_inexistente.txt")
{
throw new System.IO.FileNotFoundException($"No se encontró el archivo: {nombreArchivo}");
}
return $"Contenido del archivo {nombreArchivo}";
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Manejo básico de excepciones async ===");
// Caso 1: Manejo simple con try-catch
try
{
int resultado = await OperacionRiesgosaAsync(5);
Console.WriteLine($"Resultado obtenido: {resultado}");
}
catch (Exception ex)
{
Console.WriteLine($"Error capturado: {ex.Message}");
}
Console.WriteLine("\n=== Manejo de múltiples tipos de excepción ===");
// Caso 2: Diferentes tipos de excepciones
string[] valoresTest = { "10", "-5", "abc", "0" };
foreach (string valorTexto in valoresTest)
{
try
{
if (int.TryParse(valorTexto, out int valor))
{
int resultado = await OperacionRiesgosaAsync(valor);
Console.WriteLine($"✅ Éxito para '{valorTexto}': {resultado}");
}
else
{
Console.WriteLine($"❌ '{valorTexto}' no es un número válido");
}
}
catch (ArgumentException ex)
{
Console.WriteLine($"❌ Error de argumento para '{valorTexto}': {ex.Message}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"❌ Error de operación para '{valorTexto}': {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"❌ Error inesperado para '{valorTexto}': {ex.Message}");
}
}
Console.WriteLine("\n=== Manejo de errores con Task.WhenAll ===");
// Caso 3: Errores en operaciones concurrentes
Task<int>[] operacionesConcurrentes = new Task<int>[]
{
OperacionRiesgosaAsync(10, false), // Exitosa
OperacionRiesgosaAsync(20, true), // Fallará
OperacionRiesgosaAsync(30, false), // Exitosa
OperacionRiesgosaAsync(-5, false) // Fallará por valor negativo
};
try
{
int[] resultados = await Task.WhenAll(operacionesConcurrentes);
Console.WriteLine("Todas las operaciones completadas exitosamente");
}
catch (Exception ex)
{
Console.WriteLine($"Error en operaciones concurrentes: {ex.Message}");
// Examinar cada tarea individualmente
Console.WriteLine("\nEstado individual de las tareas:");
for (int i = 0; i < operacionesConcurrentes.Length; i++)
{
var tarea = operacionesConcurrentes[i];
if (tarea.IsCompletedSuccessfully)
{
Console.WriteLine($" Tarea {i + 1}: ✅ Exitosa, resultado: {tarea.Result}");
}
else if (tarea.IsFaulted)
{
var excepcion = tarea.Exception?.InnerException;
Console.WriteLine($" Tarea {i + 1}: ❌ Falló: {excepcion?.Message}");
}
else if (tarea.IsCanceled)
{
Console.WriteLine($" Tarea {i + 1}: ⏸️ Cancelada");
}
}
}
Console.WriteLine("\n=== Patrón try-catch anidado ===");
// Caso 4: Operaciones secuenciales con recuperación
string[] archivos = { "config.txt", "datos.txt", "archivo_inexistente.txt", "backup.txt" };
foreach (string archivo in archivos)
{
try
{
string contenido = await LeerArchivoSimuladoAsync(archivo);
Console.WriteLine($"✅ {archivo}: {contenido}");
}
catch (System.IO.FileNotFoundException)
{
Console.WriteLine($"⚠️ {archivo}: Archivo no encontrado, usando valores por defecto");
// Operación de recuperación
try
{
string backup = await LeerArchivoSimuladoAsync("backup.txt");
Console.WriteLine($" 📁 Usando backup: {backup}");
}
catch (Exception ex)
{
Console.WriteLine($" ❌ Error también en backup: {ex.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"❌ Error inesperado con {archivo}: {ex.Message}");
}
}
}
}
Mejores prácticas y patrones comunes
ConfigureAwait y el contexto de sincronización
using System;
using System.Threading.Tasks;
class Program
{
// ❌ Mal: puede causar deadlocks en aplicaciones UI
static string MetodoSincronoBloqueante()
{
Task<string> tarea = OperacionAsincronaAsync();
return tarea.Result; // ¡NUNCA hacer esto!
}
// ✅ Bien: usar ConfigureAwait(false) para bibliotecas
static async Task<string> OperacionDeBibliotecaAsync()
{
// ConfigureAwait(false) mejora rendimiento en bibliotecas
string datos = await ObtenerDatosAsync().ConfigureAwait(false);
string procesados = await ProcesarDatosAsync(datos).ConfigureAwait(false);
return procesados;
}
// ✅ Bien: async/await en aplicaciones
static async Task<string> OperacionDeAplicacionAsync()
{
// En aplicaciones UI, generalmente NO usar ConfigureAwait(false)
string datos = await ObtenerDatosAsync();
string procesados = await ProcesarDatosAsync(datos);
return procesados;
}
static async Task<string> ObtenerDatosAsync()
{
await Task.Delay(1000);
return "Datos obtenidos";
}
static async Task<string> ProcesarDatosAsync(string datos)
{
await Task.Delay(500);
return $"Procesado: {datos}";
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Ejemplos de mejores prácticas ===");
// Uso correcto de async/await
string resultado1 = await OperacionDeBibliotecaAsync();
Console.WriteLine(resultado1);
string resultado2 = await OperacionDeAplicacionAsync();
Console.WriteLine(resultado2);
}
}
Patrones de reintentos y timeout
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static async Task<string> OperacionInestableAsync(int intento)
{
Console.WriteLine($"Intento #{intento} - Ejecutando operación...");
await Task.Delay(1000);
// Simular fallo aleatorio (70% de probabilidad de éxito)
Random random = new Random();
if (random.NextDouble() < 0.3)
{
throw new Exception($"Operación falló en intento #{intento}");
}
return $"Operación exitosa en intento #{intento}";
}
static async Task<T> EjecutarConReintentosAsync<T>(
Func<int, Task<T>> operacion,
int maxIntentos = 3,
TimeSpan? delayEntreIntentos = null)
{
delayEntreIntentos ??= TimeSpan.FromSeconds(1);
for (int intento = 1; intento <= maxIntentos; intento++)
{
try
{
return await operacion(intento);
}
catch (Exception ex)
{
Console.WriteLine($"❌ Intento {intento} falló: {ex.Message}");
if (intento == maxIntentos)
{
Console.WriteLine("🚫 Se agotaron todos los intentos");
throw; // Re-lanzar la última excepción
}
Console.WriteLine($"⏳ Esperando {delayEntreIntentos.Value.TotalSeconds}s antes del siguiente intento...");
await Task.Delay(delayEntreIntentos.Value);
}
}
throw new InvalidOperationException("Este punto no debería alcanzarse");
}
static async Task<T> EjecutarConTimeoutAsync<T>(Task<T> tarea, TimeSpan timeout)
{
using (var cancellationTokenSource = new CancellationTokenSource(timeout))
{
var tareaConCancelacion = Task.Delay(timeout, cancellationTokenSource.Token);
var tareaCompletada = await Task.WhenAny(tarea, tareaConCancelacion);
if (tareaCompletada == tareaConCancelacion)
{
throw new TimeoutException($"La operación excedió el tiempo límite de {timeout.TotalSeconds} segundos");
}
cancellationTokenSource.Cancel(); // Cancelar el timer
return await tarea; // Retornar el resultado de la tarea original
}
}
static async Task<string> OperacionLentaAsync()
{
Console.WriteLine("Iniciando operación lenta...");
await Task.Delay(5000); // Simula operación de 5 segundos
return "Operación lenta completada";
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Patrón de reintentos ===");
try
{
string resultado = await EjecutarConReintentosAsync(
operacion: OperacionInestableAsync,
maxIntentos: 5,
delayEntreIntentos: TimeSpan.FromSeconds(0.5)
);
Console.WriteLine($"✅ {resultado}");
}
catch (Exception ex)
{
Console.WriteLine($"🚫 Operación falló definitivamente: {ex.Message}");
}
Console.WriteLine("\n=== Patrón de timeout ===");
try
{
// Intentar completar operación lenta en máximo 3 segundos
string resultado = await EjecutarConTimeoutAsync(
OperacionLentaAsync(),
TimeSpan.FromSeconds(3)
);
Console.WriteLine($"✅ {resultado}");
}
catch (TimeoutException ex)
{
Console.WriteLine($"⏰ {ex.Message}");
}
Console.WriteLine("\n=== Combinando reintentos y timeout ===");
try
{
string resultado = await EjecutarConReintentosAsync(
intento => EjecutarConTimeoutAsync(
OperacionInestableAsync(intento),
TimeSpan.FromSeconds(2)
),
maxIntentos: 3,
delayEntreIntentos: TimeSpan.FromSeconds(1)
);
Console.WriteLine($"✅ {resultado}");
}
catch (Exception ex)
{
Console.WriteLine($"🚫 Operación falló con reintentos y timeout: {ex.Message}");
}
}
}
Consideraciones de rendimiento
ValueTask vs Task
using System;
using System.Threading.Tasks;
class CacheSimple<T>
{
private T _valorEnCache;
private bool _tieneValor;
// Usando ValueTask para optimizar casos donde el valor ya está en caché
public ValueTask<T> ObtenerValorAsync(Func<Task<T>> proveedorValor)
{
if (_tieneValor)
{
// Si el valor está en caché, devolver inmediatamente sin allocar Task
Console.WriteLine("✅ Valor obtenido desde caché (sin asignación de memoria)");
return new ValueTask<T>(_valorEnCache);
}
// Si no está en caché, usar el patrón asíncrono normal
Console.WriteLine("⏳ Valor no en caché, obteniendo asincrónicamente...");
return new ValueTask<T>(ObtenerYCachearAsync(proveedorValor));
}
private async Task<T> ObtenerYCachearAsync(Func<Task<T>> proveedorValor)
{
_valorEnCache = await proveedorValor();
_tieneValor = true;
Console.WriteLine("💾 Valor obtenido y guardado en caché");
return _valorEnCache;
}
}
class Program
{
static async Task<string> OperacionCostosaAsync()
{
Console.WriteLine("🔄 Ejecutando operación costosa...");
await Task.Delay(2000);
return "Resultado de operación costosa";
}
static async Task Main(string[] args)
{
Console.WriteLine("=== Demostración de ValueTask ===");
var cache = new CacheSimple<string>();
// Primera llamada: irá a buscar el valor
string resultado1 = await cache.ObtenerValorAsync(OperacionCostosaAsync);
Console.WriteLine($"Resultado 1: {resultado1}");
Console.WriteLine();
// Segunda llamada: usará el valor en caché (más eficiente)
string resultado2 = await cache.ObtenerValorAsync(OperacionCostosaAsync);
Console.WriteLine($"Resultado 2: {resultado2}");
Console.WriteLine();
// Tercera llamada: también usará caché
string resultado3 = await cache.ObtenerValorAsync(OperacionCostosaAsync);
Console.WriteLine($"Resultado 3: {resultado3}");
Console.WriteLine("\n=== Comparación de rendimiento ===");
Console.WriteLine("ValueTask es más eficiente cuando:");
Console.WriteLine("- El resultado ya está disponible (caché)");
Console.WriteLine("- Se evita la asignación de objetos Task innecesarios");
Console.WriteLine("- Se reduce la presión sobre el garbage collector");
}
}
Resumen
La programación asíncrona con async
y await
es fundamental para desarrollar aplicaciones modernas eficientes y responsivas. Hemos explorado desde los conceptos básicos hasta patrones avanzados, incluyendo la ejecución concurrente de tareas, el manejo robusto de excepciones y las mejores prácticas para optimizar el rendimiento.
Las palabras clave async
y await
nos permiten escribir código asíncrono que se lee de forma natural y secuencial, mientras que herramientas como Task.WhenAll
y Task.WhenAny
nos facilitan la coordinación de múltiples operaciones concurrentes. El manejo adecuado de excepciones en contextos asíncronos, junto con patrones como reintentos y timeouts, nos ayuda a crear aplicaciones robustas y resilientes. Dominar estos conceptos y aplicar las mejores prácticas nos permite aprovechar al máximo los recursos del sistema y crear experiencias de usuario superiores.