wandres.dev
EL RECOLECTOR · incremental y generacional

Tablas débiles y finalizadores: cachés que sueltan, efímeros y resurrección

El campo de modo de la metatabla convierte una tabla en una estructura que observa sin retener: sus entradas desaparecen cuando el recolector decide que lo referenciado ya no interesa a nadie más. De ahí salen las cachés que no impiden liberar y las tablas de anotaciones que se limpian solas. Esta lección cubre las claves y valores débiles, la semántica de efímero que resuelve el ciclo clave-valor, y la interacción exacta entre débiles y finalizadores, incluida la asimetría de la resurrección que casi nadie conoce y que explica comportamientos aparentemente imposibles.

⏱ 22 min

Todas las referencias que has escrito hasta ahora comparten una propiedad que probablemente nunca te planteaste: retienen. Guardar una tabla en otra es tomar una decisión sobre su vida, y esa decisión es siempre la misma, vivir mientras yo viva. Hay estructuras enteras para las que esa semántica es exactamente la equivocada: una caché quiere recordar mientras sea útil, no impedir que las cosas mueran; una tabla que anota metadatos sobre objetos quiere que sus filas desaparezcan con el objeto anotado, no mantenerlo vivo eternamente por el crimen de haberlo descrito. Las tablas débiles son el mecanismo con el que Lua te permite escribir una referencia que observa sin retener, y su interacción con los finalizadores produce uno de los rincones más sutiles y más mal entendidos de todo el lenguaje.

🎯 Al terminar esta lección sabrás
  • Declarar tablas con claves débiles, valores débiles o ambos, y saber qué entradas pueden desaparecer.
  • Distinguir qué valores son susceptibles de eliminarse de una tabla débil y cuáles nunca lo son.
  • Aplicar la semántica de efímero para evitar la fuga clásica del valor que referencia a su clave.
  • Explicar la asimetría con que se limpian valores y claves débiles cuando hay objetos resucitados.

Débil significa que el marcado no pasa por ahí

La debilidad de una tabla se declara en su metatabla, con el campo __mode, cuyo valor es una cadena: "k" para claves débiles, "v" para valores débiles, "kv" para ambas cosas. Lo que esa declaración cambia es una única cosa, y todo lo demás se deduce de ella: el recolector no sigue esas referencias durante el marcado.

La consecuencia es que si un objeto solo es alcanzable a través de la posición débil de una tabla, para el recolector ese objeto es basura. Se libera, y en la fase atómica del ciclo la entrada correspondiente se elimina de la tabla. Nadie ve nunca una entrada apuntando a memoria muerta: o está entera o no está.

local caja = setmetatable({}, { __mode = "v" })   -- valores debiles

do
  local objeto = { nombre = "efimero" }
  caja[1] = objeto
  print(caja[1] ~= nil)        -- true, objeto sigue vivo por la local
end

collectgarbage("collect")
print(caja[1] ~= nil)          -- false, la entrada desaparecio sola

Hay una restricción que causa mucha confusión y conviene fijar de entrada: solo desaparecen los objetos con construcción explícita, es decir, tablas, closures, corrutinas y datos de usuario. Los números y los booleanos no son recolectables en absoluto. Y las cadenas, aunque internamente sí lo sean, se comportan como valores a estos efectos: su igualdad es por contenido, no por identidad, y por tanto una entrada con clave de cadena en una tabla de claves débiles nunca se elimina. Una caché indexada por nombres no se limpia sola por mucho __mode que le pongas.

⚠️
No cambies __mode después de usar la tabla como metatabla

El recolector consulta la debilidad al construir sus listas internas de tablas a limpiar. Modificar __mode en una metatabla que ya está en uso deja al recolector con una idea de la tabla que no corresponde a la realidad, y el comportamiento resultante no está definido. Declara la debilidad en el mismo constructor y no la toques.

Los dos patrones, y el ciclo que los rompe

Valores débiles sirven para cachés. La tabla recuerda un resultado costoso mientras alguien más lo esté usando, y en cuanto el último usuario real lo suelta, la entrada se evapora sin que haya que escribir una política de expulsión ni contar accesos.

local cache = setmetatable({}, { __mode = "v" })

local function cargar_textura(ruta)
  local t = cache[ruta]
  if t then return t end
  t = descomprimir_desde_disco(ruta)     -- caro
  cache[ruta] = t
  return t
end
-- Si nadie guarda la textura, el recolector la libera y la entrada se va.

Claves débiles sirven para anotar objetos sin tocarlos: asociar metadatos a una tabla de la que no eres dueño, llevar un registro de qué se ha inicializado, marcar objetos ya visitados en un recorrido. La fila vive exactamente lo que viva el objeto anotado.

