Recolección de basura en V8: como funciona el nuevo Orinoco GC

Para ser honesto, este es uno de los artículos más brutales que leí recientemente: hay mucho sobre la muerte a una edad temprana, sobre la persecución de un área de la memoria a otra y sobre una lucha feroz por la productividad. En general, bienvenido a Kat: hay una traducción de un excelente artículo de Peter Marshall sobre cómo funciona la recolección de basura en V8 hoy.



En los últimos años, el enfoque para la recolección de basura en V8 ha cambiado mucho. Como parte del proyecto Orinoco, ha pasado de un enfoque consistente de detener el mundo a enfoques paralelos y competitivos con retroceso incremental.

Nota: si prefiere ver el informe que leer el artículo, puede hacerlo aquí . Si no, entonces sigue leyendo.

Cualquier recolector de basura tiene un conjunto de tareas que deben realizarse periódicamente:

  1. Encuentra objetos vivos / muertos en la memoria.
  2. Reutilice la memoria ocupada por objetos muertos.
  3. Memoria compacta / desfragmentar (opcional).

Estas tareas se pueden realizar secuencialmente o puede alternar. La forma más fácil es detener la ejecución de JavaScript y hacer todo secuencialmente en el hilo principal. Sin embargo, esto puede conducir a retrasos, de los que hablamos en publicaciones anteriores , así como a una disminución en el rendimiento del programa en su conjunto.

GC principal (marca completa-compacta)


El GC principal recoge la basura de todo el montón.

La limpieza de basura se lleva a cabo en tres etapas: etiquetado, eliminación y compactación.

Marcado


Determinar de qué objetos puede liberar memoria es una parte esencial del recolector de basura. Considera el objeto vivo basado en información sobre su accesibilidad. Esto significa que cualquier objeto al que se haga referencia desde el tiempo de ejecución actual debe almacenarse en la memoria, y GC puede ensamblar todos los objetos inaccesibles.

El marcado es el proceso de encontrar objetos alcanzables. GC tiene un conjunto de punteros con los que comienza a buscar, el llamado conjunto raíz. Este conjunto incluye objetos de la pila de ejecución actual y el objeto global. Comenzando con este conjunto, GC sigue cada puntero a un objeto JavaScript y marca cada uno como accesible, después de lo cual se mueve a punteros desde objetos a otros objetos y repite este proceso de forma recursiva hasta que se marca cada objeto accesible.

Eliminación


La eliminación es un proceso en el que las áreas de memoria que quedan de los objetos muertos se ingresan en una lista llamada lista libre. Una vez que se completa el proceso de marcado, GC encuentra esas áreas y las agrega a la lista correspondiente. Las listas libres difieren entre sí en el tamaño de las áreas de memoria almacenadas en ellas, lo que le permite encontrar rápidamente la correcta. Posteriormente, cuando queramos asignar memoria, buscaremos en una de las listas y encontraremos una sección de tamaño adecuado.

Sello


Además, el GC principal a veces toma decisiones sobre la limpieza / compactación de algunas páginas de memoria en función de sus propias estimaciones heurísticas basadas en el grado de fragmentación de la página. Puede pensar en la compactación como un análogo de la desfragmentación de un disco duro en PC más antiguas. Copiamos los objetos supervivientes a otras páginas que aún no se han comprimido (aquí solo use la lista libre). Por lo tanto, podemos reutilizar los pequeños trozos de memoria dispersos que quedan de los objetos muertos.

Uno de los inconvenientes de la GC que copia objetos supervivientes es que cuando crea muchos objetos de larga duración, debe pagar un alto precio por copiarlos. Es por esta razón que solo se comprimen algunas páginas de memoria altamente fragmentadas, mientras que el resto simplemente se elimina, lo que no requiere la copia de los objetos supervivientes.

Dispositivo de generación de memoria


