Detectando fugas de memoria de Android en producción

Monitoreo del rendimiento móvil y del consumo de recursos en Lyft

Manuel Alcantara
Lyft Engineering en Español
9 min readSep 11, 2023

--

Este artículo fue publicado originalmente el 17 de enero de 2023 en eng.lyft.com, por Pavlo Stavytskyi y traducido por Diana Barrios y Manuel Alcántara.

Foto por Nathan Langer en Unsplash

Los desarrolladores de Android cuentan con diversas herramientas en su arsenal para detectar fugas de memoria, como Android Studio Memory Profiler, LeakCanary, Perfetto, entre otras. Estas herramientas son útiles al analizar las compilaciones de las aplicaciones de manera local. Sin embargo, en producción, la aplicación se ejecuta en una amplia gama de dispositivos en diferentes circunstancias, lo que hace difícil prever todos los casos límite al perfilar la compilación de forma local.

En Lyft, nos interesaba conocer cómo se comportan nuestras aplicaciones en producción en los dispositivos de los usuarios. Por lo tanto, decidimos introducir la observabilidad en diversas métricas de rendimiento en tiempo de ejecución y ver cómo podría mejorar la experiencia del usuario.

Ya habíamos publicado una entrada en este blog sobre el monitoreo del uso de CPU (en inglés), pero en esta historia nos centraremos en la huella de memoria de las aplicaciones móviles. Si bien el concepto general de monitorear la huella de memoria es aplicable tanto a las plataformas Android como iOS, nos enfocaremos en la primera para los detalles de implementación.

Lyft se basa en pruebas A/B al implementar nuevas características. Cuando una nueva funcionalidad está lista para producción, es cubierta por una bandera y se lanza como parte de un experimento. Este experimento se realiza para un grupo determinado de usuarios con el fin de comparar métricas con la versión estándar de la aplicación.

Cuando se lanza una funcionalidad grande y compleja, es importante asegurarse de que no cause ninguna regresión en términos de uso de memoria. Esto es especialmente importante si la funcionalidad incluye código nativo C/C++, ya que tiene una mayor probabilidad de introducir fugas de memoria.

Por lo tanto, queríamos probar la siguiente hipótesis: para cada experimento de funcionalidad, medimos su huella de memoria en todos los usuarios que tienen acceso a ella (mediante la recopilación de métricas analíticas en tiempo de ejecución). Luego, lo comparamos con la versión estándar de la aplicación. Si la variante muestra valores de uso de memoria más altos, esto es un indicador de una regresión o fuga de memoria.

Métricas de huella de memoria

En primer lugar, necesitábamos identificar la disponibilidad de métricas de memoria en Android, lo cual no es tan trivial como se podría pensar. Estamos interesados en el uso de memoria por parte del proceso de la aplicación, o en otras palabras, la huella de memoria de la app.

Android proporciona varias APIs para obtener métricas de uso de memoria para aplicaciones. Sin embargo, la parte más difícil no es obtener las métricas, sino asegurarse de que sean adecuadas y proporcionen datos significativos.

Dado que Android Studio cuenta con un perfilador de memoria integrado, decidimos utilizarlo como punto de referencia. Si obtenemos el mismo valor que el perfilador de memoria, nuestros datos son correctos. Una de las principales métricas que muestra el perfilador de memoria de Android Studio se llama PSS.

PSS (Proportional set size)

Tamaño de conjunto proporcional (PSS, por sus siglas en inglés) — es la cantidad de memoria privada y compartida utilizada por la aplicación, donde la cantidad de memoria compartida es proporcional al número de procesos con los que se comparte.

Por ejemplo, si 3 procesos están compartiendo 3 MB, cada proceso obtiene 1 MB en PSS.

Android expone una API de Debug (depuración) para estos datos.

import android.os.Debug
import android.os.Debug.MemoryInfo

val memoryInfo = MemoryInfo()
Debug.getMemoryInfo(memoryInfo)

val summary: Map<String, String> = memoryInfo.getMemoryStats()

La función Debug.MemoryInfo incluye el perfilador PSS, así como el número de componentes que lo componen. La forma más sencilla de ver el resumen de los datos que contiene es llamando a la función getMemoryStats. Esta devuelve pares de tipo clave-valor con las métricas, como se muestra en el siguiente ejemplo.

code:          12128 kB
stack: 496 kB
graphics: 996 kB
java-heap: 8160 kB
native-heap: 4516 kB
private-other: 2720 kB
system: 4955 kB // Incluye toda la memoria compartida
total-pss: 33971 kB // Suma de todo menos 'total-swap'
total-swap: 17520 kB

