wandres.dev
ALLOCATORS Y MEMORIA · a bajo nivel

Arenas y bump allocators: reservar mucho, liberar de golpe

Un bump allocator asigna avanzando un puntero y libera todo de una vez: la operación más rápida posible frente a la búsqueda en la free list de `malloc`. Cuando muchos objetos nacen y mueren en la misma fase —una petición, un frame, un pase de compilador— la arena da un salto de rendimiento real. En Rust, `bumpalo` lo trae a estable.

⏱ 20 min

En el nivel 14 de C viste la idea de las arenas: reservar un bloque grande de una vez y entregar trozos avanzando un puntero, para luego tirar todo de golpe. Ese mismo patrón, el bump allocator, es una de las herramientas de rendimiento más potentes de la programación de sistemas, y Rust lo integra con una elegancia que C no tiene: las referencias que la arena entrega quedan atadas por lifetime a la propia arena, de modo que el compilador te impide usarlas después de liberarla. Asignar se reduce a redondear por alineación y sumar; liberar, a poner un puntero a cero. No hay búsqueda en una lista de huecos, no hay free por objeto, no hay fragmentación. Cuando el patrón de vida de tus datos encaja —muchos objetos que nacen y mueren en la misma fase—, la arena no es una micro-optimización: es un cambio de orden de magnitud en el coste de gestionar memoria.

🎯 Al terminar esta lección sabrás
  • Entender el mecanismo del bump: alinear, avanzar el puntero, y resetear en O(1).
  • Usar bumpalo::Bump para asignar miles de valores sin un solo free individual.
  • Identificar el patrón de vida —“todo muere junto en una fase”— donde la arena gana.
  • Recordar el precio: sin liberación individual, y sin Drop por defecto en bumpalo.

El mecanismo: alinear y avanzar

Un bump allocator mantiene tres punteros: el inicio del bloque, el final, y el actual. Asignar n bytes con alineación a es redondear el puntero actual hacia arriba hasta un múltiplo de a, comprobar que cabe, y avanzar. El esqueleto, en pseudo-Rust para ver la aritmética:

// Nucleo conceptual de un bump allocator (bumpalo lo hace robusto y seguro).
fn bump(actual: usize, fin: usize, n: usize, align: usize) -> Option<(usize, usize)> {
    let alineado = (actual + align - 1) & !(align - 1); // redondea hacia arriba
    let nuevo = alineado.checked_add(n)?;
    if nuevo <= fin {
        Some((alineado, nuevo))  // (puntero devuelto, nuevo actual)
    } else {
        None                     // no cabe: pide otro bloque o falla
    }
}

Compáralo con lo que hace malloc: recorrer una free list buscando un hueco del tamaño adecuado, quizá dividirlo, actualizar metadatos, y en el free volver a enlazar y a veces fusionar huecos vecinos. El bump sustituye todo eso por una operación con dos sumas y una máscara. Y la liberación es aún más brutal: actual = inicio, un solo movimiento que “libera” cuanto se hubiera asignado.

bumpalo: arenas en estable

No necesitas escribir el unsafe a mano; el crate bumpalo da un bump allocator robusto, seguro y en estable:

[dependencies]
bumpalo = "3"
use bumpalo::Bump;

let arena = Bump::new();

let x: &mut u64 = arena.alloc(42);        // asigna avanzando el puntero
let s: &str = arena.alloc_str("hola");    // cadena copiada en la arena
let nodo = arena.alloc([0u8; 256]);       // un bloque grande, mismo coste O(1)

// ... miles de arena.alloc, sin un solo free individual ...

drop(arena);                              // toda la memoria vuelve de golpe

Cada alloc devuelve una referencia &mut T cuya vida está ligada a &arena: el borrow checker garantiza que ninguna de esas referencias sobreviva a la arena, así que el “liberar todo de golpe” es seguro por construcción, no una promesa que debas recordar. Para objetos grandes conviene arena.alloc_with(|| Grande::nuevo()), que construye el valor directamente en la arena a partir de la clausura, sin materializar antes una copia temporal en la pila —una diferencia real cuando el tipo mide cientos de bytes—. Si necesitas reutilizar la arena entre fases sin destruirla, arena.reset() pone el puntero al inicio y deja la capacidad reservada lista para la siguiente ronda.

Si lo que necesitas es una arena que ejecute destructores, typed-arena es la respuesta complementaria: guarda objetos de un único tipo y corre su Drop al soltar la arena, a cambio de esa restricción de homogeneidad. La elección entre bumpalo (heterogéneo, sin Drop) y typed-arena (un tipo, con Drop) es otra decisión de diseño, no un detalle: depende de si tus datos llevan recursos que liberar o son POD que puedes abandonar.

