El margen de una taza de té verde que el runtime de Go no nos quitará: GreenTea GC

¿Por qué sale otro GC?
¿Por qué el GC existente?
El GC actual de Go puede describirse con los siguientes términos.
- concurrent tri-color mark and sweep
- Utiliza un total de 3 colores (blanco, gris y negro) para rastrear el estado de los objetos.
- Blanco: objeto que aún no ha sido visitado
- Gris: objeto que ha sido visitado, pero cuyos objetos hijos no han sido todos visitados
- Negro: objeto cuyos objetos hijos han sido todos visitados
- Al terminar el recorrido, todos los objetos blancos son recolectados.
- Utiliza una Write Barrier para proteger la memoria que se crea durante el GC.
- El tiempo en que se activa y desactiva la Write Barrier constituye una parte importante del llamado Stop-The-World (en adelante, STW).
- Este proceso se ejecuta de forma concurrente.
- Utiliza un total de 3 colores (blanco, gris y negro) para rastrear el estado de los objetos.
- No Compaction
- El GC de Go no realiza compactación.
- Puede producirse fragmentación de la memoria.
- Sin embargo, los objetos de 32 KB o menos aprovechan la caché per-P del asignador de memoria (Allocator) de Go para minimizar la fragmentación.
- Non-Generational
- El GC de Go no gestiona los objetos por generaciones.
- Todos los objetos pertenecen a la misma generación.
- Escape Analysis
- Go determina mediante Escape Analysis si un objeto se asigna en el heap o en el stack.
- De forma aproximada, si se utilizan punteros colgantes o interfaces, puede considerarse que el objeto se asigna en el heap.
Lo importante
El GC de Go explora todos los objetos desde las raíces y realiza el marcado tri-color. Si se resume este proceso en una línea, puede decirse que es un algoritmo de recorrido concurrente de grafo con ordenación topológica. Sin embargo, es muy probable que cada objeto se encuentre en regiones de memoria distintas. En pocas palabras:
- Supongamos que existen regiones de memoria separadas por una distancia de unos 32 MB.
- En estas dos regiones de memoria hay 5 objetos asignados en cada una.
- Los objetos A, B y C están en la región 1.
- Los objetos D y E están en la región 2.
- El orden de referencias de los objetos es
A -> D -> B -> E -> C. - Cuando comienza el GC, se visitan A, luego D, luego B, luego E y luego C.
- En este caso, como A y D se encuentran en regiones de memoria distintas, al visitar A y después D es necesario cambiar de región de memoria.
- En este proceso, al cambiar de región de memoria y cargar nuevas líneas de caché, se produce el llamado coste de salto de memoria. Una parte considerable del tiempo de CPU de la aplicación puede destinarse a este trabajo del GC.
- Este coste se produce continuamente mientras el GC está en curso.
¿Cuál es el problema?
Este comportamiento del GC es fatal en los siguientes casos.
- Cuando hay muchos núcleos y la memoria es grande
- El GC de Go se ejecuta, por defecto, de forma concurrente.
- Sin embargo, si la región de memoria es amplia y los objetos están dispersos, varios núcleos exploran objetos en distintos espacios de memoria al mismo tiempo.
- En este proceso, si el bus entre la CPU y la memoria no es lo bastante amplio, el ancho de banda de memoria puede convertirse en un cuello de botella.
- Además, como existe una distancia física real, cada exploración puede suponer un retardo relativamente grande.
- Cuando hay muchos objetos pequeños
- Si se opera con Go una estructura de árbol o de grafo con gran profundidad o con muchos hijos, el GC debe explorar todos esos objetos.
- En este proceso, si los objetos están distribuidos en distintas regiones de memoria, se produce un retardo por la primera razón.
- Además, si hay muchos objetos pequeños, el GC debe explorarlos todos, por lo que mientras se ejecuta el GC los núcleos de CPU dedicarán una capacidad considerable al GC para realizar ese trabajo.
Para resolver estos problemas, el equipo de Golang presentó el GreenTea GC.
Ya no tienes tiempo para tomar más té verde
¿Qué es lo que quita el té verde?
Parece que el equipo consideró que la región donde se puede aplicar la solución más rápida del GC existente es la localidad de memoria. Es decir, colocar los objetos cerca unos de otros para minimizar los saltos de memoria. Sin embargo, no se puede imponer a los programadores el patrón con el que escriben código, y la asignación de objetos no puede predecirse según el flujo de trabajo.
Quizás por eso, el método elegido por el equipo de Golang es el span de memoria (Memory Span).
¿Qué es un span de memoria?
Un span de memoria es un lugar que obtiene una región de memoria relativamente grande y en el que se asignan objetos pequeños. Y el nuevo GC llamado GreenTea GC realiza la recolección de basura sobre estos spans de memoria. Los detalles de funcionamiento son casi idénticos a los del Tri-Color Mark and Sweep existente.
Funcionamiento del GreenTea GC
Primero, GreenTea GC asigna un span de memoria. Su tamaño es, como se mencionó antes, de 8 KiB, un tamaño considerable. Y dentro de él se pueden asignar objetos de hasta 512 bytes. Es un tamaño que difícilmente superarían los nodos de árboles o grafos del ejemplo anterior, o las estructuras que suelen escapar al heap. Los objetos de 512 bytes o menos se acumulan en este span de memoria cada vez que escapan al heap, y cuando este span de memoria se llena, se asigna un nuevo span de memoria.
Ahora, cuando se produce un GC, GreenTea GC coloca estos spans de memoria en una cola y los inspecciona en orden. En este proceso, GreenTea GC utiliza una planificación casi similar al modelo GMP existente. También se ha implementado un área como el robo de trabajo. En cualquier caso, el worker que extrae un span de memoria de la cola inspecciona los objetos internos del span de memoria que se le ha asignado. En este proceso se utiliza el mismo Tri-Color Mark and Sweep.
¿Cuál es la ventaja?
Vista en conjunto, la diferencia con el GC existente se reduce a una sola cosa. La unidad sobre la que el GC realiza su trabajo de una vez pasa de ser el objeto a ser el span de memoria. Gracias a ello, el GC tiene las siguientes ventajas.
- Localidad de memoria: como los objetos están agrupados en el span de memoria, al explorar objetos se minimizan los saltos de memoria. Es decir, se puede aprovechar al máximo la caché de la CPU.
- Mejora del rendimiento del GC: al realizar el GC por unidades de span de memoria, el GC funciona de forma más eficiente en entornos multinúcleo.
- Optimización de la asignación de memoria: como los spans de memoria se asignan con tamaño fijo, al asignar objetos pequeños disminuye la sobrecarga de asignación y se reduce la posibilidad de fragmentación. Esto aumenta la eficiencia de la liberación de memoria.
En resumen, a partir de ahora podemos asignar objetos de 512 bytes o menos con más frecuencia y con mayor tranquilidad.
Sin embargo, también existen algunos casos en los que resulta algo ventajoso.
- Estructuras de árbol/grafo: casos con alto fan-out y en los que apenas se producen cambios
- B+ Tree, Trie, AST (Abstract Syntax Tree)
- Estructuras de datos amigables con la caché
- Procesamiento por lotes: flujos de trabajo con asignación masiva de datos pequeños
- Múltiples objetos pequeños generados por el parseo de JSON
- Objetos DAO de los conjuntos de resultados de bases de datos
En estos casos, GreenTea GC puede ofrecer un mejor rendimiento que el GC existente. Sin embargo, no se aplica a todos los casos, y en particular, cuando los objetos están distribuidos en distintas regiones de memoria, sigue siendo difícil esperar una mejora de rendimiento drástica.
Entonces
Parece que el equipo de Golang tiene la intención de seguir mejorando el GC a largo plazo. Este GreenTea GC puede considerarse un cambio menor en un ámbito reducido y no sustituye al GC existente. Sin embargo, GreenTea GC parece un caso interesante que permite vislumbrar los problemas que el equipo de Golang enfrenta o prevé. Aunque es un problema bastante complejo, también me pareció impresionante el intento de resolverlo mediante la adición de un concepto relativamente sencillo. Personalmente, lo considero un caso interesante.
GreenTea GC es una función experimental que se introduce a partir de Go 1.25. Puede activarse y utilizarse mediante la variable de entorno GOEXPERIMENT=greenteagc. Como esta función aún es experimental, se necesitan pruebas suficientes antes de usarla en entornos de producción.