Eliminare il Garbage Collector: The RAII Way

Pubblicato: 2022-03-11

All'inizio c'era C. In C ci sono tre tipi di allocazione di memoria: statica, automatica e dinamica. Le variabili statiche sono le costanti incorporate nel file sorgente e, poiché hanno dimensioni note e non cambiano mai, non sono poi così interessanti. L'allocazione automatica può essere considerata come l'allocazione dello stack: lo spazio viene allocato quando viene inserito un blocco lessicale e viene liberato quando si esce da quel blocco. La sua caratteristica più importante è direttamente correlata a questo. Fino a C99, le variabili allocate automaticamente dovevano avere le loro dimensioni note in fase di compilazione. Ciò significa che qualsiasi stringa, elenco, mappa e qualsiasi struttura che ne derivasse doveva vivere nell'heap, nella memoria dinamica.

Eliminare il Garbage Collector: The RAII Way

La memoria dinamica è stata allocata e liberata esplicitamente dal programmatore utilizzando quattro operazioni fondamentali: malloc, realloc, calloc e free. I primi due di questi non eseguono alcuna inizializzazione, la memoria potrebbe contenere cruft. Tutti tranne quelli gratuiti possono fallire. In tal caso, restituiscono un puntatore nullo, il cui accesso è un comportamento indefinito; nel migliore dei casi, il tuo programma esplode. Nel peggiore dei casi, il tuo programma sembra funzionare per un po', elaborando i dati spazzatura prima di esplodere.

Fare le cose in questo modo è un po' doloroso perché tu, il programmatore, hai l'unico compito di mantenere un mucchio di invarianti che fanno esplodere il tuo programma quando viene violato. Deve esserci una chiamata malloc prima che si acceda alla variabile. È necessario verificare che malloc abbia restituito correttamente prima di utilizzare la variabile. Deve esistere esattamente una chiamata gratuita per malloc nel percorso di esecuzione. Se zero, perdite di memoria. Se più di uno, il tuo programma esplode. Potrebbero non esserci tentativi di accesso alla variabile dopo che è stata liberata. Vediamo un esempio di come appare effettivamente:

 int main() { char *str = (char *) malloc(7); strcpy(str, "toptal"); printf("char array = \"%s\" @ %u\n", str, str); str = (char *) realloc(str, 11); strcat(str, ".com"); printf("char array = \"%s\" @ %u\n", str, str); free(str); return(0); }
 $ make runc gcc -oc cc ./c char * (null terminated): toptal @ 66576 char * (null terminated): toptal.com @ 66576

Quel codice, per quanto semplice, contiene già un antipattern e una decisione discutibile. Nella vita reale, non dovresti mai scrivere i conteggi dei byte come letterali, ma usare invece la funzione sizeof. Allo stesso modo, allochiamo l'array char * esattamente alla dimensione della stringa di cui abbiamo bisogno due volte (una in più rispetto alla lunghezza della stringa, per tenere conto della terminazione null), che è un'operazione abbastanza costosa. Un programma più sofisticato potrebbe costruire un buffer di stringhe più grande, consentendo alle dimensioni della stringa di crescere.

L'invenzione di RAII: una nuova speranza

Tutta quella gestione manuale era sgradevole, per non dire altro. A metà degli anni '80, Bjarne Stroustrup ha inventato un nuovo paradigma per il suo linguaggio nuovo di zecca, C++. Lo chiamò Resource Acquisition Is Initialization e le intuizioni fondamentali erano le seguenti: gli oggetti possono essere specificati per avere costruttori e distruttori che vengono chiamati automaticamente al momento opportuno dal compilatore, questo fornisce un modo molto più conveniente per gestire la memoria di un dato oggetto richiede e la tecnica è utile anche per risorse che non sono memoria.

Ciò significa che l'esempio sopra, in C++, è molto più pulito:

 int main() { std::string str = std::string ("toptal"); std::cout << "string object: " << str << " @ " << &str << "\n"; str += ".com"; std::cout << "string object: " << str << " @ " << &str << "\n"; return(0); }
 $ g++ -o ex_1 ex_1.cpp && ./ex_1 string object: toptal @ 0x5fcaf0 string object: toptal.com @ 0x5fcaf0

