Unity ve C# Özelinde Bellek Yönetimi: Garbage Collection

Unity ve C# Özelinde Bellek Yönetimi: Garbage Collection

·

12 min read

Daha önce yazdığım bellek yönetimi ile ilgili yazımda, heap belleğinden bahsetmiştim. O yazımda, garbage collection’ı da çok kısa bir şekilde özetlemiş ve başka bir yazımda garbage collection ile alakalı daha çok bilgi paylaşacağımı söylemiştim. Bu yazımda, garbage collection üzerine konuşuyor olacağız. Garbage collection’ın ne olduğunu, risklerini ve kod yazarken garbage collection’ı efektif bir şekilde nasıl kullanabileceğimizi örnekler üzerinden açıklıyor olacağım.

Öncelikle, bu yazıya devam ederken stack ve heap bellekleri hakkında bilgi sahibi olduğunuzu varsayarak devam ediyor olacağım. Biraz da değer ve referans tipindeki değişkenlerden bahsediyor olacağım ve bu konuda da bilgi sahibi olduğunuzu varsayıyor olacağım. Eğer bu konular hakkında bilgi sahibi değilseniz, https://canerozdemir.hashnode.dev/c-ozelinde-bellek-yonetimi-stack-ve-heap-bellekleri linkinde paylaştığım yazımı okuyabilir ve sonrasında bu yazıya dönerek bu yazıyı okumaya devam edebilirsiniz.

Garbage Collection Nedir?

Bir bilgisayarda yazdığınız kod, bilgisayarın donanım parçalarında bir düzene göre işlenir. Bu düzende, bilgisayar belleği de çok kritik bir öneme sahiptir. Bu nedenle belleğin verimli kullanılması gerekir. Mümkün olduğu kadar az bellek kaynağı kullanarak kod yazmak, yazılım mühendisliğinin en büyük mücadelelerinden birisi olmuştur. Çünkü bellek kaynaklarını fazlaca kullanmak, uzun vadede çok büyük sorunlara yol açabileceği gibi projelerde performans sorunlarına da kaçınılmaz bir şekilde yol açacaktır.

İlk ortaya çıkan programlama dillerinde bellek kontrolünde insiyatif, daha çok kodu yazan kişideydi. C ve C++ gibi daha erken tarihlerde ortaya çıkan programlama dillerinde, bilgisayar belleğinden alan tahsis edilmek istendiğinde bunu kodu yazan kişi yapıyordu. C++ üzerinde, bellekte alan tahsis etmek için new operatörü kullanılıyor ve delete operatörü ile de kullanılan alan serbest bırakılıyor. C dilinde, bellek tahsis etmek için malloc, realloc ve calloc gibi operatörler kullanılıyor. free operatörü ile de tahsis edilen alan serbest bırakılıyor.

C# özelinde ise, bellekte alan yaratma ve temizleme işlemleri CLR (Common Language Runtime) adını verdiğimiz runtime çözümü ile gerçekleştirilir. .NET platformunda yazılan kodlar, Microsoft Intermediate Language (MIL) adını verdiğimiz bir alt aşama kod diline tercüme edilir. Bu tercüme işlemi, C# Compiler desteğini bulunduran tüm compilerlar aracılığıyla gerçekleştirilir. Bu yapıldıktan sonra, CLR tarafından bu alt aşama kod çalıştırılır ve çalıştırılırken de kodumuzda yer alan tüm bellek tahsisi yapılacak yerler için bellek tahsisi yapma sorumluluğunu da CLR üstlenir. Program çalıştırılırken, CLR’in bir diğer görevi de programda referansı kaybolan objeleri tespit etmektir. Referansı kaybolduysa, program tarafından kullanılmaya ihtiyaç kalmadığı şeklinde bir varsayım yapılır ve bu referanssız objeler bellekten otomatik olarak silinir. CLR’de bu yapmakla sorumlu olan özelliğe Garbage Collector (GC) adı verilir. Bu süreç, C#’da otomatiktir ve opsiyonel olarak kodu yazan kişinin müdahalesi ile de manipüle edilebilir. Sürecin kendisine de Garbage Collection adı verilir.

Garbage Collection Nasıl Gerçekleştirilir?