Estos son los números que Android Studio normalmente muestra en el Memory Profiler.

Para obtener el valor de PSS sin ninguna información adicional, es posible utilizar la siguiente llamada:

import android.os.Debug

val pssKb: Long = Debug.getPss()

USS (Unique Set Size)

Es la cantidad de memoria privada utilizada por la aplicación, excluyendo la memoria compartida. Su valor se puede derivar de las métricas de PSS que hemos visto anteriormente. Para obtenerlo programáticamente, podemos nuevamente utilizar Debug.MemoryInfo.

import android.os.Debug.MemoryInfo

val memoryInfo: MemoryInfo = ...

val ussKb = with(memoryInfo) {
getTotalPrivateClean() + getTotalPrivateDirty()
}

¿Qué hay de malo al usar PSS y USS?

PSS y USS son útiles, pero llamar a Debug.getMemoryInfo y Debug.getPss puede tomar al menos 200ms y 100ms respectivamente.

Necesitamos reportar métricas de memoria regularmente, por lo que no es la mejor idea usar APIs que consuman mucho tiempo para esto.

Existe una API más rápida en Android para PSS, pero hay un inconveniente. Tiene un límite estricto de frecuencia de muestreo de 5 minutos. Esto significa que si se llama con mayor frecuencia, devolverá un valor en caché de una llamada anterior. Esto no se ajusta bien a nuestro caso de uso.

import android.os.Debug.MemoryInfo

val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager

val pid = intArrayOf(android.os.Process.myPid())
// Se puede llamar a esta API una vez cada 5 minutos.
// Si se le llama más pronto, regresa los mismos datos que la llamada anterior.
val memoryInfo: MemoryInfo = activityManager.getProcessMemoryInfo(pid).first()

Sin embargo, existe una métrica similar llamada RSS.

RSS (Resident set size)

Es la cantidad de memoria privada y compartida utilizada por la aplicación, donde toda la memoria compartida está incluida.

Por ejemplo, si tres procesos están compartiendo 3MB, cada proceso obtiene 3MB en RSS.

Esto significa que los valores de RSS son pesimistas, ya que muestran más memoria de la que la aplicación ha utilizado realmente. Pero esta métrica es mucho más rápida de obtener en comparación con PSS; además, está bien usar los valores de RSS porque estamos más interesados en comparar variantes del experimento A/B, por lo que podemos sacrificar precisión hasta cierto punto como una compensación.

En general, si comparamos las métricas de tamaño en un momento dado, USS siempre mostrará el valor más pequeño, mientras que RSS será el más grande, teniendo la siguiente relación: RSS > PSS > USS.

Para obtener RSS programáticamente, necesitamos hacer referencia a un archivo del sistema. Este archivo se encuentra en /proc/[pid]/statm, donde [pid] es el ID de un proceso de aplicación. android.os.Process.myPid() se puede utilizar para obtener [pid] de forma programática.

Al leer este archivo, obtendremos algo como esto:

3693120 27503 18904 1 0 319129 0

La documentación oficial de Linux nos ayuda a comprender el significado de estos números. Solo nos interesa el segundo valor.

  • (2) resident — tamaño de conjunto residente, representado en páginas.

El tamaño de página predeterminado en Linux es de 4 kB. Por lo tanto, para calcular la métrica de RSS, utilizamos la fórmula simple que se muestra a continuación.

rssKb = resident * 4

Finalmente, decidimos utilizar RSS como la métrica principal para identificar la huella de memoria de la aplicación.

Sin embargo, eso no es todo. La memoria privada del proceso de la aplicación incluye muchos componentes, y uno de ellos es el heap ("montón", "apilamiento").

Con el fin de aumentar la precisión en las mediciones, también podemos reportar adicionalmente la cantidad de memoria asignada por la aplicación en los heaps de JVM y nativos. Esto puede ser útil cuando intentamos reducir una regresión detectada con la métrica de RSS.

Java Heap

La primera métrica nos ayudará a identificar la cantidad de memoria asignada por la aplicación en el heap de la JVM.

  val totalMemoryKb = Runtime.getRuntime().totalMemory() / 1024
val freeMemoryKb = Runtime.getRuntime().freeMemory() / 1024

val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb

Heap Nativo

La segunda métrica muestra lo mismo pero en el heap nativo. Es especialmente útil cuando la aplicación incluye bibliotecas nativas personalizadas en C/C++.

import android.os.Debug

val nativeHeapAllocatedKb = Debug.getNativeHeapAllocatedSize() / 1024

Detectando fugas de memoria

