El misterio del perfil del peldaño de la escalera Recientemente vimos , un servicio , mostraba un comportamiento extraño en lo que respecta al uso de la memoria. Un perfil de memoria de aspecto extraño es lo último que esperaría de un programa Rust, pero aquí estamos. que uno de nuestros proyectos Rust axum El servicio se ejecutaría con memoria "plana" durante un período de tiempo y luego, de repente, saltaría a una nueva meseta. Este patrón se repetiría durante horas, a veces bajo carga, pero no siempre. La parte preocupante fue que una vez que vimos un fuerte aumento, era raro que la memoria volviera a caer. Era como si la memoria se perdiera o se "filtrara" de vez en cuando. En circunstancias normales, este perfil de "escalón" tenía un aspecto extraño, pero en un momento el uso de la memoria aumentó de manera desproporcionada. El crecimiento ilimitado de la memoria puede hacer que los servicios se vean obligados a salir. Cuando los servicios se cierran abruptamente, esto puede reducir la disponibilidad... lo cual es . Quería profundizar y averiguar qué estaba pasando. malo para el negocio Normalmente, cuando pienso en un crecimiento inesperado de la memoria en un programa, pienso en fugas. Aún así, esto parecía diferente. Con una fuga, tiende a ver un patrón de crecimiento más estable y regular. A menudo, esto parece una línea inclinada hacia arriba y hacia la derecha. Entonces, si nuestro servicio no tenía fugas, ¿qué estaba haciendo? Si pudiera identificar las condiciones que causaron el salto en el uso de la memoria, tal vez podría mitigar lo que estaba sucediendo. cavando en Tenía dos preguntas candentes: ¿Algo cambió en nuestro código para promover este comportamiento? De lo contrario, ¿surgió un nuevo patrón de tráfico? Mirando las métricas históricas, pude ver patrones similares de fuertes aumentos entre largos períodos sin cambios, pero nunca antes habíamos tenido este tipo de crecimiento. Para saber si el crecimiento en sí era nuevo (a pesar de que el patrón de "escalón" es para nosotros), necesitaría una forma confiable de reproducir este comportamiento. normal Si pudiera forzar el "paso" para que se muestre, entonces tendré una forma de verificar un cambio en el comportamiento cuando tome medidas para frenar el crecimiento de la memoria. También podría retroceder a través de nuestro historial de git y buscar un punto en el tiempo en el que el servicio no exhibiera un crecimiento aparentemente ilimitado. Las dimensiones que utilicé al ejecutar mis pruebas de carga fueron: El tamaño de los cuerpos POST enviados al servicio. La tasa de solicitud (es decir, solicitudes por segundo). El número de conexiones de cliente simultáneas. La combinación mágica para mí fue: y . cuerpos de solicitud más grandes mayor simultaneidad Cuando se ejecutan pruebas de carga en un sistema local, existen todo tipo de factores limitantes, incluido el número finito de procesadores disponibles para ejecutar tanto los clientes como el propio servidor. Aún así, pude ver el "escalón" en la memoria de mi máquina local en las circunstancias adecuadas, incluso con una tasa de solicitud general más baja. Usando una carga útil de tamaño fijo y enviando solicitudes en lotes, con un breve descanso entre ellos, pude aumentar la memoria del servicio repetidamente, un paso a la vez. Me pareció interesante que, si bien podía hacer crecer la memoria con el tiempo, finalmente llegué a un punto de rendimientos decrecientes. Eventualmente, habría un techo (todavía mucho más alto de lo esperado) para el crecimiento. Jugando un poco más, descubrí que podía alcanzar un techo aún más alto enviando solicitudes con diferentes tamaños de carga útil. Una vez que identifiqué mi entrada, pude trabajar hacia atrás a través de nuestro historial de git, eventualmente aprendiendo que nuestro susto de producción probablemente no sea el resultado de cambios recientes de nuestra parte. Los detalles de la carga de trabajo para desencadenar este "escalón" son específicos de la aplicación en sí, aunque pude forzar que sucediera un gráfico similar con un . proyecto de juguete #[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } } Resultó que no necesitabas mucho para llegar allí. Logré ver un aumento similar (pero en este caso mucho más pequeño) de una aplicación con un solo controlador que recibe un cuerpo JSON. axum Si bien los aumentos de memoria en mi proyecto de juguetes no fueron tan dramáticos como los que vimos en el servicio de producción, fue suficiente para ayudarme a comparar y contrastar durante la siguiente fase de mi investigación. También me ayudó a tener el ciclo de iteración más ajustado de una base de código más pequeña mientras experimentaba con diferentes cargas de trabajo. Consulte el para obtener detalles sobre cómo ejecuté mis pruebas de carga. LÉAME Pasé un tiempo buscando en la web informes de errores o discusiones que pudieran describir un comportamiento similar. Un término que surgió repetidamente fue y después de leer un poco más sobre el tema, parecía que encajaba con lo que estaba viendo. Fragmentación de montón ¿Qué es la fragmentación del montón? Las personas de cierta edad pueden haber tenido la experiencia de ver una en DOS o mover bloques en un disco duro para consolidar las áreas "usadas" y "libres". utilidad de desfragmentación Windows En el caso de este viejo disco duro de PC, los archivos de diferentes tamaños se escribieron en el disco y luego se movieron o eliminaron, dejando un "agujero" de espacio disponible entre otras regiones utilizadas. A medida que el disco comienza a llenarse, puede intentar crear un nuevo archivo que no encaje en una de esas áreas más pequeñas. En el escenario de fragmentación del montón, eso conducirá a una falla de asignación, aunque el modo de falla de la fragmentación del disco será un poco menos drástico. En el disco, el archivo deberá dividirse en fragmentos más pequeños, lo que hace que el acceso sea mucho menos eficiente (gracias ). La solución para la unidad de disco es "desfragmentar" (desfragmentar) la unidad para reorganizar esos bloques abiertos en espacios continuos. wongarsu por la corrección Algo similar puede cuando el asignador (el responsable de administrar la asignación de memoria en su programa) agrega y elimina valores de diferentes tamaños durante un período de tiempo. Los espacios que son demasiado pequeños y están dispersos por todo el montón pueden dar lugar a que se asignen nuevos bloques de memoria "nuevos" para acomodar un nuevo valor que de otro modo no cabría. Aunque desafortunadamente, debido a cómo funciona la gestión de la memoria, no es posible una "desfragmentación". suceder La causa específica de la fragmentación podría ser cualquier número de cosas: JSON analizando con , algo a nivel de marco en , algo más profundo en , o incluso solo una peculiaridad de la implementación del asignador específico para el sistema dado. Incluso sin conocer la causa raíz (si es que existe), el comportamiento es observable en nuestro entorno y algo reproducible en una aplicación básica. (Actualización: se necesita más investigación, pero estamos bastante seguros de que es el análisis JSON, vea ews) serde axum tokio nuestro comentario en HackerN Si esto es lo que le estaba pasando a la memoria de proceso, ¿qué se puede hacer al respecto? Parece que sería difícil cambiar la carga de trabajo para evitar la fragmentación. También parece que sería complicado desenredar todas las dependencias en mi proyecto para posiblemente encontrar una causa raíz en el código de cómo ocurren los eventos de fragmentación. ¿Entonces, qué puede hacerse? al rescate Jemalloc se describe a sí mismo como un objetivo de De hecho, la concurrencia era parte del problema de mi programa, y evitar la fragmentación es el nombre del juego. parece que podría ser justo lo que necesito. jemalloc "[enfatizar] evitar la fragmentación y soporte de concurrencia escalable". jemalloc Dado que es un asignador que hace todo lo posible para evitar la fragmentación en primer lugar, la esperanza era que nuestro servicio pudiera ejecutarse por más tiempo sin aumentar gradualmente la memoria. jemalloc No es tan trivial cambiar las entradas de mi programa o la pila de dependencias de la aplicación. Sin embargo, es trivial cambiar el asignador. Siguiendo los ejemplos en el archivo Léame , se requirió muy poco trabajo para probarlo. de https://github.com/tikv/jemallocator Para mi , agregué una función de carga para cambiar opcionalmente el asignador predeterminado por y volví a ejecutar mis pruebas de carga. proyecto de juguete jemalloc La grabación de la memoria residente durante mi carga simulada muestra los dos perfiles de memoria distintos. Sin , vemos el familiar perfil de escalera. Con , vemos que la memoria sube y baja repetidamente a medida que se ejecuta la prueba. Más importante aún, si bien existe una diferencia considerable entre el uso de la memoria con durante la carga y los tiempos de inactividad, no "perdemos terreno" como lo hicimos antes, ya que la memoria siempre vuelve a la línea de base. jemalloc jemalloc jemalloc Terminando Si ve un perfil de "escalón" en un servicio de Rust, considere tomar para una prueba de manejo. Si tiene una carga de trabajo que promueve la fragmentación del almacenamiento dinámico, puede brindar un mejor resultado general. jemalloc jemalloc Por separado, en el repositorio se incluye un para usar con la herramienta de prueba de carga . Intente cambiar la concurrencia, el tamaño del cuerpo (y la duración arbitraria de la suspensión del controlador en el propio servicio), etc. para ver cómo el cambio en el asignador afecta el perfil de memoria. del proyecto de juguete benchmark.yml https://github.com/fcsonline/drill En cuanto al impacto en el mundo real, puede ver claramente el cambio de perfil cuando implementamos el cambio a . jemalloc Donde el servicio solía mostrar líneas planas y pasos grandes, a menudo independientemente de la carga, ahora vemos una línea más irregular que sigue más de cerca la carga de trabajo activa. Aparte del beneficio de ayudar al servicio a evitar el crecimiento innecesario de la memoria, este cambio nos dio una mejor idea de cómo responde nuestro servicio a la carga, por lo que, en general, este fue un resultado positivo. Si está interesado en crear un servicio sólido y escalable con Rust, ¡estamos contratando! Consulte para obtener más información. nuestra página de carreras Para obtener más contenido como este, asegúrese de seguirnos en , o para obtener las últimas actualizaciones del , o únase a la discusión en . Twitter Github RSS servicio webhook de Svix nuestra comunidad Slack También publicado aquí.