Concurrencia estructurada en handlers suspend
Dispara llamadas en paralelo con coroutineScope, sobrevive a fallos parciales con supervisorScope y deja que la desconexion del cliente cancele todo gratis.
Un handler suspend tipico de Spring WebFlux rara vez necesita una sola cosa. Para renderizar un dashboard quizas necesites el perfil del usuario, sus pedidos recientes y su saldo de facturacion, cada uno detras de un servicio o repositorio distinto. El enfoque ingenuo los espera uno tras otro, pagando la suma de todas las latencias. La concurrencia estructurada te permite lanzar esas llamadas de forma concurrente garantizando que ninguna sobreviva a la peticion que la inicio. El handler se suspende hasta que todo el trabajo que genero termina, falla o se cancela, sin corrutinas filtradas ni jobs en segundo plano colgados.
El caballo de batalla del camino feliz es coroutineScope { }. Crea un nuevo scope cuyo ciclo de vida queda ligado al bloque: lanzas cada llamada independiente con async { } y luego recoges los resultados con await() o awaitAll(). Como todas las corrutinas async corren concurrentemente, el tiempo total es aproximadamente el de la llamada mas lenta y no la suma. coroutineScope solo retorna cuando cada hijo ha terminado, lo que significa que el Dashboard que construyes dentro del bloque siempre esta completo al retornarlo. No hay join manual, ni pool de hilos que gestionar, ni forma de olvidar accidentalmente un futuro pendiente.
El rasgo definitorio de coroutineScope es su modelo de fallo de todo o nada. Si cualquier hijo lanza una excepcion, el scope cancela de inmediato a los demas hermanos en ejecucion y relanza esa excepcion fuera del bloque. Esto es justo lo que quieres para un agregado que carece de sentido si esta incompleto: si el servicio de facturacion esta caido, no tiene caso conservar un dashboard a medias, asi que fallar rapido y exponer un unico error limpio es el comportamiento correcto. La cancelacion de los hermanos tambien detiene trabajo inutil, liberando conexiones e hilos en lugar de dejar correr llamadas condenadas.
Cuando los resultados parciales son aceptables, usa supervisorScope { } en su lugar. Tiene el mismo ciclo de vida estructurado, pero el fallo de un hijo ya no cancela a los demas; cada async falla de forma aislada y guarda su excepcion hasta que lo esperes. El patron consiste en esperar cada Deferred dentro de su propio try/catch y sustituir por un valor de respaldo o null en caso de error. Un widget de noticias, un panel del clima y un ticker de bolsa pueden renderizarse de forma independiente: si el clima esta caido, la pagina aun muestra noticias y bolsa en vez de colapsar por completo. Elige supervisorScope de forma deliberada, solo donde una salida degradada realmente supere a no tener salida.
La propagacion de la cancelacion es donde la concurrencia estructurada de verdad rinde en un servidor reactivo. En WebFlux el handler suspend corre como una corrutina ligada a la suscripcion HTTP. Si el cliente cierra la conexion o navega a otra parte, el framework cancela esa corrutina, y la concurrencia estructurada propaga la cancelacion a cada hijo que lanzaste con coroutineScope o supervisorScope. Tus llamadas paralelas aguas abajo se detienen en lugar de completarse contra un cliente que nunca leera la respuesta, lo que protege tus pools de hilos, conexiones de base de datos y servicios upstream de carga inutil durante picos de trafico.
Esa cancelacion es cooperativa, no preventiva, asi que tu codigo debe alcanzar un punto de suspension para que surta efecto. Cada funcion suspend de la libreria de corrutinas, delay, y los clientes suspendentes de WebClient de Spring y de los drivers reactivos comprueban la cancelacion y lanzan CancellationException en sus puntos de suspension. Los bucles intensivos de CPU o las llamadas JDBC bloqueantes no notaran la cancelacion por si solos; si tienes esas secciones, llama a ensureActive() periodicamente o mueve el trabajo bloqueante a un dispatcher envolviendolo para que pueda interrumpirse. Agrega withTimeout(...) alrededor de los bloques de fan-out para acotar la latencia total, ya que un timeout cancela el scope usando exactamente la misma maquinaria.
Unas pocas reglas mantienen esto seguro en produccion. Nunca uses GlobalScope ni un scope desligado hecho a mano para iniciar trabajo de la peticion, porque eso corta el vinculo de ciclo de vida y reintroduce las fugas que la concurrencia estructurada existe para prevenir. Si de verdad necesitas trabajo fire-and-forget que deba sobrevivir a la respuesta, lanzalo desde un CoroutineScope de alcance de aplicacion que tu controles, con su propio SupervisorJob y manejador de excepciones, nunca desde la corrutina de la peticion. Y recuerda que CancellationException es especial: no la silencies en un catch (Exception) amplio, o romperas la propagacion de la cancelacion; relanzala, o captura las excepciones especificas que de verdad pretendes recuperar.
En conjunto, el modelo mental es simple. Usa coroutineScope para disparar llamadas paralelas cuando necesitas todas y quieres semantica de fallo rapido. Usa supervisorScope cuando piezas independientes deban degradarse con elegancia. Apoyate en el runtime para cancelar todo cuando el cliente se desconecta, y asegurate de que tu codigo ceda en los puntos de suspension para que la cancelacion se respete. Con estas tres herramientas, un handler que llama a cinco servicios se mantiene rapido, resiliente y libre de fugas de recursos, todo mientras se lee como codigo secuencial corriente.
@RestControllerclass DashboardController(private val users: UserClient,private val orders: OrderClient,private val billing: BillingClient,) {@GetMapping("/dashboard/{id}")suspend fun dashboard(@PathVariable id: String): Dashboard = coroutineScope {val profile = async { users.fetch(id) }val recentOrders = async { orders.recent(id) }val balance = async { billing.balance(id) }// async starts the three calls concurrently; await() only collects the// results, so total time ~ slowest call rather than the sum.// If billing.balance throws, profile and recentOrders are cancelled// and the exception propagates out of dashboard().Dashboard(profile = profile.await(),orders = recentOrders.await(),balance = balance.await(),)}}
Fan out three independent calls with coroutineScope + async. They run concurrently, the handler waits for all of them, and if any one fails the others are cancelled and the error surfaces. (Spring WebFlux suspend handler — copy into your project; not runnable here.)
import kotlinx.coroutines.*suspend fun loadWidgets(): List<String> = supervisorScope {val news = async { "news: 3 new articles" }val weather = async<String> { throw RuntimeException("weather service down") }val stocks = async { "stocks: ACME +2.1%" }listOf(news, weather, stocks).map { widget ->try {widget.await()} catch (e: CancellationException) {throw e // never swallow cancellation} catch (e: Exception) {"fallback (" + e.message + ")"}}}fun main() = runBlocking {// weather fails, but news and stocks still render.loadWidgets().forEach(::println)}
supervisorScope isolates failures: one async can fail without cancelling its siblings. Await each Deferred in its own try/catch and substitute a fallback, but always rethrow CancellationException first so cancellation still propagates. Runs on plain kotlinx.coroutines.
Arena IDEimport kotlinx.coroutines.*suspend fun slowCall(name: String, ms: Long): String {delay(ms) // suspension point: throws CancellationException if cancelledreturn name}fun main() = runBlocking {val request = launch {coroutineScope {val fast = async { slowCall("A", 200) }val slow = async { slowCall("B", 5000) }println(fast.await() + " " + slow.await())}}delay(500) // client disconnects while B is still runningrequest.cancelAndJoin()println("request cancelled -> both async children were stopped")}
Cancellation propagation: when the request coroutine is cancelled (here simulated by cancelling the job mid-flight), structured concurrency stops every child at its next suspension point. delay() is a cooperative cancellation point.
Arena IDE🧠Comprueba tu comprensión
0/1 · 0/1 answered1. In a suspend handler that fans out three parallel async calls, what is the key difference between wrapping them in coroutineScope { } versus supervisorScope { }?