Asincronía, concurrencia y paralelismo: esperar no es lo mismo que calcular
Escribir async delante de una función no convierte un cálculo lento en varios cálculos rápidos. Tampoco hace que una API conteste antes ni que desaparezcan sus límites. Lo que cambia es cómo organizamos el trabajo mientras una operación todavía no ha terminado. La palabra parece prometer algo mucho más espectacular; comprendo que resulte tentador dejar que el departamento de expectativas la interprete libremente.
Para distinguir las ideas, vamos a representar tres consultas independientes y una pequeña suma posterior. Usaremos esperas artificiales en Python y C#: no habrá peticiones HTTP ni modelos reales. Los programas mostrarán cómo conservar el orden de los resultados, identificar un fallo parcial y observar una cancelación. Con esas diferencias claras podremos decidir qué tipo de problema estamos intentando resolver antes de poner más tareas a competir por los mismos recursos.
Esperar una respuesta y calcular una tabla consumen recursos distintos
Si una aplicación solicita información a un servicio y pasa parte de su tiempo esperando, puede aprovechar esa espera para atender otras operaciones. La asincronía permite expresar esa continuación sin obligar a bloquear el hilo durante toda la espera, siempre que la operación utilizada ofrezca ese comportamiento. Una llamada bloqueante sigue siendo bloqueante aunque la coloques dentro de una función asíncrona.
La concurrencia significa que varias tareas progresan durante un intervalo común. No exige que estén ejecutando instrucciones al mismo instante: pueden alternarse. El paralelismo sí implica ejecución simultánea, por ejemplo utilizando varios núcleos para distintos cálculos. La explicación de programación asíncrona de C# ayuda a distinguir la espera de la ejecución del trabajo. Añadir tareas no aumenta por sí solo la capacidad del recurso del que dependen.
En nuestro caso habrá tres operaciones que representan la espera de datos para los grupos A, B y C. Cuando tengamos las tres respuestas, sumaremos sus valores. Esa suma será un cálculo normal realizado después; no se repartirá entre núcleos. Si tu problema real consiste en procesar una tabla enorme con la CPU ocupada, tendrás que estudiar el algoritmo y las opciones de ejecución paralela del entorno. El artículo de complejidad y rendimiento explica por qué conviene empezar por el coste del trabajo.
También importa cuándo iniciar cada operación. Si solicitas A, esperas su final y solo entonces solicitas B, has organizado una secuencia. Si inicias varias operaciones independientes y después esperas sus resultados, permites concurrencia. Las dependencias siguen mandando: no puedes solicitar una versión cuyo identificador todavía no has obtenido fingiendo que ambas consultas son independientes. Hacer un diagrama con flechas en paralelo no modifica la información que necesita cada paso.
Recoger resultados en orden no significa que hayan terminado en ese orden
En Python utilizaremos asyncio.gather para esperar varias operaciones. La colección resultante conservará el orden en que entregamos las operaciones, aunque las esperas sean distintas. En C# usaremos Task.WhenAll sobre tareas con resultado, que también mantiene la correspondencia con el orden de entrada. Ese comportamiento permite relacionar cada resultado con su solicitud; aun así, conservaremos el identificador en el propio resultado para no depender únicamente de una posición.
No estamos fijando el orden en que acabarán las operaciones. Nuestro programa asigna esperas pequeñas y diferentes, pero el sistema puede introducir retrasos y no vamos a convertir ese detalle en una garantía temporal. Lo que comprobaremos será la identidad y el orden de la colección devuelta. La documentación de gather y WhenAll especifica esa relación entre entradas y resultados.
La primera ejecución de cada programa tendrá éxito en las tres consultas. La segunda provocará un fallo esperado en B y conservará el resultado de cada grupo, incluido su estado. Para hacerlo, cada operación atrapará únicamente ese error previsto y lo convertirá en un resultado identificado. No trataremos cualquier excepción como si fuera una respuesta vacía. Un error de programación inesperado debe poder verse, en lugar de desaparecer en la estadística de «sin datos».
Esa decisión define qué significa completar el lote. En el caso correcto podremos sumar 12, 8 y 15 y obtener 35. Si falla B, no presentaremos la suma de los otros dos como si cubriera los tres grupos. La concurrencia permite llegar antes a un conjunto de resultados; no concede permiso para cambiar lo que ese conjunto representa. Un informe incompleto puede abrirse enseguida y seguir siendo incompleto con una eficacia admirable.
Python: esperar tres consultas, conservar el fallo y cancelar una tarea
Guarda el siguiente programa como esperas_async.py. Funciona con Python 3.10 o posterior y la biblioteca estándar. consultar utiliza asyncio.sleep para representar una espera; devuelve el identificador y un número preparado. capturar transforma solo el ValueError previsto en un resultado con error. No hace ninguna petición de red.
Ejecútalo desde la carpeta donde lo guardes:
Obtendrás cuatro líneas: orden=A,B,C, total=35, parcial=A:OK,B:ERROR,C:OK y cancelacion=observada. Las esperas de la segunda ejecución siguen siendo concurrentes, pero la aplicación conserva el fallo de B. La tercera parte inicia una tarea separada, deja que avance hasta su espera, solicita cancelarla y espera a observar su terminación por cancelación. Pedir la cancelación y comprobar qué ocurrió son pasos distintos.
En el bucle de eventos habitual de asyncio, una tarea que ejecuta código sin ceder el control impide progresar a las demás en ese mismo hilo. asyncio.sleep sí permite esa cesión; un cálculo largo o una llamada bloqueante requieren otro tratamiento. La guía de desarrollo con asyncio explica esa frontera. No sustituiría la espera artificial por una biblioteca HTTP bloqueante y daría por conservado el comportamiento solo porque el programa sigue conteniendo un await.
C#: tareas con resultado y cancelación cooperativa
Crea una aplicación de consola .NET 8:
Sustituye Program.cs por este programa. Task.Delay representa las mismas esperas. La función que captura errores mantiene un resultado por grupo y deja pasar la cancelación; solo convierte el fallo esperado de la consulta en un estado de error.
Ejecuta la aplicación:
La salida será la misma que en Python. Aquí la cancelación se comunica mediante un token: la operación debe recibirlo y colaborar con la petición de detenerse. Task.Delay lo admite; pasar un token a una función que nunca lo consulta no detiene su trabajo. La documentación de cancelación en .NET describe ese modelo cooperativo.
Los dos ejemplos tratan deliberadamente un fallo esperado por elemento. Si omites esa captura, las reglas del combinador importan: no puedes suponer que gather y WhenAll entregarán automáticamente una lista de éxitos y errores del modo que necesita tu informe. También tendrás que decidir qué hacer con las otras tareas ante un fallo, esperar su terminación y observar sus excepciones. Ese comportamiento debe diseñarse; no aparece por declarar asíncrona la función que las reúne.
Más operaciones pendientes también pueden significar más trabajo inútil
Cancelar no equivale a deshacer. En nuestro ejercicio solo cancelamos una espera. Si una operación ya hubiese enviado una escritura a un servicio externo, detener la espera local no demostraría que el cambio remoto no ocurrió. La cancelación puede afectar a la continuación del cliente sin revertir el efecto del servidor. Antes de repetir una operación cuyo resultado desconoces, necesitarás comprobar su estado y aplicar las garantías que ofrezca su contrato.
La concurrencia tampoco debe crecer sin límite. Una aplicación que lance una tarea por cada elemento de un modelo puede acumular conexiones, memoria y operaciones pendientes hasta empeorar el servicio que intentaba acelerar. Definir un límite de operaciones simultáneas y respetar las cuotas del proveedor forma parte del diseño. Nuestros programas solo reúnen tres tareas y no implementan una cola ni un limitador; ese alcance permite estudiar el comportamiento sin esconder otra arquitectura dentro del ejemplo.
Para cálculos intensivos, el paralelismo introduce otros costes: repartir datos, comunicar resultados, coordinar acceso y combinar salidas. No todo trabajo se divide bien, y no todas las APIs admiten ejecución desde cualquier hilo o proceso. Elegir hilos o procesos requiere conocer esas condiciones y medir el recorrido completo. Si la aplicación impone un contexto concreto para acceder a un documento, hay que respetarlo aunque el código que la rodea utilice tareas.
Yo empezaría distinguiendo dónde se espera, dónde se calcula y qué pasos dependen de otros. Después fijaría qué significa terminar: todos correctos, resultados parciales identificados o cancelación observada. Los programas de este artículo conservan esas diferencias y el orden de las solicitudes; no miden una mejora de rendimiento. Con esa base, el siguiente async que añadas podrá responder a una necesidad concreta. Es un uso bastante más interesante de la palabra que emplearla como amuleto contra la lentitud.
Respuestas