Bellek Yönetimi
Çeviren: Ali Çehreli
Tarih: 14 Temmuz 2009
İngilizcesi: Memory Management
Her ciddi program bellek ayırma ve geri verme işlemleriyle ilgilenmek zorundadır. Programların karmaşıklıkları, boyutları, ve hızları arttıkça bu işlemlerin önemi de artar. D'de bellek yönetimi için birçok seçenek bulunur.
D'de bellek ayırma konusunda 3 temel nesne türü vardır:
- Programın statik veri bölgesinde [static data segment] yer alan statik veriler
- CPU'nun program yığıtında [program stack] yer alan yığıt verileri
- Çöp toplamalı bellekte yer alan ve dinamik olarak ayrılan çöp toplamalı veriler
Bunların programda kullanılmaları ve bazı ileri bellek yönetimi teknikleri şunlardır:
- Dizgilerde (ve Dizilerde) Yazınca Kopyalama [copy-on-write]
- Gerçek Zamanlı [real time] Programlama
- Kesintisiz Çalışma
- Serbest Bellek Listeleri [free list]
- Referans Sayma
- Nesne Ayırmanın Özel Olarak Tanımlanması
- İşaretle ve Geri Ver [mark/release]
- Kaynakları Bozucu Fonksiyonlarda Geri Verme: RAII (Resource Acquisition is Initialization)
- Sınıf Nesnelerini Yığıtta Oluşturmak
- Yığıtta İlklenmemiş Diziler Oluşturmak
- Kesme Servisleri [Interrupt Service Routines] (ISR)
Dizgilerde (ve Dizilerde) Yazınca Kopyalama [copy-on-write]
Bir fonksiyona bir dizinin gönderildiği bir örneği ele alalım; fonksiyon dizide duruma göre değişiklik yapıyor olsun ve sonuçta değişiklik yaptığı bu diziyi fonksiyondan döndürsün. Diziler fonksiyonlara değer olarak değil, referans olarak gönderildikleri için; dizi içeriğinin kime ait olduğu cevaplanması gereken önemli bir sorudur. Örneğin bir dizideki ASCII karakterleri büyük harfe çeviren bir fonksiyon şöyle yazılabilir:
char[] büyük_harfe_çevir(char[] s) { int i; for (i = 0; i < s.length; i++) { char c = s[i]; if ('a' <= c && c <= 'z') s[i] = c - (cast(char)'a' - 'A'); } return s; }
Dikkat edilirse, fonksiyonu çağıran taraftaki dizideki karakterlerin de değiştirildikleri görülür. Bunun istenen bir sonuç olmayabileceğinin yanı sıra, s[]
yazılamayan bir bellek bölgesine bağlı bir dizi dilimi bile olabilir.
Bunun önüne geçmek için büyük_harfe_çevir()
içinde s[]
'nin bir kopyası alınabilir. Ancak bu da dizinin zaten bütünüyle büyük harflerden oluştuğu durumlarda tamamen gereksiz bir işlemdir.
Burada çözüm, yazınca kopyalama yöntemini kullanmaktır; dizi ancak gerçekten gerekiyorsa kopyalanır. Bazı dizgi işleme dillerinde bu işlem otomatiktir ama otomatik olmasının büyük bir bedeli de vardır: "abcdeF" gibi bir dizgi 5 kere kopyalanmak zorunda kalacaktır. Dolayısıyla, bu yöntemden en yüksek verimin alınabilmesi için bunun kod içinde açıkça yapılması gerekir. büyük_harfe_çevir()
yazınca kopyalama yönteminden yararlanacak şekilde etkin olarak şöyle yazılabilir:
char[] büyük_harfe_çevir(char[] s) { int değişti; int i; değişti = 0; for (i = 0; i < s.length; i++) { char c = s[i]; if ('a' <= c && c <= 'z') { if (!değişti) { char[] r = new char[s.length]; r[] = s; s = r; değişti = 1; } s[i] = c - (cast(char)'a' - 'A'); } } return s; }
D'nin Phobos kütüphenesindeki dizi fonksiyonları yazınca kopyalama yönteminden yararlanırlar.
Gerçek Zamanlı [real time] Programlama
Gerçek zamanlı programlama, işlemlerin en fazla ne kadar gecikmeyle tamamlanacaklarının garanti edilmesidir. Bu gecikme, malloc/free'nin ve çöp toplamanın da aralarında bulunduğu çoğu bellek yönetimi işlemlerinde teorik olarak sınırsızdır. Bu durumda en güvenilir yöntem; gerekli belleğin önceden ayrılmasıdır. Böylece gecikmeye tahammülü olmayan işlem sırasında bellek ayrılmaz ve çöp toplayıcı çalışmayacağı için gecikme miktarı belirli bir sınırın altında kalır.
Kesintisiz Çalışma
Gerçek zamanlı programlamaya bağlı olarak, çöp toplayıcının belirsiz anlarda çalışmaya başlayarak programda duraksamalara neden olmasının önlenmesi gerekir. Örnek olarak bir savaş oyunu programını düşünebiliriz. Her ne kadar programda bir bozukluk olarak kabul edilmese de, oyunun rastgele duraksaması kullanıcıyı son derece rahatsız edecektir.
Duraksamaları ortadan kaldırmanın veya hiç olmazsa azaltmanın bazı yolları vardır:
- Gereken bütün belleği programın kesintisiz olarak çalışması gereken yerinden önce ayırmak
- Çöp toplayıcıyı programın zaten durmuş olduğu yerlerde açıkça çağırmak. Örneğin kullanıcıdan giriş beklenen bir yerde... Böylece çöp toplayıcının kendi başına çalışma olasılığı azalmış olur.
- Kesintisiz çalışması gereken yerden önce
std.gc.disable()
'ı, sonrastd.gc.enable()
'ı çağırmak. Çöp toplayıcı çalışmaya karar vermek yerine öncelikle yeni bellek ayırmayı tercih edecektir.
Serbest Bellek Listeleri [free list]
Serbest bellek listeleri, sıklıkla ayrılıp tekrar geri verilen türlerde büyük hız kazancı sağlar. Aslında çok basit bir fikirdir: işi biten nesne geri verilmek yerine, bir serbest bellek listesine yerleştirilir. Bellek gerektiği zaman da öncelikle bu listeye bakılır.
class Foo { static Foo serbestler_listesi; // listenin başı static Foo ayır() { Foo f; if (serbestler_listesi) { f = serbestler_listesi; serbestler_listesi = f.sonraki; } else f = new Foo(); return f; } static void geri_ver(Foo f) { f.sonraki = serbestler_listesi; serbestler_listesi = f; } Foo sonraki; // bağlı liste için ... } void deneme() { Foo f = Foo.ayır(); ... Foo.geri_ver(f); }
Bu tür listeler son derece hızlı olabilirler.
- Birden fazla iş parçacığıyla kullanıldığında
ayır()
vegeri_ver()
fonksiyonlarının erişim sıralarının yönetilmesi gerekir. [synchronization] - Foo kurucusu
ayır()
içinde tekrar tekrar çağrılmadığı için döndürülen nesnenin en azından bazı üyelerinin değerlerinin tekrardan atanması gerekebilir. - Bu yönteme RAII'nin eklenmesine gerek yoktur, çünkü atılan bir hata yüzünden
geri_ver()
'in çağrılamadığı durumlarda kaybedilen bellek, çöp toplayıcı tarafından geri alınacaktır.
Referans Sayma
Burada fikir, nesne içinde bir sayaç barındırmaktır. Sayaç, nesneye yapılan her referansta arttırılır, sonlandırılan her referansta da azaltılır. Sayacın değeri sıfıra indiğinde artık nesne geri verilebilir demektir.
D'de referans saymaya yönelik olanaklar bulunmadığı için istendiğinde elle yapılması gerekir.
Win32 COM programlama, referans sayıları için AddRef()
ve Release()
fonksiyonlarını kullanır.
Nesne Ayırmanın Özel Olarak Tanımlanması
D'de her tür nesne için özel ayırma ve geri verme fonksiyonları tanımlanabilir. Nesneler normalde çöp toplamalı bellek bölgesinde yer alırlar; çöp toplayıcı çalışmaya karar verdiğinde de sonlanmış olan nesnelerin bellekleri otomatik olarak geri alınır. Ayırma ve geri verme işlemlerini bir tür için özel olarak tanımlamak için new
ve delete
bildirimleri kullanılır. Örneğin bu işi C kütüphanesindeki malloc
ve free
ile yapmak için:
import std.c.stdlib; import std.outofmemory; import std.gc; class Foo { new(size_t uzunluk) { void* p; p = std.c.stdlib.malloc(uzunluk); if (!p) throw new OutOfMemoryException(); std.gc.addRange(p, p + uzunluk); return p; } delete(void* p) { if (p) { std.gc.removeRange(p); std.c.stdlib.free(p); } } }
new()
'ün özel olarak tanımlanmasıyla ilgili önemli bazı bilgiler:
new()
'ün dönüş türü belirtilmiyor olsa davoid*
'dir venew()
'ünvoid*
döndürmesi gerekir.- Bellek ayıramadığı zaman null döndürmek yerine bir hata atması gerekir. Atılan hatanın türü programcıya kalmıştır. Bu örnekte bir
OutOfMemoryException
atılmaktadır. - Döndürülen bellek sistemin normal yerleştirme aralığında [alignment] olmalıdır. Örneğin win32 sistemlerinde bu adım 8'dir.
uzunluk
parametresinin nedeni, ayırma fonksiyonununFoo
'nun bir alt türü tarafından çağrılmış olabileceği, ve o alt türünFoo
'dan daha büyük yer tutuyor olabileceğidir.- Çöp toplamalı belleği gösteren kök işaretçiler taranırken statik veri bölgesine ve program yığıtına da bakılır. C'nin çalışma ortamı tarafından kullanılan bellek buna dahil değildir. Bu yüzden,
Foo
'nun veya ondan türemiş olan bir türün üyelerinin çöp toplamalı belleği gösterdiklerinde, bu durumdan çöp toplayıcının haberdar edilmesi gerekir. Bu işlemstd.gc.addRange()
ile yapılır. - Belleğin ilklenmesine gerek yoktur;
new()
çağrıldıktan hemen sonra sınıf üyelerine zaten ilk değerleri verilir, ve varsa kurucu fonksiyon çalıştırılır.
delete()
'in özel olarak tanımlanmasıyla ilgili önemli bazı bilgiler:
- Bozucu fonksiyon,
p
'nin gösterdiği bölgede zaten çalıştırılmıştır; dolayısıyla gösterdiği yerde artık çöp değerler bulunmaktadır. p
işaretçisi null olabilir.- Eğer çöp toplayıcıya
std.gc.addRange()
ile haber verilmişse, geri veren fonksiyonda da ona karşılık birstd.gc.removeRange()
çağrısı bulunması gerekir. - Eğer bir
delete()
tanımlanmışsa, ona karşılık gelen birnew()
'ün de tanımlanmış olması gerekir.
Bir sınıf için özel bellek ayırma ve geri verme fonksiyonları yazıldığında, bellek sızıntısı ve geçersiz işaretçiler gibi sorunları önlemek için çok dikkatli olmak gerekir. Özellikle hata atma durumlarında bellek sızıntılarını önlemek için RAII yöntemini uygulamak çok önemlidir.
Bu özel fonksiyonlar struct
ve union
türleri için de tanımlanabilirler.
İşaretle ve Geri Ver [mark/release]
Bu yöntem program yığıtına benzer. Bellekte program yığıtı gibi kullanılacak bir yer ayrılır ve nesneler için gereken yer, bu bellekte ilerleyen bir işaretçinin değeri değiştirilerek ayrılır. Bazı noktalara işaretler koyulur ve yığıt işaretçisine tekrar o noktalardan birisi göstertilerek belleğin bir bölümü tümden geri verilmiş olur.
import std.c.stdlib; import std.outofmemory; class Foo { static void[] bellek; static int indeks; static const int uzunluk = 100; static this() { void *p; p = malloc(uzunluk); if (!p) throw new OutOfMemoryException; std.gc.addRange(p, p + uzunluk); bellek = p[0 .. uzunluk]; } static ~this() { if (bellek.length) { std.gc.removeRange(bellek); free(bellek); bellek = null; } } new(size_t gereken_uzunluk) { void *p; p = &bellek[indeks]; indeks += gereken_uzunluk; if (indeks > bellek.length) throw new OutOfMemory; return p; } delete(void* p) { // [Çevirenin notu: Bu yöntemde Foo nesneleri teker // teker 'delete' ile geri verilmeyecekleri için, // 'delete'in yanlışlıkla çağrıldığı durumları // yakalamak için burada program sonlandırılıyor.] assert(0); } static int yerini_işaretle() { return indeks; } static void geri_ver(int i) { indeks = i; } } void test() { int m = Foo.yerini_işaretle(); Foo f1 = new Foo; // yer ayır Foo f2 = new Foo; // yer ayır ... Foo.geri_ver(m); // f1 ve f2'yi birlikte geri ver }
bellek[]
için ayrılan yer std.gc.addRange(p, p + uzunluk);
ile çöp toplayıcıya bildirildiği için, açıkça geri verilmesi gerekmez.
Kaynakları Bozucu Fonksiyonlarda Geri Verme: RAII (Resource Acquisition is Initialization)
RAII yöntemi, ayırma ve geri verme fonksiyonlarının açıkça kod içinde yapıldığı durumlarda bellek sızıntılarının önlenmesinde çok yararlıdır. Bu tür sınıflara scope
niteliği eklemek bu konuda yarar sağlar.
Sınıf Nesnelerini Yığıtta Oluşturmak
Sınıf nesneleri normalde çöp toplamalı bellekte yer alırlar. Bazı durumlarda ise program yığıtında oluşturulurlar:
- fonksiyonlarda yerel tanımlandıklarında
new
ile oluşturulduklarındanew
argümansız kullanıldığında (yine de kurucu için argüman kullanılabilir)scope
depolama türü ile tanımlandıklarında
Çöp toplayıcının bu nesne için zaman geçirmesi gerekmeyeceği için bu etkin bir yöntemdir. Ama böyle nesneleri gösteren referansların fonksiyondan çıkıldığı anda geçersiz hale geldiklerine dikkat etmek gerekir.
class C { ... } scope c = new C(); // c yığıttadır scope c2 = new C(5); // yığıttadır scope c3 = new(5) C(); // özel ayırıcısı ile ayrılmıştır
Eğer sınıfın bir bozucusu varsa, kapsamdan (örneğin fonksiyondan) hangi nedenle olursa olsun çıkılırken, o bozucu fonksiyon çağrılır (hata atıldığında bile).
Yığıtta İlklenmemiş Diziler Oluşturmak
D'de diziler normalde otomatik olarak ilklenirler. Dolayısıyla şu kodda dizi[]
elemanlarının ilk değerleri hiç kullanılmadıklarından (diziyi_doldur()
tarafından üzerlerine yazıldığı varsayılırsa) bu kod daha hızlandırılabilir.
void foo() { byte[1024] dizi; diziyi_doldur(dizi); ... }
Eğer programın bu kod yüzünden gerçekten hız kaybettiği kanıtlanmışsa, elemanların ilklenmelerinin istenmediği void
ile belirtilir:
void foo() { byte[1024] dizi = void; diziyi_doldur(dizi); ... }
Ancak, yığıttaki ilklenmemiş verilerle ilgili bazı uyarıları akılda tutmak gerekir:
- Yığıttaki ilklenmemiş veriler de çöp toplayıcı tarafından taranırlar ve başka bellek bölgelerine referanslar barındırıp barındırmadıklarına bakılır. Yığıtta bulunan ilklenmemiş değerler daha önce program tarafından kullanılan nesnelerin kalıntıları oldukları için, bu eski değerler çöp toplayıcı belleğindeki nesneleri gösteriyor gibi algılanabilirler ve çöp toplayıcı belleğindeki bu şanssız nesneler hiçbir zaman geri verilmeyebilirler. Bu gerçekten karşılaşılan bir hatadır ve farkedilip giderilmesi çok belalı olabilir.
- Bir fonksiyon, kendisini çağırana bir yığıt nesnesi referansı döndürme hatasına düşmüş olabilir. Bu referans yine yığıtın ilklenmemiş bir bölgesindeki geçerli bir nesneyi gösteriyor gibi algılanabilir ve programın davranışını garip ve tutarsız hale getirebilir. Oysa yığıttaki verilerin her zaman için ilklenmeleri, böyle olası hataların hiç olmazsa tutarlı ve tekrarlanabilir olmalarını sağlar.
- İlklenmemiş veri doğru kullanıldığında bile hatalara yol açabilir. D'nin tasarım hedeflerinden birisi, programların güvenilirliklerini ve taşınabilirliklerini arttırmaktır. İlklenmemiş veriler; tanımsız, taşınamaz, hatalı, ve tutarsız davranışların kaynaklarıdırlar. Bu yüzden verileri ilklemeden kullanmak, ancak ve ancak hız kazancı için diğer yollar tükendiğinde ve gerçekten bir hız kazancı sağladığı kanıtlandığında denenmelidir.
Kesme Servisleri [Interrupt Service Routines] (ISR)
Çöp toplayıcı her çalıştığında yığıtlarını ve yazmaçlarını [register] taramak için bütün iş parçacıklarını [thread] durdurmak zorundadır. Eğer bir kesme servisi iş parçacığı durdurulursa, bütün program göçebilir.
Bu yüzden kesme servislerinin durdurulmamaları gereklidir. std.thread
modülündeki fonksiyonlar tarafından başlatılan iş parçacıkları durdurulurlar. Ama C'nin _beginthread()
ve benzeri fonksiyonları tarafından başlatılan iş parçacıkları durdurulmazlar, çünkü çöp toplayıcının bunların varlığından haberi yoktur.
Bunun başarıyla çalışabilmesi için:
- ISR iş parçacığı çöp toplamalı kaynaklar kullanmamalıdır. Yani global
new
kullanılamaz, dinamik dizilerin boyları değiştirilemez, ve çağrışımlı dizilere yeni elemanlar eklenemez. D kütüphanesinin ISR tarafından her kullanımı dikkatle incelenmeli ve çöp toplayıcı belleğinin kullanılmadığından emin olunmalıdır. Daha da iyisi, kesme servisi D çalışma zamanı kütüphanesindeki hiçbir fonksiyonu çağırmamalıdır. - ISR, çöp toplamalı bellekteki bir bölgenin tek referansı olamaz; çünkü bundan haberi olmayan çöp toplayıcı belleği geri verebilir. Bir çözüm, durdurulan bir iş parçacığında veya bir globalde de o bellek bölgesini gösteren bir referans tutmaktır.