wandres.dev
ALLOCATORS Y MEMORIA · a bajo nivel

La Allocator API: elegir el asignador por estructura de datos

GlobalAlloc decide la memoria de todo el programa; la Allocator API la decide por colección. Con `#![feature(allocator_api)]`, `Vec::new_in` y `Box::new_in` reciben un allocator como parámetro de tipo, permitiendo que dos `Vec` del mismo binario usen fuentes de memoria distintas. Es la diferencia entre una decisión global y una composable.

⏱ 20 min

El allocator global tiene un límite conceptual: es uno solo para todo el programa. Sirve para cambiar la estrategia de memoria de un binario entero, pero no para decir “esta Vec corta y efímera sale de una arena, mientras aquel HashMap de vida larga sale del sistema”. Esa granularidad —elegir la fuente de memoria por estructura de datos— es lo que aporta la Allocator API. En lugar de un static global, el allocator pasa a ser un parámetro de tipo de la colección: Vec<T, A>, Box<T, A>, con constructores _in que reciben el allocator concreto. Dos Vec del mismo binario pueden así extraer memoria de fuentes distintas, y el compilador lo comprueba en el sistema de tipos. Es la diferencia entre una decisión global e irrepetible y una decisión local, componible y verificada.

🎯 Al terminar esta lección sabrás
  • Distinguir el trait Allocator del trait GlobalAlloc: por instancia frente a global, falible frente a abortante.
  • Usar Vec::new_in, Box::new_in y el parámetro de tipo A de las colecciones.
  • Entender por qué Allocator devuelve Result<NonNull<[u8]>, AllocError> y qué significa el [u8].
  • Conocer el estado real: allocator_api es inestable; en estable existe el crate allocator-api2.

Dos traits, dos propósitos

GlobalAlloc y Allocator se parecen pero resuelven problemas distintos. Compara sus firmas centrales:

// Global, unico, abortante: para TODO el programa.
pub unsafe trait GlobalAlloc {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8;         // nulo si falla
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
}

// Por instancia, falible, componible: para UNA coleccion.
pub unsafe trait Allocator {
    fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError>;
    unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout);
    // con impl por defecto: allocate_zeroed, grow, shrink, by_ref
}

Tres diferencias son decisivas. Primera: allocate devuelve Result, no un puntero que puede ser nulo; el fallo de memoria es un valor que manejas, no un aborto implícito. Nota que AllocError es un tipo de tamaño cero: no lleva mensaje ni causa, solo señala “no hubo memoria”. La falibilidad de esta API es barata precisamente porque el único fallo posible es la falta de memoria, y ese hecho no necesita más datos que su propia ocurrencia. Segunda: devuelve NonNull<[u8]> —un slice, no un puntero suelto—, de modo que el allocator te informa del tamaño real que reservó, que puede ser mayor que el pedido. Tercera, y la más importante en la práctica: Allocator se implementa para instancias que pueden tener estado, y un &A donde A: Allocator también implementa Allocator, así que puedes compartir un mismo allocator entre varias colecciones pasando referencias.

ℹ️
Por qué el tamaño real importa

Cuando allocate devuelve NonNull<[u8]>, la longitud del slice es la capacidad que el allocator realmente te concedió. Si pides 100 bytes y el allocator redondea a 128 por su granularidad interna, te devuelve un slice de 128. Vec aprovecha ese dato para ajustar su capacidad sin una segunda llamada: la información que en GlobalAlloc se perdía —el tamaño concedido frente al pedido— aquí viaja de vuelta y evita reservas redundantes.

Colecciones parametrizadas por su allocator

El cambio visible es que las colecciones ganan un segundo parámetro de tipo, con Global como valor por defecto:

#![feature(allocator_api)]

use std::alloc::System;

// El allocator es parte del TIPO de la coleccion.
let mut v: Vec<i32, System> = Vec::new_in(System);
v.push(1);
v.push(2);

let b: Box<i32, System> = Box::new_in(42, System);

// Sin _in, se usa Global, que enruta al #[global_allocator].
let normal: Vec<i32> = Vec::new(); // equivale a Vec<i32, Global>