Nessuna gestione manuale della memoria in vista! L'oggetto stringa viene costruito, dispone di un metodo di overload chiamato e viene automaticamente distrutto all'uscita della funzione. Sfortunatamente, quella stessa semplicità può portare ad altre complicazioni. Vediamo un esempio in dettaglio:

 vector<string> read_lines_from_file(string &file_name) { vector<string> lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines.push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); int count = read_lines_from_file(file_name).size(); cout << "File " << file_name << " contains " << count << " lines."; return 0; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Sembra tutto abbastanza semplice. Le linee vettoriali vengono riempite, restituite e richiamate. Tuttavia, essendo programmatori efficienti che si preoccupano delle prestazioni, qualcosa in questo ci infastidisce: nell'istruzione return, il vettore viene copiato in un nuovo vettore a causa della semantica del valore in gioco, poco prima della sua distruzione.

Questo non è più strettamente vero nel moderno C++. C++11 ha introdotto la nozione di semantica di spostamento, in cui l'origine è lasciata in uno stato valido (in modo che possa ancora essere distrutta correttamente) ma non specificato. Le chiamate di ritorno sono un caso molto semplice per il compilatore da ottimizzare per spostare la semantica, poiché sa che l'origine verrà distrutta poco prima di qualsiasi ulteriore accesso. Tuttavia, lo scopo dell'esempio è dimostrare perché le persone hanno inventato un sacco di linguaggi per la raccolta dei rifiuti alla fine degli anni '80 e all'inizio degli anni '90 e in quei tempi la semantica del movimento C++ non era disponibile.

Per dati di grandi dimensioni, questo può diventare costoso. Ottimizziamo questo e restituiamo semplicemente un puntatore. Ci sono alcune modifiche alla sintassi, ma per il resto è lo stesso codice:

In realtà, vector è un handle di valore: una struttura relativamente piccola contenente puntatori a elementi nell'heap. A rigor di termini, non è un problema restituire semplicemente il vettore. L'esempio funzionerebbe meglio se fosse restituito un array di grandi dimensioni. Poiché tentare di leggere un file in un array preallocato non avrebbe senso, utilizziamo invece il vettore. Fai finta che sia una struttura dati poco pratica, per favore.

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; }
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp Segmentation fault (core dumped)

Ahia! Ora che le linee sono un puntatore, possiamo vedere che le variabili automatiche funzionano come pubblicizzato: il vettore viene distrutto quando il suo scope si allontana, lasciando il puntatore che punta a una posizione in avanti nello stack. Un errore di segmentazione è semplicemente un tentativo di accesso alla memoria illegale, quindi avremmo dovuto aspettarcelo. Tuttavia, vogliamo in qualche modo recuperare le righe del file dalla nostra funzione e la cosa naturale è semplicemente spostare la nostra variabile fuori dallo stack e nell'heap. Questo viene fatto con la nuova parola chiave. Possiamo semplicemente modificare una riga del nostro file, dove definiamo le righe:

 vector<string> * lines = new vector<string>;
 $ make cpp && ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines.

Sfortunatamente, anche se sembra funzionare perfettamente, ha ancora un difetto: perde memoria. In C++, i puntatori all'heap devono essere eliminati manualmente dopo che non sono più necessari; in caso contrario, quella memoria diventa non disponibile una volta che l'ultimo puntatore non rientra nell'ambito e non viene ripristinata fino a quando il sistema operativo non la gestisce al termine del processo. Il moderno C++ idiomatico userebbe qui un unique_ptr, che implementa il comportamento desiderato. Elimina l'oggetto a cui punta quando il puntatore esce dall'ambito. Tuttavia, quel comportamento non faceva parte del linguaggio fino a C++11.