El montón en V8 se divide en áreas llamadas generaciones. Hay una generación joven (que a su vez se subdivide en una generación "pesebre" e "intermedia") y viejas generaciones. Los objetos creados se colocan en el "pesebre". Posteriormente, si sobreviven a la próxima recolección de basura, permanecen en la generación más joven, pero pasan a la categoría de "intermedio". Si sobreviven después de la próxima asamblea, se colocan en la generación anterior.

Un montón de V8 se divide en generaciones. Los objetos se mueven de menores a mayores si sobreviven a la recolección de basura

En la recolección de basura, existe el término importante "hipótesis generacional". En términos simples, esto significa que la mayoría de los objetos "mueren jóvenes". En otras palabras, la mayoría de los objetos se crean y mueren casi inmediatamente desde el punto de vista del GC. Y esta afirmación es cierta no solo para JavaScript, sino también para la mayoría de los lenguajes de programación dinámicos.

La organización del montón en V8 se basa en la hipótesis anterior. Por ejemplo, a primera vista, puede parecer contradictorio que el GC está compactando / moviendo objetos que han sobrevivido a la recolección de basura, porque copiar objetos es una operación bastante costosa durante la recolección de basura. Pero, según la hipótesis generacional, sabemos que muy pocos objetos sobrevivirán a este procedimiento. Por lo tanto, si mueve solo objetos supervivientes, todo lo que no se movió puede considerarse basura automáticamente. Esto significa que el precio que pagamos por copiar es proporcional al número de objetos supervivientes, y no todos se crean.

GC auxiliar (carroñero)


En realidad, hay dos recolectores de basura en V8. El principal (mark-compact) recolecta basura de todo el montón de manera bastante eficiente, mientras que el secundario recolecta basura solo en la memoria joven, porque la hipótesis de generación nos dice que los esfuerzos principales de recolección de basura deben dirigirse allí.

El principio operativo del GC auxiliar es que los objetos supervivientes siempre se mueven a una nueva página de memoria. En V8, la memoria joven se divide en dos mitades. Uno siempre es libre de permitir que los objetos supervivientes se muevan dentro de él, y durante el ensamblaje, esta área inicialmente vacía se llama espacio. El área desde la que se realiza la copia se llama From-space. En el peor de los casos, cada objeto puede sobrevivir, y luego tienes que copiarlos a todos.

Para este tipo de ensamblaje, hay un conjunto separado de punteros que hacen referencia de memoria antigua a joven. Y en lugar de escanear todo el montón, usamos barreras de escritura para mantener este conjunto. Entonces, combinando este conjunto con una pila y un objeto global, obtenemos todos los enlaces en la memoria joven sin tener que escanear todos los objetos de la memoria anterior.

Al copiar objetos del espacio del espacio al espacio, todos los objetos supervivientes se colocan en una sección continua de la memoria. Por lo tanto, es posible deshacerse de la fragmentación: espacios de memoria que quedan de los objetos muertos. Una vez completada la transferencia, To-space se convierte en From-space, y viceversa. Tan pronto como el GC finalice su trabajo, se asignará memoria para nuevos objetos a partir de la primera dirección libre en el espacio.

Scavenger transfiere objetos supervivientes a una nueva página de memoria

Si usa solo esta estrategia y no mueve objetos de la memoria joven, la memoria terminará bastante rápido. Por lo tanto, los objetos que sobrevivieron a dos recolecciones de basura se mueven a la memoria anterior.

El último paso es actualizar los punteros a los objetos que se han movido. Cada objeto copiado deja su dirección original, dejando en su lugar la dirección de reenvío, que es necesaria para encontrar el objeto original en el futuro.

Scavenger transfiere objetos "intermedios" a la memoria anterior y objetos del "administrador" - a una nueva página

Por lo tanto, la recolección de basura en la memoria joven consta de tres pasos: marcar objetos, copiarlos, actualizar punteros.

Orinoco