Vec<T> es en realidad Vec<T, Global>: el tipo Global es un allocator sin estado que reenvía al #[global_allocator] registrado. Esto revela que las dos APIs no son rivales, sino capas: Global es precisamente el adaptador que implementa el trait Allocator delegando en el GlobalAlloc de la lección uno. La Allocator API se asienta encima, y Global es el puente que baja hasta el allocator global del binario. Elegir otro allocator por colección es, simplemente, sustituir ese puente por uno que apunte a otra fuente. Al escribir Vec::new_in(otro) sustituyes esa fuente por otro, y el allocator queda grabado en el tipo. Esto tiene una consecuencia fuerte: un Vec<i32, System> y un Vec<i32, MiArena> son tipos distintos, incompatibles en una firma que espere uno concreto, y el compilador rastrea de dónde salió cada byte.

El caso donde brilla es compartir un allocator con estado entre varias colecciones mediante una referencia:

#![feature(allocator_api)]
use bumpalo::Bump;

let arena = Bump::new();
// Ambas colecciones extraen memoria de la MISMA arena, via &arena.
let mut nombres: Vec<u32, &Bump> = Vec::new_in(&arena);
let mut edades:  Vec<u32, &Bump> = Vec::new_in(&arena);
nombres.push(1);
edades.push(30);
// Al soltar la arena, toda su memoria se libera de golpe.

El allocator viaja dentro de la colección

Que A sea un parámetro de tipo tiene una consecuencia física: el valor del allocator se guarda dentro de la estructura. Un Vec<T, A> es, por dentro, un puntero, una longitud, una capacidad y un campo de tipo A. Aquí es donde reaparece el principio de coste cero que recorre todo Rust. Si A es un tipo de tamaño cero —Global y System lo son, no tienen campos—, ese campo ocupa cero bytes: la parametrización es gratis, y un Vec<i32, System> pesa exactamente lo mismo que un Vec<i32>. Solo cuando el allocator lleva estado —un &Bump, que es un puntero— la colección paga una palabra extra para recordar de dónde saca su memoria.

use core::mem::size_of;
// Global y System son ZST: no engordan la coleccion.
assert_eq!(size_of::<Vec<i32>>(), size_of::<Vec<i32, System>>());

Pagas exactamente por el estado que tu allocator necesita, ni un byte más. Y los métodos grow y shrink del trait permiten que, al crecer un Vec, el allocator intente redimensionar en el sitio en vez de copiar a un bloque nuevo, otra optimización que la firma pelada de GlobalAlloc no dejaba expresar por colección.

Que el allocator forme parte del tipo también levanta una barrera útil: no puedes mover a ciegas datos de un allocator a otro. Un Vec<T, System> no se convierte en un Vec<T, MiArena> con un simple let, porque su buffer pertenece a System y debe liberarse ahí; pasar de uno a otro exige reasignar y copiar, explícitamente. El compilador te obliga a hacer visible ese coste en vez de dejarte mezclar procedencias por accidente. Es la misma garantía que en GlobalAlloc estaba implícita —el deallocate va al allocator que hizo el allocate—, pero ahora expresada en el sistema de tipos y verificada colección por colección.

El estado real: inestable, y el puente estable

Hay que ser honesto sobre la disponibilidad. A día de hoy, la Allocator API sigue siendo inestable: requiere el compilador nightly y activar #![feature(allocator_api)]. En Rust estable, Vec::new_in y Box::new_in no existen todavía. La comunidad ha resuelto el hueco con el crate allocator-api2, que reexporta una Vec y una Box con la misma API sobre estable, y al que muchos allocators de arena —bumpalo entre ellos— se integran mediante una feature.

💡
La mayoría del código no necesita esto

Un aviso de proporción. El noventa y nueve por ciento del Rust que escribirás debe usar Global y no pensar jamás en allocators: Vec::new está perfecto. La Allocator API es una válvula de escape para necesidades concretas —una arena en un bucle caliente que el perfilador señaló, un embebido que coloca estructuras en una región física precisa, un test que inyecta fallos de memoria con un allocator falso—. Salpicar new_in por todo el código es optimización prematura con ruido de tipos añadido. El valor no está en usarla siempre, sino en que exista el día que el hardware o el profiler la exijan.

Ese último caso —inyectar fallos— es más útil de lo que parece. Un allocator por colección te deja probar de forma determinista qué hace tu código cuando no hay memoria, algo imposible con un único allocator global:

// Un allocator de prueba que rechaza toda peticion: fuerza el camino de OOM.
struct SiempreFalla;

