La historia de un problema con el velocímetro, o cómo el cromo gestiona la memoria

Un navegador moderno es un proyecto extremadamente complejo en el que incluso los cambios inofensivos pueden llevar a sorpresas inesperadas. Por lo tanto, hay muchas pruebas internas que deberían detectar dichos cambios antes de su lanzamiento. Nunca hay demasiadas pruebas, por lo que también es útil utilizar puntos de referencia públicos de terceros.

Mi nombre es Andrey Logvinov, trabajo en el grupo de desarrollo de motores de renderizado Yandex.Browser en Nizhny Novgorod. Hoy les contaré a los lectores de Habr cómo funciona la gestión de memoria en el proyecto Chromium con el ejemplo de un misterioso problema que condujo a la degradación del rendimiento en la prueba del velocímetro . Esta publicación se basa en mi informe del evento Yandex.Inside.




Una vez en nuestro panel de rendimiento, vimos un deterioro en la velocidad de la prueba del velocímetro. Esta prueba mide el rendimiento general del navegador en una aplicación que está cerca de la realidad: una lista de tareas pendientes, donde la prueba agrega elementos a la lista y luego los tacha. Los resultados de la prueba se ven afectados tanto por el rendimiento del motor V8 JS como por la velocidad de renderizado de páginas en el motor Blink. La prueba del velocímetro consta de varias subpruebas, donde la aplicación de prueba se escribe utilizando uno de los marcos JS populares, por ejemplo jQuery o ReactJS. El resultado general de la prueba se define como el promedio de los resultados para todos los marcos, pero la prueba le permite ver el rendimiento de cada marco individualmente. Vale la pena señalar que la prueba no tiene como objetivo evaluar el rendimiento de los marcos, se usan solo para hacer que la prueba sea menos sintética y más cercana a las aplicaciones web reales. Los detalles por subprueba mostraron que el deterioro se observa solo para la versión de la aplicación de prueba creada con jQuery. Y esto ya es interesante, de acuerdo.

La investigación de tales situaciones comienza de manera bastante estándar: determinamos qué compromiso particular con el código condujo al problema. Para hacer esto, almacenamos conjuntos de Yandex.Browser para cada (!) Compromiso en los últimos años (no sería práctico volver a armarlo, ya que el montaje lleva varias horas). Esto ocupa mucho espacio en los servidores, pero generalmente ayuda a encontrar rápidamente la fuente del problema. Pero esta vez rápidamente no funcionó. Resultó que el deterioro de los resultados de la prueba coincidió con un compromiso que integra la próxima versión de Chromium. El resultado no es alentador, porque la nueva versión de Chromium trae una gran cantidad de cambios a la vez.

Como no recibimos ninguna información que indique un cambio específico, tuve que hacer un estudio sustancial del problema. Para hacer esto, utilizamos las Herramientas de desarrollo para eliminar los rastros de prueba. Notamos una característica extraña: intervalos "desgarrados" para la ejecución de funciones de prueba de Javascript.

imagen

Eliminamos un rastro más técnico con about: tracing y vemos que es recolección de basura (GC) en Blink.

imagen

La siguiente pista de memoria muestra que estas pausas de GC no solo toman mucho tiempo, sino que tampoco ayudan a detener el crecimiento del consumo de memoria.

imagen

Pero si inserta una llamada GC explícita en la prueba, entonces vemos una imagen completamente diferente: la memoria se mantiene en la región cero y no se pierde. Por lo tanto, no tenemos pérdidas de memoria y el problema está relacionado con las características del recopilador. Seguimos cavando. ¡Iniciamos el depurador y vemos que el recolector de basura ha evitado alrededor de 500 mil objetos! Tal cantidad de objetos no podría afectar el rendimiento. ¿Pero de dónde vinieron?

Y aquí necesitamos un pequeño flashback sobre el dispositivo recolector de basura en Blink. Elimina objetos muertos, pero no mueve objetos vivos, lo que permite operar con punteros desnudos en variables locales en código C ++. Este patrón se usa activamente en Blink. Pero también tiene su precio: al recolectar basura, debe escanear la pila de flujo, y si se encuentra algo similar a un puntero a un objeto desde un montón, considere el objeto y todo lo que se refiere directa o indirectamente a estar vivo. Esto lleva al hecho de que algunos objetos prácticamente inaccesibles y, por lo tanto, "muertos" se identifican como vivos. Por lo tanto, esta forma de recolección de basura también se llama conservadora.

Verificamos la conexión con el escaneo de la pila y la omitimos. El problema ha desaparecido.

¿Qué puede ser así en una pila que contiene 500 mil objetos? Ponemos un punto de interrupción en la función de agregar objetos; entre otras cosas, vemos que es sospechoso:

parpadeo :: TraceTrait <parpadeo :: HeapHashTableBacking <WTF :: HashTable <parpadeo :: WeakMember ...

¡Una referencia de tabla hash es probablemente un sospechoso! Probamos la hipótesis omitiendo la adición de este enlace. El problema ha desaparecido. Bueno, estamos un paso más cerca de la respuesta.