local metadatos = setmetatable({}, { __mode = "k" })

local function anotar(objeto, info)
  metadatos[objeto] = info      -- no prolonga la vida de objeto
end

Y aquí aparece el problema clásico. Si en una tabla de claves débiles el valor guarda una referencia a la clave —cosa facilísima de hacer sin darse cuenta, por ejemplo si la información anotada incluye un puntero de vuelta al objeto—, entonces la clave es alcanzable desde el valor, el valor es fuerte, y la entrada se mantiene viva a sí misma para siempre. El mecanismo diseñado para no retener se convierte en la fuga perfecta.

Lua resuelve esto tratando las tablas de claves débiles y valores fuertes como tablas efímeras. La regla es: en una entrada efímera, el valor solo se considera alcanzable si la clave lo es por otro camino. El recolector no puede resolver eso en una pasada, porque marcar un valor puede volver alcanzable una clave que a su vez vuelve alcanzable otro valor, así que itera sobre las entradas efímeras hasta que ninguna cambia de estado. Es más caro que el marcado normal, y es la razón por la que conviene no abusar de las tablas de claves débiles gigantescas.

flowchart TD
A[Fase atomica del ciclo] --> B[Iterar tablas efimeras hasta punto fijo]
B --> C[Limpiar valores debiles no alcanzables]
C --> D[Separar objetos con finalizador pendiente]
D --> E[Marcar lo alcanzable desde esos objetos resucitados]
E --> F[Limpiar claves debiles no alcanzables]
F --> G[Barrido y llamada a los finalizadores]

Finalizadores y resurrección

Un objeto con __gc en su metatabla en el momento de asignarla queda marcado para finalización. Cuando el recolector lo encuentra inalcanzable, no lo libera: lo aparta, lo vuelve alcanzable de nuevo —eso es la resurrección— y llama al metamétodo. Todo lo que ese objeto referencia vuelve a estar vivo durante ese rato, porque el finalizador necesita poder usarlo. Solo en el ciclo siguiente, si nadie lo guardó, la memoria se recupera de verdad.

De ahí salen tres hechos que hay que memorizar. Los finalizadores corren en orden inverso al de marcado, de modo que un recurso dependiente cae antes que aquel del que depende. Un objeto se finaliza como mucho una vez: al apartarlo se le quita la marca, así que si el finalizador lo guarda en una estructura global vivirá indefinidamente pero no volverá a finalizarse. Y liberar de verdad un objeto con finalizador exige dos ciclos, uno para finalizar y otro para recuperar la memoria.

local recursos = setmetatable({}, { __mode = "k" })

local function abrir(nombre)
  local mt = { __gc = function(self) print("cerrando", self.nombre) end }
  local r = setmetatable({ nombre = nombre }, mt)   -- __gc ya presente: marcado
  recursos[r] = os.time()                            -- anotacion que no retiene
  return r
end

Dónde se cruzan los dos mundos

Y ahora la parte fina, la que explica comportamientos que parecen imposibles. Los objetos resucitados no reciben el mismo trato en las claves débiles que en los valores débiles, y la asimetría es deliberada.

Un objeto que va a ser finalizado se elimina de las posiciones de valor débil antes de ejecutar su finalizador. Razón: una caché de valores débiles no debe seguir entregando a otros un objeto que ya está en proceso de cierre.

En cambio, ese mismo objeto se elimina de las posiciones de clave débil solo en el ciclo siguiente, después de que su finalizador haya corrido. Razón: el finalizador quiere poder consultar las propiedades que alguien asoció al objeto a través de tablas de anotaciones, y si esas filas ya hubieran desaparecido, el patrón de anotar por fuera sería inservible justo en el único momento en que hace falta.

local anotaciones = setmetatable({}, { __mode = "k" })
local cache       = setmetatable({}, { __mode = "v" })

local mt = { __gc = function(self)
  print(anotaciones[self])     -- SI esta disponible: clave debil aun no limpiada
  print(cache.ultimo == self)  -- false: el valor debil ya se limpio
end }

local obj = setmetatable({}, mt)
anotaciones[obj] = "creado en el arranque"
cache.ultimo = obj
obj = nil
collectgarbage("collect")

Una consecuencia más, poco documentada y muy real: si una tabla débil está entre los objetos resucitados de un ciclo, puede quedarse sin limpiar del todo hasta el ciclo siguiente. En código que combine cachés débiles con finalizadores, un solo ciclo forzado no basta para observar el estado final.

