El trait GlobalAlloc y el atributo global_allocator
Cada Box, cada Vec, cada String pide su memoria al mismo sitio: el allocator global. Detrás hay un trait `GlobalAlloc`, un `static` marcado con `#[global_allocator]` y un tipo `Layout` que viaja en cada petición. Entender esa tubería es entender de dónde sale —y cómo se cambia— toda la memoria del heap en Rust.
Cuando escribes Box::new(7) o empujas un elemento a un Vec, no invocas ningún malloc; y sin embargo, en algún punto, alguien lo hace por ti. Ese alguien es el allocator global: una única pieza, compartida por todo el programa y todos sus hilos, a la que la biblioteca estándar delega cada reserva y cada liberación del heap. Su contrato es el trait GlobalAlloc; su identidad la fija un static marcado con #[global_allocator]; y la moneda que circula entre la colección y el allocator es Layout, un par tamaño-alineación que describe con exactitud qué se pide. Comprender esta tubería —quién pide, quién sirve, qué información viaja— es el cimiento de todo el nivel: sin ella, Box y Vec son magia; con ella, son una llamada a un trait que puedes leer, auditar y reemplazar.
- Situar el allocator global como el origen único de toda la memoria de
Box,Vec,Stringy demás. - Leer la firma del trait
GlobalAllocy entender el papel deLayouten cadaallocydealloc. - Registrar un allocator con
#[global_allocator]y conocer la regla de “uno por binario”. - Cambiar el
Systempor defecto por otro allocator, comomimalloc, y saber por qué se haría.
De Box::new al heap: la cadena de delegación
Un Box no sabe nada de malloc; un Vec tampoco. Lo que sí saben es calcular cuánta memoria necesitan y con qué alineación, empaquetar ese dato en un Layout y llamar a las funciones libres de std::alloc. Esas funciones son el punto de indirección: enrutan la petición al allocator que esté registrado. Podemos reproducir a mano lo que Box::new(7u64) hace por dentro:
use std::alloc::{alloc, dealloc, Layout};
let layout = Layout::new::<u64>(); // tamano y alineacion de un u64
let p = unsafe { alloc(layout) } as *mut u64;
if p.is_null() { std::alloc::handle_alloc_error(layout); }
unsafe { p.write(7); } // escribe en memoria sin inicializar
assert_eq!(unsafe { *p }, 7);
unsafe { dealloc(p as *mut u8, layout); } // devuelve el bloque
Layout::new::<T>() calcula el tamaño y la alineación de T en tiempo de compilación; para un buffer de n elementos existe Layout::array::<T>(n). La función alloc devuelve *mut u8 o nulo si no hay memoria, y dealloc recibe el puntero y el mismo Layout. Ese detalle —que la liberación exija de vuelta el Layout— es el eje de toda la arquitectura, y volveremos a él.
Un Vec no hace más que repetir este baile en bucle. Cada vez que se llena, calcula un Layout::array::<T>(nueva_capacidad), pide un bloque mayor, copia los elementos y libera el viejo —normalmente en una sola operación de realloc—. De ahí que duplicar la capacidad al crecer, en lugar de sumar uno, no sea un capricho: es lo que mantiene el número de visitas al allocator logarítmico en vez de lineal. Cada nivel de abstracción que Rust te da —Vec, String, HashMap— es, en el fondo, un patrón disciplinado de llamadas a alloc, realloc y dealloc sobre Layout calculados a partir de tipos.
El trait GlobalAlloc y la moneda Layout
Las funciones libres delegan en un tipo que implementa GlobalAlloc. Su firma cabe en unas pocas líneas:
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
// con implementacion por defecto:
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { /* alloc + poner a cero */ }
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, nuevo: usize) -> *mut u8 { /* ... */ }
}
Solo hay dos métodos obligatorios. alloc recibe un Layout y devuelve un bloque válido, alineado y de al menos layout.size() bytes, o nulo. dealloc recibe el puntero junto con el Layout original. alloc_zeroed y realloc traen implementación por defecto, pero un allocator puede redefinirlas si conoce un atajo del sistema.
El trait es unsafe trait por una razón profunda: implementarlo es afirmar un puñado de invariantes que el compilador no puede verificar —que el bloque devuelto tiene la alineación pedida, que no solapa con otro bloque vivo, que aceptas de vuelta exactamente lo que entregaste—. La palabra unsafe en el impl es tu firma sobre ese contrato.
Un matiz que sorprende: un Layout puede tener tamaño cero, y ese caso no llega al allocator. Asignar un tipo de tamaño cero —un Vec<()> o un Box<()>— no invoca alloc en absoluto; la colección fabrica un puntero dangling pero correctamente alineado, porque no hay bytes que reservar y desreferenciarlo nunca ocurrirá. El allocator, por tanto, solo ve peticiones de tamaño estrictamente positivo, y su contrato exige además que la alineación de todo Layout sea una potencia de dos.
En C, free(ptr) no lleva tamaño: el allocator guarda, en una cabecera oculta junto a cada bloque, cuántos bytes reservó, para saber cuánto liberar. Rust hace lo contrario: como el tipo ya conoce su tamaño en compilación, la colección devuelve el Layout en el dealloc. El allocator no necesita almacenar esa cabecera, lo que ahorra memoria por bloque y abre la puerta a allocators más rápidos y compactos. Es información que el sistema de tipos ya poseía, transportada hacia abajo en lugar de recalculada en ejecución.
Los dos métodos con implementación por defecto
Que solo alloc y dealloc sean obligatorios, y alloc_zeroed y realloc traigan implementación por defecto, no es capricho: las dos por defecto se pueden derivar de las dos primeras, pero a veces con pérdida de rendimiento. La versión por defecto de alloc_zeroed llama a alloc y luego pone el bloque a cero byte a byte; la de realloc reserva un bloque nuevo, copia el contenido y libera el viejo.
Un allocator puede redefinirlas cuando el sistema le ofrece un atajo. El sistema operativo suele entregar páginas recién mapeadas ya a cero, así que un alloc_zeroed afinado puede saltarse el memset entero —justo lo que hace calloc frente a malloc más memset—. Y muchos allocators pueden crecer un bloque en el sitio si hay espacio contiguo libre, convirtiendo un realloc de “reservar, copiar, liberar” en una simple actualización de metadatos, sin mover un solo byte. Que estas dos operaciones sean puntos de extensión con valor por defecto correcto es el equilibrio del trait: mínimo obligatorio, máximo optimizable.
Registrar y cambiar el allocator
Por defecto, desde Rust 1.32, el allocator es std::alloc::System, que delega en el malloc y free de la plataforma. Puedes escribirlo de forma explícita sin cambiar nada del comportamiento:
use std::alloc::System;
#[global_allocator]
static GLOBAL: System = System;
El atributo #[global_allocator] se aplica a un static cuyo tipo implementa GlobalAlloc, y fija la identidad del allocator para todo el binario. Sustituirlo por otro es un cambio de tres líneas que afecta a cada Vec, cada String y cada HashMap del programa —tuyos y de tus dependencias— sin tocar ni una línea de su código:
[dependencies]
mimalloc = "0.1"
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
Otra opción muy usada en servidores es jemalloc, que rinde bien bajo asignación intensa y multihilo; el patrón de registro es idéntico, solo cambia el tipo:
use tikv_jemallocator::Jemalloc;
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
La regla es estricta: exactamente un #[global_allocator] en todo el grafo de dependencias. Dos son un error de compilación, porque el enlazador no sabría a cuál enrutar. Por eso el allocator global es una decisión del binario final, nunca de una librería: una librería que fijara el suyo se lo impondría a quien la usara.
Este mismo gancho es el que usan los perfiladores de heap. Herramientas como dhat se registran como #[global_allocator] para interceptar y medir cada asignación del programa sin recompilar la biblioteca estándar ni instrumentar el código a mano: se limitan a ocupar la costura. En la próxima lección construirás uno tú, envolviendo System para contar bytes.
Antes de Rust 1.32, el compilador empaquetaba jemalloc dentro de cada binario, porque rendía mejor bajo carga multihilo. El coste era binarios más grandes, compilación más compleja y sorpresas de portabilidad. En 1.32 el defecto pasó a ser el allocator del sistema —el malloc de la plataforma— a cambio de binarios más pequeños y comportamiento predecible, y quien quisiera jemalloc lo recuperaría explícitamente vía #[global_allocator]. Así que cambiar hoy a mimalloc o jemallocator no es una rareza: es recuperar, por decisión visible, lo que un día fue automático. La historia confirma la moraleja: convertir el allocator en una costura no quitó rendimiento, lo volvió una elección informada.
Quién pide
Box, Vec, String, HashMap… calculan su Layout y llaman a las funciones libres de std::alloc. Nunca ven malloc.
Quién registra
Un static con #[global_allocator]. Uno solo por binario; fija la identidad del allocator para todo el programa.
Quién sirve
El tipo que implementa GlobalAlloc. Por defecto System, que delega en el malloc/free del sistema operativo.
flowchart TD A[Box new o Vec push] --> B[funciones libres alloc y dealloc] B --> C[static marcado con global_allocator] C --> D[GlobalAlloc alloc recibe un Layout] D --> E[System delega en el malloc del sistema] style A fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b style E fill:#a6e3a1,color:#11111b
Detente en lo que Rust ha hecho aquí. En C, malloc está soldado al lenguaje: para cambiar de allocator hay que interceptar símbolos del enlazador, precargar bibliotecas o reescribir cada llamada. Rust toma la decisión opuesta y convierte el origen de la memoria en una costura explícita: un trait, GlobalAlloc, y un punto de registro, #[global_allocator]. Toda la biblioteca estándar —cada colección, cada puntero inteligente— está escrita contra esa costura, no contra un malloc concreto. La consecuencia es doble. Primero, poder: puedes sustituir la estrategia de memoria de un programa entero, dependencias incluidas, en tres líneas, y medir el efecto sin refactorizar nada. Segundo, y más sutil, economía de información: al exigir el Layout de vuelta en el dealloc, Rust traslada hacia el allocator un dato que el sistema de tipos ya conocía —el tamaño del bloque— en lugar de obligar al allocator a guardarlo en una cabecera oculta como hace free. Esa aparente minucia es la que permite que allocators especializados —arenas, pools, allocators de tamaño fijo— sean más pequeños y más rápidos que un malloc de propósito general, porque no cargan con contabilidad que el llamante ya tenía. La lección trasciende la memoria: cuando un lenguaje convierte una dependencia ambiental en un trait nombrado, no solo la hace reemplazable, sino que revela qué información viajaba implícita y permite optimizarla. GlobalAlloc es memoria; el patrón es abstracción con costura.
Toda la memoria del heap en Rust —Box, Vec, String, HashMap— sale del allocator global. Las colecciones calculan un Layout (tamaño más alineación) y llaman a las funciones libres de std::alloc, que delegan en el tipo registrado con #[global_allocator]. Ese tipo implementa GlobalAlloc, cuyos únicos métodos obligatorios son alloc y dealloc; este último recibe de vuelta el Layout, algo que free de C no necesita porque guarda el tamaño en una cabecera. El allocator por defecto es System (el malloc del sistema). Solo puede haber un #[global_allocator] por binario, y por eso es siempre decisión del ejecutable final, nunca de una librería.
- Reproduce
Box::new(7u64)a mano conalloc,Layout::new,writeydealloc. Explica por qué cada paso exigeunsafe. - Imprime
size()yalign()deLayout::newparau8,u64y unstructtuyo. Compara conLayout::array::<u64>(10). - Escribe el
Systemexplícito con#[global_allocator]y confirma, ejecutando un programa que useVec, que el comportamiento no cambia. - Añade la dependencia
mimalloc, registraMiMalloccomo allocator global y verifica que el programa sigue compilando y corriendo. - Argumenta, en tres frases, por qué
deallocrecibe elLayouty qué le ahorra eso al allocator frente alfreede C.