Recordamos otra característica del recolector de basura en Blink: si ve un puntero al interior de la tabla hash, entonces considera que esto es un signo de iteración continua sobre la tabla, lo que significa que considera que todos los enlaces en esta tabla son útiles y continúa omitiéndolos. En nuestro caso, inactivo. Pero, ¿qué función es la fuente de este enlace?

Avanzamos unos pocos cuadros de la pila más arriba, tomamos la posición actual del escáner, miramos el marco de la pila en qué función se encuentra. Esta es una función llamada ScheduleGCIfNeeded . Parece que aquí él es el culpable, pero ... miramos el código fuente de la función y vemos que no hay tablas hash allí. Además, esto ya es parte del recolector de basura en sí, y simplemente no necesita referirse a objetos del montón de Blink. ¿De dónde vino este enlace "malo"?

Establecimos un punto de interrupción al cambiar la celda de memoria, en el que encontramos un enlace a la tabla hash. Vemos que una de las funciones internas llamadas V8PerIsolateData :: AddActiveScriptWrappable escribe allí. Allí, agregan algunos elementos HTML creados de algunos tipos, incluida la entrada, a una única tabla hash active_script_wrappables_. Esta tabla es necesaria para evitar la eliminación de elementos a los que ya no se hace referencia desde Javascript o el árbol DOM, pero que están asociados con cualquier actividad externa que, por ejemplo, pueda generar eventos.

El recolector de basura durante el recorrido normal de la mesa tiene en cuenta el estado de los elementos contenidos en él y los marca como activos o no los marca, luego se eliminan en la siguiente etapa del ensamblaje. Sin embargo, en nuestro caso, aparece un puntero al almacenamiento interno de esta tabla cuando se escanea la pila, y todos los elementos de la tabla se marcan como activos.

¿Pero cómo el valor de la pila de una función golpeó la pila de otra?

Piense en ScheduleGCIfNeeded. Recuerde que no se encontró nada útil en el código fuente de esta función, pero esto solo significa que es hora de bajar a un nivel inferior y verificar el compilador . El prólogo desmontado de la función ScheduleGCIfNeeded tiene este aspecto:

0FCDD13A push ebp 0FCDD13B mov ebp,esp 0FCDD13D push edi 0FCDD13E push esi 0FCDD13F and esp,0FFFFFFF8h 0FCDD142 sub esp,0B8h 0FCDD148 mov eax,dword ptr [__security_cookie (13DD3888h)] 0FCDD14D mov esi,ecx 0FCDD14F xor eax,ebp 0FCDD151 mov dword ptr [esp+0B4h],eax 

Se puede ver que la función se mueve especialmente a 0B8h , y este lugar no se usa más. Pero debido a esto, el escáner de pila ve lo que otras funciones registraron previamente. Y por casualidad, un puntero al interior de la tabla hash dejado por la función AddActiveScriptWrappable entra en este "agujero". Al final resultó que, la razón de la aparición de un "agujero" en este caso fue la macro de depuración VLOG dentro de la función, que muestra información adicional en el registro.

Pero, ¿por qué la tabla active_script_wrappable_ tenía cientos de miles de elementos? ¿Por qué se observa una degradación del rendimiento solo en la prueba jQuery? La respuesta a ambas preguntas es la misma: en esta prueba en particular, para cada cambio (como una marca de verificación en la casilla de verificación) se recrea completamente la IU completa. La prueba produce elementos que casi inmediatamente se convierten en basura. El resto de las pruebas en el velocímetro son más prudentes y no crean elementos innecesarios, por lo tanto, no se observa degradación del rendimiento en ellos. Si está desarrollando servicios web, debe tener esto en cuenta para no crear trabajo innecesario para el navegador.

Pero, ¿por qué el problema surgió solo ahora si la macro VLOG era antes? No hay una respuesta exacta, pero lo más probable es que, durante la actualización, la posición relativa de los elementos en la pila haya cambiado, por lo que el puntero a la tabla hash se volvió accidentalmente accesible para el escáner. De hecho, ganamos la lotería. Para cerrar rápidamente el "agujero" y restaurar el rendimiento, eliminamos la macro de depuración de VLOG. Para los usuarios, es inútil, y para nuestras propias necesidades de diagnóstico, siempre podemos volver a encenderlo. También compartimos nuestras experiencias con otros desarrolladores de Chromium. La respuesta confirmó nuestros temores: este es un problema fundamental de recolección de basura conservadora en Blink, que no tiene una solución sistémica.

Enlaces interesantes


1. Si está interesado en aprender sobre otra vida cotidiana inusual de nuestro grupo, entonces recordamos la historia del rectángulo negro , que condujo a la aceleración no solo de Yandex.Browser, sino de todo el proyecto Chromium.

2. Y también los invito a escuchar otros informes en el próximo evento Yandex. Dentro del evento el 16 de febrero, la inscripción está abierta, la transmisión también lo estará.

Source: https://habr.com/ru/post/es436154/


All Articles