La mayoría de estos algoritmos se describen en varias fuentes y a menudo se usan en entornos de tiempo de ejecución que admiten la recolección de basura automática. Pero el GC en V8 ha recorrido un largo camino antes de convertirse en una herramienta verdaderamente moderna. Una de las métricas significativas que describen su rendimiento es la frecuencia y la duración de la pausa del subproceso principal mientras el recolector de basura realiza sus funciones. Para los constructores clásicos de stop-the-world, esta vez deja su huella en la experiencia de usar la página debido a retrasos, renderizado de baja calidad y mayor tiempo de respuesta.

Logotipo de Orinoco GC V8

Orinoco es el nombre del código GC que utiliza técnicas de recolección de basura paralelas, incrementales y competitivas de última generación. Hay algunos términos que tienen significados específicos en el contexto de la GC, así que primero demos sus definiciones.

Paralelismo


El paralelismo es cuando los hilos principales y auxiliares realizan aproximadamente la misma cantidad de trabajo por unidad de tiempo. Este sigue siendo el enfoque de detener el mundo, pero la duración de la pausa en este caso se divide por el número de subprocesos que participan en el trabajo (menos el costo de sincronización).

Esta es la más simple de las tres técnicas. El montón no cambia porque JavaScript no se ejecuta, por lo que es suficiente para que los subprocesos mantengan la sincronización del acceso a los objetos.

Los subprocesos principales y auxiliares funcionan en la misma tarea al mismo tiempo

Incrementalidad


La incrementalidad es cuando el hilo principal hace una pequeña cantidad de trabajo de manera intermitente. En lugar de la recolección completa de basura, se realizan pequeñas tareas para la recolección parcial.

Esta es una tarea más difícil, porque JavaScript se ejecuta entre ensamblajes incrementales, lo que significa que el estado del montón cambia, lo que a su vez puede invalidar parte del trabajo realizado en la iteración anterior.

Como se puede ver en el diagrama, este enfoque no reduce la cantidad total de trabajo (y, como regla, incluso lo aumenta), pero distribuye este trabajo a tiempo. Por lo tanto, esta es una buena manera de resolver una de las tareas principales: reducir el tiempo de respuesta de la transmisión principal.
Al permitir que JavaScript se ejecute con poca interrupción en la recolección de basura, la aplicación puede seguir respondiendo: responder a las aportaciones del usuario y actualizar las animaciones.

Pequeñas áreas de trabajo de GC en el hilo principal

Competitividad


La competencia es cuando el hilo principal ejecuta JavaScript continuamente y los hilos auxiliares recolectan basura en el fondo. Esta es la más difícil de las tres técnicas: el montón puede cambiar en cualquier momento, invalidando el trabajo realizado por GC antes.

Además de eso, también hay carreras de lectura / escritura, ya que las secuencias auxiliares y principales leen o modifican simultáneamente los mismos objetos.

El ensamblaje se realiza completamente en segundo plano, el hilo principal en este momento puede ejecutar JavaScript

Estado de GC en V8


Carroñero


V8 distribuye el trabajo de recolección de basura entre subprocesos auxiliares en la memoria joven (barrido). Cada hilo recibe un conjunto de punteros, después de lo cual mueve todos los objetos vivos al espacio espacial.

Al mover objetos en el espacio To, los hilos deben sincronizarse a través de operaciones de lectura / escritura / comparación atómica e intercambio para evitar una situación en la que, por ejemplo, otro hilo haya detectado el mismo objeto, pero siguiendo una ruta diferente, y también intente moverlo.

El subproceso que movió el objeto al espacio Al regresa y deja un puntero de reenvío para que otros subprocesos que encuentren este objeto puedan seguir la dirección correcta. Para una asignación de memoria rápida y sin sincronización para los objetos supervivientes, los subprocesos utilizan memorias intermedias locales de subprocesos.

El conjunto paralelo distribuye el trabajo entre varios hilos auxiliares y el hilo principal

Core gc


El GC principal en V8 comienza marcando objetos. Tan pronto como el montón alcanza un cierto límite (calculado dinámicamente), los marcadores competitivos comienzan su trabajo. Cada una de las secuencias recibe un conjunto de punteros y, después de ellos, marcan cada objeto encontrado como accesible.

