Eliminacja śmieciarza: sposób RAII

Opublikowany: 2022-03-11

Na początku było C. W C są trzy rodzaje alokacji pamięci: statyczna, automatyczna i dynamiczna. Zmienne statyczne to stałe osadzone w pliku źródłowym, a ponieważ mają znane rozmiary i nigdy się nie zmieniają, nie są aż tak interesujące. Alokację automatyczną można traktować jako alokację stosu — miejsce jest przydzielane po wejściu do bloku leksykalnego i zwalniane po wyjściu z tego bloku. Jego najważniejsza cecha jest z tym bezpośrednio związana. Do wersji C99 automatycznie przydzielane zmienne musiały mieć znane rozmiary w czasie kompilacji. Oznacza to, że każdy ciąg, lista, mapa i każda struktura, która się z nich wywodzi, musiała żyć na stercie, w pamięci dynamicznej.

Eliminacja śmieciarza: sposób RAII

Pamięć dynamiczna została jawnie przydzielona i zwolniona przez programistę przy użyciu czterech podstawowych operacji: malloc, realloc, calloc i free. Pierwsze dwa z nich nie wykonują żadnej inicjalizacji, pamięć może zawierać szczątki. Wszystkie z wyjątkiem darmowych mogą zawieść. W takim przypadku zwracają wskaźnik o wartości null, do którego dostęp jest niezdefiniowanym zachowaniem; w najlepszym przypadku Twój program eksploduje. W najgorszym przypadku twój program wydaje się działać przez chwilę, przetwarzając dane śmieci przed wybuchem.

Robienie rzeczy w ten sposób jest trochę bolesne, ponieważ ty, programista, masz wyłączną odpowiedzialność za utrzymanie zbioru niezmienników, które powodują, że twój program eksploduje, gdy zostanie naruszony. Przed uzyskaniem dostępu do zmiennej musi być wywołanie malloc. Musisz sprawdzić, czy malloc powrócił pomyślnie przed użyciem zmiennej. W ścieżce wykonania musi istnieć dokładnie jedno bezpłatne wywołanie na malloc. Jeśli zero, przecieki pamięci. Jeśli więcej niż jeden, Twój program eksploduje. Po zwolnieniu zmiennej może nie być żadnych prób dostępu. Zobaczmy przykład, jak to naprawdę wygląda:

 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

Ten kod, jakkolwiek prosty, zawiera już jeden antywzorzec i jedną wątpliwą decyzję. W prawdziwym życiu nigdy nie należy zapisywać liczby bajtów jako literałów, ale zamiast tego używać funkcji sizeof. Podobnie, przypisujemy tablicę char * dokładnie do rozmiaru ciągu, którego potrzebujemy dwa razy (o jeden więcej niż długość ciągu, aby uwzględnić zakończenie wartości NULL), co jest dość kosztowną operacją. Bardziej zaawansowany program może skonstruować większy bufor ciągów, co pozwoli na zwiększenie rozmiaru ciągu.

Wynalezienie RAII: Nowa nadzieja

Całe to ręczne zarządzanie było co najmniej nieprzyjemne. W połowie lat 80. Bjarne Stroustrup wynalazł nowy paradygmat dla swojego zupełnie nowego języka, C++. Nazwał to pozyskiwanie zasobów jest inicjalizacją, a podstawowe spostrzeżenia były następujące: obiekty można określić tak, aby miały konstruktory i destruktory, które są wywoływane automatycznie w odpowiednich momentach przez kompilator, zapewnia to znacznie wygodniejszy sposób zarządzania pamięcią danego obiektu wymaga, a technika ta jest również użyteczna w przypadku zasobów, które nie są pamięcią.

Oznacza to, że powyższy przykład w C++ jest znacznie czystszy:

 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

Nie widać ręcznego zarządzania pamięcią! Obiekt ciągu jest konstruowany, ma wywołaną przeciążoną metodę i jest automatycznie niszczony po zakończeniu funkcji. Niestety ta sama prostota może prowadzić do innych komplikacji. Przyjrzyjmy się bliżej przykładowi:

 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.

To wszystko wydaje się całkiem proste. Linie wektora są wypełniane, zwracane i wywoływane. Jednak będąc sprawnymi programistami, którym zależy na wydajności, coś w tym nas niepokoi: w instrukcji return wektor jest kopiowany do nowego wektora ze względu na działającą semantykę wartości, na krótko przed jego zniszczeniem.

Nie jest to już całkowicie prawdziwe we współczesnym C++. W C++11 wprowadzono pojęcie semantyki ruchu, w której pochodzenie jest pozostawiane w prawidłowym (aby nadal mogło być poprawnie zniszczone), ale nieokreślonym stanie. Wywołania zwrotne są bardzo łatwym przypadkiem dla kompilatora, który może zoptymalizować w celu przeniesienia semantyki, ponieważ wie, że źródło zostanie zniszczone na krótko przed dalszym dostępem. Jednak celem tego przykładu jest pokazanie, dlaczego ludzie wymyślili całą masę języków ze śmieciami w późnych latach 80. i wczesnych 90., a w tamtych czasach semantyka ruchów C++ nie była dostępna.

W przypadku dużych ilości danych może to być drogie. Zoptymalizujmy to i po prostu zwróćmy wskaźnik. Jest kilka zmian składni, ale poza tym to ten sam kod:

W rzeczywistości wektor jest uchwytem wartości: stosunkowo małą strukturą zawierającą wskaźniki do elementów na stercie. Ściśle mówiąc, po prostu zwrócenie wektora nie stanowi problemu. Przykład działałby lepiej, gdyby była zwracana duża tablica. Ponieważ próba wczytania pliku do wstępnie przydzielonej tablicy byłaby bezsensowna, zamiast tego używamy wektora. Po prostu udawaj, że to niepraktycznie duża struktura danych, proszę.

 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)

Auć! Teraz, gdy linie są wskaźnikiem, widzimy, że zmienne automatyczne działają tak, jak zapowiadano: wektor jest niszczony, gdy jego zakres jest opuszczany, pozostawiając wskaźnik wskazujący do przodu na stosie. Błąd segmentacji to po prostu próba uzyskania dostępu do nielegalnej pamięci, więc naprawdę powinniśmy się tego spodziewać. Mimo to chcemy jakoś odzyskać wiersze pliku z naszej funkcji, a naturalną rzeczą jest po prostu przeniesienie naszej zmiennej ze stosu na stertę. Odbywa się to za pomocą nowego słowa kluczowego. Możemy po prostu edytować jedną linię naszego pliku, w której definiujemy linie:

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

Niestety, chociaż wydaje się, że działa to doskonale, nadal ma wadę: wycieka pamięć. W C++ wskaźniki do sterty muszą być usuwane ręcznie, gdy nie są już potrzebne; jeśli nie, ta pamięć staje się niedostępna, gdy ostatni wskaźnik wypadnie poza zakres i nie jest odzyskiwana, dopóki system operacyjny nie zarządza nim po zakończeniu procesu. Idiomatic nowoczesny C++ użyłby tutaj unique_ptr, który implementuje pożądane zachowanie. Usuwa wskazany obiekt, gdy wskaźnik wykracza poza zakres. Jednak to zachowanie nie było częścią języka aż do C++11.

W tym przykładzie można to łatwo naprawić:

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

Niestety, w miarę jak programy wykraczają poza skalę zabawek, szybko staje się trudniejsze do zrozumienia, gdzie i kiedy należy usunąć wskaźnik. Kiedy funkcja zwraca wskaźnik, czy jesteś teraz jego właścicielem? Czy powinieneś sam go usunąć, gdy skończysz, czy też należy do jakiejś struktury danych, która zostanie później zwolniona? Zrób to źle w jeden sposób i przecieki pamięci, zrób to źle w drugim, a uszkodziłeś daną strukturę danych i prawdopodobnie inne, ponieważ próbują wyłuskać wskaźniki, które teraz nie są już ważne.

Powiązane: Debugowanie wycieków pamięci w aplikacjach Node.js

„Do Zbieracza Śmieci, flyboyu!”

Zbieracze śmieci nie są nową technologią. Zostały wynalezione w 1959 roku przez Johna McCarthy'ego dla Lisp. Wraz z Smalltalk-80 w 1980 roku wywóz śmieci zaczął wchodzić do głównego nurtu. Jednak lata 90. były prawdziwym rozkwitem tej techniki: między 1990 a 2000 r. wydano wiele języków, z których wszystkie wykorzystywały różne rodzaje śmieci: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml , a C# należą do najbardziej znanych.

Co to jest wywóz śmieci? Krótko mówiąc, jest to zestaw technik służących do automatyzacji ręcznego zarządzania pamięcią. Często jest dostępna jako biblioteka dla języków z ręcznym zarządzaniem pamięcią, takich jak C i C++, ale jest znacznie częściej używana w językach, które tego wymagają. Wielką zaletą jest to, że programista po prostu nie musi myśleć o pamięci; to wszystko jest oderwane. Na przykład odpowiednik naszego kodu odczytującego pliki w Pythonie jest po prostu taki:

 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.

Tablica linii powstaje przy pierwszym przypisaniu do i jest zwracana bez kopiowania do zakresu wywołującego. Zostaje wyczyszczony przez Odśmiecacza jakiś czas po tym, jak wypadnie z tego zakresu, ponieważ czas jest nieokreślony. Ciekawą uwagą jest to, że w Pythonie RAII dla zasobów innych niż pamięć nie jest idiomatyczny. Jest to dozwolone - moglibyśmy po prostu napisać fp = open(file_name) zamiast używać bloku with i pozwolić GC posprzątać później. Ale zalecanym wzorcem jest użycie menedżera kontekstu, gdy jest to możliwe, aby można je było zwolnić w deterministycznych czasach.

Choć oderwanie od zarządzania pamięcią jest przyjemne, wiąże się to z pewnym kosztem. W przypadku wyrzucania elementów bezużytecznych zliczania odwołań wszystkie przypisania zmiennych i wyjścia z zakresu zyskują niewielki koszt aktualizacji odwołań. W systemach mark-and-sweep, w nieprzewidywalnych odstępach czasu całe wykonywanie programu jest zatrzymywane, podczas gdy GC czyści pamięć. Jest to często nazywane wydarzeniem typu „stop-the-world”. Implementacje takie jak Python, które korzystają z obu systemów, podlegają obydwóm karom. Te problemy zmniejszają przydatność języków gromadzonych w czasie rzeczywistym w przypadkach, w których wydajność ma krytyczne znaczenie lub wymagane są aplikacje czasu rzeczywistego. Można zobaczyć spadek wydajności w akcji nawet w tych programach z zabawkami:

 $ 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

Wersja Pythona zajmuje prawie trzy razy więcej czasu rzeczywistego niż wersja C++. Chociaż nie całą tę różnicę można przypisać zbieraniu śmieci, nadal jest ona znaczna.

Własność: RAII Przebudzenia

Czy to już koniec? Czy wszystkie języki programowania muszą wybierać między wydajnością a łatwością programowania? Nie! Badania nad językiem programowania trwają i zaczynamy widzieć pierwsze implementacje paradygmatów językowych nowej generacji. Szczególnie interesujący jest język o nazwie Rust, który obiecuje ergonomię zbliżoną do Pythona i szybkość zbliżoną do C, jednocześnie uniemożliwiając zwisające wskaźniki, wskaźniki zerowe i tym podobne - nie będą się kompilować. Jak może wysuwać te roszczenia?

Podstawowa technologia, która pozwala na te imponujące twierdzenia, to tzw. Jednak zanim zagłębimy się w implikacje, musimy porozmawiać o warunkach wstępnych.

Własność

Przypomnijmy, w naszej dyskusji na temat wskaźników w C++ poruszyliśmy pojęcie własności, które w przybliżeniu oznacza „kto jest odpowiedzialny za usunięcie tej zmiennej”. Rdza formalizuje i wzmacnia tę koncepcję. Każde powiązanie zmiennej ma własność powiązanego zasobu, a moduł sprawdzania pożyczek zapewnia, że ​​istnieje dokładnie jedno powiązanie, które ma całkowitą własność zasobu. Oznacza to, że poniższy fragment z Rust Book nie skompiluje się:

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

Zadania w Rust mają domyślnie semantykę przenoszenia - przenoszą własność. Możliwe jest nadanie semantyki kopiowania do typu, i jest to już zrobione w przypadku prymitywów numerycznych, ale jest to niezwykłe. Z tego powodu, począwszy od trzeciego wiersza kodu, v2 jest właścicielem danego wektora i nie można już do niego uzyskać dostępu jako v. Dlaczego jest to przydatne? Kiedy każdy zasób ma dokładnie jednego właściciela, ma również jeden moment, w którym wykracza poza zakres, który można określić w czasie kompilacji. Oznacza to z kolei, że Rust może spełnić obietnicę RAII, inicjując i niszcząc zasoby deterministycznie w oparciu o ich zakres, nigdy nie używając garbage collectora ani nie wymagając od programisty ręcznego zwolnienia czegokolwiek.

Porównaj to z wyrzucaniem elementów bezużytecznych ze zliczaniem odwołań. W implementacji RC wszystkie wskaźniki zawierają co najmniej dwie informacje: wskazywany obiekt i liczbę odwołań do tego obiektu. Obiekt jest niszczony, gdy ta liczba osiągnie 0. Podwaja to wymagania dotyczące pamięci wskaźnika i dodaje niewielki koszt do jego użycia, ponieważ liczba jest automatycznie zwiększana, zmniejszana i sprawdzana. System własności Rusta oferuje taką samą gwarancję, że obiekty zostaną automatycznie zniszczone, gdy zabraknie im referencji, ale robi to bez żadnych kosztów czasu pracy. Własność każdego obiektu jest analizowana, a wywołania niszczenia wstawiane w czasie kompilacji.

Pożyczanie

Gdyby semantyka ruchu była jedynym sposobem przekazywania danych, typy zwracane przez funkcje byłyby bardzo skomplikowane i bardzo szybkie. Jeśli chciałbyś napisać funkcję, która używała dwóch wektorów do wytworzenia liczby całkowitej, która później nie zniszczyła wektorów, musiałbyś uwzględnić je w wartości zwracanej. Chociaż jest to technicznie możliwe, korzystanie z niego jest okropne:

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

Zamiast tego Rust ma koncepcję pożyczania. Możesz napisać tę samą funkcję w ten sposób, a ona pożyczy odniesienie do wektorów, oddając je właścicielowi po zakończeniu funkcji:

 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 przywracają prawo własności do pierwotnego zakresu po powrocie fn foo, wypadaniu poza zakres i automatycznym niszczeniu po zamknięciu zakresu zawierającego.

Warto w tym miejscu wspomnieć, że istnieją ograniczenia dotyczące pożyczania, narzucane przez kontroler pożyczki w czasie kompilacji, które Rust Book ujmuje bardzo zwięźle:

Każda pożyczka musi trwać w zakresie nie większym niż właściciel. Po drugie, możesz mieć jeden lub drugi z tych dwóch rodzajów pożyczek, ale nie oba jednocześnie:

jedno lub więcej odniesień (&T) do zasobu

dokładnie jedno zmienne odniesienie (&mut T)

Jest to godne uwagi, ponieważ stanowi krytyczny aspekt ochrony Rusta przed wyścigami danych. Zapobiegając wielokrotnym modyfikowalnym dostępom do danego zasobu w czasie kompilacji, gwarantuje to, że nie można napisać kodu, w którym wynik jest nieokreślony, ponieważ zależy to od tego, który wątek dotarł do zasobu jako pierwszy. Zapobiega to problemom, takim jak unieważnianie iteratorów i używanie po zwolnieniu.

Sprawdzanie pożyczki w praktyce

Teraz, gdy wiemy już o niektórych funkcjach Rusta, przyjrzyjmy się, jak zaimplementujemy ten sam licznik linii pliku, który widzieliśmy wcześniej:

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

Oprócz elementów już skomentowanych w kodzie źródłowym, warto prześledzić i prześledzić czasy życia różnych zmiennych. file_name i file_lines trwają do końca funkcji main(); ich destruktory są wywoływane w tym czasie bez dodatkowych kosztów, używając tego samego mechanizmu, co automatyczne zmienne C++. Podczas wywoływania read_lines_from_file , file_name jest wypożyczana niezmiennie tej funkcji na czas jej trwania. Wewnątrz read_lines_from_file , buffer działa w ten sam sposób, niszczony, gdy wyjdzie poza zakres. Z drugiej strony lines trwają i są pomyślnie zwracane do main . Czemu?

Pierwszą rzeczą, na którą należy zwrócić uwagę, jest to, że ponieważ Rust jest językiem opartym na wyrażeniach, połączenie zwrotne może początkowo wyglądać inaczej. Jeśli ostatni wiersz funkcji pomija końcowy średnik, to wyrażenie jest wartością zwracaną. Drugą rzeczą jest to, że zwracane wartości są traktowane w specjalny sposób. Zakłada się, że chcą żyć przynajmniej tak długo, jak osoba wywołująca funkcję. Ostatnia uwaga jest taka, że ​​ze względu na zastosowaną semantykę ruchu, nie ma potrzeby kopiowania z Ok(lines) do Ok(file_lines) , kompilator po prostu wskazuje zmienną na odpowiedni bit pamięci.

„Dopiero na końcu zdajesz sobie sprawę z prawdziwej mocy RAII”.

Ręczne zarządzanie pamięcią to koszmar, którego programiści wymyślali od czasu wynalezienia kompilatora. RAII był obiecującym wzorcem, ale ułomnym w C++, ponieważ po prostu nie działał dla obiektów alokowanych na stercie bez kilku dziwnych obejść. W konsekwencji w latach 90. nastąpiła eksplozja śmieciowych języków, które miały uprzyjemnić życie programiście, nawet kosztem wydajności.

Jednak to nie jest ostatnie słowo w projektowaniu języka. Używając nowych i silnych pojęć własności i pożyczania, Rustowi udaje się połączyć oparte na zakresie wzorce RAII z ​​bezpieczeństwem pamięci związanym z wyrzucaniem śmieci; wszystko to bez konieczności, aby śmieciarz powstrzymał świat, jednocześnie zapewniając gwarancje bezpieczeństwa niespotykane w żadnym innym języku. To jest przyszłość programowania systemów. W końcu „błądzić jest rzeczą ludzką, ale kompilatorzy nigdy nie zapominają”.


Dalsza lektura na blogu Toptal Engineering:

  • WebAssembly/Rust Tutorial: Doskonałe przetwarzanie dźwięku
  • Polowanie na wycieki pamięci w Javie