unsafe impl Allocator for SiempreFalla {
    fn allocate(&self, _: Layout) -> Result<NonNull<[u8]>, AllocError> {
        Err(AllocError)                 // cada reserva falla, a propósito
    }
    unsafe fn deallocate(&self, _: NonNull<u8>, _: Layout) {}
}
// Un Vec::new_in(SiempreFalla) mas try_reserve devuelve Err de inmediato,
// probando que tu manejo de la falta de memoria es correcto.
⚠️
No confundas allocator_api con try_reserve

El manejo falible de memoria en estable ya existe, pero por otra vía: Vec::try_reserve y try_reserve_exact devuelven Result<(), TryReserveError> sin necesidad de allocator_api. Eso cubre “no quiero abortar si no hay memoria” con el allocator global. Lo que allocator_api añade, y aún es nightly, es elegir un allocator distinto por colección. Son ejes ortogonales: falibilidad la tienes en estable; allocator por instancia, todavía no.

flowchart TD
G[GlobalAlloc] --> GU[uno por binario]
G --> GA[abortante devuelve puntero o nulo]
G --> GG[decision global del ejecutable]
A[Allocator] --> AI[uno por coleccion via Vec new_in]
A --> AF[falible devuelve Result de NonNull slice]
A --> AC[componible un ref A tambien es Allocator]
style G fill:#f38ba8,color:#11111b
style A fill:#89b4fa,color:#11111b
Mover el allocator al tipo convierte la memoria en algo que el compilador razona

La idea central de la Allocator API no es dar más allocators, sino cambiar dónde vive la decisión. En GlobalAlloc, la fuente de memoria es un hecho ambiental del programa: existe, es única, y ningún tipo la menciona. Al convertirla en un parámetro de tipo —Vec<T, A>—, la fuente de memoria deja de ser ambiente y pasa a ser información que el sistema de tipos transporta, comprueba y compone. Las repercusiones son las de siempre que algo asciende al nivel de los tipos. Composición: puedes tener, en el mismo programa, una Vec de arena junto a una Box del sistema, y firmas genéricas que abstraen sobre A: Allocator sin saber cuál. Verificación: es imposible mezclar por accidente memoria de dos allocators incompatibles, porque son tipos distintos y el compilador los distingue; el deallocate siempre va al mismo allocator que hizo el allocate, garantizado por construcción. Y economía de información: al devolver NonNull<[u8]> en vez de un puntero pelado, el allocator informa del tamaño realmente concedido, dato que GlobalAlloc tiraba y que ahora Vec reutiliza. Que la API siga siendo nightly no la hace teórica: el crate allocator-api2 la lleva a estable hoy, y arenas como bumpalo ya se conectan a ella. La lección de sistemas es la misma que recorre todo Rust: cuando una decisión de recursos sube del ambiente al tipo, ganas composición, verificación y precisión a la vez, y lo que antes era una constante oculta se vuelve un parámetro que el programa —y el compilador— pueden razonar.

📝
Lo esencial de la Allocator API

GlobalAlloc fija la memoria de todo el binario; la Allocator API la fija por colección. El trait Allocator es por instancia, falible (Result<NonNull<[u8]>, AllocError>) y componible (&A también es Allocator), frente al GlobalAlloc global y abortante. Las colecciones ganan un parámetro A con Global por defecto: Vec<T> es Vec<T, Global>, y Vec::new_in(a) / Box::new_in(x, a) eligen otro. Devolver un slice NonNull<[u8]> informa del tamaño realmente concedido. La API es nightly bajo #![feature(allocator_api)]; el crate allocator-api2 la ofrece en estable, y Vec::try_reserve cubre la falibilidad sin ella.

⚔️ Elige el allocator por estructura de datos
  1. Con nightly y #![feature(allocator_api)], crea un Vec<i32, System> con Vec::new_in(System) y un Box::new_in. Comprueba que compilan y funcionan.
  2. Explica por qué Vec<i32, System> y Vec<i32, Global> son tipos distintos y qué pasa si intentas asignar uno donde se espera el otro.
  3. Compara las firmas de GlobalAlloc::alloc y Allocator::allocate. Enumera las tres diferencias y qué problema resuelve cada una.
  4. Comparte una Bump entre dos Vec vía Vec::new_in(&arena) y razona qué ocurre con su memoria al soltar la arena.
  5. Investiga Vec::try_reserve en estable y explica en qué eje se diferencia de allocator_api: falibilidad frente a allocator por instancia.