Stack belleğinde, kodumuzda bulunan metotların stack frame adındaki data blokları şeklinde tutulup metodun çalıştırılması bitince de otomatik olarak bu stack framelerin bellekte serbest bırakıldığından bahsetmiştik. Eğer metotta referans tipinde değişkenler varsa, stack frame bellekten silinirken sadece referans tipindeki değişkenin heap belleğindeki asıl yerine giden referans silinir. Referans tipindeki değişkenler, asıl sahip oldukları değere giden adres bilgisi ile depolanırlar. Bundan dolayı da, metodun çalıştırılması bitmesine rağmen o metot içerisinde yer alan referans tipi değişken bellekte yaşamaya devam eder. Bu da, belleğin gereksiz yere dolu kalması anlamına gelir. Garbage Collector, bu aşamada devreye girer ve bellekte bu gereksiz yere doluluk yapan değişkenleri siler. Böylece bellekte yer açılmış olur.

NOT: Garbage Collection işlemi, sadece heap belleğinde yapılır. Stack belleğinin böyle bir işleme ihtiyacı yoktur çünkü stack belleği, basit bir düzende çalışır ve kendi kendini organize edebilecek basitliktedir. Stack belleğinde tutulan stack frameler, işi bittiklerinde otomatik olarak bellekten silinir. Heap belleğinde Garbage Collection işlemine ihtiyaç duyulmasının sebebi, heap belleğinin dinamik yapıda olması ve daha kompleks bir altyapıda çalışmasından ötürü bellek temizliğinin otomatikleştirilememesindendir.

Garbage Collector, bu işlemi 3 aşamada gerçekleştirir:

  • Mark Stage (İşaretleme Aşaması): Bu aşamada, heap belleğinde referansı kaybolmamış bütün objeler işaretlenir ve tabiri caizse hayatta kalan objeler tespit edilir.

  • Sweep Stage (Süpürme Aşaması): İşaretleme aşamasında işaretlenmemiş tüm objeler, referansı kaybolmuş ve hayatta olmayan objeler olarak konumlandırılır. Bu objeler, bellekten silinir ve bellekte yer açılır.

  • Compact Stage (Sıkıştırma Aşaması): Bellekten silinen objeler, heap belleğinde boşluklar yaratır. Bellekteki objeler, birbiriyle sıkıştırılır ve bu boşluklar doldurulur.

Fakat daha ilk aşamada, Garbage Collection’ın maliyeti ortaya çıkmaya başlar. Çünkü, işaretleme aşamasına girildiğinde bütün bellek taranır. Bu, oldukça maliyetli bir işlemdir ve özellikle bu taramanın bir şekilde daraltılmadan tekrar tekrar yapılması da maliyeti büyütür ve program çalıştırıldığında programda donmalar ve çeşitli performans sıkıntıları gözlemlenmeye başlanır. Bu sıkıntıları önlemek ve GC’nin daha verimli çalışmasını sağlamak amacıyla Garbage Collection süreci, jenerasyonel olarak gerçekleştirilir. Toplamda 3 jenerasyon vardır ve objeler 3 jenerasyona bölünerek bellekteki yaşam sürelerine göre Garbage Collection süreci de parçalara bölünür. Teoride, Garbage Collection maliyeti önemli bir ölçüde düşer ve bazı kritik performans maliyetleri engellenmiş olur. Jenerasyonlar, bellekteki fiziksel bölgeleri temsil etmez. Mantıken oluşturulan aşamalar olarak düşünebilirsiniz. GC, bunu kendi içerisinde mantıksal olarak gerçekleştirir. Bu jenerasyonlarda sürecin nasıl işlediğini şu şekilde özetleyebiliriz:

  • 0.Jenerasyon: Bellekte ilk defa tanımlanan tüm objeler, bu jenerasyona dahil edilir. Başlangıç jenerasyonu olarak da tanımlanabilir. Bu jenerasyon üzerinde bir Garbage Collection döngüsü gerçekleştirilir (yani yukarıda bahsettiğimiz 3 aşama uygulanır.).

  • 1.Jenerasyon: 0.Jenerasyon sonucu bellekte kalmaya devam eden objeler, bu jenerasyona taşınır. Hayatta kalan objelere yapılan değer atamaları, yine 0.jenerasyona atanır ama objenin kendisi 1.jenerasyonda kalmaya devam eder. Bu jenerasyonda da GC döngüsü uygulanır fakat uygulanma sıklığı, 0.jenerasyona göre oldukça azdır.

  • 2.Jenerasyon: Son jenerasyondur ve bu jenerasyondaki objelere yapılan değer atamaları, bir önceki jenerasyonlara tanımlanarak başlar. Bu jenerasyona yetişen tüm objeler, uzun vadeli objeler olarak tanımlanır ve GC tarafından bu objelerin uzun süre programda referans sahibi olacağı varsayılır. Bu jenerasyonda da GC döngüsü uygulanır fakat uygulanma sıklığı, 0. ve 1.jenerasyonlara göre oldukça azdır.

Jenerasyon mantığı ile yapılan Garbage Collection işlemi her ne kadar verimli olsa da, maalesef yine bir performans maliyeti ile karşımıza gelir. Buradaki problem, oldukça büyük objeler tanımlandığı takdirde oluşacak maliyettir. Sıkıştırma operasyonu, işaretleme operasyonu gibi, potansiyel olarak oldukça maliyetli olabilecek bir işlemdir ve büyük objelerin bellekte sıkıştırılması da yine bunu körükleyebilir. Fakat bunun da önüne geçmek adına oldukça pratik bir çözüm üretilmiştir. Bu çözüm, heap belleğinin ikiye bölünmesi ile alakalıdır. Small Object Heap(SOH) ve Large Object Heap(LOH) şeklinde bellek ikiye ayrılmış ve büyük objelere bu maliyetin uygulanmasının önüne geçilmiştir.

Eğer bir objenin boyutu 85 bin byte’dan fazla ise, bu obje LOH belleğine taşınır. LOH belleğindeki objeler, sadece 1 kere Garbage Collection döngüsüne girer. O da, 2.jenerasyonda gerçekleşir. SOH içerisindeki objeler, düzenli olarak jenerasyonel bir şekilde bellekte işlenirken LOH içerisindeki objeler bu işlemlere uğramadan yaşamaya devam eder. SOH objeleri 2.jenerasyona ulaştığında, bu jenerasyonda GC uygulanırken LOH içerisinde de GC uygulanır. Böylece büyük objeler için sadece bir kere performans maliyeti oluşturulur. Sıkıştırma işleminden muaf tutulan büyük objeler, GC işleminin daha rahat ve etkili yapılmasını engellememiş olur.

NOT: Unity’nin kendi içerisindeki Garbage Collection, jenerasyonel değildir. Yani bütün objeler tek seferde Garbage Collection’a dahil edilir. Incremental GC, sadece garbage collection sürecini parçalara böler ama jenerasyonel GC ile aynı mantıkta çalışmaz. Sadece bütün heap belleğinde tek seferde temizlenecek tüm objeler belirlendikten sonra parçalar halinde süreç gerçekleşir ve SOH-LOH belleği ayrımı da Incremental versiyonda uygulanmaz.

Garbage Collection Maliyetini Düşürmek İçin Alabileceğimiz Önlemler

Garbage collection’ın ne olduğunu konuştuk, nasıl çalıştığından ve maliyet problemlerinden de bahsettik. Peki pratikte bu önlemleri alarak nasıl kod yazabiliriz?

1-) Mümkünse Tanımlanan Tüm IEnumerable Tipindeki Değişkenleri Kapasitesi Belli Bir Şekilde Tanımlamak

Her IEnumerable tipindeki değişken için olmasa da, bu tipteki pek çok değişken dinamiktir ve boyutu büyüdükçe alan tahsis edilir. Fakat bunun gizli bir maliyeti vardır, bu değişkenlerin boyutu büyüdüğünde heap belleğinde dinamik olarak alan açma ihtiyacı duyulur ve kapasite tanımlanmadığı için de bellek tarafından bu değişkene tahsis edilmesi gereken alan bilinmez. Bundan dolayı da sürekli olarak bu değişkene tanımlanan alan genişletilir. Dinamik array tipindeki değişkenlerin de en büyük sorunlarından birisi, bellekte ekstra alan açma maliyetine sebep olmalarıdır. Kod üzerinden örnek vererek bunu daha iyi bir şekilde açıklayabiliriz:

Yukarıdaki kod örneğimizde, bir listemiz var. Bu listeye bir kapasite tanımlamadık ve bir for döngüsü içerisinde 1000 defa bu listeye bir eleman ekliyoruz. Bu kodun çalıştırıldığı senaryoda şöyle bir durum ortaya çıkacak:

  • İlk başta bu listeye bir kapasite tanımlanmayacak.

  • İlk Add işleminden sonra, kapasitesi 4’e çıkarılacak.

  • Her kapasite dolduğunda, kapasitesi otomatik olarak 2 ile çarpılacak ve heap belleğinde otomatik olarak büyüme miktarı kadar alan tahsis edilecek.

  • for döngüsü çalıştırılırken, birden fazla kez heap belleğinde bu liste için yer açılması gerekiyor olacak ve bir performans maliyeti ortaya çıkacak.

Her ne kadar çok kötü bir durum ortaya çıkarmasa da, eğer büyük bir liste ile çalışacağımız kesin ise, bu listeye aşağı yukarı eklenmesini beklediğimiz eleman sayısını ön görebildiğimiz durumlarda kapasiteli bir şekilde bunu tanımlamak çok doğru bir karardır. Birden fazla kez gereksiz yere bellekte yer açma işleminin uygulanması ile birlikte, GC maliyeti de buna eklendiğinde, potansiyel olarak bir performans sorununun ortaya çıkması muhtemel oluyor.

Bu örnekte ise listeyi, bir kapasite tanımlayarak, tanımladık. Yani listenin 1000 tane elemana sahip olacağını öngördüğümüz için listeye önceden 1000 elemanlık olacak şekilde alan tahsis ettik. Böylece liste için sadece 1 kere alan açma maliyeti oluştu. Bu listenin boyutunun, türüne ve boyutuna göre, 85 bin kilobaytı aştığı durumda ise bunu önceden tanımladığımız için bu liste direkt olarak LOH içerisinde depolanıyor olacak ve bu liste için gereksiz yere GC taraması yapılmamış olacak. Böylece ciddi bir performans kazancı sağlamış olacağız.

NOT: List üzerinden verilen örneği, diğer dinamik array tipleri için de geçerli kılabiliriz. Dictionary, Stack, Queue, Hashset gibi dinamik olarak tanımlanan array tiplerinde de bu örnekteki önlemi uygulayabiliriz ve aynı şekilde ciddi bir performans kaybının önüne geçmiş oluruz. Dinamik olmayan array tipleri, zaten kapasite ile tanımlanmak zorunda olduklarından dolayı bu çözüm zaten otomatik olarak uygulanmış olur.

2-) StringBuilder Kullanmak

Stringler, aslında bir dizidir. Bu dizi de char tipindedir. Bu nedenle string değişkenleri, heap belleğinde depolanırlar. Fakat stringler değişmez(immutable) değişkenlerdir. Yani, string değişkenlerini tanımladıktan sonra değiştirmek mümkün değildir. Peki, bir string değişkenini runtime’da değiştirdiğimizde nasıl yeni değer atayabiliyoruz?

Yukarıdaki kod örneğinde, kelime isimli bir string değişkenimiz var. Tanımladığımız for döngüsü içerisinde, her indexte, mevcut indexi stringe çeviriyor ve kelime stringine ekleme yapıyoruz. Fakat her ekleme yaptığımızda, string değişkenleri runtime’da değişmez olduğundan, bellekte yeni bir string değişkeni oluşturulup kelime değişkeninin referansı bu yeni string değişkenine işaretleniyor. Bunu 100 defa yaptığımızı düşünürsek, potansiyel olarak ciddi bir performans maliyeti ortaya çıkıyor. Peki bunu nasıl engelleyebiliriz?