🗑️

Débil no es opcional para el recolector

Una entrada débil no es una sugerencia. Si el objeto no es alcanzable por otra vía, desaparece en el primer ciclo. Nunca escribas lógica que dependa de que una entrada débil siga ahí.

🧵

Las cadenas no se van

Cadenas, números y booleanos se comportan como valores en tablas débiles y no se eliminan nunca. Una caché indexada por texto necesita una política de expulsión escrita a mano.

🔄

Efímero rompe el ciclo

En una tabla de claves débiles, el valor solo cuenta si la clave es alcanzable por otro camino. Sin esa regla, un valor que apunta a su clave mantendría viva la entrada para siempre.

Dos ciclos, siempre

Finalizar y liberar son ciclos distintos. Y si hay tablas débiles resucitadas de por medio, el estado observable puede tardar todavía otro ciclo en estabilizarse.

La debilidad es la forma de decirle al recolector qué referencias no son opiniones sobre la vida

Conviene detenerse en lo que una tabla débil declara de verdad, porque su nombre sugiere una referencia de segunda categoría y no es eso en absoluto. Toda referencia fuerte que escribes es simultáneamente dos cosas: un camino para llegar a un objeto y una afirmación sobre su vida. En la abrumadora mayoría de los casos ambas coinciden y por eso nunca hay que pensarlo. Pero existe una familia entera de estructuras cuya razón de ser es observar el grafo sin participar en él: cachés, índices, registros de observadores, tablas de anotaciones, colecciones de objetos vistos durante un recorrido, mapas de identidad. En todas ellas la referencia es un camino legítimo y la afirmación sobre la vida es sencillamente falsa, y el resultado de no poder expresar esa diferencia es un patrón que todo programador conoce: la caché que crece hasta consumir la memoria, la lista de suscriptores que retiene medio programa, el registro de depuración que impide que se libere nada de lo que registró. Lo que __mode te da no es una referencia más débil, es la capacidad de separar las dos afirmaciones y decir con precisión quiero saber llegar hasta ahí, pero no soy yo quien decide si eso vive. El diseño de Lua lleva ese razonamiento hasta el final con dos decisiones que merecen respeto. La primera es la semántica de efímero, que reconoce que una anotación no debe mantener vivo lo anotado ni siquiera cuando la propia anotación menciona al anotado, algo que resolver correctamente exige iterar hasta punto fijo y que muchos lenguajes con referencias débiles simplemente no hacen. La segunda es la asimetría de la resurrección: limpiar los valores débiles antes de finalizar, para que nadie reciba un objeto moribundo, pero conservar las claves débiles hasta después, para que el finalizador pueda consultar lo que se anotó sobre él. Ese par de reglas no salió de una preferencia estética; salió de tomarse en serio que la última cosa que un objeto hace antes de morir es cerrarse, y que para cerrarse bien necesita seguir teniendo memoria de lo que fue.

📝
Lo esencial

El campo __mode con "k", "v" o "kv" hace que el recolector no siga esas referencias al marcar, de modo que las entradas desaparecen solas cuando lo referenciado deja de ser alcanzable por otra vía. Solo se eliminan objetos con construcción explícita: cadenas, números y booleanos se quedan. Las tablas de claves débiles con valores fuertes son efímeras —el valor cuenta solo si la clave es alcanzable— y eso evita la fuga del valor que apunta a su clave. Los finalizadores resucitan temporalmente al objeto, corren en orden inverso al marcado, se ejecutan como mucho una vez y exigen dos ciclos para liberar. Y la asimetría clave: un objeto en finalización se borra de los valores débiles antes de correr su __gc, pero de las claves débiles solo en el ciclo siguiente, justo para que el finalizador pueda leer sus anotaciones.

⚔️ Comprueba cada regla con un experimento
  1. Construye una caché de valores débiles, guarda tres objetos, mantén una referencia fuerte a uno solo y verifica tras un ciclo que quedan exactamente las entradas que esperas.
  2. Repite el experimento usando cadenas como valores en lugar de tablas y explica por qué no desaparece ninguna.
  3. Crea una tabla de claves débiles donde el valor guarde una referencia de vuelta a su clave. Comprueba que la entrada se libera igualmente y describe qué habría pasado sin la semántica de efímero.
  4. Reproduce el ejemplo de la asimetría y confirma con tus propias impresiones que la anotación sigue disponible dentro del finalizador y la entrada de la caché no.
  5. Combina una caché débil con objetos que tengan finalizador y determina experimentalmente cuántos ciclos completos hacen falta para que el estado de la tabla se estabilice.