Ahora que hemos identificado algunas métricas de uso de memoria, veamos cómo podemos usarlas para detectar regresiones.

Primero, debemos decidir cuándo y con qué frecuencia deberíamos reportar las métricas de memoria a nuestras analíticas. Planteemos dos escenarios:

  • Reportar una captura con métricas cada vez que se cierre una pantalla de la interfaz de usuario.
  • Reportar una captura con métricas periódicamente si un usuario permanece en una sola pantalla de la interfaz de usuario durante mucho tiempo. Normalmente, utilizamos intervalos de 1 minuto, pero esto se puede configurar de forma remota.

Ahora veamos cómo interpretar los datos.

Las pruebas A/B nos permiten comparar los valores reportados entre dos variantes de la app:

  • Tratamiento — el grupo de usuarios que utiliza la app con la nueva funcionalidad habilitada.
  • Control — el grupo de usuarios que utiliza la app en el estado normal con la nueva funcionalidad inhabilitada.

Ejemplo 1 — sin regresiones

Primero, como punto de referencia, examinaremos una característica que no ha introducido ninguna regresión en cuanto a la huella de memoria.

Este es el ejemplo de un experimento que agrega una funcionalidad a la app de Lyft para pasajeros en Android.

Ejemplo 1 — un experimento que no introdujo regresiones

Como podemos observar, tanto las variantes de control (línea verde) como las de tratamiento (línea naranja) son iguales. Esto significa que la funcionalidad no ha introducido ninguna regresión y es seguro implementarla en producción desde una perspectiva de uso de memoria.

Ejemplo 2 — regresión

Ahora revisemos una funcionalidad que ha introducido una regresión.

Este es un ejemplo de un experimento que agrega una funcionalidad a la app de Lyft para pasajeros en Android.

Ejemplo 2 — un experimento que introdujo una regresión en uso de memoria

La nueva funcionalidad aquí claramente ha aumentado la huella de memoria de la app en cada percentil. Esto es un indicador de una regresión que nos permitió identificar una fuga de memoria.

Esta gráfica se basa en la métrica de RSS. Para acotar la causa principal del problema, se utilizaron gráficas similares con heap allocations de JVM y nativos.

Ejemplo 3 — Fuga de memoria en el percentil 99

El último ejemplo es el más importante, ya que demuestra la mayor ventaja de este enfoque de monitoreo de memoria.

En la gráfica a continuación, ambas variantes muestran valores casi iguales, excepto por una diferencia notable alrededor del percentil 99.

Ejemplo 3 — un experimento que introdujo una regresión en uso de memoria en el percentil 99

La huella de memoria de la variante de tratamiento ha aumentado significativamente en el percentil 99.

Esto llevó a identificar una fuga de memoria que ocurrió en circunstancias muy específicas para un pequeño número de usuarios. Sin embargo, cuando ocurrió, aumentó significativamente el uso de memoria.

En el caso del segundo ejemplo, es más probable detectar una fuga de memoria que afecte a todos los usos de la aplicación al perfilar localmente. Sin embargo, se convierte en una tarea mucho más difícil con el tercer ejemplo, ya que la fuga de memoria estaba relacionada con un caso límite que es fácil pasar por alto en el entorno local.

Aprendizajes

Uno de los desafíos al implementar una herramienta de monitoreo de memoria como esta es seleccionar las métricas adecuadas que muestren datos válidos. Por otro lado, es importante que la recopilación de esas métricas no consuma mucho tiempo ni recursos.

Otra tarea es visualizar los datos en un informe eficaz. Comparar los valores promedio entre las variantes de control y tratamiento a diario no produce datos significativos. Un enfoque mejor es utilizar una distribución de percentiles desde el inicio del experimento, como se muestra en los ejemplos anteriores.

En general, cuanto más tiempo dura el experimento, mejores son los datos. Se necesitan unos días para comenzar a obtener datos significativos después del lanzamiento de un experimento. También depende de cuántos usuarios están expuestos al experimento.

Este enfoque es especialmente útil cuando una fuga de memoria ocurre alrededor del percentil 99. Esto significa que ocurre debido a un caso límite específico que sería mucho más difícil de detectar con perfiles locales.

Usar herramientas para la detección local de fugas de memoria es una tarea importante para evitar promover regresiones a los usuarios. Reportar métricas de rendimiento en tiempo real arroja luz sobre problemas que de otra manera serían mucho más difíciles de detectar de otra forma.

Recursos

Si estás interesado en trabajar en herramientas y rendimiento en Lyft, date una vuelta por nuestra página de bolsa de trabajo para más detalles.

--

--