Criando Custom Allocators em Zig: Guia Passo a Passo

Nos artigos anteriores, exploramos os allocators da biblioteca padrão: tipos de allocators e arena allocator. Agora vamos dar o próximo passo: criar allocators personalizados que atendam exatamente às necessidades da sua aplicação.

Por Que Criar Custom Allocators?

Allocators personalizados são úteis quando:

  • Você precisa rastrear o uso de memória (logging, métricas)
  • Precisa impor limites de memória por componente
  • Quer um pool allocator para objetos de tamanho fixo
  • Precisa de estratégias especiais para sistemas embarcados ou real-time

A Interface Allocator de Zig

Para criar um allocator, você precisa implementar a vtable std.mem.Allocator.VTable:

const std = @import("std");

// A vtable requer estas duas funções:
// 1. alloc: fn (ctx, len, ptr_align, ret_addr) ?[*]u8
// 2. resize: fn (ctx, buf, buf_align, new_len, ret_addr) bool
// 3. free: fn (ctx, buf, buf_align, ret_addr) void

Vamos implementar três allocators progressivamente mais complexos.

Exemplo 1: Logging Allocator

Nosso primeiro custom allocator simplesmente registra todas as operações de memória. Perfeito para debugging e profiling.

const std = @import("std");

const LoggingAllocator = struct {
    backing_allocator: std.mem.Allocator,
    total_alocado: usize,
    total_liberado: usize,
    num_alocacoes: usize,
    num_liberacoes: usize,

    fn init(backing: std.mem.Allocator) LoggingAllocator {
        return .{
            .backing_allocator = backing,
            .total_alocado = 0,
            .total_liberado = 0,
            .num_alocacoes = 0,
            .num_liberacoes = 0,
        };
    }

    fn allocator(self: *LoggingAllocator) std.mem.Allocator {
        return .{
            .ptr = self,
            .vtable = &.{
                .alloc = alloc,
                .resize = resize,
                .free = free,
            },
        };
    }

    fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8 {
        const self: *LoggingAllocator = @ptrCast(@alignCast(ctx));
        const result = self.backing_allocator.rawAlloc(len, ptr_align, ret_addr);

        if (result != null) {
            self.total_alocado += len;
            self.num_alocacoes += 1;
            std.debug.print("[ALLOC] {d} bytes (total: {d} bytes em {d} alocações)\n", .{
                len, self.total_alocado, self.num_alocacoes,
            });
        } else {
            std.debug.print("[ALLOC FALHOU] {d} bytes\n", .{len});
        }

        return result;
    }

    fn resize(ctx: *anyopaque, buf: [*]u8, buf_align: u8, new_len: usize, ret_addr: usize) bool {
        const self: *LoggingAllocator = @ptrCast(@alignCast(ctx));
        const old_len = @as(usize, @intFromPtr(buf)); // simplified
        _ = old_len;

        const result = self.backing_allocator.rawResize(buf, buf_align, new_len, ret_addr);
        if (result) {
            std.debug.print("[RESIZE] para {d} bytes\n", .{new_len});
        }
        return result;
    }

    fn free(ctx: *anyopaque, buf: [*]u8, buf_align: u8, ret_addr: usize) void {
        const self: *LoggingAllocator = @ptrCast(@alignCast(ctx));
        self.num_liberacoes += 1;
        std.debug.print("[FREE] (total: {d} liberações)\n", .{self.num_liberacoes});

        self.backing_allocator.rawFree(buf, buf_align, ret_addr);
    }

    fn imprimirRelatorio(self: *const LoggingAllocator) void {
        std.debug.print("\n=== Relatório de Memória ===\n", .{});
        std.debug.print("Total alocado: {d} bytes\n", .{self.total_alocado});
        std.debug.print("Alocações: {d}\n", .{self.num_alocacoes});
        std.debug.print("Liberações: {d}\n", .{self.num_liberacoes});
        std.debug.print("Pendentes: {d}\n", .{self.num_alocacoes - self.num_liberacoes});
    }
};

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

    var logger = LoggingAllocator.init(gpa.allocator());
    const alloc = logger.allocator();

    // Usar como qualquer allocator
    const dados = try alloc.alloc(u8, 256);
    const numeros = try alloc.alloc(i32, 10);

    alloc.free(dados);
    alloc.free(std.mem.sliceAsBytes(numeros));

    logger.imprimirRelatorio();
}

