async let: paralelismo declarativo para un número fijo de trabajos
Qué crea realmente un `async let`, por qué la hija empieza en la declaración y no en el `await`, cómo se comportan los errores y las salidas anticipadas del ámbito, y el criterio preciso para elegirlo frente a un grupo de tareas.
La forma más simple de concurrencia estructurada en Swift cabe en una palabra clave y parece casi un descuido del lenguaje: escribir async let en lugar de let convierte una expresión asíncrona en una tarea hija que empieza a correr de inmediato mientras la ejecución continúa a la línea siguiente. Su virtud no es que ahorre teclas, sino que hace explícita en el texto una decisión de diseño que en el modelo de callbacks quedaba enterrada en la fontanería: estos trabajos son independientes entre sí y su número lo conozco al escribir el programa. Cuando esa afirmación es cierta, async let es la herramienta más segura y más legible que existe; cuando deja de serlo, se rompe de maneras muy instructivas.
- Explicar la semántica exacta de
async let: cuándo nace la hija, cuándo se consume y qué ocurre si no se consume. - Aplicar correctamente
awaitytrysobre una constante concurrente y justificar dónde deben aparecer. - Analizar el comportamiento del ámbito ante errores,
returnanticipados y cancelación implícita. - Decidir con criterio entre
async lety un grupo de tareas según la aridad y el origen del trabajo.
Qué ocurre realmente en la declaración
Un async let hace tres cosas a la vez: crea una tarea hija, la arranca sin esperar, y declara una constante que representa su resultado futuro. La ejecución no se detiene ahí.
func montarPantalla() async throws -> Pantalla {
async let perfil = api.perfil() // arranca ya
async let noticias = api.noticias() // arranca ya, en paralelo
async let ajustes = api.ajustes() // arranca ya
// aqui sigue corriendo codigo sincrono mientras las tres trabajan
let plantilla = plantillaPorDefecto()
return try await Pantalla(perfil: perfil,
noticias: noticias,
ajustes: ajustes,
plantilla: plantilla)
}
La constante declarada no es un valor sino un punto de encuentro. Solo al escribir await sobre ella la tarea padre se suspende hasta que la hija correspondiente termine. Por eso el await va donde se usa el valor, no donde se declara, y por eso el orden de los tres await no cambia el tiempo total: las tres llamadas ya estaban en vuelo.
El try sigue la misma lógica. Si la expresión puede lanzar, el error no aparece en la declaración sino en el consumo, porque es ahí donde el resultado se materializa. De ahí la forma idiomática try await delante del uso, con un único try capaz de cubrir varias constantes en la misma expresión.
Cuando solo hace falta reunirlas sin construir nada, la tupla es la forma más económica de escribir el punto de encuentro.
async let a = api.perfil()
async let b = api.ajustes()
let (perfil, ajustes) = try await (a, b) // un unico punto de reunion
Si escribes let perfil = try await api.perfil() seguido de let noticias = try await api.noticias(), has hecho dos llamadas secuenciales y el tiempo total es la suma. La prueba definitiva no es leer el código sino medir: si el tiempo de la función es cercano al máximo de sus partes, hay paralelismo; si es cercano a la suma, hay una cadena de esperas disfrazada.
Vida, muerte y salidas anticipadas
La regla del ámbito se aplica sin excepciones, y produce comportamientos que sorprenden la primera vez. Si el ámbito termina sin que una constante haya sido consumida, la tarea hija se cancela implícitamente y el padre espera a que muera antes de retornar.
func portada() async throws -> Datos {
async let principal = api.principal()
async let opcional = api.recomendaciones() // quiza no se use
guard let d = try? await principal else {
return .vacio // al salir: se cancela y se espera a opcional
}
return d // idem: opcional nunca se consumio
}
Ese return no deja nada colgando, pero tampoco retorna instantáneamente: hay una espera implícita. Es la contrapartida honesta de la garantía. Y hay un matiz importante: la cancelación es cooperativa, de modo que si api.recomendaciones no observa la cancelación, la espera durará lo que dure la operación completa. La estructura garantiza que se espera, no que se acabe pronto.
El comportamiento ante errores es simétrico. Si el consumo de una constante lanza, el ámbito empieza a desmontarse: el resto de hijas recibe la cancelación y el padre no propaga el error hasta que todas hayan terminado. Nunca queda trabajo suelto tras una excepción.
flowchart TB D1[Declaracion de async let A] --> T1[Hija A en vuelo] D2[Declaracion de async let B] --> T2[Hija B en vuelo] D1 --> D2 --> C[Codigo sincrono del padre] C --> W[await sobre A y B] T1 --> W T2 --> W W --> F[Fin del ambito: nada queda vivo] X[Salida anticipada o error] -.->|cancela y espera| T1 X -.->|cancela y espera| T2 style W fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
Restricciones y sus razones
Las limitaciones de async let no son caprichos: derivan de que la hija vive atada a un ámbito léxico concreto, sin ninguna indirección.
La constante no puede escapar del ámbito: no se guarda en una propiedad, no se devuelve, no se captura en una clausura que sobreviva. Tampoco puede declararse en el nivel de tipo ni en un contexto no asíncrono, porque no habría padre que la esperase. No admite reasignación, y su número queda fijado en el texto: no existe forma de escribir un async let dentro de un bucle cuyo tamaño se conozca en ejecución, precisamente porque el compilador necesita saber cuántos puntos de encuentro tiene que colocar antes del cierre.
// no compila: la constante no puede escapar del ambito que la posee
func mal() async -> () async -> Int {
async let n = calcular()
return { await n }
}
Esa rigidez es la fuente de su seguridad. Como el conjunto de hijas es estático, el compilador coloca las esperas y las cancelaciones en las salidas correctas sin necesidad de contabilidad en ejecución, y el coste de crear una hija se aproxima al de una llamada a función con una asignación en el montículo.
Aridad conocida
Dos, tres o cuatro trabajos escritos a mano: territorio de async let. Un número que sale de una colección: territorio de TaskGroup.
Tipos heterogéneos
Cada constante conserva su propio tipo. Un grupo, en cambio, exige un tipo de resultado común o un enum que lo unifique.
Sin dependencias
Si el segundo trabajo necesita el resultado del primero, no hay paralelismo que ganar: encadena con await y no finjas concurrencia.
El criterio de elección
Reduce la decisión a cuatro preguntas y no necesitarás más. ¿Sé cuántos trabajos hay al escribir el código? Si no, grupo. ¿Los resultados tienen tipos distintos que quiero conservar? Si sí, async let. ¿Necesito recolectar resultados según van llegando, o limitar cuántos corren a la vez? Si sí, grupo. ¿Hay dependencias entre los trabajos? Si sí, ninguno de los dos: una cadena secuencial expresa mejor lo que ocurre.
Un antipatrón frecuente merece mención aparte: usar async let para un solo trabajo. Declarar una hija y consumirla en la línea siguiente no aporta paralelismo alguno y solo añade un nodo al árbol; el await directo es más claro y más barato. El paralelismo empieza en dos.
Las dos herramientas también se combinan sin fricción, y esa combinación resuelve la forma más común de problema real: unas cuantas piezas heterogéneas de las que una es a su vez una colección.
func informe(de ids: [ID]) async throws -> Informe {
async let cabecera = api.cabecera() // pieza unica
async let detalles = withThrowingTaskGroup( // coleccion
of: Detalle.self
) { grupo in
for id in ids { grupo.addTask { try await api.detalle(id) } }
return try await grupo.reduce(into: []) { $0.append($1) }
}
return try await Informe(cabecera: cabecera, detalles: detalles)
}
El grupo entero cuelga de la hija creada por su async let, de modo que el árbol sigue teniendo una raíz única y cancelar la función cancela también cada descarga de detalle. La estructura compone, y esa es la razón por la que no hay que elegir una herramienta para toda la función: se elige por trabajo.
Que async let exija conocer el número de trabajos en tiempo de compilación parece una limitación menor, casi una molestia de implementación, y sin embargo es la línea que separa dos regímenes de garantías radicalmente distintos, la misma línea que atraviesa toda la historia de los lenguajes de programación. Cuando la cardinalidad de un conjunto de recursos es estática, el compilador puede razonar exhaustivamente sobre él: puede enumerar todas las salidas del ámbito —retorno normal, retorno anticipado, error propagado, cancelación entrante— y colocar en cada una el código de espera y de cancelación que corresponde, sin ninguna estructura de datos en ejecución y sin ninguna posibilidad de olvido. Es exactamente el mismo mecanismo que permite a Swift destruir valores de forma determinista, a Rust verificar préstamos y a C++ ejecutar destructores durante el desenrollado de la pila: la verificación es posible porque el conjunto está fijado en el texto. En cuanto la cardinalidad pasa a ser dinámica, ese razonamiento se desmorona y hay que sustituirlo por una estructura viva que lleve la cuenta en tiempo de ejecución —el grupo de tareas— con su propio bloque de estado, su sincronización interna y su coste. La lección, que trasciende con mucho a Swift, es que conocer una cantidad en tiempo de compilación no es un detalle de rendimiento sino un cambio de categoría en lo que se puede demostrar, y que un diseño maduro ofrece las dos herramientas en lugar de forzar la general para todo. Cuando escojas async let porque el trabajo es de aridad fija, no estás optimizando: estás declarando un hecho verdadero sobre tu problema y cobrando, a cambio de esa declaración, una verificación que ninguna prueba unitaria te habría dado. Y cuando descubras que la aridad se ha vuelto dinámica —porque la lista viene del servidor, porque el usuario elige cuántos elementos sincronizar— no fuerces la sintaxis rígida: reconoce que has cambiado de mundo y cambia también de herramienta, porque intentar simular un conjunto dinámico con constantes estáticas es el camino más corto hacia el trabajo huérfano que la estructura venía a impedir.
- Escribe tres llamadas de red secuenciales, mídelas, conviértelas a
async lety comprueba que el tiempo pasa de la suma al máximo. - Declara una constante concurrente que nunca consumas y verifica con trazas que la hija se cancela y que el padre la espera igualmente.
- Provoca un error en la primera constante consumida y comprueba el orden exacto entre la cancelación de las hermanas y la propagación del error.
- Intenta devolver una constante concurrente desde la función y explica, con la regla del ámbito en la mano, por qué el compilador lo rechaza.
- Reescribe con
async letun caso de aridad dinámica y documenta el punto exacto en el que la sintaxis deja de servir.