Çöp Toplayıcıyı Ortadan Kaldırmak: RAII Yolu

Yayınlanan: 2022-03-11

Başlangıçta C vardı. C'de üç tür bellek ayırma vardır: statik, otomatik ve dinamik. Statik değişkenler, kaynak dosyaya gömülü sabitlerdir ve boyutları bilindiklerinden ve asla değişmediklerinden, o kadar da ilginç değillerdir. Otomatik tahsis, yığın tahsisi olarak düşünülebilir - sözlüksel bir blok girildiğinde alan tahsis edilir ve bu bloktan çıkıldığında serbest bırakılır. En önemli özelliği doğrudan bununla ilgilidir. C99'a kadar, otomatik olarak tahsis edilen değişkenlerin boyutlarının derleme zamanında bilinmesi gerekiyordu. Bu, herhangi bir dizi, liste, harita ve bunlardan türetilen herhangi bir yapının dinamik bellekte öbek üzerinde yaşaması gerektiği anlamına gelir.

Çöp Toplayıcıyı Ortadan Kaldırmak: RAII Yolu

Dinamik bellek, programcı tarafından dört temel işlem kullanılarak açıkça tahsis edildi ve serbest bırakıldı: malloc, realloc, calloc ve free. Bunlardan ilk ikisi herhangi bir başlatma gerçekleştirmez, bellekte boşluk olabilir. Ücretsiz hariç hepsi başarısız olabilir. Bu durumda, erişimi tanımsız davranış olan bir boş gösterici döndürürler; en iyi durumda, programınız patlar. En kötü durumda, programınız bir süre çalışır gibi görünür ve patlamadan önce çöp verileri işler.

İşleri bu şekilde yapmak biraz acı verici çünkü programcı olarak, programınızın ihlal edildiğinde patlamasına neden olan bir grup değişmezi korumaktan yalnızca siz sorumlusunuz. Değişkene erişilmeden önce bir malloc çağrısı yapılmalıdır. Değişkeninizi kullanmadan önce malloc'un başarıyla döndüğünü kontrol etmelisiniz. Yürütme yolunda her malloc için tam olarak bir ücretsiz çağrı bulunmalıdır. Sıfırsa, bellek sızdırıyor. Birden fazla ise, programınız patlar. Serbest bırakıldıktan sonra değişkene erişim girişimi olmayabilir. Bunun gerçekte nasıl göründüğüne dair bir örnek görelim:

 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

Bu kod, ne kadar basit olursa olsun, zaten bir anti-kalıp ve bir şüpheli karar içeriyor. Gerçek hayatta, bayt sayılarını asla değişmez değerler olarak yazmamalısınız, bunun yerine sizeof işlevini kullanmalısınız. Benzer şekilde, char * dizisini tam olarak ihtiyacımız olan dizgenin boyutuna iki kez (boş sonlandırmayı hesaba katmak için dizgenin uzunluğundan bir fazlası) tahsis ederiz, bu oldukça pahalı bir işlemdir. Daha karmaşık bir program, daha büyük bir dize arabelleği oluşturabilir ve dize boyutunun büyümesine izin verebilir.

RAII'nin İcadı: Yeni Bir Umut

Tüm bu manuel yönetim, en azından söylemek gerekirse, tatsızdı. 80'lerin ortalarında, Bjarne Stroustrup yepyeni dili C++ için yeni bir paradigma icat etti. Buna Kaynak Edinme Başlatmadır adını verdi ve temel görüşler şunlardı: nesneler, derleyici tarafından uygun zamanlarda otomatik olarak çağrılan yapıcılara ve yıkıcılara sahip olacak şekilde belirtilebilir, bu, belirli bir nesnenin belleğini yönetmek için çok daha uygun bir yol sağlar. gerektirir ve teknik, bellek olmayan kaynaklar için de yararlıdır.

Bu, C++'daki yukarıdaki örneğin çok daha temiz olduğu anlamına gelir:

 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

