wandres.dev
EL RECOLECTOR · incremental y generacional

Incremental: trocear el ciclo, la barrera de escritura y el precio de no parar

Un recolector que detiene el mundo es perfectamente aceptable en un compilador por lotes e intolerable en un juego a sesenta imágenes por segundo o en un editor que responde a cada pulsación. Esta lección explica cómo Lua reparte el ciclo en pasos guiados por la deuda de asignación, por qué esa decisión obliga a instalar una barrera de escritura en cada asignación de campo, en qué se diferencian la barrera hacia adelante y la barrera hacia atrás, y por qué reducir la pausa máxima siempre cuesta rendimiento total.

⏱ 21 min

La latencia no es un promedio, y ese es el malentendido que hunde a los recolectores ingenuos. Un motor de juego que dedica el tres por ciento de su tiempo a recolectar puede estar perfectamente inservible si ese tres por ciento se concentra en una pausa de ochenta milisegundos cada dos segundos: el usuario no percibe el promedio, percibe el tirón. Un editor de texto que congela el cursor doscientos milisegundos al abrir un archivo grande se siente roto aunque el perfilador diga que va rapidísimo. El recolector incremental de Lua existe para atacar exactamente esa métrica —el máximo, no la media— y lo hace pagando un precio que conviene conocer en detalle, porque no es gratis y porque está escrito en cada asignación de campo que hace tu programa.

🎯 Al terminar esta lección sabrás
  • Justificar por qué la pausa máxima, y no el porcentaje total, es la métrica que decide en interactivos.
  • Describir cómo se trocea el ciclo en pasos y qué papel juega la deuda de asignación como disparador.
  • Explicar el invariante que rompe la ejecución intercalada y cómo lo restauran las dos barreras de escritura.
  • Razonar el compromiso entre pausa máxima y rendimiento total, y en qué dirección mover cada parámetro.

Por qué parar el mundo es inaceptable aquí

Un ciclo completo de marcado y barrido tiene un coste proporcional al tamaño del grafo vivo más el número de objetos creados. En un programa pequeño eso son microsegundos y nadie se entera. En un juego con el estado del mundo en tablas, un motor de interfaz con miles de nodos o un servidor con sesiones activas, el grafo vivo tiene cientos de miles de objetos, y recorrerlo entero de una sentada cuesta decenas de milisegundos.

El presupuesto de una imagen a sesenta por segundo es de dieciséis milisegundos y medio para todo: entrada, lógica, física, animación, envío de geometría. Un recolector que se lleve cuarenta de golpe no ha quitado un poco de margen, ha borrado dos imágenes enteras. En un editor la métrica es otra pero la conclusión es la misma: la pulsación de una tecla debe verse antes de unos cien milisegundos o la escritura deja de sentirse continua.

Y hay un agravante que suele pasarse por alto: las pausas del recolector no llegan repartidas al azar, llegan correlacionadas con la carga. El recolector se dispara cuando se asigna mucha memoria, y se asigna mucha memoria justo en los momentos en que pasan cosas: una explosión que crea partículas, un archivo que se abre, una petición que llega en avalancha. Es decir, el tirón aparece precisamente en la imagen que ya iba justa de tiempo, lo que convierte un problema de latencia media aceptable en un problema de latencia catastrófica en el peor momento.

La observación que salva la situación es que el trabajo del recolector es divisible. Marcar no es una operación atómica: es vaciar una lista de pendientes, y una lista se puede vaciar a ratos. Si en lugar de recorrer los cien mil objetos de una vez recorres mil cada vez que el programa pide memoria, el coste total es el mismo pero se reparte en cien mordiscos que nadie nota. Eso es un recolector incremental: el mismo algoritmo de la lección anterior, entrelazado con la ejecución del programa.

flowchart LR
P[Pausa: esperar a que crezca la memoria] --> M[Propagacion: marcar por pasos]
M --> M
M --> A[Atomico: cerrar el marcado sin cortes]
A --> B[Barrido por pasos]
B --> B
B --> F[Llamar finalizadores pendientes]
F --> P

Deuda de asignación: quién decide cuándo se da un paso

Lua no usa un temporizador ni un hilo aparte. El disparador es la propia asignación de memoria: el estado mantiene una deuda que crece cada vez que el programa reserva bytes, y cuando esa deuda supera el umbral, la siguiente operación que asigne memoria ejecuta un paso de recolección antes de continuar. El programa paga su propia recolección, en proporción exacta a la basura que está generando.