Bu örnekte, StringBuilder adını verdiğimiz bir obje tanımladık. StringBuilder, dinamik bir objedir. Aynı zamanda, tıpkı yukarıda bahsettiğimiz dinamik array tiplerinde olduğu gibi, kapasitesi önden tanımlanabilen bir objedir. Dinamik yapısından dolayı, sürekli bir string objesi oluşturmamıza gerek kalmadan, string objelere ekleme yapabilmemizi sağlar. Kapasite ile önceden tanımladığımız takdirde, bellekte yeni alan açma maliyetini de oluşturmadan, bir string objesini gayet optimize bir yöntemle manipüle edebilmemizi sağlar. Append metodu ile StringBuilder objesine tanımlı string’e ekleme yapabiliriz. Eğer herhangi bir sebepten ötürü string objesini sıfırlamamız gerekiyorsa, StringBuilder objesinde yer alan Clear metodunu çağırıp yine aynı şekilde string objesini temizleyebiliriz. Bu da, yeniden kullanıma imkan sağlar.

StringBuilder hakkında detaylı bilgiye Microsoft’un bu dökümanından ulaşabilirsiniz: https://learn.microsoft.com/en-us/dotnet/standard/base-types/stringbuilder

3-) Boxing ve Unboxing İşlemlerinden Kaçınma

Referans tipleri ile değer tiplerini, birbirine dönüştürebilmemiz mümkündür. Fakat, bu dönüşümlerin heap belleğinde yer açma maliyeti vardır. Değer tipini referans tipine dönüştürüyorsak boxing, tam tersini yapıyorsak da unboxing işlemi yapmış oluruz.

Yukarıdaki kod örneğinde, ObjeyiYazdir isminde bir metodu çağırıyoruz. Bu metot, object türünden bir parametre alıyor ve bu parametreyi loglayarak Unity’de konsola yazdırıyor. ObjeyiYazdir metodunu çağırdığımızda, parametre olarak 42 sayısını gönderiyoruz. Burada, bir boxing yapmış oluyoruz. Çünkü 42 sayısı bir int değişkenidir ve değer tipindedir. Fakat metodun parametresi referans türünde bir parametredir. Bundan dolayı, referans türünde gönderilmesi gerekir ve parametrenin türüne dönüştürülür. Yani, bir değer tipi değişkeni referans tipi değişkene çevirmemiz gerekir. Bu da, heap belleğinde ekstra bir alan açılması demektir.

Yukarıdaki örnekte ise, ObjeyiYazdir metodunu generic bir parametre ile tanımladık. Böylece, metoda gidecek olan parametre özel bir generic tipinde parametre olarak tutuluyor olacak. Herhangi bir generic tanımlama yaptığımızda, generic olarak tanımlanan objenin türü runtime’da değil compile time’da belli olur. Generic olarak tanımlanan tüm objelerin tipleri, çağrıldıkları kısımlar compile edilmeden belirlenmez ve geçici bir generic parametre olarak tutulurlar. Proje henüz compile edilirken ve çalıştırılma aşamasına geçmeden, compile time’da bu metodun kullanıldığı yerler ve parametre olarak bu metoda hangi türde değerlerin gönderildiği analiz edilir. Buna göre de, değişken türüne göre, yer alması gereken belleklerde bu parametre için alan tahsis edilir. Yani bu metoda hem referans hem de değer tipinde parametre gönderiyor olsak bile, proje compile edildiğinde önceden her ihtimale karşı alan tahsisi yapılacağı için tekrardan boxing veya unboxing yapmaya ihtiyaç olmaz. Böylece heap belleğinde gereksiz yere alan açma işlemi yapmamış oluruz.

4-) Objeleri Dönüşümlü Kullanmak

Eğer belirli bir sayıda benzer tipte objeyi projemizde kullanmamız gerekiyorsa, Object Pooling adını verdiğimiz bir yöntem ile bu objeleri dönüşümlü olarak kullanabiliriz. O objenin türevlerini devamlı olarak kodda yeniden oluşturmak yerine, bu objenin benzerlerini bir listede tutarak lazım olduğunda bu listeden o objenin bir örneğini çekmek daha performanslı bir çözüm olacaktır. Böylece sürekli yeni obje yaratarak bellekte yeni alan açmamıza gerek olmadan, önceden bellekte ayrılmış bir alanda yer alan objeleri kullanıp bellek alanından tasarruf etmiş oluruz.