Görünürde manuel bellek yönetimi yok! Dize nesnesi oluşturulur, çağrılan aşırı yüklenmiş bir yönteme sahiptir ve işlev çıktığında otomatik olarak yok edilir. Ne yazık ki, aynı basitlik başka komplikasyonlara yol açabilir. Bir örneğe biraz ayrıntılı olarak bakalım:

 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.

Bunların hepsi oldukça basit görünüyor. Vektör çizgileri doldurulur, döndürülür ve çağrılır. Ancak, performansı önemseyen verimli programcılar olarak, bununla ilgili bir şey bizi rahatsız ediyor: return ifadesinde, vektör, yok edilmeden kısa bir süre önce, oyundaki değer semantiği nedeniyle yeni bir vektöre kopyalanır.

Bu artık modern C++'da kesinlikle doğru değil. C++11, Origin'in geçerli bir durumda (böylece hala uygun şekilde yok edilebilmesi için) ancak belirtilmemiş bir durumda bırakıldığı hareket semantiği kavramını tanıttı. Geri aramalar, derleyicinin, daha fazla erişimden kısa bir süre önce kaynağın yok edileceğini bildiğinden, anlambilimi taşımak için optimize etmesi için çok kolay bir durumdur. Bununla birlikte, örneğin amacı, insanların neden 80'lerin sonlarında ve 90'ların başlarında bir sürü çöp toplanmış dil icat ettiğini ve o zamanlarda C++ hareket semantiğinin mevcut olmadığını göstermektir.

Büyük veriler için bu pahalı olabilir. Bunu optimize edelim ve sadece bir işaretçi döndürelim. Birkaç sözdizimi değişikliği var, ancak bunun dışında aynı kod:

Aslında vektör bir değer tutamacıdır: yığındaki öğelere işaretçiler içeren nispeten küçük bir yapıdır. Açıkçası, vektörü basitçe döndürmek sorun değil. Örnek, döndürülen büyük bir dizi olsaydı daha iyi çalışırdı. Bir dosyayı önceden tahsis edilmiş bir diziye okumaya çalışmak anlamsız olacağından, bunun yerine vektörü kullanırız. Bunun pratik olmayan büyük bir veri yapısı olduğunu varsayın, lütfen.

 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)

Ah! Artık çizgiler bir işaretçi olduğuna göre, otomatik değişkenlerin ilan edildiği gibi çalıştığını görebiliriz: vektör, kapsamı ayrıldığında yok edilir ve işaretçi yığında ileri bir konumu işaret eder. Bir segmentasyon hatası, yalnızca yasadışı belleğe erişim girişimidir ve bu yüzden gerçekten bunu beklemeliydik. Yine de, dosyanın satırlarını bir şekilde fonksiyonumuzdan geri almak istiyoruz ve doğal olan şey, değişkenimizi yığının dışına ve yığına taşımaktır. Bu, yeni anahtar kelime ile yapılır. Satırları tanımladığımız dosyamızın bir satırını basitçe düzenleyebiliriz:

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

Ne yazık ki, bu mükemmel çalışıyor gibi görünse de, yine de bir kusuru var: bellek sızdırıyor. C++'da, yığına yönelik işaretçiler artık gerekmedikten sonra el ile silinmelidir; değilse, son işaretçi kapsam dışına çıktığında bu bellek kullanılamaz hale gelir ve işlem bittiğinde işletim sistemi onu yönetene kadar kurtarılmaz. Deyimsel modern C++, burada istenen davranışı uygulayan bir unique_ptr kullanır. İşaretçi kapsam dışına çıktığında işaret edilen nesneyi siler. Ancak bu davranış, C++11'e kadar dilin bir parçası değildi.

Bu örnekte, bu kolayca düzeltilebilir:

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

Ne yazık ki, programlar oyuncak ölçeğinin ötesine geçtikçe, bir işaretçinin tam olarak nerede ve ne zaman silinmesi gerektiği konusunda akıl yürütmek hızla zorlaşıyor. Bir işlev bir işaretçi döndürdüğünde, şimdi ona sahip misiniz? İşiniz bittiğinde kendiniz silmeli misiniz, yoksa daha sonra bir kerede serbest bırakılacak bir veri yapısına mı ait? Bir şekilde yanlış yapın ve bellek sızıntıları yapın, diğerinde yanlış yapın ve artık geçerli olmayan işaretçileri kaldırmaya çalıştıkları için söz konusu veri yapısını ve muhtemelen başkalarını bozdunuz.

İlgili: Node.js Uygulamalarında Bellek Sızıntılarında Hata Ayıklama

"Çöp Toplayıcıya, flyboy!"

Çöp Toplayıcılar yeni bir teknoloji değildir. 1959'da John McCarthy tarafından Lisp için icat edildiler. 1980 yılında Smalltalk-80 ile çöp toplama ana akım haline gelmeye başladı. Bununla birlikte, 1990'lar tekniğin gerçek gelişimini temsil ediyordu: 1990 ve 2000 arasında, hepsi bir tür çöp toplama kullanan çok sayıda dil yayınlandı: Haskell, Python, Lua, Java, JavaScript, Ruby, OCaml , ve C# en iyi bilinenler arasındadır.

Çöp toplama nedir? Kısacası, manuel bellek yönetimini otomatikleştirmek için kullanılan bir dizi tekniktir. Genellikle C ve C++ gibi manuel bellek yönetimine sahip diller için bir kitaplık olarak bulunur, ancak bunu gerektiren dillerde çok daha yaygın olarak kullanılır. En büyük avantajı, programcının bellek hakkında düşünmesine gerek olmamasıdır; hepsi soyutlanmış. Örneğin, yukarıdaki dosya okuma kodumuza eşdeğer Python basitçe şudur:

 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.

Satır dizisi, ilk atandığında ortaya çıkar ve çağrı kapsamına kopyalanmadan döndürülür. Zamanlama belirsiz olduğu için, bu kapsamın dışına çıktıktan bir süre sonra Çöp Toplayıcı tarafından temizlenir. İlginç bir not, Python'da, bellek dışı kaynaklar için RAII'nin deyimsel olmadığıdır. İzin verilir - with bloğu kullanmak yerine basitçe fp = open(file_name) ve GC'nin daha sonra temizlemesine izin verebilirdik. Ancak önerilen model, belirli zamanlarda yayınlanabilmeleri için mümkün olduğunda bir bağlam yöneticisi kullanmaktır.

Bellek yönetimini soyutlamak ne kadar güzel olursa olsun, bir bedeli vardır. Referans sayma çöp toplamada, tüm değişken atamaları ve kapsam çıkışları, referansları güncellemek için küçük bir maliyet kazanır. İşaretle ve süpür sistemlerinde, GC belleği temizlerken öngörülemeyen aralıklarla tüm program yürütmesi durdurulur. Buna genellikle dünyayı durdurma olayı denir. Her iki sistemi de kullanan Python gibi uygulamalar her iki cezadan da zarar görür. Bu sorunlar, performansın kritik olduğu veya gerçek zamanlı uygulamaların gerekli olduğu durumlar için çöpten toplanan dillerin uygunluğunu azaltır. Şu oyuncak programlarında bile performans cezası iş başında görülebilir:

 $ 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

Python sürümü, C++ sürümünden neredeyse üç kat daha fazla gerçek zamanlı zaman alır. Bu farkın tamamı çöp toplamaya atfedilemeyecek olsa da, yine de önemli.

Sahiplik: RAII Uyanıyor

O zaman bu son mu? Tüm programlama dilleri performans ve programlama kolaylığı arasında seçim yapmak zorunda mı? Numara! Programlama dili araştırmaları devam ediyor ve yeni nesil dil paradigmalarının ilk uygulamalarını görmeye başlıyoruz. Özellikle ilgi çekici olan, Python benzeri ergonomi ve C benzeri hız vaat ederken sarkan işaretçiler, boş işaretçiler ve benzeri imkansız hale getiren Rust adlı dildir - bunlar derlenmez. Bu iddialarda nasıl bulunabilir?