La elección de no usar un hilo aparte tampoco es casual. Un recolector concurrente exigiría sincronización en cada acceso a memoria compartida, complicaría el empotrado en aplicaciones de C con sus propios modelos de hilos y multiplicaría el tamaño del intérprete, que es la cifra que Lua defiende por encima de casi todo lo demás. Repartir el trabajo dentro del mismo hilo es peor en teoría y muchísimo mejor en la práctica para el nicho al que Lua se dirige.

Esta decisión de diseño tiene tres consecuencias que hay que tener presentes. La primera es que un programa que no asigna memoria no recolecta: un bucle cerrado que solo hace aritmética sobre variables locales puede correr indefinidamente sin que el recolector avance un milímetro, y eso es correcto, porque tampoco está produciendo basura. La segunda es que los pasos ocurren en puntos impredecibles del código, dentro de cualquier constructor de tabla, concatenación o creación de closure. La tercera es que el recolector es determinista respecto a la asignación, no respecto al tiempo: dos ejecuciones que asignen lo mismo en el mismo orden recolectan en los mismos puntos.

-- El paso ocurre dentro de la creacion de la tabla, no entre lineas.
for i = 1, 200000 do
  local punto = { x = i, y = i * 2 }   -- aqui puede correr un paso del GC
  procesar(punto)
end

-- Un bucle sin asignacion no avanza el recolector en absoluto:
local suma = 0
for i = 1, 1e8 do suma = suma + i end  -- cero pasos de recoleccion

El ciclo troceado conserva las mismas fases que ya conoces, con una diferencia crucial: la propagación y el barrido se ejecutan a mordiscos, pero la fase atómica sigue sin poder partirse. Ahí se reprocesa lo que las barreras devolvieron a gris, se resuelven las tablas débiles, se apartan los objetos con finalizador pendiente y se invierte el blanco. Su duración es corta y acotada, pero existe, y por eso ningún recolector incremental promete pausa cero: promete que la pausa no crece con el tamaño del grafo salvo en ese tramo.

ℹ️
Un paso no es una unidad de tiempo

El tamaño de un paso se mide en trabajo de marcado y bytes barridos, no en microsegundos. Dos pasos del mismo tamaño nominal pueden durar muy distinto según lo que se encuentren por delante: un vector de enteros se recorre a velocidad de memoria, y una maraña de tablas pequeñas obliga a saltar de línea de caché en línea de caché. Cualquier presupuesto de latencia basado en contar pasos hay que validarlo midiendo.

La barrera de escritura: el precio que se paga en cada campo

Trocear el marcado rompe el invariante tricolor, y lo rompe de una forma concreta y fácil de ver. Supongamos que el recolector ya terminó con una tabla y la dejó negra. El programa se reanuda y escribe en esa tabla una referencia a un objeto recién creado, que es blanco. Ahora hay un negro apuntando a un blanco: el recolector nunca volverá a mirar la tabla negra, nunca descubrirá al objeto blanco, y en el barrido lo liberará estando vivo. Eso no es una fuga: es un uso de memoria liberada, el peor fallo posible.

La solución es interceptar toda escritura de una referencia dentro de un objeto ya negro. Eso es la barrera de escritura, y Lua tiene dos variantes que resuelven el mismo problema con estrategias opuestas.

La barrera hacia adelante actúa sobre el objeto apuntado: si un negro pasa a apuntar a un blanco, se tiñe el blanco de gris inmediatamente, de modo que el recolector lo visitará. Es la que Lua usa para upvalues de closures, prototipos de función y usuarios de datos, es decir, para objetos que tienen pocos campos y se escriben pocas veces.

La barrera hacia atrás actúa sobre el objeto que escribe: en lugar de perseguir al apuntado, devuelve la tabla negra al estado gris y la vuelve a encolar para reprocesarla al cerrar el marcado. Es la que Lua usa para las tablas, y la razón es puramente económica: una tabla se escribe muchísimas veces seguidas, y teñir cada valor nuevo obligaría a hacer trabajo por cada asignación. Devolviendo la tabla entera a la cola, las mil escrituras siguientes ya no cuestan nada, porque la tabla ya no es negra, y el recolector la revisará una sola vez al final.

local cache = {}                 -- supongamos que el GC ya la marco negra
collectgarbage("step")           -- avanzamos el marcado un poco

for i = 1, 1000 do
  cache[i] = { id = i }          -- la primera escritura activa la barrera
