Introdução a Zig - Parte 6

Alocadores

Enzo Soares

Publicado em 25 de Março de 2025 às 13:58

Oi, amigo!

Na última postagem falamos de como a memória de um programa de computador funciona, em seus tipos: stack e heap. Essa segunda é o tema sobre o qual vos escrevo hoje. Em linguagens de programação de baixo nível, o gerenciamento de memória é feito através de uma API que abstrai as chamadas de sistema, permitindo ao usuário alocar e liberar memória. Em C, essa API é representada pelas chamadas malloc e free.

#include <stdio.h>
#include <stdlib.h>  // Required for malloc and free

int main() {
    int *ptr;
    int n = 5;

    // Reserva memória para 5 inteiros
    ptr = (int*)malloc(n * sizeof(int));

    if (ptr == NULL) {
        printf("Alocação falhou!\n");
        return 1;  // Exit with error
    }

    // Armazena valores
    for (int i = 0; i < n; i++) {
        ptr[i] = i * 10;
    }

    // Imprime os valores
    printf("Valores armazenados:\n");
    for (int i = 0; i < n; i++) {
        printf("%d ", ptr[i]);
    }
    printf("\n");

    // Libera a memória
    free(ptr);

    return 0;
}

No exemplo acima, caso o programador esqueça de chamar free(ptr);, a memória de ptr nunca vai ser liberada. A gravidade desse vazamento de memória está no fato de que esse problema é silencioso. Você só percebe o problema quando a sua aplicação consome toda a memória disponível e nada te acusa qual linha é a culpada.

Zig, por outro lado, te oferece várias implementações dessa API, na forma de allocators. É responsabilidade do programador entender qual a melhor para o seu caso de uso.

Alocadores

GPA

O GPA, ou General Purpose Allocator, é o allocador que deve ser usado como padrão. Ele tem um equilíbrio entre performance e segurança, prevenindo vazamentos de memória, free duplo e uso depois de free.

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    _ = try allocator.alloc(u8, 100);

    _ = gpa.deinit();
}

Como a memória reservada por allocator.alloc(u8, 100); não é liberada, gpa.deinit(); lança um erro, acusando o vazamento de memória. Para remover esse erro, é só liberar a memória:

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();

    const bytes = try allocator.alloc(u8, 100);
    allocator.free(bytes);

    _ = gpa.deinit();
}

Arena Allocator

O Arena Allocator é extremamente rápido, mas só permite a liberação de toda a memória reservada simultâneamente. Ele não tem os dispositivos de seguranaça do GPA. Ao chamar o método deinit(), toda a memória reservada pelo allocador é liberada simultâneamente.

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    const allocator = arena.allocator();

    _ = try allocator.alloc(u8, 1);
    _ = try allocator.alloc(u8, 10);
    _ = try allocator.alloc(u8, 100);

    arena.deinit();
}

allocator.free(), para o Arena Allocator, é um noop.

Fixed Buffer Allocator

O Fixed Buffer Allocator, embora tenha allocator no nome, não faz nenhuma reserva de memória no heap: ele recebe como argumento um buffer da stack e lá coloca toda a memória. Ele é útil quando o uso do heap não é desejado:

pub fn main() !void {
    var buffer: [1000]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buffer);
    const allocator = fba.allocator();

    const memory = try allocator.alloc(u8, 100);
    allocator.free(memory);
}

Alocador de testes

É alocador de testes tem o máximo possível de funcionalidades de segurança e pode apenas ser usado em testes:

const std = @import("std");

test "test" {
    var array = std.ArrayList(i32).init(std.testing.allocator);

    const expected: i32 = 42;
    try array.append(expected);

    try std.testing.expectEqual(expected, array.items[0]);

    array.deinit();
}

defer

Talvez a melhor funcionalidade do Zig para lidar com gerenciamento de memória é a palavra defer. Ela permite executar um bloco de código ao final do bloco atual:

const std = @import("std");
const print = std.debug.print;

pub fn main() !void {
    defer print("2\n", .{});
    print("1\n", .{});
}

O código acima escreve no terminal 1 e depois 2, apesar da ordem onde foram declaradas. Mas como isso pode nos ajudar a evitar vazamentos de memória? Em vez de precisar lembrar de liberar manualmente o recurso, você pode adicionar uma instrução defer logo após a instrução que aloca o recurso.

const std = @import("std");

test "defer" {
    var array = std.ArrayList(i32).init(std.testing.allocator);
    defer array.deinit();

    const expected: i32 = 42;
    try array.append(expected);

    try std.testing.expectEqual(expected, array.items[0]);
}

test "sem defer" {
    var array = std.ArrayList(i32).init(std.testing.allocator);

    const expected: i32 = 42;
    try array.append(expected);

    try std.testing.expectEqual(expected, array.items[0]);

    array.deinit();
}

O primeiro teste é muito mais fácil de validar, já que a reserva e a liberação de memória são declaradas juntas.

const std = @import("std");

test "defer" {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    const allocator = arena.allocator();

    _ = try allocator.alloc(u8, 1);
    _ = try allocator.alloc(u8, 10);
    _ = try allocator.alloc(u8, 100);
}

test "sem defer" {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    const allocator = arena.allocator();

    _ = try allocator.alloc(u8, 1);
    _ = try allocator.alloc(u8, 10);
    _ = try allocator.alloc(u8, 100);

    arena.deinit();
}

Nesse exemplo acima, novamente, é muito mais simples de verificar que arena.deinit(); foi chamado. Imagine se o bloco

    _ = try allocator.alloc(u8, 1);
    _ = try allocator.alloc(u8, 10);
    _ = try allocator.alloc(u8, 100);

tivesse na verdade 150 linhas?

Isso é tudo por hoje. Obrigado!