Gestión de memoria en Zig: allocators, arenas y control total sin GC ni unsafe

En la mayoría de los lenguajes modernos, la memoria es algo que ocurre en segundo plano. El GC la gestiona, el runtime la reserva y libera, y tú te olvidas del asunto hasta que hay una fuga o el sistema va lento. Zig toma la dirección contraria: la memoria es una decisión explícita que tomas en cada punto del código donde la necesitas.

El principio del allocator explícito

En Zig no existe una función global malloc ni un heap implícito. Cuando una función necesita memoria dinámica, recibe un parámetro de tipo std.mem.Allocator. Esta interfaz es la abstracción que Zig usa para desacoplar «quién pide la memoria» de «quién la gestiona».

El resultado práctico es que puedes cambiar el allocator de toda una biblioteca sin tocar su código. En tests usas std.testing.allocator, que detecta fugas. En producción usas el que mejor encaja con tu caso de uso.

const std = @import("std");

// Esta función no sabe de dónde viene la memoria, solo la usa
fn crearLista(allocator: std.mem.Allocator) !std.ArrayList(u32) {
    var lista = std.ArrayList(u32).init(allocator);
    try lista.append(1);
    try lista.append(2);
    try lista.append(3);
    return lista;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var lista = try crearLista(gpa.allocator());
    defer lista.deinit();

    for (lista.items) |n| {
        std.debug.print("{d}n", .{n});
    }
}

Los allocators principales de la biblioteca estándar

GeneralPurposeAllocator

Es el allocator de uso general para desarrollo. En modo Debug y ReleaseSafe detecta dobles liberaciones y fugas de memoria, e imprime por dónde se asignó cada bloque perdido. En ReleaseFast y ReleaseSmall elimina esas comprobaciones y se comporta como page_allocator con algo de overhead adicional por los metadatos.

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
    const result = gpa.deinit();
    if (result == .leak) @panic("¡Hay fugas de memoria!");
}
const allocator = gpa.allocator();

ArenaAllocator

El arena allocator reserva memoria en bloques grandes y la libera toda de golpe al destruir el arena. Es ideal cuando tienes una fase de trabajo (parsear un fichero, construir una respuesta HTTP, procesar una petición) donde asignas mucha memoria temporal y la liberas al terminar. No hay que llamar a free por cada asignación individual.

const std = @import("std");

pub fn procesarPeticion(backing_allocator: std.mem.Allocator) !void {
    var arena = std.heap.ArenaAllocator.init(backing_allocator);
    defer arena.deinit(); // libera TODA la memoria del arena de golpe

    const alloc = arena.allocator();

    // Todas estas asignaciones se liberan con el defer anterior
    const buffer = try alloc.alloc(u8, 4096);
    const lista = try std.ArrayList([]const u8).initCapacity(alloc, 64);
    _ = buffer;
    _ = lista;

    // ... procesar la petición ...
    std.debug.print("Petición procesadan", .{});
}

El patrón arena es muy común en servidores web escritos en Zig: cada petición tiene su propio arena, y al terminar de responder se destruye entero sin contabilizar nada.

FixedBufferAllocator

Asigna memoria desde un buffer de tamaño fijo que tú proporcionas. Cuando se agota el buffer, devuelve error.OutOfMemory. Útil para sistemas embebidos o para partes del código donde quieres limitar estrictamente cuánta memoria se puede usar.

var buffer: [8192]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();

// Solo puede usar hasta 8192 bytes
const datos = try allocator.alloc(u8, 100);
defer allocator.free(datos);

page_allocator

Llama directamente a mmap (o equivalente en Windows) para cada asignación. Es rápido para asignaciones grandes, pero muy ineficiente para muchas asignaciones pequeñas porque cada llamada tiene overhead de syscall. Úsalo como backing allocator para el GeneralPurposeAllocator o el ArenaAllocator, no directamente para asignaciones individuales pequeñas.

testing.allocator

Solo disponible en tests. Detecta cualquier fuga de memoria y hace fallar el test si la hay. Es la razón por la que en Zig es habitual que los tests detecten fugas sin ningún tooling adicional.

const std = @import("std");
const testing = std.testing;

test "sin fugas de memoria" {
    const allocator = testing.allocator;
    const lista = try std.ArrayList(u32).initCapacity(allocator, 10);
    defer lista.deinit(); // sin este defer, el test falla

    try testing.expect(lista.capacity == 10);
}

Componer allocators

Una de las cosas más interesantes del sistema es que puedes combinar allocators. Un ArenaAllocator necesita un «backing allocator» del que toma páginas; ese backing allocator puede ser un GeneralPurposeAllocator, el page_allocator u otro arena. Esta composición da mucha flexibilidad sin añadir ningún overhead en tiempo de compilación si el tipo es conocido.

// Arena respaldado por el GPA para desarrollo
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();

const alloc = arena.allocator();

Memoria en la pila vs. en el heap

Zig tiene tipos de datos que viven en la pila por defecto. Los arrays de tamaño fijo, los structs y los valores escalares se asignan en la pila automáticamente. Solo cuando el tamaño no se conoce en tiempo de compilación o cuando los datos deben sobrevivir al scope actual es necesario el heap.

// En la pila: array de tamaño conocido en tiempo de compilación
var arr: [10]u32 = undefined;
arr[0] = 42;

// En el heap: slice de tamaño dinámico
const arr_dyn = try allocator.alloc(u32, tamanyo_dinamico);
defer allocator.free(arr_dyn);

Esta distinción explícita entre pila y heap, combinada con el sistema de allocators, hace que el comportamiento de memoria de un programa Zig sea completamente predecible leyendo el código fuente. No hay sorpresas del GC ni de un runtime que asigna a tus espaldas.

Errdefer: limpiar en caso de error

Cuando inicializas varios recursos y uno de ellos falla, necesitas limpiar los que ya inicializaste. errdefer ejecuta su bloque solo si la función termina con un error, lo que permite escribir código de limpieza sin anidar condicionales.

fn inicializar(allocator: std.mem.Allocator) !MiStruct {
    const buffer = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buffer); // solo se ejecuta si hay error

    const tabla = try allocator.alloc(u32, 256);
    errdefer allocator.free(tabla); // ídem

    // si llegamos aquí, todo fue bien; transferimos ownership al struct
    return MiStruct{ .buffer = buffer, .tabla = tabla };
}

El patrón defer / errdefer junto con el sistema de allocators explícitos es la base de la gestión de memoria en Zig. Una vez que lo interiorizas, escribir código sin fugas es la norma, no la excepción. Si quieres ver cómo este sistema encaja con el resto del lenguaje, en el artículo de introducción a Zig tienes el contexto completo del lenguaje.

Imagen: Pexels / Valentine Tanasovich

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP