Eliminarea gunoiului: Calea RAII

Publicat: 2022-03-11

La început, a existat C. În C, există trei tipuri de alocare de memorie: statică, automată și dinamică. Variabilele statice sunt constantele încorporate în fișierul sursă și, deoarece au dimensiuni cunoscute și nu se modifică niciodată, nu sunt chiar atât de interesante. Alocarea automată poate fi considerată ca o alocare a stivei - spațiul este alocat atunci când este introdus un bloc lexical și eliberat când acel bloc este ieșit. Caracteristica sa cea mai importantă este direct legată de asta. Până la C99, variabilele alocate automat erau necesare pentru a avea dimensiunile cunoscute în momentul compilării. Aceasta înseamnă că orice șir, listă, hartă și orice structură derivată din acestea trebuiau să trăiască în heap, în memoria dinamică.

Eliminarea gunoiului: Calea RAII

Memoria dinamică a fost alocată și eliberată în mod explicit de către programator folosind patru operațiuni fundamentale: malloc, realloc, calloc și free. Primele două dintre acestea nu efectuează nicio inițializare, memoria poate conține cruft. Toate, cu excepția celor gratuite, pot eșua. În acest caz, returnează un pointer nul, al cărui acces este un comportament nedefinit; în cel mai bun caz, programul tău explodează. În cel mai rău caz, programul dvs. pare să funcționeze pentru o perioadă, procesând datele de gunoi înainte de a exploda.

A face lucrurile în acest fel este oarecum dureros pentru că tu, programatorul, ai singur sarcina de a menține o grămadă de invariante care fac ca programul tău să explodeze atunci când este încălcat. Trebuie să existe un apel malloc înainte ca variabila să fie accesată vreodată. Trebuie să verificați dacă malloc a revenit cu succes înainte de a utiliza variabila. Trebuie să existe exact un apel gratuit per malloc în calea de execuție. Dacă este zero, memoria se scurge. Dacă mai mult de unul, programul dvs. explodează. Este posibil să nu existe încercări de acces la variabilă după ce aceasta a fost eliberată. Să vedem un exemplu despre cum arată de fapt:

 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

Acest cod, oricât de simplu este, conține deja un antimodel și o decizie discutabilă. În viața reală, nu ar trebui să scrieți niciodată numărul de octeți ca literali, ci să utilizați funcția sizeof. În mod similar, alocam matricea char * la exact dimensiunea șirului de care avem nevoie de două ori (una mai mult decât lungimea șirului, pentru a ține cont de terminarea nul), ceea ce este o operație destul de costisitoare. Un program mai sofisticat ar putea construi un buffer de șir mai mare, permițând dimensiunea șirului să crească.

Invenția RAII: O nouă speranță

Toată această gestionare manuală a fost cel puțin neplăcută. La mijlocul anilor '80, Bjarne Stroustrup a inventat o nouă paradigmă pentru limbajul său nou-nouț, C++. El a numit-o Achiziția resurselor este inițializare, iar informațiile fundamentale au fost următoarele: obiectele pot fi specificate să aibă constructori și destructori care sunt apelați automat la momente adecvate de către compilator, aceasta oferă o modalitate mult mai convenabilă de a gestiona memoria unui anumit obiect. necesită, iar tehnica este utilă și pentru resurse care nu sunt memorie.

Aceasta înseamnă că exemplul de mai sus, în C++, este mult mai curat:

 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

Nicio gestionare manuală a memoriei la vedere! Obiectul șir este construit, are o metodă supraîncărcată numită și este distrus automat când funcția iese. Din păcate, aceeași simplitate poate duce la alte complicații. Să ne uităm la un exemplu în detaliu:

 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.

Totul pare destul de simplu. Liniile vectoriale sunt umplute, returnate și apelate. Totuși, fiind programatori eficienți cărora le pasă de performanță, ceva în acest sens ne deranjează: în instrucțiunea return, vectorul este copiat într-un vector nou datorită semanticii valorii în joc, cu puțin timp înainte de distrugerea sa.

Acest lucru nu mai este strict adevărat în C++ modern. C++11 a introdus noțiunea de semantică a mișcării, în care originea este lăsată într-o stare validă (astfel încât să poată fi în continuare distrusă corespunzător) dar nespecificată. Apelurile de returnare sunt un caz foarte ușor de optimizat de către compilator pentru a muta semantica, deoarece știe că originea va fi distrusă cu puțin timp înainte de orice alt acces. Totuși, scopul exemplului este de a demonstra de ce oamenii au inventat o grămadă de limbaje colectate în gunoi la sfârșitul anilor 80 și începutul anilor 90, iar în acele vremuri semantica de mișcare C++ nu era disponibilă.

Pentru date mari, acest lucru poate deveni costisitor. Să optimizăm acest lucru și să returnăm doar un pointer. Există câteva modificări de sintaxă, dar în rest este același cod:

De fapt, vectorul este un mâner de valoare: o structură relativ mică care conține pointeri către elementele din heap. Strict vorbind, nu este o problemă să returnezi pur și simplu vectorul. Exemplul ar funcționa mai bine dacă ar fi returnat o matrice mare. Deoarece încercarea de a citi un fișier într-o matrice prealocată ar fi lipsită de sens, folosim în schimb vectorul. Prefaceți-vă că este o structură de date nepractic de mare, vă rog.

 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)

Ai! Acum că liniile sunt un pointer, putem vedea că variabilele automate funcționează așa cum este anunțat: vectorul este distrus pe măsură ce domeniul său de aplicare este părăsit, lăsând indicatorul îndreptat către o locație înainte din stivă. O eroare de segmentare este pur și simplu o încercare de acces la memorie ilegală, așa că ar fi trebuit să ne așteptăm la asta. Totuși, vrem să recuperăm cumva liniile fișierului din funcția noastră, iar lucrul natural este să ne mutăm pur și simplu variabila din stivă și în heap. Acest lucru se face cu noul cuvânt cheie. Putem edita pur și simplu o linie a fișierului nostru, unde definim linii:

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

Din păcate, deși pare să funcționeze perfect, încă are un defect: pierde memorie. În C++, pointerii către heap trebuie șterse manual după ce nu mai sunt necesari; dacă nu, acea memorie devine indisponibilă odată ce ultimul pointer iese din domeniul de aplicare și nu este recuperată până când sistemul de operare nu o gestionează când procesul se termină. Idiomatic modern C++ ar folosi aici un unique_ptr, care implementează comportamentul dorit. Acesta șterge obiectul către care indică atunci când indicatorul iese din domeniul de aplicare. Cu toate acestea, acest comportament nu a făcut parte din limbaj până în C++11.

În acest exemplu, acest lucru poate fi remediat cu ușurință:

 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; }

Din păcate, pe măsură ce programele se extind dincolo de scara jucăriei, devine rapid mai dificil să se raționeze unde și când anume trebuie șters un indicator. Când o funcție returnează un pointer, îl deții acum? Ar trebui să îl ștergeți singur când ați terminat cu el sau aparține unei structuri de date care vor fi toate eliberate odată mai târziu? Găsiți greșit într-un fel și pierderile de memorie, greșiți în celălalt și ați corupt structura de date în cauză și probabil altele, deoarece încearcă să dereferente indicatorii care acum nu mai sunt validi.

Înrudit: Depanarea pierderilor de memorie în aplicațiile Node.js

„În colectorul de gunoi, flyboy!”

Colectorii de gunoi nu sunt o tehnologie nouă. Au fost inventate în 1959 de John McCarthy pentru Lisp. Cu Smalltalk-80 în 1980, colectarea gunoiului a început să intre în curent. Cu toate acestea, anii 1990 au reprezentat adevărata înflorire a tehnicii: între 1990 și 2000, au fost lansate un număr mare de limbaje, toate care foloseau colectarea gunoiului de un fel sau altul: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml , și C# sunt printre cele mai cunoscute.

Ce este colectarea gunoiului? Pe scurt, este un set de tehnici folosite pentru a automatiza gestionarea manuală a memoriei. Este adesea disponibil ca o bibliotecă pentru limbi cu management manual al memoriei, cum ar fi C și C++, dar este mult mai frecvent utilizat în limbile care necesită acest lucru. Marele avantaj este că programatorul pur și simplu nu trebuie să se gândească la memorie; totul este abstractizat. De exemplu, echivalentul Python cu codul nostru de citire a fișierelor de mai sus este pur și simplu acesta:

 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.

Matricea de linii ia ființă atunci când este atribuită pentru prima dată și este returnată fără a fi copiată în domeniul de apelare. Este curățat de Garbage Collector cândva după ce iese din acest domeniu, deoarece momentul este nedeterminat. O notă interesantă este că în Python, RAII pentru resurse non-memory nu este idiomatică. Este permis - am fi putut scrie pur și simplu fp = open(file_name) în loc să folosim un bloc with și să lăsăm GC să curețe ulterior. Dar modelul recomandat este de a folosi un manager de context atunci când este posibil, astfel încât acestea să poată fi eliberate în momente deterministe.

Oricât de frumos este să abstragi gestionarea memoriei, există un cost. În colectarea gunoiului de numărare a referințelor, toate alocările variabilelor și ieșirile de domeniu câștigă un cost mic pentru actualizarea referințelor. În sistemele de marcare și măturare, la intervale imprevizibile, toată execuția programului este oprită în timp ce GC curățește memoria. Acesta este adesea numit un eveniment stop-the-world. Implementările precum Python, care folosesc ambele sisteme, suferă de ambele penalizări. Aceste probleme reduc adecvarea limbajelor colectate de gunoi pentru cazurile în care performanța este critică sau sunt necesare aplicații în timp real. Se poate vedea penalizarea performanței în acțiune chiar și în aceste programe de jucării:

 $ 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

Versiunea Python durează de aproape trei ori mai mult timp real decât versiunea C++. Deși nu toată această diferență poate fi atribuită colectării gunoiului, este totuși considerabilă.

Proprietate: RAII Awakens

Ăsta e sfârșitul, atunci? Toate limbajele de programare trebuie să aleagă între performanță și ușurință de programare? Nu! Cercetarea limbajului de programare continuă și începem să vedem primele implementări ale următoarei generații de paradigme de limbaj. De un interes deosebit este limbajul numit Rust, care promite ergonomie asemănătoare Python-ului și viteză asemănătoare C, în timp ce face indicatori suspendați, indicatori nuli și astfel de imposibile - nu se vor compila. Cum poate face aceste afirmații?

Tehnologia de bază care permite aceste afirmații impresionante se numește verificatorul de împrumut, un verificator static care rulează la compilare, respingând codul care ar putea cauza aceste probleme. Cu toate acestea, înainte de a intra prea adânc în implicații, va trebui să vorbim despre cerințele preliminare.

Proprietate

Amintiți-vă în discuția noastră despre indicatorii în C++, am atins noțiunea de proprietate, care în termeni grosori înseamnă „cine este responsabil pentru ștergerea acestei variabile”. Rugina oficializează și întărește acest concept. Fiecare legare variabilă are dreptul de proprietate asupra resursei pe care o leagă, iar verificatorul de împrumut asigură că există exact o legătură care deține proprietatea generală asupra resursei. Adică, următorul fragment din Rust Book nu se va compila:

 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]); ^

Misiunile din Rust au semantică de mișcare în mod implicit - își transferă dreptul de proprietate. Este posibil să dați semantică de copiere unui tip, iar acest lucru se face deja pentru primitivele numerice, dar este neobișnuit. Din această cauză, începând cu a treia linie de cod, v2 deține vectorul în cauză și nu mai poate fi accesat ca v. De ce este util acest lucru? Când fiecare resursă are exact un proprietar, are, de asemenea, un singur moment în care iese din domeniul de aplicare, care poate fi determinat în timpul compilării. Aceasta înseamnă, la rândul său, că Rust poate îndeplini promisiunea RAII, inițialând și distrugând resursele în mod determinist pe baza domeniului lor, fără să folosească vreodată un colector de gunoi sau să solicite programatorului să lanseze manual ceva.

Comparați acest lucru cu colectarea gunoiului cu numărarea referințelor. Într-o implementare RC, toți pointerii au cel puțin două informații: obiectul spre care s-a indicat și numărul de referințe la acel obiect. Obiectul este distrus când acel număr ajunge la 0. Acest lucru dublează necesarul de memorie a indicatorului și adaugă un cost mic la utilizarea sa, deoarece numărul este automat incrementat, decrementat și verificat. Sistemul de proprietate Rust oferă aceeași garanție, că obiectele sunt distruse automat atunci când epuizează referințele, dar face acest lucru fără niciun cost de rulare. Proprietatea fiecărui obiect este analizată și apelurile de distrugere sunt inserate în timpul compilării.

Împrumutarea

