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:
- Nessuna allocazione nascosta: se una funzione
alloca, deve ricevere un
std.mem.Allocatorcome parametro. Guardando la firma possiamo capire se può allocare. - L'allocazione può fallire, e il fallimento è un
errore esplicito (
error.OutOfMemory) che va gestito contry/catch. - Il chiamante decide la strategia: la stessa libreria può girare con un arena, con un buffer sullo stack o con un allocatore di debug, senza modifiche.
- ogni funzione che alloca memoria riceve un std.mem.Allocator. Questo è da prendere con un po' di elasticità, è un concetto che si legge anche su qualche sito ma in realtà le cose non stanno proprio così. In genere si, è la situazione diciamo standard, potremmo dire il principio guida forse è più preciso dire che in Zig non ci dovrebbero essere allocazioni nascoste: se una funzione alloca, l'allocatore dovrebbe essere visibile da qualche parte. Questo non significa che debba sempre comparire come parametro della funzione. Ci sono dei casi in cui l'allocatore può essere salvato in una struct oppure si usa un allocatore globale (come detto all'inizio, non nascosto). In questo paragrafo però non ci occuperemo di tecniche avanzate ma proprio delle tipologie di allocatori e il loro uso nel caso più tipico.
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,
};
alloc: restituisce memoria onull.resize: prova a ridimensionare sul posto; restituiscetruese ci riesce, senza mai spostare la memoria.remap: come resize, ma può anche spostare la memoria (tipomremap). Introdotto nella 0.14.free: libera.
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'istruzionevar 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.