Frío frente a caliente: qué cambia cuando el flujo deja de nacer con cada recolector
La distinción entre flujo frío y flujo caliente no es una etiqueta taxonómica sino un cambio de propietario: en un flujo frío la producción pertenece al recolector, nace con él, hereda su contexto y muere con su cancelación; en un flujo caliente la producción pertenece a otro y el recolector es un invitado que llega tarde. Esta lección diseca qué cinco cosas cambian exactamente en ese traspaso —identidad de la corrutina productora, propiedad de la cancelación, destino de las excepciones, existencia de contrapresión y posibilidad de perder emisiones— y por qué cada una de ellas abre una clase de fallo que en el mundo frío sencillamente no podía ocurrir.
Casi todo el mundo aprende que un flujo frío no hace nada hasta que alguien lo colecta y que un flujo caliente emite aunque nadie escuche. Es cierto y es inútil, porque describe el síntoma y no la causa. La causa es una cuestión de propiedad: en un Flow frío la corrutina que produce los valores es la corrutina que los colecta, de modo que el productor hereda su contexto, obedece su cancelación y le entrega sus excepciones; en un flujo caliente la producción vive en otra corrutina, con otro ciclo de vida, y colectar deja de ser arrancar para convertirse en suscribirse. Todo lo que aparece en este nivel —el buffer de repetición, las políticas de desbordamiento, la conflación por igualdad, WhileSubscribed— son respuestas a preguntas que solo existen una vez que has cruzado esa frontera.
- Explicar la frialdad como una propiedad estructural del
Flowy no como una mera evaluación perezosa. - Enumerar con precisión las cinco cosas que cambian cuando la producción deja de pertenecer al recolector.
- Reconocer las clases de fallo nuevas que aparecen al calentar un flujo: emisiones perdidas, terminales que no terminan y fugas de corrutina.
- Distinguir un flujo caliente de un envoltorio frío sobre una fuente caliente, como los que construyen
callbackFlowychannelFlow.
Frialdad no es pereza: es propiedad
Un Flow<T> construido con el constructor flow no es una secuencia, ni un buffer, ni un objeto que contenga valores. Es un objeto que contiene una lambda suspendida. Cuando escribes collect, esa lambda se ejecuta —en tu corrutina, con tu contexto, bajo tu Job— y cada emit es sencillamente una llamada a una función suspendida que tú, el recolector, le pasaste disfrazada de FlowCollector. No hay concurrencia, no hay cola, no hay entrega diferida: hay una llamada directa.
val temperaturas: Flow<Int> = flow {
println("abriendo el sensor") // se ejecuta una vez POR CADA collect
repeat(3) { emit(leerSensor()) }
}
temperaturas.collect { println(it) } // abre el sensor, emite 3
temperaturas.collect { println(it) } // vuelve a abrirlo, emite 3 más
De esa estructura —y no de una decisión de diseño sobre pereza— se derivan todas las garantías del mundo frío. La producción es secuencial porque es una llamada a función. El contexto de emisión es el del recolector, y por eso existe la regla de la transparencia de contexto que prohíbe emitir desde otro CoroutineContext. La cancelación funciona sin escribir nada, porque cancelar al recolector cancela la única corrutina que hay. Las excepciones del productor llegan al try del recolector porque están en la misma pila. Y la contrapresión es automática: mientras tu lambda de collect tarda, emit está suspendida esperándola, sin buffer intermedio que disimule el desequilibrio.
Todo eso convierte a un flujo frío en algo muy parecido a una función pura que devuelve valores a lo largo del tiempo: puedes colectarlo dos veces y obtener dos ejecuciones independientes, puedes reintentarlo con retry porque reejecutar es trivial, y puedes razonar sobre él localmente. Un flujo caliente pierde exactamente esas cuatro propiedades, y a cambio gana una sola: puede tener más de un observador del mismo hecho.
Una Sequence también es perezosa y no es fría en este sentido: es síncrona y monousuario. La frialdad de un Flow es una afirmación sobre quién ejecuta el productor, no sobre cuándo. Confundir ambas cosas lleva a esperar que SharedFlow sea simplemente un Flow que empieza antes, y no lo es.
Las cinco cosas que cambian al calentar
Cuando el productor pasa a vivir en su propia corrutina, cinco propiedades cambian a la vez. Conviene nombrarlas por separado porque cada equipo descubre solo una de ellas, y siempre en producción.
Identidad de la corrutina
En frío el productor es tu corrutina. En caliente es otra, con su propio contexto y su propio despachador. Lo que hagas en collect ya no influye en dónde se produce.
Propiedad de la cancelación
Cancelar al recolector ya no detiene la producción: solo lo desuscribe. Quien detiene al productor es el dueño del ámbito donde vive, y si ese ámbito es global no lo detiene nadie.
Destino de las excepciones
Un fallo del productor caliente no aterriza en tu catch: aterriza en su propio ámbito. El recolector ve, como mucho, que el flujo dejó de emitir.
Contrapresión y pérdida
Sin recolector no hay a quién esperar, así que la emisión necesita una política: suspender, descartar o guardar. Es la primera vez que hay que decidirlo explícitamente.
La quinta es la más sutil y la que rompe más código de test: un flujo caliente normalmente no termina. Un SharedFlow no tiene concepto de completarse, porque completar significaría que la fuente del mundo se acabó, y las fuentes del mundo no se acaban. Eso convierte a los operadores terminales que esperan la terminación en trampas silenciosas.
val frio: Flow<Int> = flowOf(1, 2, 3)
val caliente: MutableSharedFlow<Int> = MutableSharedFlow()
frio.toList() // [1, 2, 3]
caliente.toList() // NO retorna nunca: no hay evento de terminación
caliente.first() // sí retorna: es un terminal que se conforma con uno
Y hay una consecuencia temporal aún más incómoda: entre el instante en que arrancas la corrutina que colecta y el instante en que esa corrutina llega efectivamente a suscribirse hay una ventana, pequeña pero real, en la que las emisiones se pierden. En el mundo frío esa ventana no existía porque colectar causaba la producción. En el caliente hay que cerrarla a mano, con replay, con un subscriptionCount que espera, o aceptando que ciertas emisiones se pierden por diseño.
flowchart LR subgraph Frio C1[Recolector uno] --> P1[Produccion propia] C2[Recolector dos] --> P2[Produccion propia] end subgraph Caliente F[Fuente unica viva] --> B[Buffer de repeticion] B --> S1[Suscriptor uno] B --> S2[Suscriptor dos] B --> S3[Suscriptor tardio] end
Envoltorios fríos sobre fuentes calientes
Existe una tercera categoría que casi nunca se nombra y que causa la mitad de las discusiones estériles sobre este tema: el flujo frío que envuelve una fuente caliente. Es lo que construyes con callbackFlow cuando adaptas un sensor, un socket o un oyente de la plataforma. El objeto Flow resultante es frío en sentido estricto —cada collect registra su propio oyente y lo da de baja al cancelarse— aunque la cosa del mundo que hay debajo lleve emitiendo desde antes de que arrancara tu proceso.
fun ubicaciones(): Flow<Ubicacion> = callbackFlow {
val oyente = Oyente { trySend(it) } // el mundo no puede suspender
servicio.registrar(oyente)
awaitClose { servicio.dar_de_baja(oyente) } // uno por recolector
}
Esa distinción importa por una razón muy concreta: el coste. Si dos pantallas colectan ese flujo, hay dos registros, dos oyentes y dos suscripciones al servicio. La frialdad se paga en duplicación de trabajo, y ese es precisamente el problema que resuelve shareIn. Al revés, calentar un flujo que era barato de reejecutar no compra nada y sí cuesta: una corrutina viva, un buffer y una clase entera de errores nuevos.
La regla práctica que se deriva de todo esto es que la temperatura no es una propiedad estética del código sino la respuesta a una pregunta sobre el dominio: ¿el valor existe porque alguien pregunta, o existía ya y alguien se asoma a mirarlo? Una consulta a base de datos, una petición de red, una lectura de fichero, existen porque alguien pregunta: son frías. La posición del cursor, el estado de la conexión, la sesión del usuario, la temperatura de la sala: existían ya, y son calientes con independencia de cómo las modeles. Modelar una cosa caliente como flujo frío produce duplicación; modelar una fría como caliente produce trabajo que nadie pidió y estado que nadie limpia.
Como el recolector ya no controla la producción, un flujo caliente lanzado sobre GlobalScope sigue vivo, sigue consumiendo su fuente y sigue llenando su buffer mucho después de que la última pantalla haya desaparecido. No falla, no avisa y no aparece en ninguna traza: simplemente ocupa.
Probar lo caliente exige controlar el tiempo
La última asimetría, y la que más código de prueba rompe, es que un test sobre un flujo frío es determinista por construcción: colectas, el productor se ejecuta en tu corrutina, obtienes la lista completa y afirmas sobre ella. Nada de eso sobrevive al calentamiento. Al no haber terminación, toList no vale; al no haber relación causal entre suscribirse y producir, el resultado depende de quién llegó antes; y al vivir el productor en otro ámbito, el fallo puede ocurrir después de que tu aserción haya pasado.
@Test fun estadoCaliente() = runTest {
val compartido = fuente.shareIn(backgroundScope, SharingStarted.Eagerly, replay = 1)
val vistos = mutableListOf<Int>()
val trabajo = backgroundScope.launch { compartido.toList(vistos) }
advanceUntilIdle() // el tiempo virtual sustituye a la espera real
assertEquals(listOf(1, 2, 3), vistos)
trabajo.cancel() // hay que cancelar: nadie más lo hará
}
Tres piezas hacen falta siempre y ninguna es opcional. Un ámbito que muera con el test —backgroundScope en runTest— para que el productor no sobreviva al caso. Un control explícito del tiempo virtual, porque los operadores temporales dejan de ser detalles y pasan a ser la definición del comportamiento. Y una cancelación manual del recolector, porque colectar algo que no termina en un test que espera a sus hijos es la receta canónica de una prueba que se cuelga.
Existe una consecuencia metodológica más profunda: en el mundo frío basta con probar el flujo; en el caliente hay que probar además la ventana, es decir, qué ve un suscriptor que llega en el peor instante posible. Ese caso —el suscriptor tardío— es el que revela si tu replay está bien elegido, y es exactamente el que ningún test escrito por costumbre incluye.
El resumen operativo, para llevárselo a la revisión de código, cabe en cuatro preguntas que solo tienen sentido del lado caliente y que conviene exigir respondidas en cada shareIn que aparezca en un cambio.
Quién lo mantiene vivo
Qué ámbito posee la corrutina productora y cuánto dura ese ámbito comparado con la utilidad del dato.
Qué pasa en el vacío
Si se sigue produciendo cuando nadie escucha, y quién paga esa producción en batería, cuota o conexiones.
Qué hereda el tardío
Cuántos valores ve un suscriptor que llega después, y si esa cantidad es correcta tanto para el primero como para el que vuelve.
Dónde aterriza el fallo
A qué manejador llega una excepción de la fuente, sabiendo que ya no es el catch de quien colecta.
Conviene entender que la palabra caliente no describe una temperatura sino un contrato de propiedad, y que ese contrato se firma en un solo sentido y no se deshace. Mientras el flujo es frío, existe una simetría perfecta entre observación y ejecución: nadie mira, nada ocurre; alguien mira, ocurre para él; deja de mirar, deja de ocurrir. Esa simetría es la que permite razonar localmente, porque el único ámbito relevante para entender qué pasa es el del recolector que tienes delante, y es también la que hace que reintentar, cancelar y capturar excepciones sean operaciones triviales: todo sucede dentro de una única corrutina cuya pila puedes seguir con el dedo. En el instante en que introduces shareIn, MutableSharedFlow o cualquier otra forma de calentar, esa simetría se rompe de manera irreversible y el sistema adquiere una entidad nueva —un productor con vida propia— que ya no responde a nadie que colecte, sino a un ámbito que alguien eligió en otro fichero, quizá hace meses. A partir de ahí toda pregunta cambia de forma: ya no basta con preguntar qué emite el flujo, hay que preguntar desde cuándo lleva emitiendo, quién lo mantiene vivo, qué hace cuando nadie escucha, cuántos valores guarda para el que llegue tarde y qué ocurre con el que llega tarde a un evento que ya no está en el buffer. Ninguna de esas preguntas tiene respuesta por defecto correcta, y por eso la API te obliga a contestarlas todas: replay contesta cuánta historia hereda un recién llegado, extraBufferCapacity y onBufferOverflow contestan qué se sacrifica cuando el productor va más rápido que el consumidor, SharingStarted contesta cuándo empieza y cuándo para la producción, y la conflación por igualdad de StateFlow contesta —silenciosamente, sin preguntarte— que dos valores iguales consecutivos son un solo hecho. Cada una de esas respuestas es una política de pérdida disfrazada de parámetro de configuración, y la razón por la que este nivel entero existe es que en el mundo frío ninguna de ellas hacía falta: la contrapresión era la propia suspensión de emit, la historia era la reejecución completa, y el ciclo de vida era el del recolector. Calentar te da multidifusión y te quita, a cambio, la posibilidad de no decidir.
- Toma un
Flowde tu proyecto y añade una traza en la primera línea del constructorflow. Coléctalo desde dos corrutinas simultáneas y cuenta cuántas veces se imprime. Esa cuenta es la definición operativa de frialdad. - Escribe un
MutableSharedFlowsin repetición, emite diez valores y arranca el recolector inmediatamente después delaunch. Repite el experimento veinte veces y cuenta cuántos valores ve. Explica la dispersión. - Llama a
toListsobre ese mismo flujo caliente dentro de unwithTimeoutde un segundo y observa qué excepción obtienes. Razona por qué no es un fallo del flujo. - Convierte un
callbackFlowtuyo en compartido y comprueba, con una traza enawaitClose, cuántos registros al servicio hay antes y después del cambio. - Inventaria las fuentes de datos de un módulo real y clasifícalas en existen porque alguien pregunta y existían ya. Señala cada desajuste entre esa clasificación y la temperatura que tiene hoy el código.