ZIG - Gli allocatori


Approfondiamo ora un discorso che avevamo accennato nel capitolo 18 cercando di mettere le cose in chiaro.

Il concetto di base è il seguente, che funziona da base per l'intera architettura del linguaggio:

 In Zig non esiste un allocatore globale nascosto. Non c'è un malloc implicito, né un new o un Box::new che allocano dietro le quinte. Tutto ruota attorno a tre principi:

Da un punto di vista operativo Allocator è un'interfaccia type-erased: un puntatore di contesto (ptr: *anyopaque) più una vtable. Dalla 0.14 (tutto confermato almeno fino alla 0.17 che è l'ultima versione uscita mentre sto scrivendo) la vtable ha quattro funzioni:

pub const VTable = struct {
alloc: *const fn (ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8,
resize: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool,
remap: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8,
free: *const fn (ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void,
};

Per ora comunque questi dettagli implementativi possono essere presi così come sono "per cultura", anche perchè non richiamiamo mai la vtable direttamente ma, per fortuna, abbiamo a disposizione delle funzioni di alto livello:

Metodo Cosa fa
alloc(T, n) alloca uno slice []T di n elementi
free(slice) libera uno slice
create(T) alloca un singolo *T
destroy(ptr) libera un puntatore creato con create
realloc(slice, n) ridimensiona, spostando se serve
dupe(T, slice) copia uno slice in memoria nuova
allocSentinel, dupeZ come sopra, con terminatore (es. stringhe C)
alignedAlloc allocazione con allineamento specifico
allocPrint (std.fmt) formatta in memoria allocata

 

Noi vedremo all'opera in particilare la prima di queste funzioni, ovvero alloc.
Va ricordato, come scritto nella pagina ufficiale del linguaggio relativamente alla gestione della memoria, che il compilatore non fa alcuna gestione della memoria in luogo del programmatore (come noto, in Zig niente garbage collector e niente barrow-checker in stile Rust). La cosa è simile a quanto avviene in C ad esempio ma in questo caso abbiamo strumenti migliori, diciamo più moderni, per gestire le cose e gli allocatori svolgono proprio il ruolo di gestori che ci serve.

Possiamo iniziare dagli allocatori disponibili, facendo riferimento alla versione 0.17 del linguaggio (chissà che poi non cambino ancora...) ed alle relative note di rilascio, se presenti.

Allocatore A cosa serve Stato in 0.17
SafeAllocator Allocatore per debug e controllo delle perdite. È thread-safe, fa panic o segfault su mismatch, double free e race, e deinit segnala le perdite. Nuovo, sostituisce DebugAllocator.
DebugAllocator Vecchio allocatore di debug. Deprecato insieme a std.heap.Check.
GeneralPurposeAllocator Predecessore di DebugAllocator. Già rimosso nella 0.16, secondo una guida di migrazione di terze parti.
ArenaAllocator † Alloca molto e libera tutto in blocco, appoggiandosi a un allocatore "figlio". Invariato nelle note.
FixedBufferAllocator † Usa un buffer che viene passato dall'utente, senza heap. È adatto a contesti vincolati. Invariato nelle note.
StackFallbackAllocator Usa prima un buffer sullo stack e ripiega su un altro allocatore quando non basta. Rielaborato
page_allocator † Chiede al sistema operativo pagine intere, quindi anche un solo byte può occupare più KiB. Invariato nelle note.
smp_allocator † Alte prestazioni, ma pochissime protezioni. Invariato nelle note.
c_allocator, raw_c_allocator, wasm_allocator † Appoggio a libc o, per wasm_allocator, a WebAssembly. Invariato nelle note.
ThreadSafeAllocator † Wrapper che rende thread-safe un altro allocatore. Invariato nelle note.
memory_pool Pool di oggetti di tipo fisso. Le varianti AlignedManaged e ExtraManaged sono rimosse a favore di Aligned e Extra.

Ora, non ho la pretesa di vederli tutti all'opera ma darò qualche esempio per illustrarne l'utilizzo. Vediamo un primo semplice esempio:

Esempio 26.1

const std = @import("std");
const Punto = struct { x: i32, y: i32 };
pub fn main() !void {
    const allocator = std.heap.page_allocator;

    // slice di 100 interi
    const numeri = try allocator.alloc(u32, 100);
    //   defer allocator.free(numeri);
    std.debug.print("numeri.len = {}\n", .{numeri.len});

    // singolo oggetto
    const p = try allocator.create(Punto);
    //   defer allocator.destroy(p);
    p.* = .{ .x = 1, .y = 2 };
    std.debug.print("p = ({}, {})\n", .{ p.x, p.y });
}
const allocator = std.heap.page_allocator;
definiamo un allocatore

e lo usiamo per definire una slice

const numeri = try allocator.alloc(u32, 100);

questo è interessante. In pratica alloc trova o chiede al sistema operativo un blocco di memoria adatto. Ricevutolo, impacchetta indirizzo di memoria e lunghezza in una slice. La particolarità, come potete vedere è data dalle operazioni di defer che sono disabilitate tramite commento. Non viene segnalato nulla in quanto page_allocator si limita ad assegnare memoria, non tiene traccia delle allocazioni e quindi non evidenzia eventuali leak. Molto semplice ma non esattamente il più sicuro.

Diverso invece è il caso in cui usiamo il nuovo (presentato dalla versione 0.17 del compilatore) SafeAllocator che di questi (e altri) problemi tiene invece rigorosamente traccia. Infatti Le release notes dicono che SafeAllocator non è un allocator "autonomo": ci costruisce sopra uno strato di controlli. Quindi si appoggia ad un altro allocatore. Ecco l'esempio:

Esempio 26.2

const std = @import("std");
const Punto = struct { x: i32, y: i32 };

pub fn main() !void {
    var safe = std.heap.SafeAllocator.init(std.heap.page_allocator, .{});
    defer _ = safe.deinit(); // segnala i leak e libera tutto

    const allocator = safe.allocator(); // interfaccia std.mem.Allocator

    const numeri = try allocator.alloc(u32, 100);
    defer allocator.free(numeri);
    std.debug.print("numeri.len = {}\n", .{numeri.len});

    const p = try allocator.create(Punto);
    defer allocator.destroy(p);
    p.* = .{ .x = 1, .y = 2 };
    std.debug.print("p = ({}, {})\n", .{ p.x, p.y });
}

Nell'istruzione
var safe = std.heap.SafeAllocator.init(std.heap.page_allocator, .{});
noterete la presenza di due parametri, il primo in particolare è l'allocatore di appoggio, il secondo è una struct che, essendo vuota, indica che devono essere usati i valori di default.

Anche AreaAllocator come abbiamo viato nell'elenco inziale, ha bisogno di un compagno, di un allocatore figlio:

Esempio 27.3

const std = @import("std");
const Punto = struct { x: i32, y: i32 };
pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    const numeri = try allocator.alloc(u32, 100);
    //defer allocator.free(numeri);
    std.debug.print("numeri.len = {}\n", .{numeri.len});

    const p = try allocator.create(Punto);
    //defer allocator.destroy(p);
    p.* = .{ .x = 1, .y = 2 };
    std.debug.print("p = ({}, {})\n", .{ p.x, p.y });
}

Anche ArenaAllocator non ha bisogno di defer in quanto gestisce in maniera per così dire massiva le allocazioni e, purchè venga distrutta alla fine, non lascia indietro dei memory leak. Questo allocatore non ha opzioni eventualmente da settare come SafeAllocator in quanto cerca solo memoria, in buona sostanza che poi distribuisce agli elementi da allocare. Quando viene terminato si porta dietro tutto e fa pulizia.Dal punto di vista del codice, questo lavoro viene svolto in pratica da arena.deinit(). Per quanto ne so, questo è l'allocatore più usato, piuttosto comodo, allochiamo tutto quello che voglianmo e poi viene fatta pulizia in un'unica passata, anche se al momento in cui scrivo il linguaggio non è alla versione 1.0 per cui vedremo cosa eventualmente cambierà e se Kelley tirerà fuori qualche altro cambiamento tale da stravolgere le cose, visto che ce l'ha un po' per vizio.

Vediamo ora un ultimo esempio che coinvolge smp_allocator:

Esempio 27.4

const std = @import("std");
pub fn main() !void {
    const allocator = std.heap.smp_allocator;

    const bytes = try allocator.alloc(u8, 100);
    //   defer allocator.free(bytes);
    std.debug.print("bytes.len = {}\n", .{bytes.len});
}

anche in questo caso, come si vede essendo il defer "commentato" il programma gira senza rilevare alcun leak, perché quell'allocatore non tiene traccia delle allocazioni e non ha un controllo in uscita.

Un piccolo chiarimento lo merita defer che abbiamo visto all'opera varie volte senza però spiegarlo per bene. Ebbene, esso ha uno scopo ben preciso, programma l'esecuzione di un'istruzione (o di un blocco) all'uscita dallo scope (si badi bene quindi: lavoriamo a livello di scope) in cui è scritto, qualunque sia il modo in cui si esce: fine del blocco, return, break, continue, o un errore propagato da try. Non esegue nulla in modo asincrono e non è un distruttore. Va anche sottolineato, per quanto intuitivo,  non possiamo usare return, try o break verso l'esterno all'interno del blocco di un defer. Se in uno stesso scope ci sono più defer, vengono eseguiti in ordine inverso (LIFO), come un'annullamento a pila. È utile perché le risorse acquisite per ultime vengono rilasciate per prime. Ovvero:

const a = try allocator.alloc(u8, 10);
defer allocator.free(a);
const b = try allocator.alloc(u8, 20);
defer allocator.free(b);
// uscita: prima free(b), poi free(a)

Lo vedremo spesso all'opera. Come raccomandazione generale, è bene scriverlo subito dopo l'allocazione, come negli esempi che abbiamo visto.
Diverso, anche se concettualmente molto simile, è errdefer, che viene chiamato in causa solo in presenza di una condizione di errore.

Ora dovremmo domandarci su quale sia l'allocatore da scegliere in base alle varie situazioni. La risposta, personalmente non ce l'ho in quanto non ho esperienza sufficiente relativamente al linguaggio per tirare delle sentenze. A occhio mi pare che SafeAllocator, come dice il nome stesso, sia il più rigoroso e senza dubbio consigliabile nella fasi di test. Invece page_allocator è quello più "libero" e leggero a livello di codifica. Poi c'è tutto il discorso della interoperabilità con il linguaggio C ma qui si apre un altro capitolo che non affrontiamo in questo paragrafo.

Da ultimo, è possibile anche costruire degli allocatori custom. Come potrete intuire si tratta di un argomento piuttosto avanzato che affronteremo più avanti, per adesso mi basta che abbiamo a disposizione gli strumenti per lavorare normalmente.