Bu etkileyici iddialara izin veren temel teknoloji, bu sorunlara neden olabilecek kodu reddederek derleme üzerinde çalışan statik bir denetleyici olan ödünç alma denetleyicisi olarak adlandırılır. Ancak, sonuçlara çok derinlemesine girmeden önce, önkoşullar hakkında konuşmamız gerekecek.

Mülkiyet

C++'daki işaretçiler tartışmamızda hatırlayın, kabaca "bu değişkeni silmekten kim sorumlu" anlamına gelen sahiplik kavramına değindik. Pas bu kavramı resmileştirir ve güçlendirir. Her değişken bağlama, bağladığı kaynağın mülkiyetine sahiptir ve ödünç alma denetleyicisi, kaynağın genel sahipliğine sahip olan tam olarak bir bağlamanın olmasını sağlar. Yani, Rust Book'tan aşağıdaki pasaj derlenmeyecek:

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

Rust'taki atamalar varsayılan olarak hareket semantiğine sahiptir - sahipliği aktarırlar. Bir türe kopya semantiği vermek mümkündür ve bu, sayısal ilkeller için zaten yapılmıştır, ancak bu olağandışıdır. Bu nedenle, üçüncü kod satırından itibaren v2, söz konusu vektörün sahibidir ve artık v olarak erişilemez. Bu neden yararlıdır? Her kaynağın tam olarak bir sahibi olduğunda, derleme zamanında belirlenebilen kapsam dışı kaldığı tek bir an da vardır. Bu, Rust'ın RAII vaadini yerine getirebileceği, kaynakları kapsamlarına göre deterministik bir şekilde başlatarak ve yok ederek, bir çöp toplayıcı kullanmadan veya programcının herhangi bir şeyi manuel olarak serbest bırakmasını gerektirmeden yerine getirebileceği anlamına gelir.

Bunu referans sayma çöp toplama ile karşılaştırın. Bir RC uygulamasında, tüm işaretçiler en az iki parça bilgiye sahiptir: işaret edilen nesne ve bu nesneye yapılan başvuruların sayısı. Bu sayı 0'a ulaştığında nesne yok edilir. Bu, işaretçinin bellek gereksinimini iki katına çıkarır ve sayım otomatik olarak artırıldığından, azaltıldığından ve kontrol edildiğinden kullanımına küçük bir maliyet ekler. Rust'ın sahiplik sistemi, nesnelerin referansları tükendiğinde otomatik olarak yok edileceğine dair aynı garantiyi sunar, ancak bunu herhangi bir çalışma zamanı maliyeti olmadan yapar. Her nesnenin mülkiyeti analiz edilir ve derleme zamanında eklenen yok etme çağrıları.

Borçlanma

Verileri aktarmanın tek yolu taşıma semantiği olsaydı, işlev dönüş türleri çok hızlı bir şekilde çok karmaşık hale gelirdi. Bir tamsayı üretmek için iki vektör kullanan ve daha sonra vektörleri yok etmeyen bir fonksiyon yazmak isteseydiniz, onları dönüş değerine dahil etmeniz gerekirdi. Bu teknik olarak mümkün olsa da, kullanımı korkunç:

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

Bunun yerine, Rust ödünç alma kavramına sahiptir. Aynı işlevi şöyle yazabilirsiniz ve vektörlere referansı ödünç alarak işlev bittiğinde sahibine geri verir:

 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 ve v2, fn foo geri döndükten sonra sahipliklerini orijinal kapsama döndürür, kapsam dışına düşer ve kapsayıcı kapsam çıktığında otomatik olarak yok edilir.

Burada, Rust Book'un çok kısa ve öz bir şekilde belirttiği, derleme zamanında ödünç alma denetleyicisi tarafından uygulanan ödünç alma kısıtlamaları olduğunu belirtmekte fayda var:

