Back-pressure en Flow: secuencial por defecto, y qué compra buffer()
emit() no retorna hasta que tu colector terminó. Ese único hecho explica todas las preguntas de rendimiento sobre Flow que te van a hacer.
Explorar la explicación completa 8 ideas
La propiedad más importante de un `Flow` es la menos publicitada: es **secuencial**. `emit(x)` es una llamada suspendible que no retorna hasta que el lambda del colector terminó de procesar `x`. Productor y consumidor se alternan entonces — producir, consumir, producir, consumir — y el tiempo total para `n` elementos es `n * (producir + consumir)`. No hay ningún thread pool haciendo algo astuto por debajo, ni ninguna cola oculta. Por eso un Flow no necesita sincronización y por eso su manejo de excepciones es simple; y también por eso un pipeline ingenuo puede tardar el doble de lo necesario.
`buffer()` es el operador que rompe la alternancia. Inserta un canal entre las dos etapas, así el productor corre adelante hacia el buffer mientras el colector trabaja, y el total pasa a estar acotado por la etapa *más lenta* en vez de por su suma. Con cuatro items, 30 ms de producción y 70 ms de consumo, secuencial cuesta 400 ms y con buffer cuesta 310 — `producir + n * consumir`. Se conservan todos los valores; lo que pagás es memoria, y `buffer(n)` te deja decir cuánta. Es el operador de mayor apalancamiento en Flow, y es una palabra.
`conflate()` es `buffer(CONFLATED)`: un buffer de un lugar donde un valor nuevo pisa a uno viejo. El productor nunca espera, la memoria queda acotada en un elemento, y el precio es que los valores para los que el colector estaba ocupado se **saltean por completo** — nunca se empiezan. Para una UI que renderiza el último estado, un dashboard, un ticker de precios, eso es exactamente lo correcto: un valor que ya fue superado no vale el trabajo. Para cualquier cosa que no podés perder, es pérdida de datos con nombre amable.
`collectLatest { }` se ve parecido y es fundamentalmente distinto: en vez de saltear valores que no empezaste, **cancela el trabajo en curso** cuando llega un valor más nuevo. Solo el último valor llega al final del bloque. Ese es el operador del trabajo obsoleto — búsqueda mientras escribís, una pantalla que se re-renderiza desde el estado más nuevo, cualquier request cuyo resultado no vale nada en cuanto existe una entrada más nueva. Como la cancelación es cooperativa, el cuerpo tiene que suspender de verdad para que funcione: un cuerpo que hace un cómputo bloqueante largo nunca se cancela, y `collectLatest` degrada en silencio a un `collect` plano.
`flowOn(dispatcher)` cambia dónde corre lo que está **aguas arriba**. Todo lo que está encima en la cadena se ejecuta en ese dispatcher; todo lo de abajo se queda donde está el colector. Es la única forma correcta de mover el trabajo del productor fuera del hilo actual, porque emitir directamente desde otro contexto es ilegal (esa es la regla de preservación de contexto del contrato de Flow). Dos cosas sorprenden: aplica solo hacia arriba — la posición en la cadena importa — e introduce un buffer propio, así que mover una etapa a otro dispatcher puede cambiarte los tiempos aunque nunca hayas escrito `buffer()`.
La concurrencia dentro de un Flow es opcional y explícita. `map { }` es secuencial por construcción. Cuando cada elemento necesita una llamada asíncrona independiente, `flatMapMerge(concurrency = k) { flow { … } }` corre hasta `k` flows internos a la vez e intercala sus resultados; `flatMapConcat` mantiene el orden de la fuente a costa del paralelismo; `flatMapLatest` cancela el flow interno anterior cada vez que llega un elemento nuevo. Esas tres son las mismas tres respuestas que dan `flatMap`, `concatMap` y `switchMap` en Reactor, y elegir entre ellas es elegir entre throughput, orden y obsolescencia.
La fusión de operadores es por qué esto sigue siendo barato. Los operadores adyacentes `flowOn`, `buffer` y `conflate` los colapsa el runtime en un único canal con una única configuración, así que escribir `.flowOn(IO).buffer()` no crea dos colas. También significa que la posición de `buffer()` es significativa de un modo en que la de `map` no lo es: un buffer insertado antes de una etapa cara desacopla esa etapa de lo que tiene arriba, y buffers en ambos extremos de una etapa lenta del medio dejan correr las tres a la vez.
Una checklist práctica. Medí antes de agregar un buffer — bajo `runTest`, `currentTime` te da tiempos virtuales exactos, así que «¿el colector es el cuello de botella?» es una pregunta con un número y no una opinión. Agregá `buffer()` cuando ambas etapas son genuinamente lentas y tenés que conservar todos los valores. Usá `conflate()` cuando solo importa el último valor y el trabajo por valor es corto. Usá `collectLatest` cuando el trabajo en vuelo pierde valor ante una entrada más nueva. Y dejalo secuencial cuando ya es lo bastante rápido, porque secuencial es la versión sin cola, sin crecimiento de memoria y sin sorpresas.
Aprendamos juntos.
Pregúntame sobre este curso o por dónde empezar.
import kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {val n = 4L; val produce = 30L; val consume = 70Lfun src() = flow { for (i in 1..n) { delay(produce); emit(i) } }var t = currentTimeval a = mutableListOf<Long>()src().collect { delay(consume); a.add(it) }println("sequential: " + a.size + " items in " + (currentTime - t) + "ms")t = currentTimeval b = mutableListOf<Long>()src().buffer().collect { delay(consume); b.add(it) }println("buffered: " + b.size + " items in " + (currentTime - t) + "ms")t = currentTimeval c = mutableListOf<Long>()src().conflate().collect { delay(consume); c.add(it) }println("conflated: " + c.size + " items in " + (currentTime - t) + "ms")}
The same pipeline measured three ways on runTest's virtual clock. The numbers are exact, not approximate — delay() costs no wall-clock time and currentTime reports the virtual instant.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {fun src() = flow { for (i in 1..5) { delay(10); emit(i) } }val plain = mutableListOf<Int>()src().collect { delay(35); plain.add(it) }println("plain = " + plain)val conflated = mutableListOf<Int>()src().conflate().collect { delay(35); conflated.add(it) }println("conflate = " + conflated) // skipped, never startedval latest = mutableListOf<Int>()src().collectLatest { delay(35); latest.add(it) }println("collectLatest = " + latest) // cancelled mid-flight}
Skip versus abandon. conflate() never STARTS the values it skipped; collectLatest CANCELS the ones it started, so only the final value survives to the end of the block.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.*@OptIn(ExperimentalCoroutinesApi::class)fun main() = runTest {// Later ids are FASTER, so ordering differences become visible.fun fetch(id: Int) = flow { delay((4 - id) * 50L); emit("r" + id) }val ids = flowOf(1, 2, 3)var t = currentTimeprintln("concat = " + ids.flatMapConcat { fetch(it) }.toList() + " in " + (currentTime - t))t = currentTimeprintln("merge = " + ids.flatMapMerge(concurrency = 3) { fetch(it) }.toList() + " in " + (currentTime - t))t = currentTimeprintln("latest = " + ids.flatMapLatest { fetch(it) }.toList() + " in " + (currentTime - t))}
Concurrency is opt-in: flatMapConcat keeps order, flatMapMerge(k) runs k inner flows at once, flatMapLatest cancels the previous one. The elapsed times tell you which is which.
Arena IDEimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*fun main() = runBlocking {val names = mutableListOf<String>()flow {names.add("produce on " + Thread.currentThread().name.substringBefore('@').trim())emit(1)}.map { it * 2 } // still upstream: moved too.flowOn(Dispatchers.Default) // everything ABOVE runs on Default.collect {names.add("collect on " + Thread.currentThread().name.substringBefore('@').trim())}// Producer on the Default pool, collector back on the caller's context.println(names.joinToString(" | "))}
flowOn moves only what is ABOVE it. Position is the whole API — and note it also inserts a buffer, which is why a dispatcher change can alter your timings on its own.
Arena IDE🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. A Flow produces an item every 30 ms and the collector takes 70 ms per item. You add `.buffer()`. What changes?