end                              -- y devuelve cache a gris; las 999 restantes
                                 -- ya no pagan nada

La elección entre una barrera y otra no es una preferencia de estilo, es una consecuencia directa del patrón de escritura esperado. Teñir el objeto apuntado cuesta poco por escritura pero se paga en todas las escrituras; devolver el contenedor a gris cuesta bastante más la primera vez pero amortiza el resto a cero. Con pocos campos escritos una vez, la primera gana; con un contenedor que recibe miles de valores seguidos, la segunda gana por goleada. Lua no elige una barrera universal precisamente porque no existe una que sea buena para los dos perfiles, y clasifica los tipos según cuál les corresponde.

Queda un detalle que suele sorprender: la barrera solo se activa cuando se escribe una referencia, no cuando se escribe un número o un booleano. Un campo que guarda coordenadas no puede violar el invariante tricolor, porque un entero no es un objeto al que haya que llegar. Esa asimetría es una de las razones técnicas por las que separar datos numéricos de datos con referencias en estructuras distintas no es solo una cuestión de memoria: también rebaja el trabajo de las barreras.

💡
La barrera explica por qué mutar es más caro que construir

Escribir en una tabla vieja puede desencadenar una barrera; construir una tabla nueva y llenarla no, porque una tabla recién creada nunca es negra. Ese detalle es una de las razones por las que el estilo funcional de construir y devolver rinde mejor de lo que la intuición sugiere en programas Lua con el recolector muy activo, pese a asignar más memoria.

Pausa contra rendimiento: no se puede ganar en los dos

Aquí está el compromiso que da sentido a los parámetros. El trabajo total de un recolector incremental es estrictamente mayor que el de uno que para el mundo, y la diferencia tiene tres orígenes: las barreras cuestan una comprobación en cada escritura, el reprocesado de objetos que volvieron a gris repite trabajo ya hecho, y los objetos que mueren en mitad de un ciclo largo sobreviven hasta el siguiente porque ya estaban marcados —lo que se conoce como basura flotante—.

A cambio obtienes que ninguna pausa individual sea larga. Es un intercambio directo: pasos más pequeños y más frecuentes bajan la pausa máxima y suben el coste total; pasos más grandes y espaciados hacen lo contrario. No existe una configuración que mejore ambas cosas, y quien te diga lo contrario está mirando un solo banco de pruebas.

Ajuste Efecto en la pausa máxima Efecto en el coste total Cuándo tiene sentido
Pasos más pequeños Baja Sube Juegos, interfaces, audio
Pasos más grandes Sube Baja Lotes, compiladores, análisis
Esperar más entre ciclos Igual Baja, pero sube el pico de memoria Memoria abundante
Esperar menos entre ciclos Igual Sube Memoria escasa o dispositivos empotrados

Hay un matiz que se olvida a menudo y que conviene incorporar antes de tocar nada. Alargar el intervalo entre ciclos no baja la pausa máxima, porque la pausa la fija el tamaño del paso, no la frecuencia; lo que hace es que se recolecte menos veces y, por tanto, que se recorra menos veces el grafo vivo. Es decir, la perilla de la pausa es la palanca de rendimiento total contra memoria pico, y la del tamaño de paso es la palanca de latencia contra rendimiento total. Confundirlas produce el error más común de todos: subir la pausa esperando menos tirones y encontrarse con la misma latencia y el doble de consumo.

Los tres parámetros clásicos del modo incremental controlan exactamente esas tres perillas: cuánto hay que crecer antes de empezar un ciclo nuevo, con qué velocidad avanza el recolector respecto a la asignación del programa, y de qué tamaño es cada mordisco. La lección cuatro los trata en detalle; lo importante ahora es entender que no son tres números independientes sino tres puntos de un mismo triángulo, y que mover uno mueve los otros.

⚠️
Un ciclo demasiado lento se convierte en un ciclo que nunca termina

Si bajas tanto la velocidad del recolector que avanza menos de lo que el programa asigna, el ciclo no llega a cerrarse nunca y la memoria crece de forma indefinida pese a que el recolector esté trabajando sin descanso. Es el fallo más difícil de diagnosticar de todos los que se pueden provocar ajustando parámetros, porque el síntoma —memoria que sube— apunta a una fuga que no existe.

Queda por señalar que todo este mecanismo es invisible desde el lenguaje. Ningún programa Lua correcto se comporta de forma distinta según el modo o los parámetros del recolector: la semántica es idéntica y lo único que cambia es cuándo se devuelve la memoria y cuánto tarda cada operación. Esa separación estricta entre semántica y política es la que permite cambiar de modo en caliente, ajustar por fases o publicar una versión nueva con un recolector distinto sin romper una sola línea de código existente.