Herhangi bir ödünç alma, sahibininkinden daha büyük olmayan bir kapsam için sürmelidir. İkincisi, bu iki tür ödünç alma türünden birine veya diğerine sahip olabilirsiniz, ancak ikisine aynı anda sahip olamazsınız:

bir kaynağa bir veya daha fazla referans (&T)

tam olarak bir değişken referans (&mut T)

Bu, Rust'ın veri yarışlarına karşı korumasının kritik bir yönünü oluşturduğu için dikkate değerdir. Derleme zamanında belirli bir kaynağa birden çok değişken erişimi engelleyerek, kaynağa ilk önce hangi iş parçacığının ulaştığına bağlı olduğundan, sonucun belirsiz olduğu kodun yazılamamasını garanti eder. Bu, yineleyici geçersiz kılma ve ücretsiz kullanımdan sonra kullanım gibi sorunları önler.

Pratik Terimlerde Borç Denetleyicisi

Artık Rust'ın bazı özelliklerini bildiğimize göre, daha önce gördüğümüz aynı dosya satırı sayacını nasıl uyguladığımıza bakalım:

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

Kaynak kodda zaten üzerinde yorum yapılan öğelerin ötesinde, çeşitli değişkenlerin yaşam sürelerini gözden geçirmeye ve izlemeye değer. file_name ve file_lines main()'in sonuna kadar sürer; yıkıcıları, C++'ın otomatik değişkenleriyle aynı mekanizmayı kullanarak, ekstra ücret ödemeden çağrılır. read_lines_from_file , file_name , süresi boyunca bu işleve değişmez bir şekilde ödünç verilir. read_lines_from_file içinde buffer aynı şekilde davranır, kapsam dışına çıktığında yok edilir. lines ise devam eder ve başarıyla main öğesine döndürülür. Niye ya?

Unutulmaması gereken ilk şey, Rust ifade tabanlı bir dil olduğundan, geri aramanın ilk bakışta göründüğü gibi görünmeyebileceğidir. Bir işlevin son satırı, sondaki noktalı virgülü atlarsa, bu ifade dönüş değeridir. İkinci şey, dönüş değerlerinin özel olarak ele alınmasıdır. En azından işlevi çağıran kadar yaşamak istedikleri varsayılır. Son not, ilgili hareket semantiği nedeniyle, Ok(lines) Ok(file_lines) dönüştürmek için gerekli bir kopya olmamasıdır, derleyici yalnızca değişken noktasını uygun bellek bitinde yapar.

"RAII'nin gerçek gücünü ancak sonunda anlıyorsunuz."

Manuel bellek yönetimi, programcıların derleyicinin icadından bu yana kaçınmanın yollarını keşfettiği bir kabustur. RAII umut verici bir modeldi, ancak C++'da sakat kaldı çünkü bazı garip geçici çözümler olmadan yığınla ayrılmış nesneler için işe yaramadı. Sonuç olarak, 90'larda, performans pahasına bile programcı için hayatı daha keyifli hale getirmek için tasarlanmış, çöp toplanmış dillerde bir patlama oldu.

Ancak, dil tasarımındaki son söz bu değil. Rust, yeni ve güçlü sahiplik ve ödünç alma kavramlarını kullanarak, RAII kalıplarının kapsam-tabanını çöp toplamanın bellek güvenliği ile birleştirmeyi başarır; tüm dünyayı durdurmak için bir çöp toplayıcıya ihtiyaç duymadan, başka hiçbir dilde görülmeyen güvenlik garantileri verirken. Bu, sistem programlamanın geleceğidir. Ne de olsa, "hata yapmak insanidir, ancak derleyiciler asla unutmaz."


Toptal Mühendislik Blogunda Daha Fazla Okuma:

  • WebAssembly/Pas Eğitimi: Mükemmel Ses İşleme
  • Java'da Bellek Sızıntılarını Avlamak