launch frente a async: efecto o valor
Los dos constructores de corrutinas se parecen tanto en la superficie que se eligen por costumbre, y sin embargo encarnan contratos opuestos. Esta lección contrasta el disparo de un efecto con la producción de un valor, explica qué es exactamente un Deferred y en qué se diferencia de una promesa clásica, deriva desde el modelo estructural por qué las excepciones de uno se propagan al instante y las del otro esperan a que alguien pregunte, y fija los criterios para paralelizar de verdad sin caer en los dos errores más frecuentes.
Toda biblioteca de concurrencia acaba ofreciendo dos formas de arrancar trabajo, y la razón es que hay dos preguntas distintas detrás. A veces lo que se quiere es que algo ocurra y basta con saber que ocurrirá; otras veces lo que se quiere es un dato que todavía no existe y que se necesitará más adelante. Kotlin llama a esas dos formas launch y async, y su gran acierto pedagógico y su gran trampa son la misma cosa: las escribe con una sintaxis casi idéntica. Un carácter de diferencia en el nombre separa un constructor que devuelve un Job y trata el fallo como un problema que hay que anunciar de otro que devuelve un Deferred y trata el fallo como parte del resultado que hay que entregar a quien lo reclame. Confundirlos no produce un error de compilación sino un comportamiento distinto ante los errores, que es la categoría de defecto que más tarda en aparecer y más cuesta reproducir.
- Distinguir el contrato de disparar un efecto del contrato de producir un valor y elegir el constructor adecuado.
- Explicar qué es un
Deferredy por qué su comportamiento difiere de una promesa clásica. - Derivar desde la estructura del árbol por qué las excepciones se propagan de forma distinta en cada constructor.
- Escribir código realmente paralelo evitando la espera prematura y el ámbito equivocado.
Dos constructores, dos contratos
El constructor launch declara que hay algo que hacer y que el resultado de hacerlo no es un dato sino un efecto sobre el mundo: escribir en disco, enviar un evento, actualizar una vista. Devuelve un Job, que sirve para esperar y para cancelar, y nada más, porque no hay valor que ofrecer. El constructor async declara que hay un cálculo cuyo resultado se necesitará, y devuelve un Deferred que es un Job con un valor prometido encima.
suspend fun cargarPerfil(id: Id): Perfil = coroutineScope {
val usuario = async { api.usuario(id) } // quiero el dato
val ajustes = async { api.ajustes(id) } // quiero el dato
launch { metricas.registrar("perfil", id) } // solo quiero el efecto
Perfil(usuario.await(), ajustes.await())
}
Esta llamada dispara las tres corrutinas casi a la vez y no retorna hasta que las tres han terminado, incluida la de métricas aunque su resultado no se use en ninguna parte. Nótese que la elección entre uno y otro no es una cuestión de rendimiento sino de honestidad de la firma: quien lee la línea de métricas sabe de inmediato que su valor no importa, y quien lee las dos primeras sabe que sin ellas no hay perfil.
Escribir async y no invocar nunca await es una señal casi infalible de un error de diseño, porque significa que se ha pedido un resultado que a nadie le importa y, de paso, que un fallo del cálculo quedará guardado sin que nadie lo consulte. Cuando se quiere un efecto en paralelo, launch es la elección correcta y además comunica la intención. La regla práctica es simple: cada async del código debería tener su await visible en la misma función.
Deferred no es una promesa clásica
Un Deferred representa un valor futuro, igual que una promesa, pero hay tres diferencias que cambian su forma de usarse. La primera es que hereda de Job, de modo que está registrado en el árbol y se cancela con él; una promesa clásica no tiene padre ni forma estándar de cancelarse. La segunda es que su lectura se hace con una función suspend y no con una devolución de llamada, lo que permite escribir la continuación como código secuencial ordinario en lugar de encadenar transformaciones. Y la tercera es que se puede leer más de una vez sin recalcular nada, porque el valor queda memorizado en el objeto.
val precio: Deferred<Dinero> = async { calcularPrecio() }
val a = precio.await() // suspende hasta que este listo
val b = precio.await() // devuelve el mismo valor al instante
La cancelación de un Deferred merece una nota. Cancelarlo cancela el cálculo, y cualquier await pendiente o posterior lanzará una CancellationException en quien esperaba. No hay valor de consuelo ni estado indeterminado: o el valor existe, o existe una excepción que explica por qué no existe.
Ambos constructores admiten además un modo de arranque diferido, en el que la corrutina se crea pero no empieza hasta que alguien la reclama. Es el único caso en que un Deferred se parece de verdad a un cálculo perezoso, y su utilidad se limita a situaciones en las que se preparan varias alternativas y solo se necesitará una.
val caro = async(start = CoroutineStart.LAZY) { calculoCostoso() }
if (haceFalta) {
usar(caro.await()) // solo aqui empieza a ejecutarse
} else {
caro.cancel() // nunca llego a arrancar
}
Conviene usarlo con parsimonia, porque introduce una asimetría peligrosa: la corrutina diferida es hija del ámbito desde que se crea, de modo que el bloque no retornará hasta que ella termine, y si nadie la arranca ni la cancela el ámbito entero se queda esperando a algo que jamás va a ocurrir. El arranque diferido es la única forma de provocar un bloqueo permanente en un ámbito estructurado que por lo demás está bien escrito.
flowchart TD A[Constructor de corrutina] --> B[launch devuelve Job] A --> C[async devuelve Deferred] B --> D[El fallo sube al padre de inmediato] C --> E[El fallo se guarda en el resultado] E --> F[Se relanza cuando alguien llama a await] D --> G[Cancela hermanos y ambito]
Por qué las excepciones se comportan al revés
Esta es la diferencia que de verdad importa y no es un capricho de la biblioteca: se deduce del contrato de cada constructor. Como el resultado de launch no se consulta nunca, si su excepción no se anunciara de inmediato se perdería para siempre; por eso una corrutina lanzada con launch propaga el fallo hacia arriba en cuanto ocurre, cancelando a sus hermanos y llegando en última instancia al manejador de excepciones del ámbito o al del hilo. Como el resultado de async sí se consulta, la excepción es parte de ese resultado y se entrega en el mismo lugar y en el mismo momento en que se habría entregado el valor, es decir, dentro del await.
// Con launch: el fallo estalla donde ocurre, no donde se espera
scope.launch {
error("boom") // llega al manejador del ambito
}
// Con async: el fallo espera en el resultado
val d = scope.async { error("boom") }
try {
d.await() // aqui, y solo aqui, se relanza
} catch (e: IllegalStateException) {
manejar(e)
}
Aquí aparece la sutileza que engaña a casi todo el mundo. En un async hijo de un ámbito estructurado, envolver el await en un try captura la excepción, sí, pero no impide que el fallo haya subido también al padre por la vía estructural y haya cancelado a los hermanos. Es decir, capturar el error en el punto de espera no lo desactiva como fallo del árbol. La única forma de que un async falle sin arrastrar a nadie es que su padre sea un supervisor, que es justo el tema de la última lección de este nivel.
Si un async falla y nadie invoca su await, la excepción queda almacenada en el Deferred. En un ámbito corriente el fallo sí habrá subido al padre y se notará por otra vía; pero bajo un supervisor no sube, y entonces desaparece por completo: no hay registro, no hay traza y no hay síntoma, solo un valor que nunca llegó. Es la causa más habitual de errores que solo se manifiestan como una pantalla vacía.
Paralelizar de verdad
Los dos errores más comunes al usar async no tienen que ver con excepciones sino con no obtener el paralelismo que se creía haber escrito. El primero es esperar demasiado pronto: si se invoca await inmediatamente después de crear cada tarea, las llamadas se serializan y el código es más complicado que la versión secuencial sin ser más rápido.
// Secuencial disfrazado: cada await bloquea el arranque del siguiente
val a = async { primero() }.await()
val b = async { segundo() }.await()
// Paralelo real: primero se arranca todo, despues se espera
val ta = async { primero() }
val tb = async { segundo() }
val resultado = combinar(ta.await(), tb.await())
El segundo error es más grave y consiste en lanzar el async en un ámbito que no es el de la operación en curso, típicamente un ámbito guardado en una propiedad de la clase. El código funciona, pero la tarea deja de pertenecer a la llamada: no se cancela cuando la llamada se cancela y su fallo se propaga a un árbol que no tiene nada que ver. La regla es que las tareas auxiliares de una operación deben nacer del ámbito local de esa operación, creado con coroutineScope.
Cuando el número de tareas no se conoce de antemano porque proviene de una colección, la forma correcta es construir la lista de tareas primero y esperarla después con awaitAll, que además tiene una propiedad valiosa: si alguna falla, relanza el primer fallo sin esperar al resto en lugar de ir descubriendo los errores de uno en uno.
suspend fun cargarTodos(ids: List<Id>): List<Perfil> = coroutineScope {
ids.map { id -> async { api.perfil(id) } }.awaitAll()
}
Queda una advertencia sobre este idioma que se olvida con frecuencia: paralelizar una colección de tamaño desconocido significa abrir tantas peticiones simultáneas como elementos tenga, lo que puede agotar un pozo de conexiones o provocar que el servicio remoto empiece a rechazar. La estructura del árbol garantiza la corrección del flujo, no la prudencia con los recursos; limitar la concurrencia sigue siendo responsabilidad de quien escribe, normalmente con un semáforo o troceando la colección.
Efecto sin valor
Usa launch. Devuelve Job, el fallo se anuncia en el momento y la firma comunica que no hay resultado que recoger.
Valor que se necesitará
Usa async y llama a await. El fallo viaja con el valor y se relanza en el punto de espera.
Varias fuentes a la vez
Crea todos los async primero y espera después. Cualquier await intercalado convierte el paralelismo en secuencia.
Vale la pena detenerse en por qué la biblioteca eligió que dos constructores con contratos opuestos se escriban casi igual, porque la decisión parece imprudente y sin embargo es profundamente coherente con el resto del diseño. La respuesta es que ambos son la misma operación estructural, crear un hijo en el árbol, y difieren únicamente en si ese hijo publica o no un resultado; hacerlos parecer distintos habría sugerido que se trata de dos mecanismos de concurrencia, cuando en realidad hay uno solo con dos formas de leer su salida. Lo que se deduce de ahí es que la diferencia en el manejo de excepciones no es una regla adicional que memorizar, sino el corolario obligado de esa única distinción: allí donde hay un canal de resultado, el error viaja por el canal, porque ese es el sitio donde alguien está mirando; allí donde no lo hay, el error tiene que interrumpir, porque no habrá otra oportunidad de ser visto. Cualquier otro reparto sería incoherente: un launch que guardase su excepción para siempre estaría perdiendo información sin que nadie pudiera recuperarla, y un async que interrumpiera al instante estaría haciendo imposible el patrón de pedir cinco cosas y tolerar que dos fallen. La consecuencia práctica que conviene llevarse es un cambio de pregunta a la hora de escribir el código: en lugar de preguntarse cuál de los dos constructores usar, hay que preguntarse quién es el destinatario del posible error de esta tarea. Si el destinatario es el propio flujo que la creó y que va a leer su resultado, el error pertenece al valor y corresponde async. Si el destinatario es el supervisor del componente, porque nadie va a leer nada y un fallo aquí significa que algo del sistema está roto, el error pertenece al árbol y corresponde launch. Formulada así, la elección deja de ser una cuestión de estilo y pasa a ser lo que siempre fue: una decisión sobre dónde se atienden los errores de este programa.
- Escribe la misma operación con dos
awaitintercalados y con los dosasyncprimero, y mide el tiempo total de ambas. Explica la diferencia con el árbol en la mano. - Haz fallar un
asyncy captura la excepción en elawait. Comprueba si sus hermanos siguen vivos y razona el resultado. - Crea un
asyncque falle y no lo esperes nunca, primero bajo un ámbito corriente y después bajo un supervisor. Compara qué se registra en cada caso. - Sustituye un
asynccuyo valor no usas por unlaunchy describe qué información gana quien lea la firma. - Busca en tu código un
asynclanzado sobre un ámbito de clase dentro de una funciónsuspendy reescríbelo concoroutineScope. Enumera qué cambia al cancelar la llamada.