El modelo mental de suspend + WebFlux
En WebFlux, suspend no hace tu handler más rápido: permite que un hilo atienda miles de peticiones aparcando el trabajo en lugar de bloquearlo.
El Spring MVC clásico sigue un modelo de hilo-por-petición: cada petición HTTP entrante toma un hilo dedicado de un pool, y ese hilo queda atado a la petición hasta que la respuesta se escribe por completo. Cuando el handler llama a una base de datos u otro servicio HTTP, el hilo se queda inactivo — bloqueado — esperando a que lleguen los bytes. Con unos cientos de peticiones lentas concurrentes, el pool se agota y las nuevas peticiones se encolan aunque la CPU casi no haga nada. Spring WebFlux existe para romper ese acoplamiento entre 'una petición en curso' y 'un hilo secuestrado'.
WebFlux corre sobre un pool pequeño y de tamaño fijo de hilos de event-loop (los de Netty por defecto — normalmente uno por núcleo de CPU). La regla de oro del event loop es simple y absoluta: nunca lo bloquees. Un handler debe devolver el control al loop en el instante en que de otro modo esperarÃa por I/O, para que ese mismo hilo pueda tomar de inmediato trabajo de otra petición. Por debajo, WebFlux se apoya en Mono y Flux de Project Reactor, que modelan un cómputo como un pipeline no bloqueante que emite su resultado más tarde mediante un callback en lugar de aparcar el hilo que llama.
Los pipelines de Reactor son potentes pero se leen de forma incómoda — cadenas de flatMap, zipWith y switchIfEmpty oscurecen el flujo de control ordinario. Las corrutinas de Kotlin resuelven esto. Una función suspend puede pausarse en un punto de suspensión y reanudarse después sin retener su hilo: cuando una corrutina se suspende, el hilo de event-loop subyacente se libera para atender otro trabajo, y la corrutina se reanuda (posiblemente en otro hilo) en cuanto el resultado esperado está listo. Esto te da el comportamiento no bloqueante que WebFlux exige, pero escrito como código secuencial normal, de arriba abajo, con try/catch, bucles y condicionales corrientes.
Spring conecta ambos mundos directamente. Un método de controlador puede declararse simplemente como suspend, y Spring se suscribirá a la corrutina resultante, traducirá la suspensión al contrato reactivo de Reactor y escribirá la respuesta cuando la corrutina termine — sin ningún Mono.fromCallable ni .block() manual. Devolver un Flow<T> se mapea a una respuesta en streaming Flux<T>. El framework adapta por ti tu código idiomático de corrutinas al runtime reactivo, asà que el handler parece sÃncrono mientras se comporta de forma asÃncrona.
Por eso todos los métodos de la cadena de llamadas deberÃan ser suspend, hasta el fondo. Un handler suspendido que llama a una función bloqueante normal no gana nada: en el momento en que se ejecuta la llamada bloqueante (digamos, una consulta JDBC o un Thread.sleep), congela el hilo de event-loop, y has reintroducido exactamente el cuello de botella que WebFlux fue diseñado para eliminar — solo que ahora con un pool mucho más pequeño, lo que empeora el daño. La suspensión solo se propaga si todo el camino coopera: controlador suspend, servicio suspend, repositorio suspend y un driver no bloqueante (R2DBC para SQL, Mongo reactivo, un cliente HTTP compatible con corrutinas) en el fondo.
La distinción crucial es I/O no bloqueante frente a bloqueante, no sintaxis async frente a sync. La I/O no bloqueante (R2DBC, el cliente de Netty, drivers reactivos) registra interés en un socket y retorna de inmediato; el SO notifica al event loop cuando los datos están listos, y ningún hilo espera entremedias. La I/O bloqueante (JDBC, streams de java.io, RestTemplate, lecturas de archivo) ocupa su hilo durante toda la espera. Envolver una llamada bloqueante en una corrutina no la hace no bloqueante — solo mueve el bloqueo a otro sitio.
Cuando realmente debes llamar código bloqueante heredado desde una app WebFlux, la salida de emergencia correcta es delegarlo a un pool de hilos dedicado con withContext(Dispatchers.IO) (o un dispatcher propio acotado). Eso mantiene libres los hilos de event-loop mientras el trabajo bloqueante ocupa un hilo IO que puedes dimensionar y aislar. Es un puente pragmático, no la meta — el destino es una pila totalmente no bloqueante donde la suspensión, y no el aparcado de hilos, carga con la espera.
Quédate con esta imagen mental: un handler suspend en WebFlux es una máquina de estados que cede voluntariamente su hilo en cada await, deja que ese hilo atienda otras peticiones mientras una llamada de red está en curso, y se reanuda solo cuando llegan sus datos. El throughput deja de escalar con el número de hilos y empieza a escalar con la memoria y las conexiones disponibles. Ese es todo el beneficio — y solo se sostiene si mantienes el camino no bloqueante desde el controlador hasta el driver.
@RestControllerclass OrderController(private val service: OrderService) {// suspend handler: Spring adapts it onto Reactor's reactive contract@GetMapping("/orders/{id}")suspend fun getOrder(@PathVariable id: Long): OrderDto =service.findOrder(id)// returning a Flow maps to a streaming Flux<T> response@GetMapping("/orders")fun streamOrders(): Flow<OrderDto> =service.allOrders()}@Serviceclass OrderService(private val repo: OrderRepository) {// every layer is suspend so suspension propagates all the way downsuspend fun findOrder(id: Long): OrderDto =repo.findById(id)?.toDto() ?: throw OrderNotFound(id)fun allOrders(): Flow<OrderDto> =repo.findAll().map { it.toDto() }}// Coroutine-aware R2DBC repository: suspend + Flow, non-blocking driverinterface OrderRepository : CoroutineCrudRepository<Order, Long>
A fully suspending WebFlux stack. Spring subscribes to the suspend controller automatically; suspension flows from controller to service to a non-blocking R2DBC repository. No .block(), no Mono plumbing — yet nothing pins the event-loop thread.
@GetMapping("/report/{id}")suspend fun badReport(@PathVariable id: Long): Report {// WRONG: JDBC is blocking I/O. It does NOT suspend.// The event-loop thread is pinned until the query returns.val rows = jdbcTemplate.queryForList("SELECT * FROM big_table WHERE id = ?", id)Thread.sleep(200) // also blocks the loop — never do this in WebFluxreturn buildReport(rows)}// Correct escape hatch when you MUST use legacy blocking code:@GetMapping("/report/{id}")suspend fun goodReport(@PathVariable id: Long): Report = withContext(Dispatchers.IO) {// blocking work now runs on a dedicated IO pool; event-loop stays freeval rows = jdbcTemplate.queryForList("SELECT * FROM big_table WHERE id = ?", id)buildReport(rows)}
The trap. This handler LOOKS async because it is suspend, but the blocking JDBC/sleep call freezes a precious event-loop thread for the whole wait. Under load the tiny Netty pool starves and latency collapses.
import kotlinx.coroutines.*import kotlin.system.measureTimeMillissuspend fun handleRequest(id: Int): Int {delay(1000) // suspends, like awaiting non-blocking I/O — no thread heldreturn id * 2}fun main() = runBlocking {val elapsed = measureTimeMillis {val results = (1..10_000).map { id ->async { handleRequest(id) } // 10k concurrent coroutines}.awaitAll()println("Handled ${results.size} requests")}// ~1000ms total, not 10,000 seconds: suspension, not threads, carries the waitprintln("Took ${elapsed}ms on a tiny thread pool")}
Why suspension scales while blocking does not — runnable on plain kotlinx.coroutines. delay() suspends (frees the thread), so 10,000 'requests' finish in ~1s on a handful of threads. Swap delay for Thread.sleep and you would need 10,000 threads to match it.
Arena IDE🧠Comprueba tu comprensión
0/1 · 0/1 answered1. You add a suspend keyword to a WebFlux controller method, but inside it you call a JDBC repository (blocking) without any dispatcher change. What actually happens under load?