Yukarıdaki örnekte, basit bir Object Pool tanımlaması yaptık. Pool tanımlasını yaparken de poolun türünün generic olmasını sağladık. Yani, herhangi bir objenin poolunu tanımlayabiliriz. Bir stack listesinin içerisinde, poola eklemek istediğimiz obje türlerinin daha önce yaratılmış örneklerini tutuyor olacağız. Get metodunu kullanarak, bu pooldan obje çekmeye çalışacağız. Eğer poolda obje yoksa, bu metot bize sıfırdan yeni obje yaratıp o objeyi bize veriyor olacak ve bellekte bu obje için alan tahsis etmiş olacak. Çektiğimiz obje ile işimiz bittiğinde bu pool classındaki Return metodunu çağırıp objenin kendisini parametre olarak bu metoda ekleyeceğiz ve objeyi stack listesine atıyor olacağız. Bundan sonra pooldan bir obje çekmeye çalıştığımızda, bu sefer pooldaki obje listesi dolu olduğu için bize listeden bir obje veriyor olacak. Zaten bu obje için alan tahsis edildiğinden ötürü, yeniden obje yaratarak bellekte yeni yer açmamıza gerek kalmıyor olacak. Bellek alanını idareli kullanmış olacağız.

5-) Uygun Olduğu Durumlarda Struct Kullanmak

Struct, değer tipinde bir değişkendir. Yani, stack belleğinde depolanır. Stack belleğinin kendini ayarlama süreci oldukça basittir. Class ile çok benzer bir yapıdadır fakat stack belleğinde depolanması gibi ciddi bir avantaja sahiptir. Structlar, stack belleğinde olmalarından ötürü, Garbage Collection maliyetine dahil olmazlar. Bu nedenle, eğer çoğunlukla basit tipte değişkenleri bir class içerisinde toplama ihtiyacınız varsa, class yerine struct kullanmanın performans açısından size önemli bir kazancı olur. Struct tanımlamak, class tanımlama ile neredeyse birebir aynıdır.

Yukarıdaki örnekte, bir struct tanımladık. Bu structın oluşturulacak her kopyası, stack belleğinde yer alacaktır. İçerisinde x,y ve z olmak üzere 3 tane float değeri vardır. Kompeks verileri içerde tutmamız gerekmediği için bunu bir class olarak değil de struct olarak tanımladık. Böylece basit verilerle yapacağımız işlemler için heap belleğini meşgul etmemiş olduk.

NOT: Bu örneği vermemin sebebi, Unity’deki Vector3 ve türevi olan vektörel değişkenlerin de struct olduğunu ima etmekti. Kaynak kodunu incelediğinizde, Vector3 ve türevlerinin aslında bir struct olarak tanımlandığını göreceksiniz. Bunun sebebi, Vector3’ün sadece sayısal veri tutması ve kompleks veri tiplerini bünyesinde barındırmamasıdır.

Sonuç

Garbage Collection, C# ve .NET platformlarında bellek yönetimini kolaylaştıran ve kod yazan kişinin yazdığı koda ek olarak bellek temizleme ile de uğraşmak zorunda kalmamasını sağlayan güçlü bir araçtır. Fakat GC’nin otomatik yapılan bir süreç olması, onun her şeyi harika yaptığı anlamına gelmez. Oldukça verimli ve çoğu durumda yazılım mühendislerini rahatlatan bir yapısı olsa da, bizim de GC’ye destek olmak için önlemler almamız gerektiği gerçeği değişmiyor. Bu yazıda ele aldığımız gibi, GC'nin nasıl çalıştığını anlamak ve buna uygun kod yazmak, projelerinizde hem bellek kullanımını optimize etmek hem de performans maliyetlerini azaltmak için kritik öneme sahiptir. Yukarıda paylaştığım bilgiler ve önlemler, daha optimize ve verimli kod yazmanıza yardımcı olacaktır.

Daha fazla bilgi edinmek için Microsoft’un kendi kaynaklarından Garbage Collection’ın nasıl çalıştığına dair detaylı bilgi alabilirsiniz: https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals

Herkese iyi çalışmalar diliyorum!