Con la feature adecuada, &Bump implementa el trait Allocator de la lección anterior, así que puedes tener colecciones enteras respaldadas por la arena:

use bumpalo::collections::Vec as BumpVec;

let arena = Bump::new();
let mut v = BumpVec::new_in(&arena);      // Vec cuya memoria sale de la arena
v.push(1);
v.push(2);
⚠️
bumpalo no ejecuta Drop por defecto: cuidado con lo que metes

La trampa clásica. arena.alloc(x) no ejecuta el destructor de x cuando la arena se libera: los valores se abandonan en bloque, sin correr su Drop. Para datos Copy o sin recursos (números, str, structs de POD) es exactamente lo que quieres y por eso es tan rápido. Pero si metes en la arena un String, un File o cualquier tipo con Drop significativo, ese destructor nunca corre y filtras el recurso. Para necesitar destrucción usa bumpalo::boxed::Box, que sí lo ejecuta, o simplemente no pongas tipos con Drop en la arena. Esta es la conexión directa con el nivel 8: liberar memoria y ejecutar destructores son cosas distintas, y una arena separa deliberadamente la primera de la segunda.

Una arena crece por bloques

El esqueleto de más arriba tenía un final abrupto: cuando el bloque se llena, devuelve None. Una arena de verdad no se rinde ahí. Cuando el bloque actual se agota, bumpalo pide al allocator global un bloque nuevo y más grande, lo encadena al anterior y sigue avanzando el puntero sobre él. Un Bump, por dentro, es una lista enlazada de bloques, no un buffer único de tamaño fijo. Esto preserva la promesa de rendimiento: la asignación sigue siendo O(1) amortizado, porque la ruta lenta —pedir un bloque al sistema— ocurre rarísima vez, y como cada bloque nuevo es geométricamente mayor, el número de peticiones al malloc global crece de forma logarítmica con la memoria usada.

Y reset() afina el ciclo: no devuelve los bloques al sistema, sino que conserva la capacidad ya reservada y solo pone los punteros al principio. Por eso una arena reutilizada frame a frame toca el allocator global casi nunca tras los primeros fotogramas: ya tiene todos sus bloques, y cada reset es gratis.

let mut arena = Bump::new();
loop {                          // bucle principal de un juego
    // ... miles de arena.alloc para la fisica y el render de este frame ...
    dibujar(&arena);
    arena.reset();              // libera todo el frame en O(1), sin devolver RAM
}

Tras el primer puñado de iteraciones, ese bucle no vuelve a llamar al malloc global: asigna y recicla dentro de bloques que ya posee.

Cuándo gana la arena

La arena no es universal: brilla cuando el patrón de vida de los datos es por fases, un grupo de objetos que nacen juntos y mueren juntos. Los casos canónicos son exactamente esos:

🌐

Una peticion web

Todo lo que reservas para procesar una petición —parseos, buffers, estructuras temporales— muere cuando la respuesta se envía. Arena por petición, reset al terminar.

🎮

Un frame de juego

Datos transitorios de física, colisiones y render viven un fotograma. Arena por frame, reset a 60 Hz sin tocar el malloc global.

🌳

Un pase de compilador

El AST y las estructuras intermedias de compilar un archivo mueren al terminar el pase. Los compiladores usan arenas masivamente por esto.

Hay además una cuarta fuente de velocidad, fácil de pasar por alto: una arena por hilo no necesita sincronización. El allocator global debe ser seguro entre hilos —cada alloc compite con todos los demás por los mismos metadatos—, pero una Bump local a un hilo o a una tarea asigna sin un solo átomo ni lock, avanzando su puntero en soledad. En un servidor con una arena por petición o un juego con una arena por hilo de trabajo, eso elimina de la ruta caliente la contención que el malloc global no puede evitar.

El salto de velocidad viene de tres frentes a la vez. Uno, el coste de asignar cae a un puñado de instrucciones sin ramas ni búsquedas. Dos, no hay coste de liberación por objeto: se paga un solo reset por miles de asignaciones. Tres, y a menudo el mayor, la localidad de caché: como los objetos se colocan contiguos en el orden en que se crean, recorrerlos después acaricia la caché en vez de saltar por todo el heap. Donde no gana es en lo opuesto: objetos de vida larga e independiente que se liberan en momentos dispares; ahí, no poder liberar uno solo se vuelve un desperdicio de memoria, y malloc o el allocator global es la elección correcta.