Exemplo 2: Allocator com Limite de Memória

Um allocator que impõe um teto máximo de memória — essencial para evitar OOM em servidores.

const std = @import("std");

const LimitedAllocator = struct {
    backing: std.mem.Allocator,
    limite: usize,
    usado: usize,

    const Error = error{LimiteExcedido};

    fn init(backing: std.mem.Allocator, limite: usize) LimitedAllocator {
        return .{
            .backing = backing,
            .limite = limite,
            .usado = 0,
        };
    }

    fn allocator(self: *LimitedAllocator) std.mem.Allocator {
        return .{
            .ptr = self,
            .vtable = &.{
                .alloc = alloc,
                .resize = resize,
                .free = free,
            },
        };
    }

    fn alloc(ctx: *anyopaque, len: usize, ptr_align: u8, ret_addr: usize) ?[*]u8 {
        const self: *LimitedAllocator = @ptrCast(@alignCast(ctx));

        if (self.usado + len > self.limite) {
            std.debug.print("BLOQUEADO: {d} bytes excederia limite de {d} (usado: {d})\n", .{
                len, self.limite, self.usado,
            });
            return null;
        }

        const result = self.backing.rawAlloc(len, ptr_align, ret_addr);
        if (result != null) {
            self.usado += len;
        }
        return result;
    }

    fn resize(ctx: *anyopaque, buf: [*]u8, buf_align: u8, new_len: usize, ret_addr: usize) bool {
        const self: *LimitedAllocator = @ptrCast(@alignCast(ctx));
        return self.backing.rawResize(buf, buf_align, new_len, ret_addr);
    }

    fn free(ctx: *anyopaque, buf: [*]u8, buf_align: u8, ret_addr: usize) void {
        const self: *LimitedAllocator = @ptrCast(@alignCast(ctx));
        self.backing.rawFree(buf, buf_align, ret_addr);
    }

    fn usoAtual(self: *const LimitedAllocator) void {
        const porcentagem = @as(f64, @floatFromInt(self.usado)) /
            @as(f64, @floatFromInt(self.limite)) * 100.0;
        std.debug.print("Uso: {d}/{d} bytes ({d:.1}%)\n", .{
            self.usado, self.limite, porcentagem,
        });
    }
};

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

    // Limite de 1 KB
    var limited = LimitedAllocator.init(gpa.allocator(), 1024);
    const alloc = limited.allocator();

    // Alocação dentro do limite
    const a = try alloc.alloc(u8, 500);
    limited.usoAtual();

    const b = try alloc.alloc(u8, 400);
    limited.usoAtual();

    // Esta alocação será bloqueada
    const c = alloc.alloc(u8, 200);
    if (c) |dados| {
        alloc.free(dados);
    } else {
        std.debug.print("Alocação recusada (esperado!)\n", .{});
    }

    alloc.free(a);
    alloc.free(b);
}

Exemplo 3: Pool Allocator para Objetos de Tamanho Fixo

Pool allocators são extremamente eficientes quando você aloca e libera muitos objetos do mesmo tamanho:

const std = @import("std");

fn PoolAllocator(comptime T: type) type {
    return struct {
        const Self = @This();
        const POOL_SIZE = 64;

        const Node = struct {
            dados: T,
            proximo: ?*Node,
        };

        livre: ?*Node,
        backing: std.mem.Allocator,
        total_criados: usize,
        total_em_uso: usize,

        fn init(backing: std.mem.Allocator) Self {
            return .{
                .livre = null,
                .backing = backing,
                .total_criados = 0,
                .total_em_uso = 0,
            };
        }

        fn obter(self: *Self) !*T {
            if (self.livre) |node| {
                // Reutilizar nó da lista livre
                self.livre = node.proximo;
                self.total_em_uso += 1;
                return &node.dados;
            }

            // Criar novo nó
            const node = try self.backing.create(Node);
            self.total_criados += 1;
            self.total_em_uso += 1;
            return &node.dados;
        }

        fn devolver(self: *Self, ptr: *T) void {
            const node: *Node = @fieldParentPtr("dados", ptr);
            node.proximo = self.livre;
            self.livre = node;
            self.total_em_uso -= 1;
        }

        fn status(self: *const Self) void {
            std.debug.print("Pool<{s}>: {d} criados, {d} em uso, {d} livres\n", .{
                @typeName(T),
                self.total_criados,
                self.total_em_uso,
                self.total_criados - self.total_em_uso,
            });
        }

        fn deinit(self: *Self) void {
            // Liberar nós na lista livre
            var atual = self.livre;
            while (atual) |node| {
                const proximo = node.proximo;
                self.backing.destroy(node);
                atual = proximo;
            }
            self.livre = null;
        }
    };
}