In questo esempio, questo può essere facilmente risolto:

 vector<string> * read_lines_from_file(string &file_name) { vector<string> * lines = new vector<string>; string line; ifstream file_handle (file_name.c_str()); while (file_handle.good() && !file_handle.eof()) { getline(file_handle, line); lines->push_back(line); } file_handle.close(); return lines; } int main(int argc, char* argv[]) { // get file name from the first argument string file_name (argv[1]); vector<string> * file_lines = read_lines_from_file(file_name); int count = file_lines->size(); delete file_lines; cout << "File " << file_name << " contains " << count << " lines."; return 0; }

Sfortunatamente, poiché i programmi si espandono oltre la scala dei giocattoli, diventa rapidamente più difficile ragionare su dove e quando esattamente un puntatore dovrebbe essere eliminato. Quando una funzione restituisce un puntatore, lo possiedi ora? Dovresti eliminarlo tu stesso quando hai finito o appartiene a una struttura di dati che verrà liberata tutta in una volta in seguito? Sbagli in un modo e perdite di memoria, sbagli nell'altro e hai danneggiato la struttura dei dati in questione e probabilmente altri, poiché tentano di dereferenziare i puntatori che ora non sono più validi.

Correlati: debug di perdite di memoria nelle applicazioni Node.js

"Nel Garbage Collector, ragazzo volante!"

I Garbage Collector non sono una nuova tecnologia. Sono stati inventati nel 1959 da John McCarthy per Lisp. Con Smalltalk-80 nel 1980, la raccolta dei rifiuti iniziò a diventare un fenomeno mainstream. Tuttavia, gli anni '90 hanno rappresentato la vera fioritura della tecnica: tra il 1990 e il 2000 sono stati rilasciati un gran numero di linguaggi, tutti utilizzati per la raccolta dei rifiuti di un tipo o dell'altro: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml e C# sono tra i più noti.

Cos'è la raccolta dei rifiuti? In breve, è un insieme di tecniche utilizzate per automatizzare la gestione manuale della memoria. È spesso disponibile come libreria per linguaggi con gestione manuale della memoria come C e C++, ma è molto più comunemente usata nei linguaggi che la richiedono. Il grande vantaggio è che il programmatore semplicemente non ha bisogno di pensare alla memoria; è tutto astratto. Ad esempio, l'equivalente Python del nostro codice di lettura dei file sopra è semplicemente questo:

 def read_lines_from_file(file_name): lines = [] with open(file_name) as fp: for line in fp: lines.append(line) return lines if __name__ == '__main__': import sys file_name = sys.argv[1] count = len(read_lines_from_file(file_name)) print("File {} contains {} lines.".format(file_name, count))
 $ python3 python3.py makefile File makefile contains 38 lines.

L'array di righe viene creato quando viene assegnato per la prima volta e viene restituito senza essere copiato nell'ambito della chiamata. Viene ripulito dal Garbage Collector qualche tempo dopo che è uscito da quell'ambito, poiché i tempi sono indeterminati. Una nota interessante è che in Python, RAII per le risorse non di memoria non è idiomatico. È consentito: avremmo potuto semplicemente scrivere fp = open(file_name) invece di utilizzare un blocco with e lasciare che il GC si pulisse in seguito. Ma il modello consigliato è utilizzare un gestore di contesto quando possibile in modo che possano essere rilasciati in momenti deterministici.

Per quanto sia bello astrarre la gestione della memoria, c'è un costo. Nel conteggio dei riferimenti Garbage Collection, tutte le uscite di assegnazione e ambito di variabili ottengono un piccolo costo per aggiornare i riferimenti. Nei sistemi mark-and-sweep, a intervalli imprevedibili, tutta l'esecuzione del programma viene interrotta mentre il GC ripulisce la memoria. Questo è spesso chiamato un evento stop-the-world. Implementazioni come Python, che utilizzano entrambi i sistemi, subiscono entrambe le penalità. Questi problemi riducono l'idoneità dei linguaggi di Garbage Collection per i casi in cui le prestazioni sono critiche o sono necessarie applicazioni in tempo reale. Si può vedere la penalizzazione delle prestazioni in azione anche su questi programmi giocattolo:

 $ make cpp && time ./c++ makefile g++ -o c++ c++.cpp File makefile contains 38 lines. real 0m0.016s user 0m0.000s sys 0m0.015s $ time python3 python3.py makefile File makefile contains 38 lines. real 0m0.041s user 0m0.015s sys 0m0.015s

La versione Python impiega quasi tre volte più tempo reale della versione C++. Sebbene non tutta questa differenza possa essere attribuita alla raccolta dei rifiuti, è comunque considerevole.

Proprietà: RAII Awakens

È la fine, allora? Tutti i linguaggi di programmazione devono scegliere tra prestazioni e facilità di programmazione? No! La ricerca sul linguaggio di programmazione continua e stiamo iniziando a vedere le prime implementazioni della prossima generazione di paradigmi linguistici. Di particolare interesse è il linguaggio chiamato Rust, che promette un'ergonomia simile a Python e una velocità simile a C mentre rende impossibili i puntatori penzolanti, i puntatori nulli e simili: non verranno compilati. Come può fare quelle affermazioni?

La tecnologia di base che consente queste affermazioni impressionanti è chiamata il controllo del prestito, un controllo statico che viene eseguito sulla compilazione, rifiutando il codice che potrebbe causare questi problemi. Tuttavia, prima di approfondire le implicazioni, dovremo parlare dei prerequisiti.

Proprietà

Ricordiamo nella nostra discussione sui puntatori in C++, abbiamo toccato la nozione di proprietà, che in termini approssimativi significa "chi è responsabile dell'eliminazione di questa variabile". La ruggine formalizza e rafforza questo concetto. Ogni associazione di variabile ha la proprietà della risorsa che lega e il controllo del prestito assicura che ci sia esattamente un'associazione che ha la proprietà complessiva della risorsa. Cioè, il seguente frammento di Rust Book non verrà compilato:

 let v = vec![1, 2, 3]; let v2 = v; println!("v[0] is: {}", v[0]);
 error: use of moved value: `v` println!("v[0] is: {}", v[0]); ^

Le assegnazioni in Rust hanno la semantica di spostamento per impostazione predefinita: trasferiscono la proprietà. È possibile assegnare una semantica di copia a un tipo, e questo è già stato fatto per le primitive numeriche, ma è insolito. Per questo motivo, a partire dalla terza riga di codice, v2 possiede il vettore in questione e non è più possibile accedervi come v. Perché è utile? Quando ogni risorsa ha esattamente un proprietario, ha anche un singolo momento in cui non rientra nell'ambito, che può essere determinato in fase di compilazione. Ciò significa a sua volta che Rust può mantenere la promessa di RAII, inizializzando e distruggendo le risorse in modo deterministico in base al loro ambito, senza mai utilizzare un garbage collector o richiedere al programmatore di rilasciare manualmente nulla.

Confrontalo con la raccolta dei rifiuti con conteggio dei riferimenti. In un'implementazione RC, tutti i puntatori hanno almeno due informazioni: l'oggetto a cui punta e il numero di riferimenti a quell'oggetto. L'oggetto viene distrutto quando il conteggio raggiunge 0. Ciò raddoppia il requisito di memoria del puntatore e aggiunge un piccolo costo al suo utilizzo, poiché il conteggio viene automaticamente incrementato, decrementato e verificato. Il sistema di proprietà di Rust offre la stessa garanzia, che gli oggetti vengono distrutti automaticamente quando esauriscono i riferimenti, ma lo fa senza alcun costo di runtime. La proprietà di ogni oggetto viene analizzata e le chiamate di distruzione inserite in fase di compilazione.

Prendere in prestito

Se la semantica di spostamento fosse l'unico modo per passare i dati, i tipi restituiti dalle funzioni diventerebbero molto complicati, molto veloci. Se vuoi scrivere una funzione che utilizza due vettori per produrre un intero, che non distrugge i vettori in seguito, dovresti includerli nel valore restituito. Sebbene sia tecnicamente possibile, è terribile da usare:

 fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) { // do stuff with v1 and v2 // hand back ownership, and the result of our function (v1, v2, 42) } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let (v1, v2, answer) = foo(v1, v2);

Invece, Rust ha il concetto di prestito. Puoi scrivere la stessa funzione in questo modo e prenderà in prestito il riferimento ai vettori, restituendolo al proprietario al termine della funzione:

 fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 { // do stuff 42 } let v1 = vec![1, 2, 3]; let v2 = vec![1, 2, 3]; let answer = foo(&v1, &v2);

v1 e v2 restituiscono la loro proprietà all'ambito originale dopo il ritorno di fn foo, uscendo dall'ambito e vengono distrutti automaticamente quando l'ambito contenitore esce.

Vale la pena ricordare qui che ci sono restrizioni sul prestito, imposte dal controllore di prestito al momento della compilazione, che il Rust Book mette molto succintamente:

Qualsiasi prestito deve durare per una portata non superiore a quella del proprietario. In secondo luogo, potresti avere uno o l'altro di questi due tipi di prestiti, ma non entrambi allo stesso tempo:

uno o più riferimenti (&T) a una risorsa

esattamente un riferimento mutevole (&mut T)

Ciò è degno di nota perché costituisce un aspetto critico della protezione di Rust contro le corse di dati. Impedendo più accessi mutevoli a una determinata risorsa in fase di compilazione, garantisce che non sia possibile scrivere codice in cui il risultato è indeterminato perché dipende da quale thread è arrivato per primo alla risorsa. Ciò previene problemi come l'invalidazione dell'iteratore e l'utilizzo gratuito.

Il controllo del prestito in termini pratici

Ora che conosciamo alcune delle funzionalità di Rust, diamo un'occhiata a come implementiamo lo stesso contatore di righe di file che abbiamo visto prima:

 fn read_lines_from_file(file_name: &str) -> io::Result<Vec<String>> { // variables in Rust are immutable by default. The mut keyword allows them to be mutated. let mut lines = Vec::new(); let mut buffer = String::new(); if let Ok(mut fp) = OpenOptions::new().read(true).open(file_name) { // We enter this block only if the file was successfully opened. // This is one way to unwrap the Result<T, E> type Rust uses instead of exceptions. // fp.read_to_string can return an Err. The try! macro passes such errors // upwards through the call stack, or continues otherwise. try!(fp.read_to_string(&mut buffer)); lines = buffer.split("\n").map(|s| s.to_string()).collect(); } Ok(lines) } fn main() { // Get file name from the first argument. // Note that args().nth() produces an Option<T>. To get at the actual argument, we use // the .expect() function, which panics with the given message if nth() returned None, // indicating that there weren't at least that many arguments. Contrast with C++, which // segfaults when there aren't enough arguments, or Python, which raises an IndexError. // In Rust, error cases *must* be accounted for. let file_name = env::args().nth(1).expect("This program requires at least one argument!"); if let Ok(file_lines) = read_lines_from_file(&file_name) { println!("File {} contains {} lines.", file_name, file_lines.len()); } else { // read_lines_from_file returned an error println!("Could not read file {}", file_name); } }

Al di là degli elementi già commentati nel codice sorgente, vale la pena esaminare e tracciare le vite delle varie variabili. file_name e file_lines durano fino alla fine di main(); i loro distruttori vengono chiamati in quel momento senza costi aggiuntivi, utilizzando lo stesso meccanismo delle variabili automatiche di C++. Quando si chiama read_lines_from_file , file_name viene prestato in modo immutabile a quella funzione per la sua durata. All'interno read_lines_from_file , il buffer si comporta allo stesso modo, distrutto quando esce dall'ambito. lines , d'altra parte, persiste e viene restituito correttamente a main . Come mai?

La prima cosa da notare è che poiché Rust è un linguaggio basato su espressioni, la chiamata di ritorno potrebbe non sembrare tale all'inizio. Se l'ultima riga di una funzione omette il punto e virgola finale, tale espressione è il valore restituito. La seconda cosa è che i valori restituiti ottengono una gestione speciale. Si presume che vogliano vivere almeno quanto il chiamante della funzione. La nota finale è che a causa della semantica di spostamento coinvolta, non è necessaria alcuna copia per trasmutare Ok(lines) in Ok(file_lines) , il compilatore semplicemente fa puntare la variabile sul bit di memoria appropriato.

“Solo alla fine ti rendi conto del vero potere della RAII”.

La gestione manuale della memoria è un incubo che i programmatori hanno inventato modi per evitare dall'invenzione del compilatore. RAII era un modello promettente, ma paralizzato in C++ perché semplicemente non funzionava per oggetti allocati nell'heap senza alcune strane soluzioni alternative. Di conseguenza, negli anni '90 c'è stata un'esplosione di linguaggi di raccolta differenziata, progettati per rendere la vita più piacevole al programmatore anche a scapito delle prestazioni.

Tuttavia, questa non è l'ultima parola nel design del linguaggio. Utilizzando nuove e forti nozioni di proprietà e prestito, Rust riesce a fondere la base dell'ambito dei modelli RAII con la sicurezza della memoria della raccolta dei rifiuti; il tutto senza mai richiedere a un netturbino di fermare il mondo, pur facendo garanzie di sicurezza che non si vedono in nessun'altra lingua. Questo è il futuro della programmazione dei sistemi. Dopotutto, "errare è umano, ma i compilatori non dimenticano mai".


Ulteriori letture sul blog di Toptal Engineering:

  • Tutorial WebAssembly/Rust: elaborazione audio perfetta
  • Caccia alle perdite di memoria in Java