Marcado y barrido: las raíces, los tres colores y el fin de los ciclos
El recolector de Lua no cuenta referencias: sigue caminos. Esta lección reconstruye el algoritmo base —qué es exactamente una raíz, cómo el marcado con tres colores separa lo vivo de lo muerto en un solo recorrido, qué hace el barrido con lo que quedó blanco— y explica por qué un recolector con seguimiento es la condición técnica que permite que dos tablas se apunten mutuamente sin que ninguna de las dos se filtre jamás.
Hay una pregunta que el recolector de Lua no se hace nunca: ¿alguien sigue usando este objeto? Esa pregunta es indecidible y ningún sistema serio la intenta. La que sí se hace es mucho más humilde y perfectamente mecánica: ¿existe todavía un camino de punteros que llegue hasta aquí desde algún sitio conocido? Toda la maquinaria del recolector —los colores, las listas grises, las barreras que verás en las lecciones siguientes— existe para contestar esa segunda pregunta con exactitud y sin detener el programa más de la cuenta. Entender el algoritmo base no es un lujo académico: es lo que separa a quien acepta que su motor de juego tenga tirones inexplicables de quien sabe exactamente qué está pasando y en qué fase.
- Distinguir alcanzabilidad de uso, y enumerar cuáles son exactamente las raíces del estado de Lua.
- Describir el invariante de los tres colores y por qué basta un solo recorrido para clasificarlo todo.
- Explicar qué hace la fase de barrido y por qué el blanco cambia de significado entre ciclos.
- Argumentar por qué un recolector con seguimiento libera ciclos que un contador de referencias no puede tocar.
Alcanzable, que no es lo mismo que usado
Un recolector con seguimiento parte de un conjunto de objetos que se declaran vivos por definición y desde ahí camina el grafo de punteros. Todo lo que alcanza, vive. Todo lo que no alcanza, muere. Ese conjunto de partida son las raíces, y en Lua es asombrosamente pequeño: el hilo principal, el registro —la tabla interna donde la biblioteca de C guarda lo que quiere mantener vivo, y que contiene a su vez la tabla de globales— y las metatablas de los tipos básicos que el estado cachea.
Eso es todo. Tu tabla de configuración global no es una raíz: vive porque el registro apunta a la tabla de globales, y esa tabla apunta a ella. Una variable local viva no es una raíz: vive porque está en la pila de un hilo, y el hilo cuelga del registro o de otro objeto alcanzable. La consecuencia práctica es que una fuga de memoria en Lua tiene siempre la misma forma, sin excepción: alguna estructura alcanzable guarda una referencia que ya no te sirve. No hay fugas espontáneas; hay caches sin política de expulsión, listas de observadores que nunca se dan de baja y closures que capturan más de lo que necesitan.
local registro_de_eventos = {} -- alcanzable desde _G
local function suscribir(objeto_pesado)
registro_de_eventos[#registro_de_eventos + 1] = objeto_pesado
end
do
local temporal = { datos = string.rep("x", 1e6) }
suscribir(temporal)
end
-- temporal salio de alcance, pero el objeto sigue vivo:
-- hay un camino desde el registro hasta el, y con eso basta.
collectgarbage("collect")
print(collectgarbage("count")) -- el megabyte sigue ahi
La distancia entre dejé de usarlo y dejó de ser alcanzable es donde vive el noventa por ciento de los problemas de memoria en programas Lua reales. El recolector es correcto; el grafo es lo que está mal.
De esa misma observación sale el método de diagnóstico, que es más mecánico de lo que parece. Ante una memoria que crece sin parar, la pregunta no es qué está fallando sino qué camino queda, y ese camino empieza obligatoriamente en una de las cuatro raíces. Recorrer el grafo desde la tabla de globales anotando qué se alcanza y comparar dos instantáneas separadas en el tiempo localiza al culpable en minutos, porque la estructura que crece entre ambas fotos es exactamente la que retiene lo que no debería.
El invariante de los tres colores
El marcado clásico de Dijkstra reparte los objetos en tres clases durante el recorrido, y esas clases se llaman colores por tradición.
- Blanco: no visitado todavía. Al final del marcado, blanco significa basura.
- Gris: visitado, pero sus referencias aún no se han explorado. Es la frontera del recorrido, la lista de pendientes.
- Negro: visitado y con todos sus hijos ya visitados o encolados. Terminado.
El algoritmo es un recorrido en anchura con una sola regla: se toma un objeto gris, se pintan de gris todos los blancos a los que apunta y el objeto pasa a negro. Se repite hasta que no queda nada gris. Entonces todo lo alcanzable es negro y todo lo blanco es, demostrablemente, inalcanzable.
flowchart LR B[Blanco: no visitado, candidato a basura] --> G[Gris: visitado, hijos pendientes] G --> N[Negro: visitado, hijos ya encolados] N -.-> G R[Raices: hilo principal, registro, metatablas de tipos basicos] --> G
De aquí sale el invariante tricolor, que es la afirmación central de todo el sistema: ningún objeto negro apunta directamente a un objeto blanco. Mientras eso se cumpla, el conjunto gris contiene toda la frontera pendiente y el algoritmo no puede perder nada por el camino. Cuando el gris se vacía, la clasificación es completa y correcta.
Ese invariante es trivial de mantener si el programa está parado durante todo el marcado, porque nadie mueve punteros mientras el recolector camina. En cuanto se permite que el programa siga ejecutándose entre paso y paso —que es de lo que trata la lección siguiente— el invariante deja de ser gratis y hay que defenderlo activamente. La flecha punteada del diagrama, la que devuelve un objeto de negro a gris, es exactamente el precio de esa defensa.
En la implementación, el color vive en unos pocos bits del encabezado común que comparten todos los valores recolectables. No hay tres listas separadas de objetos: hay una lista de todos los objetos creados, y listas auxiliares que enlazan solo los grises pendientes. Es una diferencia de ingeniería importante, porque hace que teñir un objeto cueste una operación con bits y no una inserción en una estructura.
Barrido, y por qué el blanco cambia de bando
Terminado el marcado, la fase de barrido recorre linealmente la lista de todos los objetos creados. Los negros vuelven a blanco, preparados para el siguiente ciclo. Los que siguen blancos se liberan. Es una pasada simple, sin recursión y sin decisiones: la inteligencia estaba toda en el marcado.
Pero aquí aparece un problema de sincronización que conviene ver, porque explica un detalle del que se habla poco. Si el programa puede crear objetos mientras el recolector barre, ¿de qué color nace un objeto nuevo? Si nace blanco, el barrido que aún no ha llegado a su posición lo liberará, y habremos matado un objeto recién nacido y perfectamente vivo. Si nace negro, sobrevive, pero rompe el invariante en cuanto apunte a algo blanco.
La solución de Lua son dos blancos alternos. El estado mantiene una noción de blanco actual que se invierte al final de cada ciclo. Un objeto blanco del ciclo anterior es basura confirmada; un objeto blanco del ciclo en curso está recién nacido y es intocable. El barrido solo libera lo que lleva el blanco viejo, y así puede convivir con un programa que sigue asignando memoria a su espalda.
-- Lo que un ciclo completo hace, visto desde fuera:
-- 1. pausa esperar a que la memoria crezca lo suficiente
-- 2. marcado tenir de gris las raices y propagar hasta vaciar
-- 3. atomico cerrar el marcado sin interrupciones y cambiar el blanco
-- 4. barrido liberar lo que quedo con el blanco viejo
-- 5. finales ejecutar los finalizadores pendientes
print(collectgarbage("count")) -- kilobytes en uso, con decimales
collectgarbage("collect") -- fuerza un ciclo completo
print(collectgarbage("count")) -- lo que de verdad estaba vivo
Conviene fijarse en la asimetría de costes entre las dos fases, porque explica bastantes cosas. Marcar cuesta en proporción a lo vivo: cuanto más grande sea tu grafo alcanzable, más caro. Barrer cuesta en proporción a lo creado, vivo o muerto, porque hay que recorrer la lista entera de objetos para decidir. Un programa con poca memoria viva y mucha rotación tiene marcados baratos y barridos caros; uno con estructuras enormes y estables tiene lo contrario. Saber en cuál de los dos casos estás es el primer paso de cualquier diagnóstico serio.
La fase atómica merece un nombre propio porque es la única parte del ciclo que no se puede trocear jamás. Ahí se vuelve a visitar lo que quedó pendiente, se resuelven las tablas débiles, se separan los objetos con finalizador y se invierte el blanco. Es corta por diseño, y su duración es el suelo absoluto de la pausa que el recolector puede llegar a imponer: por muy incremental que sea el resto, este trozo se paga entero de una vez.
El regalo: los ciclos dejan de existir como problema
Contar referencias es la alternativa obvia y la que muchos lenguajes eligieron. Cada objeto lleva un contador, cada asignación lo sube, cada abandono lo baja, y al llegar a cero el objeto se libera de inmediato. Es determinista, tiene pausas mínimas y una propiedad fatal: no puede liberar ciclos. Dos objetos que se apuntan mutuamente mantienen sus contadores en uno para siempre, aunque nadie más los conozca.
local padre = { nombre = "raiz" }
local hijo = { nombre = "hoja" }
padre.hijo = hijo
hijo.padre = padre -- ciclo perfecto, natural, correcto
padre, hijo = nil, nil -- nadie los alcanza ya
collectgarbage("collect") -- Lua los libera sin despeinarse
Este código es el idioma más natural del mundo: un nodo que conoce a su padre, una vista que conoce a su modelo, un buffer que conoce a la ventana que lo muestra. En un lenguaje con conteo de referencias puro, ese hijo.padre es una fuga, y el programador tiene que aprender un vocabulario entero de referencias débiles y punteros no propietarios para evitarla. En Lua no tiene que aprender nada, porque el recolector no pregunta cuántos te apuntan, sino se llega hasta ti, y a un ciclo aislado no se llega.
La contrapartida es igual de real y hay que enunciarla con la misma claridad. El conteo de referencias libera en el instante exacto en que la última referencia desaparece, y esa inmediatez es una propiedad valiosísima cuando el objeto retiene algo escaso: un descriptor de fichero, una conexión, un bloqueo. El recolector con seguimiento no ofrece nada parecido: un objeto muere cuando deja de ser alcanzable, pero se libera cuando el recolector pase por ahí, y esa distancia puede ser de milisegundos o de minutos. Lua asume ese intercambio conscientemente —recorre el grafo en vez de contar, y a cambio pierde el momento— y compensa la pérdida con un mecanismo aparte, las variables de cierre, que no tienen nada que ver con el recolector porque el problema que resuelven tampoco lo tiene.
El coste del marcado es proporcional a lo que sobrevive, no a lo que muere. Un programa que mantiene una estructura de medio millón de tablas la recorre entera en cada ciclo aunque no haya cambiado ni un campo desde el arranque. Esa observación, evidente en cuanto se enuncia, es exactamente la que motiva el modo generacional de la lección tres.
Las raíces son cuatro cosas
Hilo principal, registro, tabla de globales dentro del registro y metatablas cacheadas de los tipos básicos. Todo lo demás vive por herencia de alcance. Si algo no muere, hay un camino desde una de esas cuatro cosas.
El invariante lo es todo
Ningún negro apunta a un blanco. Cada mecanismo que verás después —barreras, listas de reprocesado, edades generacionales— existe únicamente para sostener esa frase mientras el programa sigue corriendo.
Dos blancos, no uno
El blanco actual y el blanco anterior se alternan en cada ciclo. Sin ese truco, un recolector que convive con el programa no podría distinguir un objeto muerto de uno recién nacido durante el barrido.
El coste es proporcional a lo vivo
Marcar cuesta en función de los objetos alcanzables; barrer cuesta en función de todos los creados. Una tabla enorme y viva se paga en cada ciclo aunque no cambie nunca. Ese detalle es la semilla del modo generacional.
Conviene mirar el marcado y barrido no como una técnica de gestión de memoria sino como una decisión sobre el reparto del trabajo intelectual entre el programador y la máquina, porque ahí es donde se ve su verdadero alcance. En un lenguaje sin recolector, cada referencia que escribes lleva implícita una pregunta de propiedad que tienes que contestar tú y documentar para los demás: quién es el dueño de este puntero, quién lo libera, qué pasa si dos estructuras lo comparten, en qué orden hay que desmontarlo todo. Esa pregunta no aparece en el código, aparece en la cabeza del programador y en los comentarios, y por eso se contesta mal con tanta frecuencia. El recolector con seguimiento no responde esa pregunta más rápido: la elimina, porque sustituye el concepto de propiedad por el de alcanzabilidad, que es una propiedad global del grafo y no un contrato local entre dos módulos. Ese cambio es lo que permite que en Lua puedas devolver una tabla desde una función sin pensar quién la liberará, guardarla en tres sitios a la vez sin decidir cuál es el dueño y construir grafos cíclicos —árboles con enlace al padre, listas doblemente enlazadas, observadores que conocen a su sujeto— con la misma naturalidad con la que dibujarías esas flechas en una pizarra. La contrapartida es real y honesta: pierdes el control del cuándo. Un objeto muere en el instante en que deja de ser alcanzable, pero se libera cuando el recolector pase por ahí, y esa distancia temporal es exactamente lo que las cuatro lecciones siguientes intentan domar. La lectura que hay que llevarse es que el recolector no te regala memoria infinita ni te exime de pensar en el grafo: te regala el derecho a olvidarte del orden de destrucción, que es la parte del problema en la que los humanos somos irremediablemente malos, y te deja a cambio la parte en la que somos buenos, que es saber qué debe seguir siendo alcanzable y qué no.
Lua usa un recolector con seguimiento: parte de un conjunto minúsculo de raíces —hilo principal, registro, globales y metatablas de tipos básicos— y marca todo lo alcanzable. Los tres colores clasifican el recorrido: blanco sin visitar, gris en la frontera, negro terminado, bajo el invariante de que ningún negro apunta a un blanco. El barrido libera lo que quedó con el blanco del ciclo anterior, y los dos blancos alternos permiten que el programa cree objetos mientras tanto. La fase atómica no se puede trocear y fija la pausa mínima. Como se recorre el grafo en vez de contar referencias, los ciclos se liberan solos y la única fuga posible es una referencia alcanzable que ya no querías.
- Construye el ciclo padre-hijo del ejemplo, anula las dos variables locales y comprueba con
collectgarbage("count")que la memoria vuelve a su nivel tras un ciclo forzado. - Repite el experimento dejando el objeto registrado en una tabla global. Explica en una frase por qué ahora no baja, sin usar la palabra referencia.
- Mide el coste de marcar creando una tabla con un millón de enteros y comparando el tiempo de una recolección completa antes y después de crearla. Después repite con un millón de tablas pequeñas y explica la diferencia.
- Escribe una función que reciba una tabla y devuelva cuántos objetos distintos son alcanzables desde ella, recorriendo el grafo y anotando los ya vistos. Acabas de escribir la fase de marcado.
- Añade a esa función una tabla que se apunte a sí misma y verifica que tu marcado no entra en bucle infinito. Identifica qué papel juega exactamente tu conjunto de vistos en el algoritmo real.