Fugas de scope: corrutinas que sobreviven a la pantalla
Lanzar en el ámbito equivocado produce dos fallos opuestos y ambos son caros: el trabajo que sobrevive a la pantalla y sigue reteniendo memoria y consumiendo batería, y el trabajo que muere antes de tiempo y deja al usuario sin el resultado que había pedido. Esta lección establece el criterio que resuelve los dos a la vez —el ámbito debe corresponder a la vida esperada del resultado, no a la del código que lo lanza—, cataloga los cinco ámbitos disponibles en una aplicación Android con sus garantías reales, diagnostica las fugas que sobreviven a un ámbito correcto por falta de cooperación con la cancelación, y aborda el caso de las suscripciones a fuentes externas, donde el fallo no es la corrutina sino la devolución de llamada que nadie retiró.
La concurrencia estructurada resolvió el problema de las corrutinas huérfanas de una forma tan limpia que produjo un efecto secundario inesperado: convenció a mucha gente de que el problema estaba resuelto del todo. Y no lo está, porque la estructura garantiza que toda corrutina tenga un padre y muera con él, pero no decide cuál debe ser ese padre. Esa decisión sigue siendo enteramente humana, se toma en cada launch que se escribe, casi siempre por costumbre y no por análisis, y equivocarla produce dos fallos que son opuestos entre sí y por eso rara vez se estudian juntos. Uno es el trabajo que sobrevive a la pantalla que lo pidió; el otro es el trabajo que muere antes de entregar lo que el usuario esperaba. Los dos tienen la misma causa formal —una discrepancia entre la vida del ámbito y la vida del resultado— y por eso admiten un único criterio de corrección.
- Aplicar el criterio del resultado para elegir ámbito, en lugar de heredarlo del lugar donde se escribe el código.
- Distinguir las garantías reales de los cinco ámbitos disponibles y sus fallos característicos.
- Diagnosticar corrutinas que ignoran la cancelación y sobreviven pese a tener un ámbito correcto.
- Cerrar suscripciones a fuentes externas y reconocer la fuga que no está en la corrutina sino en la devolución de llamada.
El criterio del resultado
La pregunta que hay que hacerse antes de escribir un launch no es dónde estoy, sino para quién es esto. El ámbito debe corresponder a la vida esperada del resultado, y la vida esperada del resultado es una propiedad del requisito, no del fichero. Si el resultado deja de tener sentido cuando la pantalla desaparece, el ámbito es el de la pantalla. Si el resultado sigue teniendo sentido aunque el usuario se vaya, el ámbito debe ser mayor, y probablemente ni siquiera debe ser un ámbito de corrutinas sino un planificador de trabajo persistente.
La formulación importa porque invierte la costumbre. Lo natural es lanzar en el ámbito que se tiene a mano: en un composable, el que devuelve rememberCoroutineScope; en un ViewModel, viewModelScope; en una Activity, lifecycleScope. Esa heurística acierta la mayoría de las veces por accidente estadístico, porque el código suele escribirse cerca de donde vive su resultado, y por eso los fallos que produce son escasos, tardíos y desconcertantes.
viewModelScope
Vive lo que vive el ViewModel: sobrevive a los cambios de configuración y muere en onCleared. Usa un SupervisorJob y el despachador principal inmediato. Es el ámbito correcto para casi todo el trabajo de pantalla.
lifecycleScope y rememberCoroutineScope
Mueren con la vista o con la salida de la composición. Sirven para animaciones, gestos y trabajo puramente visual. Usarlos para una petición de red convierte cada rotación en una cancelación y un reinicio.
Ámbito de aplicación y trabajo persistente
Un ámbito inyectado con vida de proceso sirve para lo que debe terminar aunque la pantalla se vaya, y nada más. Si además debe sobrevivir a la muerte del proceso, ningún ámbito basta y hace falta un planificador de trabajo.
Conviene añadir un ámbito más a la lista porque es el que produce los fallos más difíciles de atribuir: el que se crea a mano dentro de una clase con un constructor de ámbito y nunca se cancela. No es un ámbito global, así que pasa las revisiones que buscan esa palabra, y sin embargo se comporta igual, porque nadie llama a cancelarlo. Un ámbito propio es legítimo solo si la clase que lo posee tiene un momento definido de fin de vida y lo cancela ahí; si no puedes señalar esa línea, no tienes un ámbito, tienes una fuga con buen nombre.
El caso didáctico es la subida de un archivo. El usuario pulsa enviar y sale de la pantalla; con viewModelScope, la subida se cancela y el archivo no llega, y el fallo se atribuye a la red porque su manifestación es indistinguible. Con un ámbito de aplicación, la subida termina, pero se pierde si el sistema mata el proceso. La única solución completa la delega a un planificador que persiste la petición y la reintenta, y esa decisión es de producto antes que técnica: alguien tiene que decidir si la subida es una cortesía o una promesa.
// El ambito es una decision sobre el resultado, no sobre el fichero
class EditorViewModel(
private val subidas: GestorDeSubidas, // planifica trabajo persistente
) : ViewModel() {
fun guardarBorrador(texto: String) = viewModelScope.launch {
repo.guardarLocal(texto) // muere con la pantalla y esta bien
}
fun publicar(texto: String) {
subidas.encolar(texto) // debe terminar aunque el usuario salga
}
}
Lanzar en un ámbito global elimina el síntoma inmediato porque nada cancela nunca, y por eso funciona en la demostración. Lo que renuncia a cambio es a todo lo que la concurrencia estructurada aportaba. La corrutina deja de propagar la cancelación, de modo que ninguna pantalla puede detenerla. Deja de propagar los errores hacia un supervisor, de modo que una excepción no capturada acaba en el gestor por defecto y a menudo en un cierre inesperado. Y como retiene sus capturas mientras vive, una corrutina global que capture el ViewModel lo mantiene vivo indefinidamente junto con todo su estado. Un ámbito de aplicación inyectado tiene la misma vida y ninguno de estos problemas, porque es un objeto con dueño al que se le puede pedir cuentas. La diferencia entre ambos no es técnica sino de gobierno.
El ámbito correcto no basta
Hay una segunda familia de fugas que sobrevive a un ámbito perfectamente elegido, y por eso desconcierta tanto: la corrutina está bien alojada, el ámbito se cancela cuando debe, y sin embargo el trabajo sigue. La causa es que la cancelación en Kotlin es cooperativa. Cancelar no interrumpe nada: marca el trabajo como cancelado y confía en que el código consulte esa marca. Un bucle de cálculo que no llama a ninguna función suspendida no consulta nada y sigue hasta terminar, ocupando un hilo que ya nadie espera.
// No coopera: la cancelacion se marca y el bucle la ignora
while (quedaTrabajo) { procesarBloque() }
// Coopera: comprueba la marca en cada vuelta y sale con la excepcion adecuada
while (quedaTrabajo) { ensureActive(); procesarBloque() }
La comprobación de la marca no es el único mecanismo de cooperación y conviene conocer los otros dos, porque cada uno cubre un caso que los demás no. Toda función suspendida de la biblioteca estándar comprueba la cancelación por su cuenta, de modo que un bucle que llame a una de ellas en cada vuelta ya es cooperativo sin que su autor haya hecho nada; ese es el motivo por el que este fallo se concentra en el cálculo puro. Y para el trabajo que no es ni suspendido ni tuyo —una llamada a una biblioteca nativa, una operación de bloqueo sobre un socket—, ninguna comprobación sirve, y la única salida es envolverlo en una corrutina cancelable que cierre el recurso subyacente en su bloque de cancelación.
La variante inversa también existe y es más deliberada. Envolver una operación en un contexto no cancelable para asegurar que termine es legítimo en casos muy concretos —cerrar un fichero, liberar un recurso, escribir una entrada de auditoría— y desastroso si se aplica a una operación larga, porque convierte una cancelación en una espera indefinida. La regla práctica es que lo no cancelable debe caber en un bloque de limpieza y durar milisegundos; si dura más, no era limpieza.
flowchart TB A[El usuario abandona la pantalla] A --> B[El ambito se cancela] B --> C[Se marca el trabajo como cancelado] C --> D[La corrutina consulta la marca] D --> E[Termina y libera lo que retenia] C --> F[La corrutina nunca consulta la marca] F --> G[Sigue ocupando hilo y reteniendo memoria] style E fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
Existe una tercera variante, propia de las pruebas y de los repositorios, que consiste en fijar el despachador dentro de la función en lugar de recibirlo. El código así escrito ignora el despachador que le impone quien lo llama, lo que rompe el control de la concurrencia desde arriba y hace que las pruebas no puedan usar un despachador de prueba. No es una fuga de memoria, pero es una fuga de control, y produce pruebas lentas e intermitentes que el equipo acaba marcando como inestables en lugar de arreglar.
Conviene cerrar este apartado con la observación que más ahorra en la práctica: en Orbit, todo el cuerpo de un intent corre en el ámbito del contenedor, que está atado al ViewModel, de modo que el ámbito viene dado y la primera familia de fugas desaparece por construcción. Eso es una ventaja real y también una invitación al descuido, porque desplaza toda la atención a las otras dos familias, que la biblioteca no puede resolver por ti. Un intent cuyo cuerpo no coopera con la cancelación sigue vivo después de onCleared exactamente igual que cualquier otra corrutina, y un intent que registra una devolución de llamada sin desregistrarla filtra lo mismo que si estuviera escrito a mano.
Suscripciones que nadie cerró
La última familia no está en la corrutina sino en lo que la corrutina abrió. Cuando se envuelve una interfaz de devoluciones de llamada en un flujo, la cancelación del recolector no desregistra nada por sí sola: hay que escribir el desregistro en el bloque de cierre. Sin él, el emisor externo conserva una referencia hacia la lambda, la lambda captura su entorno y la cadena mantiene vivo todo lo que hubiera detrás, incluido el ViewModel entero.
// Sin el cierre, el sensor conserva la referencia para siempre
fun sensor(): Flow<Lectura> = callbackFlow {
val oyente = Oyente { trySend(it) }
sensorManager.registrar(oyente)
awaitClose { sensorManager.desregistrar(oyente) } // imprescindible
}
El mismo razonamiento se aplica a cualquier recurso que la corrutina abra y no solo a las devoluciones de llamada: un fichero, un cursor de base de datos, una conexión, un sensor. La regla general es que quien adquiere dentro de una corrutina debe liberar en un bloque que se ejecute también cuando la corrutina se cancela, y la cancelación es precisamente el caso que un bloque de captura de excepciones mal escrito deja fuera, porque la cancelación viaja como una excepción que no conviene tratar como un error.
La fuga simétrica es la que va en dirección contraria y no se detecta mirando los campos de la clase. Un objeto de larga vida —un repositorio singleton, un gestor de sesión, un bus de eventos— que guarde una referencia hacia el ViewModel impide su recolección aunque el ViewModel no guarde nada de nadie. Por eso el diagnóstico completo tiene dos direcciones: qué retiene mi objeto y quién retiene a mi objeto. La segunda pregunta es la que casi nadie hace y la que explica la mayoría de las fugas que sobreviven a una revisión cuidadosa.
No hace falta un perfilador para encontrar la mayoría de estos fallos. Sospecha de todo launch cuyo ámbito no aparezca justificado por lo que hace el cuerpo. Sospecha de todo callbackFlow o suspendCancellableCoroutine sin su bloque de cierre correspondiente, que es un fallo puramente sintáctico y por tanto detectable de un vistazo. Y sospecha de todo bucle largo sin ninguna llamada suspendida dentro, que es el patrón exacto de la corrutina que no coopera. Las tres se ven en una revisión de código en menos tiempo del que cuesta reproducir el fallo una sola vez, y las tres explican, juntas, la mayor parte de lo que un perfilador acabaría señalando una semana más tarde.
Medir lo que la lectura no ve
Las señales anteriores cubren la mayoría de los casos, pero hay una parte que solo aparece midiendo, y conviene saber qué mide cada herramienta porque responden a preguntas distintas y se confunden con facilidad.
Un detector de fugas de referencias responde a la pregunta de qué objetos siguen alcanzables cuando ya no deberían estarlo, y su salida más valiosa no es el nombre del objeto filtrado sino la cadena de referencias que lo sostiene, porque esa cadena señala el punto exacto donde cortar. Un perfilador de memoria responde a otra pregunta: si el consumo crece de forma monótona con una operación repetida, que es la firma de una fuga aunque no se sepa todavía de qué. La técnica es abrir y cerrar la misma pantalla veinte veces, forzar una recolección y comparar; si el número de instancias del ViewModel es veinte, tienes veinte pantallas muertas y ya no hace falta discutir si hay fuga.
Hay una tercera medida que casi nadie usa y que resulta desproporcionadamente útil: nombrar las corrutinas. Añadir un nombre al contexto hace que aparezca en las trazas de excepción y en los volcados de hilos, y convierte un diagnóstico de horas en uno de minutos, porque la traza deja de decir que algo falló en un despachador anónimo y pasa a decir qué operación de qué pantalla estaba en vuelo. El coste es una línea por lanzamiento y el beneficio se cobra en el peor momento posible, que es cuando llega un informe de producción sin pasos de reproducción.
// Un nombre por operacion convierte trazas anonimas en trazas legibles
viewModelScope.launch(CoroutineName("perfil.cargarMuro")) {
repo.cargarMuro(usuarioId)
}
La cuarta medida es de comportamiento y no de memoria: comprobar qué sigue ocurriendo después de cerrar la pantalla. Un registro en el punto de entrada y otro en el de salida de cada operación larga basta para ver, en la propia consola, qué operaciones siguen emitiendo cuando ya no hay nadie a quien entregarle nada. Es la comprobación más barata de todas y la única que detecta la fuga de trabajo, que consume batería y datos sin retener un solo byte de más y por eso es invisible para las tres anteriores.
Conviene ver lo que hay debajo de esta lección, porque no es una lista de ámbitos con sus reglas de uso sino un principio general sobre la propiedad de los procesos. Todo trabajo asíncrono tiene un dueño, entendido como la entidad cuya desaparición hace que ese trabajo deje de tener sentido, y la concurrencia estructurada no es más que la exigencia de que ese dueño esté escrito en el código en lugar de existir solo en la cabeza de quien lo escribió. Esa exigencia es la misma que resuelven la propiedad de la memoria en un lenguaje de sistemas o la jerarquía de supervisión en un sistema de actores, y no es casualidad que las tres tradiciones hayan convergido en la misma idea desde puntos de partida completamente distintos: cuando un recurso no tiene un dueño explícito, alguien acaba pagando su liberación de forma no determinista. La consecuencia práctica es que la pregunta correcta al escribir un launch no es técnica sino casi jurídica: si esto tarda treinta segundos y el usuario se va a los dos, ¿quién sigue teniendo interés legítimo en el resultado? Cuando la respuesta es nadie, el ámbito de la pantalla es el correcto y la cancelación es la conducta deseada, no un problema que resolver. Cuando la respuesta es el usuario, aunque ya no esté mirando, el ámbito de la pantalla es un error de propiedad y hay que subir un nivel. Y cuando la respuesta es el sistema, porque hay una promesa que cumplir aunque el proceso muera, entonces ningún ámbito de corrutinas es suficiente y lo que hace falta es persistencia, porque un ámbito solo puede garantizar lo que ocurre mientras el proceso viva. Casi todas las fugas que hemos catalogado son, en el fondo, un caso de trabajo cuyo dueño nominal no coincidía con su dueño real; y casi todas las cancelaciones prematuras son el mismo desajuste visto desde el otro lado. Una vez que se pregunta por el dueño, la elección del ámbito deja de ser una decisión y se convierte en una consecuencia.
- Recorre los
launchde un módulo real y anota, para cada uno, quién sigue teniendo interés en el resultado si el usuario abandona la pantalla a los dos segundos. - Encuentra al menos un caso donde el ámbito elegido cancele algo que el usuario esperaba recibir, y describe cómo se manifestó el fallo ante el usuario.
- Escribe un bucle de cálculo que ignore la cancelación y demuestra que sigue vivo tras cerrar la pantalla. Luego hazlo cooperativo y repite la medición.
- Busca una envoltura de devoluciones de llamada sin bloque de cierre y razona qué cadena de referencias mantiene viva.
- Elige un trabajo que deba sobrevivir a la muerte del proceso y explica por qué ningún ámbito de corrutinas puede garantizarlo.