const Particula = struct {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
    vida: f32,
};

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

    var pool = PoolAllocator(Particula).init(gpa.allocator());
    defer pool.deinit();

    // Criar partículas
    var particulas: [10]*Particula = undefined;
    for (&particulas, 0..) |*p, i| {
        p.* = try pool.obter();
        p.*.* = .{
            .x = @floatFromInt(i * 10),
            .y = 0.0,
            .vx = 1.0,
            .vy = -9.8,
            .vida = 1.0,
        };
    }
    pool.status();

    // Devolver algumas ao pool
    for (particulas[0..5]) |p| {
        pool.devolver(p);
    }
    pool.status();

    // Obter novamente — reutiliza do pool, sem nova alocação
    for (0..5) |_| {
        const p = try pool.obter();
        p.* = .{ .x = 0, .y = 0, .vx = 0, .vy = 0, .vida = 1.0 };
    }
    pool.status();

    // Devolver todas
    for (&particulas) |p| {
        pool.devolver(p);
    }
    pool.status();
}

Combinando Allocators: O Padrão Composto

Na prática, você frequentemente combina allocators para obter o melhor de cada um:

const std = @import("std");

const AppAllocators = struct {
    // GPA como base — detecta leaks
    gpa: std.heap.GeneralPurposeAllocator(.{}),
    // Arena para dados temporários por requisição
    arena: std.heap.ArenaAllocator,

    fn init() AppAllocators {
        var gpa = std.heap.GeneralPurposeAllocator(.{}){};
        return .{
            .gpa = gpa,
            .arena = std.heap.ArenaAllocator.init(gpa.allocator()),
        };
    }

    // Allocator para dados de longa duração
    fn permanente(self: *AppAllocators) std.mem.Allocator {
        return self.gpa.allocator();
    }

    // Allocator para dados temporários
    fn temporario(self: *AppAllocators) std.mem.Allocator {
        return self.arena.allocator();
    }

    fn resetTemporario(self: *AppAllocators) void {
        _ = self.arena.reset(.retain_capacity);
    }

    fn deinit(self: *AppAllocators) void {
        self.arena.deinit();
        _ = self.gpa.deinit();
    }
};

pub fn main() !void {
    var allocs = AppAllocators.init();
    defer allocs.deinit();

    // Dados permanentes
    const config = try allocs.permanente().alloc(u8, 100);
    defer allocs.permanente().free(config);
    @memcpy(config[0..11], "app_config!");

    // Dados temporários por operação
    for (0..3) |i| {
        allocs.resetTemporario();
        const temp = try std.fmt.allocPrint(
            allocs.temporario(),
            "Operação temporária {d}",
            .{i},
        );
        std.debug.print("{s}\n", .{temp});
        // Sem necessidade de free individual — reset cuida disso
    }

    std.debug.print("Config permanente: {s}\n", .{config[0..11]});
}

Diretrizes para Custom Allocators

  1. Sempre implemente a vtable completa — mesmo que resize apenas retorne false
  2. Teste com o GPA como backing — para detectar bugs no seu allocator
  3. Documente as garantias — thread-safety, alinhamento, limites
  4. Considere usar @returnAddress() para stack traces em modo debug
  5. Prefira composição — combine allocators existentes antes de criar do zero

Próximos Passos

No próximo e último artigo da série, vamos aprender técnicas de debugging de memória para encontrar e corrigir problemas como leaks, use-after-free e buffer overflows.

Referências

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.