Yazılım Mühendisliği’ni en çok meşgul eden konulardan birisi, direkt ya da dolaylı olarak, bellek yönetimi olmuştur. Çünkü yazılım mühendisliğinin en büyük odak noktalarından birisi, yazılan kodların mümkün olduğu kadar optimize ve verimli çalışması olmuştur. Bunu yapabilmenin en iyi yolu, yazılan kodların çalıştırılması aşamasında arka planda nelerin olduğunu iyi bir şekilde anlayabilmektir. Bu kapsamda, yazılan kodların bilgisayar belleğinde nasıl depolandığı ve manipüle edildiğini doğru bir şekilde anlayabilmek oldukça önemli.
C# gibi birçok modern programlama dili, değişken ve metotların bellekte nasıl depolanacağına dair belirli kurallara sahiptir. Yazdığımız kodlardaki metot ve değişkenlerin, karakteristiklerine göre bilgisayar belleğinde çeşitli depolanma yöntemleri vardır. Bu kurallara göre kullanılan iki türlü bellek alanı bulunur. Bu bellek alanlarının ne olduğuna bakalım:
1-) Stack Belleği: Sıralı Bellek Yönetimi
Yazdığımız kodlardaki metodlara dair bilgilerin depolandığı belleğe stack belleği denir. Stack frame adını verdiğimiz veri bloklarıyla, her bir metodun çalıştırılması için gereken bilgiler depolanır. Bu bloklar, adeta bir yığın objeyi üst üste dizer gibi depolandığı için de bu blokların depolandığı belleğe stack belleği adı verilmiştir. Stack framelerde depolanan veriler genel olarak şöyledir:
Parametreler: Eğer çağırılan metodun parametreleri varsa, bu parametreler stack frame içerisinde depolanır.
Lokal değişkenler: Eğer çağırılan metodun içerisinde lokal değişkenler yer alıyorsa, stack frame içerisinde bu değişkenler de depolanır.
Return adresi: Metodun çağrıldığı kod satırının bir sonraki satırına işaret eden pointer adresidir. Böylece çağırdığımız metot çalıştırıldığında, return adresinde gösterilen adres lokasyonuna gidilir ve o adresteki kod çalıştırılır. Böylece yazdığımız kodun çalıştırılmasında bir aksaklık yaşanmamış olur.
Return değeri: Eğer çağırdığımız metodun bir return değeri varsa, stack frame içerisinde bu değer için de bir data alanı açılır.
Exception içerikli metadata: Programınız çalışırken, eğer bir metod içerisinde bir exception kaynaklı hata ortaya çıkarsa, stack trace adını verdiğimiz bir nevi rapor oluşturularak hangi metodun hangi satırında hata verildiği gösterilir (.NET Runtime, otomatik olarak exception bloğu girmeseniz bile, exception ortaya çıktığı durumlarda stack trace oluşturur. Exception bloklarında da exception türüne göre spesifik işlemler yapabilirsiniz. Pek çok C# framework’ünde stack trace talimat girmeye gerek kalmadan oluşturulur.).
CPU Register Dataları: Eğer çağırdığımız metot, bazı CPU register datalarını düzenliyorsa veya bu dataların değerlerine ihtiyaç duyuyorsa, bu registerlardaki mevcut datalar geçici olarak stack framede depolanır.
Her bir metodun çalıştırılması bittiğinde, bu stack frameler de stack belleğinden silinir. Bu silinme işlemi LIFO (Last-In, First-Out) mantığı ile gerçekleştirilir. Yani en son eklenen stack frame, ilk olarak stack belleğinden silinir. Bunun yapılması da, stack pointer adı verilen bir CPU register’ı sayesinde gerçekleşir. Peki stack pointer tam olarak bunu nasıl sağlıyor?
Yazdığımız kodda bir metot çağırdığımızda, aslında CALL ismindeki CPU komutunu tetikliyoruz. Bu komut, az önce yukarıda bahsettiğimiz return adresini oluşturuyor ve stack frame'in ilk datasını oluşturur. Böylece stack pointer da bu frame’in hafızadaki adresini depolayarak stack hafızasındaki en güncel stack frame’ini işaretliyor. Metodun çalışması için gerekli olan alan için hafızada yer ayrılır. Metodun çalıştırılması bittiğinde LIFO yöntemi kullanılarak stack pointerın gösterdiği stack frame, yani en son oluşturulan stack frame silinir (Diğer adıyla POP işlemi uygulanır.).
NOT: Eskiden, frame pointer adı verilen bir CPU register’ı, stack belleğinde işaretleme yapmak için kullanılırdı. Frame pointer (genellikle EBP register’ı), bir fonksiyonun stack frame’inin başlangıç noktasını işaret ederdi. Bu sayede, parametrelere ve lokal değişkenlere sabit offsetlerle kolayca erişilirdi. Ancak bu yapı, CPU register'larının verimli kullanımını sınırlıyordu. Modern compilerlar, frame pointer kullanımına duyulan ihtiyacı büyük ölçüde kaldırdı. Bunun yerine, x86 mimarilerinde kullanılan ESP (Extended Stack Pointer) register’ı, hem stack pointer hem de frame pointer’ın görevlerini üstlenmeye başladı. Frame pointer’a artık ihtiyaç duyulmadığı için frame pointer register’ı, genel kullanım amacıyla serbest bırakılmış oldu. Bu da, özellikle x86 mimarisi gibi sınırlı sayıda register bulunan mimarilerde performansı artırmak için çok önemli bir avantaj sağlamakla birlikte modern CPU’ların daha verimli kullanılabilmesini de sağladı.
Stack Overflow
Pek çok yazılım mühendisi, stackoverflow.com üzerinde hatrı sayılır bir zaman geçirmiştir. Bu sitenin adının aslında oldukça önemli bir anlamı var. Stack Overflow, aslında bir exception türüdür. Stack hafızasının kapasitesinin dolmasına rağmen yeni stack framelerin stack hafızasına eklenmeye çalışılmasından dolayı StackOverflowException hatası verilir. Bir kod örneği ile bunu daha iyi anlayabiliriz:
Unity’de play mode’a girdiğimizde, Start metodunun içerisinden OverflowMethodOrnek metodu çağrılıyor olacak. Böylece bu metoda ait ilk stack frame, stack belleğinde oluşturulmuş olacak. Fakat metodun içerisinde herhangi bir sınırlama olmadan, sürekli metodun kendisi çağrıldığı için devamlı olarak stack frame oluşturuluyor olacak ve bu sonsuz kere devam edecek. Stack belleğinin alanı, dinamik olarak belirlenmediği için de en sonunda yer kalmıyor olacak ve StackOverflowException hatası verilecek. Böyle bir durumla karşılaşmamak için, özellikle recursive fonksiyonlar yazarken, dikkat edilmesi ve böyle fonksiyonların derinliliğini (yani çalıştırılma sayısını) belirleyen mantığın iyi test edilmesi gerekiyor.
2-) Heap Belleği: Dinamik Bellek Yönetimi
Stack belleğinin dinamik olmadığından bahsetmiştik. Bu nedenle stack belleği, daha kısa vadeli ve planlı işlemlerde kullanılır. Ancak bazı durumlarda, daha kompleks veri tipleri ile çalışmak zorunda kalabiliriz. Böyle durumlarda da bu kompleks veri tipleri ile yaptığımız işlemler daha kompleks işlemler olacaktır. Bu nedenle heap belleği adı verilen özel bir bellek alanı, bilgisayar belleğinden tahsis edilir.
Heap belleği, dinamik bir yapıya sahiptir. Kullanım durumuna ve bilgisayarın belleğine göre, kendini genişletir ve daha çok alan kullanabilir. Stack belleğindeki gibi belirli bir yöne doğru genişleyerek oluşturulmaz ve herhangi bir yöne doğru genişleyebilir. Bu nedenle heap belleğinde veri depolamanın maliyeti daha yüksektir. Çünkü buradaki dataya erişim için önce datanın adresinin bilinmesi gerekir. Adresi bulunduktan sonra da o adrese gitme maliyeti eklenir çünkü stack belleğinde olduğu gibi sıralı bir düzen yoktur.
Stack belleği otomatik olarak kendini ayarladığı için kendi içerisinde ekstradan boş yer oluşturma gibi ekstra bir işlemin yapılmasına gerek yoktur. Ama heap belleğinde böyle bir dinamik bulunmuyor. Dinamik yapısından ötürü heap belleğinde temizlik işleminin daha manuel bir süreçle yapılması gerekir. Bu sürece “Garbage Collection” denir. Bir collector aracılığıyla, heap belleği taranır ve referansı olmayan data tipleri heap belleğinden temizlenir. Fakat bu da maliyetli bir işlemdir çünkü belirli bir sıklıkla belleğin taranması ve temizliğin yapılması gerektiğinden ötürü programın çalışmasını yavaşlatma riski vardır. Bu nedenle heap belleğinin dikkatli kullanılması gerekir (Garbage Collection için ilerleyen günlerde daha detaylı bir yazı paylaşacağım.).
3-) Değişkenlerin Stack ve Heap Belleklerinde Depolanması
Bellek tiplerini öğrenmiş olduk. Bu bellek tiplerinin karakteristik özelliklerini de öğrendiğimize göre, kod yazarken tanımladığımız değişkenlerin bu belleklerde nasıl depolandığını da inceleyelim.
a-) Değer Türleri (Value Types)
Değer türleri, değişkenin sahip olduğu değerin ve türünün bellekte beraber depolandığı data türleridir. Değişkenin değerini değiştirmenin maliyeti düşüktür çünkü değişkene erişim sağlarken değerine de direkt olarak erişim sağlayabiliriz. Değer türlerine örnek olarak:
Tam sayı türleri: sbyte, byte, short, ushort, int, uint, long, ulong, nint, nuint.
Ondalık sayı türleri: float, decimal, double.
bool
char
enum
struct
türleri örnek gösterilebilir.
Değer türleri, stack veya heap belleklerinden birinde yer alabilir. Herhangi birinde olma zorunluluğu yoktur. Tanımlanmasına bağlı olarak değişir. Varsayılan olarak, stack belleğinde depolanırlar ama duruma göre heap bellekte de olabilirler (az sonra bunun istisnasından bahsediyor olacağım.).
Bu kod örneğinde, iki tane int türünde değişkenimiz var. Bu metod çağırıldığında, sahip olduğumuz iki int türündeki değişken de stack frame içerisinde depolanıyor olacak. Yani, stack belleğinde tutulacak. İlk satırda, 5 değerinde sayi isimli bir int değişkeni tanımladık. İkinci satırda ise, ikinciSayi isminde ikinci bir int değişkeni tanımladık ve bu değişkeni sayi değişkeni ile eşitledik. Bu durumda ikinciSayi değişkeni, sayi değişkeninin değerini alıyor olacaktır. Yani iki değer türünü eşitlerken, aslında değerlerini eşitliyoruz. İkisi de stack belleğinde farklı değişkenler olarak kalmaya devam ediyor olacaklar. Yani bir değişkende yaptığımız bir değişiklik, öbürünü etkilemiyor olacak.
Aynı şekilde, iki değer türünü karşılaştırırken değerlerine bakarak karşılaştırırız. Çünkü değer türleri, sahip oldukları değerler ile depolanırlar. Yine paylaştığım kod örneğinden gidersek ikinciSayi değişkeni ile sayi değişkeninin eşit olup olmadığını kontrol ettiğimizde, aynı değişken olup olmadıklarını değil aynı değere sahip olup olmadıklarına bakarız. Aynı değişken olup olmadıklarına bakmak için iki değişkenin bellekteki adresinin aynı yerde olup olmadığına bakmak gerekir. Bu da güvensiz kod adı verilen bir C# syntaxi ile yapılabilir.
b-) Referans Türleri (Reference Types)
Referans türündeki değişkenler, sahip oldukları değerlerin referansı ile depolanırlar. Yani referans türündeki bir değişkenin değeri, asıl değerinin depolandığı bellekteki adresin referansıdır. Bu nedenle referans türleri, kompleks veri tipleri kapsamına girer ve heap belleğinde depolanır. Referans türleri tanımlandığında, tanımlandıkları bellekte, sahip oldukları tip ile heap belleğinde yer alan bir bellek adresine referans ile tutulurlar. Değer türlerinin aksine, referans türlerinin değerleri heap belleğinde başka bir yere yerleştirilir. Referans türlerine örnek olarak:
class
string
object
dynamic
delegate
record
türleri örnek gösterilebilir. Bu türlerden türeyen tüm değişkenler, referans türünde değişkenlerdir.
Yukarıdaki kod örneğinde, KargoBilgisi adında bir class tanımladık. KargoOlustur metodu içerisinde de KargoBilgisi türünde olan kargoBilgisi adında bir obje oluşturduk. Daha sonra da yeniKargoBilgisi adında aynı türden yeni bir obje oluşturduk. kargoBilgisi objesinde yer alan kargoNumarasi ve kargoAdresi değişkenlerine değer ataması yaptık ve yeniKargoBilgisi ile kargoBilgisi değişkenini birbirlerine eşitledik. Böylece iki değişkenin adreslerini eşitlemiş olduk. Yani birinde yapacağımız değişiklik, artık öbürüne de yansıyor olacak çünkü iki objenin bellekteki adresi de eşitlenmiş oldu. Bunu, yeniKargoBilgisi değişkeninin kargoNumarasi değişkenini değiştirerek kanıtlamış olduk. kargoBilgisi değişkeninden aldığımız kargoNumarasi değişkenini logladığımızda, yeniKargoBilgisi değişkeninin kargoNumarasi değişkeni ile aynı sayıyı bize veriyor.
Eğer yeniKargoBilgisi ve kargoBilgisi değişkenlerini karşılaştırıyor olsaydık, yine aynı şekilde referanslarına bakarak karşılaştırma yapıyor olacaktık. Yine bir kod örneği ile ilerleyelim:
Bu örnekte, KargoBilgisi class’ından oluşturduğumuz nesnelerin kargoNumarasi ve kargoAdresi değişkenleri önceden tanımlı olarak oluşuyor olacak. Bu class türünde ardı ardına iki nesne oluşturup ikisinin eşit olup olmadığını kontrol ettiğimizde sonuç “false” olacak. Çünkü bu nesneler, referans türünde olduklarından dolayı eşitlik kontrolü de referanslarına bakılarak yapılır. İkisi de heap belleğinde farklı yerlerde tanımlanmış değişkenler olmalarından ötürü referansları da farklı adresleri işaret edecektir. Bundan dolayı eşitlik kontrolünde false sonucu çıkar. Nesnelerin değişkenlerinin birbiriyle eşit olması sonucu etkilemez. Ama bir sonraki satırda kargoBilgisi değişkeninin adresi, yeniKargoBilgisi değişkeninin adresi ile eşitlenmiştir. Bundan dolayı da ikinci kez eşitlik kontrolü yapıldığında, bu sefer sonuç “true” çıkacaktır.
NOT: KargoBilgisi class’ında yer alan int türündeki kargoNumarasi değişkeni, class’ın parçası olmasından dolayı, heap belleğinde yer alacaktır. Eğer KargoBilgisi bir class değil de bir struct olsaydı, o zaman stack belleğinde yer alıyor olacaktı.
4-) Sonuç
Genel olarak, C#’da heap ve stack belleklerinde veri depolama mantığının nasıl çalıştığını bu yazıda özetlemeye çalıştım. Daha verimli ve optimize projeler geliştirebilmek için arka planda işlerin nasıl yürüdüğünü anlamak büyük önem taşıyor. Bellek yönetimi, sadece bir teori değil, aynı zamanda performans sorunlarını çözmede ve sürdürülebilir yazılım geliştirmede güçlü bir araçtır.
Bu yazıyı hazırlamamdaki amaç, konuyu sebep-sonuç ilişkisiyle ve ezberden uzak bir şekilde açıklayarak sizlere daha iyi bir kavrayış sunmaktı. Umuyorum ki bu yazı, bellek yönetimiyle ilgili temel bilgilerinizi tazelemenize veya yeni şeyler öğrenmenize katkı sağlamıştır.
Eğer bu konu hakkında görüşlerinizi paylaşmak ya da sorularınızı iletmek isterseniz, memnuniyetle yardımcı olmak isterim.
Herkese iyi çalışmalar dilerim!