Dacă semantica de mutare ar fi singura modalitate de a transmite date, tipurile de returnare a funcției ar deveni foarte complicate, foarte rapid. Dacă doriți să scrieți o funcție care a folosit doi vectori pentru a produce un număr întreg, care nu a distrus vectorii după aceea, ar trebui să îi includeți în valoarea returnată. Deși acest lucru este posibil din punct de vedere tehnic, este groaznic să utilizați:

 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);

În schimb, Rust are conceptul de împrumut. Puteți scrie aceeași funcție așa și va împrumuta referința la vectori, dând-o înapoi proprietarului când funcția se termină:

 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 și v2 își revin dreptul de proprietate la domeniul inițial după ce fn foo revine, iese din domeniul de aplicare și fiind distrus automat când domeniul de aplicare care le conține.

Este demn de menționat aici că există restricții privind împrumuturile, impuse de verificatorul de împrumut în momentul compilării, pe care Rust Book le pune foarte succint:

Orice împrumut trebuie să dureze pentru un domeniu nu mai mare decât cel al proprietarului. În al doilea rând, este posibil să aveți unul sau altul dintre aceste două tipuri de împrumuturi, dar nu ambele în același timp:

una sau mai multe referințe (&T) la o resursă

exact o referință mutabilă (&mut T)

Acest lucru este demn de remarcat deoarece formează un aspect critic al protecției Rust împotriva curselor de date. Prin prevenirea acceselor multiple mutabile la o anumită resursă în timpul compilării, garantează că nu poate fi scris cod în care rezultatul este nedeterminat, deoarece depinde de ce fir a ajuns primul la resursă. Acest lucru previne probleme precum invalidarea iteratorului și utilizarea după gratuită.

Verificatorul de împrumut în termeni practici

Acum că știm despre unele dintre caracteristicile lui Rust, să vedem cum implementăm același contor de linii de fișiere pe care l-am văzut înainte:

 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); } }

Dincolo de elementele deja comentate în codul sursă, merită să parcurgem și să urmărim duratele de viață ale diferitelor variabile. file_name și file_lines durează până la sfârșitul main(); destructorii lor sunt apelați în acel moment fără costuri suplimentare, folosind același mecanism ca și variabilele automate ale C++. Când apelați read_lines_from_file , file_name este împrumutat imuabil acelei funcții pe durata ei. În read_lines_from_file , buffer acționează în același mod, distrus atunci când iese din domeniul de aplicare. lines , pe de altă parte, persistă și este returnat cu succes la main . De ce?

Primul lucru de remarcat este că, deoarece Rust este un limbaj bazat pe expresii, apelul de returnare poate să nu arate ca unul la început. Dacă ultima linie a unei funcții omite punctul și virgulă final, acea expresie este valoarea returnată. Al doilea lucru este că valorile returnate primesc o gestionare specială. Se presupune că vor să trăiască cel puțin atâta timp cât apelantul funcției. Nota finală este că, din cauza semanticii de mutare implicată, nu este necesară o copie pentru a transmuta Ok(lines) în Ok(file_lines) , compilatorul pur și simplu face punctul variabil în bitul corespunzător de memorie.

„Abia la sfârșit îți dai seama de adevărata putere a RAII.”

Gestionarea manuală a memoriei este un coșmar pe care programatorii au inventat modalități de a evita încă de la inventarea compilatorului. RAII a fost un model promițător, dar paralizat în C++ pentru că pur și simplu nu a funcționat pentru obiectele alocate în heap fără niște soluții ciudate. În consecință, a avut loc o explozie de limbaje colectate de gunoi în anii 90, menite să facă viața mai plăcută pentru programator chiar și în detrimentul performanței.

Cu toate acestea, acesta nu este ultimul cuvânt în designul limbajului. Folosind noțiuni noi și puternice de proprietate și împrumut, Rust reușește să îmbine sfera de aplicare a modelelor RAII cu securitatea memoriei de colectare a gunoiului; totul fără a fi nevoie vreodată de un colector de gunoi să oprească lumea, oferind în același timp garanții de siguranță nevăzute în nicio altă limbă. Acesta este viitorul programării sistemelor. La urma urmei, „a greși este uman, dar compilatorii nu uită niciodată”.


Citiți suplimentare pe blogul Toptal Engineering:

  • WebAssembly/Rust Tutorial: Procesare audio perfectă
  • Vânătoarea scurgerilor de memorie în Java