⏱️

El máximo, no la media

Un recolector se juzga por su percentil noventa y nueve. El tiempo total dedicado a recolectar es la métrica que consuela al ingeniero; la pausa máxima es la que siente el usuario.

💳

Pagas cuando asignas

No hay hilo de fondo ni temporizador. La deuda de asignación dispara los pasos, así que el que genera basura es el que la limpia, en el mismo instante y en proporción.

🚧

Dos barreras, una razón

Hacia adelante para objetos que se escriben poco; hacia atrás para tablas, que se escriben en ráfagas. Elegir mal la barrera no rompe nada, pero multiplica el trabajo del recolector.

👻

Basura flotante

Lo que muere después de haber sido marcado sobrevive hasta el ciclo siguiente. Es memoria retenida sin motivo, y es el impuesto inevitable de no parar el mundo.

Lo incremental no reparte el trabajo: reparte la percepción del trabajo

Merece la pena decir con precisión qué compra un recolector incremental, porque casi todo el mundo lo describe mal. No hace menos trabajo, hace más: las barreras añaden una comprobación a cada escritura de referencia del programa, el reprocesado repite marcado ya hecho y la basura flotante retiene memoria que ya estaba muerta. Si midieras únicamente instrucciones ejecutadas, el recolector que para el mundo gana siempre. Lo que compra la versión incremental es algo que ninguna métrica agregada captura: convierte una interrupción estructural en un impuesto distribuido. Y esa distinción es cualitativa, no cuantitativa, porque los sistemas interactivos no fallan cuando consumen mucho, fallan cuando dejan de responder. Ochenta milisegundos de recolección repartidos en ochenta mordiscos de un milisegundo desaparecen dentro del ruido de cualquier bucle de imagen; los mismos ochenta milisegundos juntos son un tirón que el usuario ve, que el probador reporta y que el perfilador promedia hasta hacerlo invisible para el ingeniero. Hay una lección de diseño transferible mucho más allá del recolector: cuando un sistema tiene un plazo duro, el enemigo no es el coste, es la varianza del coste, y casi siempre merece la pena pagar un veinte por ciento más de trabajo total a cambio de que ese trabajo no llegue nunca en bloque. Esa es la razón por la que la versión incremental es el modo por defecto de Lua desde 5.1 pese a ser medible y demostrablemente más lenta en total, y es también la razón por la que la evolución natural del recolector no fue hacerlo aún más troceado —el troceado ya estaba resuelto— sino atacar la otra mitad del problema: dejar de recorrer una y otra vez lo que evidentemente no ha muerto. De ahí nace el modo generacional, que es la lección siguiente.

📝
Lo esencial

El recolector incremental entrelaza el marcado y el barrido con la ejecución del programa, disparando pasos cuando la deuda de asignación supera un umbral: quien asigna, recolecta. Intercalar rompe el invariante tricolor, y las barreras de escritura lo restauran —hacia adelante tiñendo el objeto apuntado, hacia atrás devolviendo la tabla a gris para reprocesarla al cerrar el ciclo—. La fase atómica sigue sin poder trocearse y marca el suelo de la pausa. El coste total sube por barreras, reprocesado y basura flotante; lo que baja es la pausa máxima, que es la métrica que decide en juegos, editores y audio.

⚔️ Provoca y mide una pausa
  1. Construye un grafo de doscientas mil tablas enlazadas, mantenlo vivo y mide con un reloj de alta resolución el tiempo de una recolección completa forzada. Esa cifra es tu pausa en el peor caso.
  2. Repite la medida ejecutando pasos sueltos en lugar de un ciclo completo, y anota el tiempo del paso más lento. Compara ese máximo con el de la primera medida.
  3. Escribe un bucle que solo haga aritmética sobre locales y comprueba, midiendo la memoria antes y después, que el recolector no avanza. Después mete un constructor de tabla en el bucle y repite.
  4. Diseña un caso que exhiba basura flotante: crea objetos, ejecuta un paso, abandónalos y comprueba que un ciclo completo posterior es el que de verdad los libera.
  5. Simula un bucle de imagen a sesenta por segundo que asigne memoria de forma constante e instrumenta cuántas imágenes superan los dieciséis milisegundos. Luego cambia el tamaño de paso y repite la cuenta.