💡
Arena para vidas en fase; pool para tamaño fijo

La arena tiene un hermano que viste en el nivel 14 de C: el pool o free list. Si tus objetos no mueren todos juntos sino de uno en uno, pero son todos del mismo tamaño —nodos de un árbol, entidades de un juego, entradas de una tabla—, un pool te da alloc y dealloc individuales en O(1) y sin fragmentación, porque cada hueco liberado encaja exactamente en cualquier petición. En Rust, crates como slab y typed-arena cubren estos patrones. La regla que unifica todo el nivel: elegir el allocator es emparejar la estrategia con el patrón de vida —y de tamaño— de tus datos. Fase que muere junta, arena; tamaño fijo con vida individual, pool; vida larga e irregular, el allocator global.

flowchart LR
I[inicio del bloque] --> P1[objeto 1]
P1 --> P2[objeto 2]
P2 --> P3[objeto 3]
P3 --> A[puntero actual avanza]
A -.reset o drop.-> I
style I fill:#89b4fa,color:#11111b
style A fill:#f9e2af,color:#11111b
style P2 fill:#a6e3a1,color:#11111b
La arena cambia la unidad de gestion: del objeto a la fase

El bump allocator parece un truco de rendimiento —sumar un puntero es más rápido que buscar en una lista— y lo es, pero su verdadera potencia es conceptual: cambia la unidad de razonamiento sobre la memoria del objeto a la fase. Con malloc/free o con el allocator global, tu modelo mental es “gestiono cada objeto”: cada uno tiene su nacimiento y su muerte, y cada muerte es una llamada que puedes olvidar, adelantar o repetir. La arena disuelve ese modelo. Dejas de gestionar objetos individuales y pasas a gestionar el ciclo de vida de un grupo: todo lo que reservo para esta petición, este frame, este pase, muere junto, y lo digo una sola vez. De ese cambio de unidad salen tres bienes a la vez, y no por casualidad, sino porque los tres son la misma idea vista desde ángulos distintos. Velocidad: asignar es avanzar un puntero y liberar es un reset, porque no hay contabilidad por objeto que mantener. Robustez: es imposible filtrar dentro de la fase, porque no existe un free por objeto que olvidar —la fuga que en C acecha en cada reserva aquí no tiene dónde ocurrir—. Y en Rust, seguridad: las referencias que la arena entrega quedan atadas por lifetime a la arena, y el compilador rechaza usarlas tras liberarla, algo que en C era tu responsabilidad y tu segfault. El precio es coherente con la filosofía: renuncias a liberar objetos por separado y, con bumpalo, a ejecutar sus destructores, porque ambas cosas reintroducirían la contabilidad por objeto que la arena existe para abolir. La lección que trasciende el rendimiento: elegir un allocator no es elegir una implementación más rápida, es elegir la granularidad con la que piensas la vida de tus datos. Cuando esa granularidad es la fase y no el objeto, la arena no compite con malloc: juega a otro juego.

📝
Lo esencial de arenas y bump

Un bump allocator asigna redondeando por alineación y avanzando un puntero —un puñado de instrucciones, sin búsqueda—, y libera todo de golpe con un reset O(1). En Rust, bumpalo::Bump lo trae a estable: arena.alloc(x) devuelve una &mut T atada por lifetime a la arena, y reset() la recicla. Gana cuando los datos viven por fases (petición, frame, pase de compilador): baja el coste de asignar, elimina el de liberar por objeto y mejora la localidad de caché. El precio: no hay liberación individual y bumpalo no ejecuta Drop, así que no metas tipos con destructor significativo. Para vidas largas e independientes, usa el allocator global.

⚔️ Mide el salto de la arena
  1. Escribe la función bump conceptual (alinear, comprobar, avanzar) y razona por qué la máscara & !(align - 1) redondea hacia abajo y el + align - 1 la convierte en redondeo hacia arriba.
  2. Usa bumpalo::Bump para asignar cien mil enteros en un bucle; compara el tiempo con cien mil Box::new y comenta la diferencia.
  3. Mete un String en la arena y confirma, con un tipo que imprima en su Drop, que el destructor no corre. Cámbialo por bumpalo::boxed::Box y observa la diferencia.
  4. Intenta devolver una referencia obtenida de la arena más allá de la vida de esta y explica el error del borrow checker.
  5. Describe un patrón de tu propio código que encaje en “todo muere junto en una fase” y cómo lo reescribirías con una arena.