El etiquetado competitivo ocurre completamente en segundo plano mientras JavaScript se ejecuta en el hilo principal. Las barreras de escritura se utilizan para realizar un seguimiento de los nuevos enlaces entre objetos que se crean en JavaScript mientras se marcan los hilos.


El GC primario utiliza etiquetado competitivo, eliminación y compactación paralela y actualización de punteros

Al final del etiquetado competitivo, el hilo principal realiza un paso rápido para finalizar el etiquetado. Durante esto, la ejecución de JavaScript en el hilo principal está en pausa.

El conjunto raíz se escanea nuevamente para asegurarse de que todos los objetos vivos estén marcados, y luego la compresión de la memoria y la actualización de los punteros comienzan en varios hilos.
No todas las páginas de la memoria anterior están compactadas; las que no lo estén se escanearán a las áreas de memoria liberadas (barrido) para incluirlas en listas libres.

Durante esta pausa, se inician las tareas de barrido que compiten con las tareas de compresión de memoria y el hilo principal y pueden continuar incluso cuando se ejecuta JavaScript en el hilo principal.

GC de inactividad


Los desarrolladores de JavaScript no tienen acceso al GC: es parte del entorno de implementación. Y aunque el código JS no puede invocar el GC directamente, V8 proporciona dicho acceso al entorno que integra el motor.

GC puede enviar tareas (tareas inactivas) que se pueden hacer "en su tiempo libre" y que son partes del trabajo que tendrían que hacerse de todos modos. Un entorno como Chrome, donde el motor está integrado, puede tener una idea de qué considerar como tiempo libre. Por ejemplo, en Chrome, a una velocidad de cuadro de 60 cuadros por segundo, el navegador tiene aproximadamente 16,6 ms para representar un cuadro de animación.

Si el trabajo de animación se completa antes, en su tiempo libre, antes del siguiente cuadro, Chrome puede realizar algunas de las tareas recibidas del GC.

GC utiliza el tiempo libre de la secuencia principal para realizar una limpieza previa

Los detalles se pueden encontrar en nuestra publicación sobre Idle-time GC .

Resumen


GC en V8 ha recorrido un largo camino desde su introducción. Agregarle técnicas paralelas, incrementales y competitivas le tomó varios años, pero valió la pena, permitiéndole hacer la mayor parte del trabajo en segundo plano.

Todo lo relacionado con las pausas de la transmisión principal, el tiempo de respuesta y la carga de la página ha mejorado significativamente, lo que permite que las animaciones, el desplazamiento y la interacción del usuario en la página sean mucho más fluidos. El colector paralelo permitió reducir el tiempo de procesamiento total de la memoria joven en un 20-50%, dependiendo de la carga.

El tiempo de inactividad GC reduce el tamaño del almacenamiento dinámico usado para Gmail en un 45%. El etiquetado y la eliminación competitivos (barrido) pueden reducir la duración de las pausas GC en juegos WebGL pesados ​​hasta en un 50%.

Sin embargo, el trabajo aún no está terminado. Reducir las pausas sigue siendo una tarea importante para simplificar la vida de los usuarios de la web, y estamos buscando la posibilidad de utilizar técnicas más avanzadas para lograr el objetivo.

Además de eso, Blink (un procesador en Chrome) también está equipado con una olla de aceite, y estamos trabajando para mejorar la interacción entre los dos GC, así como para usar las técnicas de Orinoco en Oilpan.

La mayoría de los desarrolladores de JavaScript no necesitan pensar en cómo funciona el GC, pero comprenderlo puede ayudarlo a tomar las mejores decisiones con respecto al uso de la memoria y los patrones de programación. Por ejemplo, dada la estructura generacional del montón V8, los objetos de baja vida son realmente bastante baratos desde el punto de vista de GC, ya que pagamos principalmente por los objetos sobrevivientes. Y este tipo de patrones son característicos no solo de JavaScript, sino también de muchos idiomas con soporte para recolección de basura.

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


All Articles