.NET Performans & Bellek Yönetimi 3 dk okuma 20 okunma

.NET'te Kuyruk ve Yığın (Stack vs. Heap) Performansı

.NET'te değer tipleri genellikle stack'te, referans tipleri heap'te yaşar; ancak bu kural mutlak değildir (closure'lar, async state machine'leri, sınıf alanları değer tiplerini heap'e taşır). Stack erişimi çok hızlıdır ve GC yükü yaratmaz; heap tahsisi ise GC baskısı, parçalanma ve önbellek isabetsizliği demektir. Performans farkı, "stack vs heap" dogmasından değil, tahsis sayısını ve nesne ömrünü azaltmaktan gelir.

.NET'te Kuyruk ve Yığın (Stack vs. Heap) Performansı

.NET'te Kuyruk ve Yığın (Stack vs. Heap) Performansı

.NET geliştiricilerinin ilk öğrendiği kural genellikle şudur: "Değer tipleri stack'te, referans tipleri heap'te tutulur." Bu kural, başlangıç için faydalı bir basitleştirmedir; ancak gerçek performans davranışını anlamak için yetersizdir. Stack ve heap arasındaki fark, yalnızca "hızlı vs. yavaş" değil; bellek düzeni, GC baskısı ve önbellek dostu olma açısından çok boyutludur.


1. Stack ve Heap: Temel Farklar

Özellik Stack Heap
Yönetim Otomatik (LIFO); metot çıkışında temizlenir Garbage Collector (GC) tarafından yönetilir
Tahsis Hızı Çok hızlı (pointer kaydırma) Daha yavaş (uygun blok bulma + GC baskısı)
Erişim Hızı Çok hızlı (CPU önbelleğine yakın) Daha yavaş (önbellek isabetsizliği olabilir)
Boyut Sınırlı (genellikle 1MB) Çok büyük (RAM sınırına kadar)
Ömür Metot kapsamıyla sınırlı GC tarafından belirlenir
GC Baskısı Yok Var (Gen0/Gen1/Gen2 koleksiyonları)

Kritik Nokta: Stack tahsisi "bedava" gibidir; bir pointer'ı ilerletmek yeterlidir. Heap tahsisi ise GC'nin işini artırır; her tahsis, gelecekte bir koleksiyon anlamına gelir.


2. "Değer Tipleri Stack'te" Kuralı Neden Yanıltıcı?

Değer tiplerinin her zaman stack'te olduğu düşüncesi yaygın bir yanılgıdır. Gerçekte, bir değer tipinin nerede yaşayacağı bağlama bağlıdır:

  • Metot içinde yerel değişken: Stack'te (genellikle).

  • Bir sınıfın alanı: Heap'te (sınıfın parçası olarak).

  • Bir closure (lambda) tarafından yakalanmışsa: Heap'te (closure nesnesinin parçası).

  • Async metot içinde await sonrası kullanılıyorsa: Heap'te (state machine nesnesinin parçası).

  • Bir dizi içinde: Heap'te (dizinin parçası).

  • Boxing yapılmışsa: Heap'te.

Sonuç: "Değer tipi = stack" denklemi yanlıştır. Performans açısından önemli olan, tipin adı değil, kaç tahsis yapıldığı ve nesnenin ne kadar süre yaşadığıdır.


3. Heap Tahsisinin Gerçek Maliyeti

Heap tahsisi, yalnızca "biraz daha yavaş" değildir; zincirleme etkileri vardır:

  • GC Baskısı: Her tahsis, Gen0 koleksiyonlarını tetikler. Gen0 koleksiyonları hızlıdır, ancak sık çalıştığında CPU tüketir.

  • Nesne Ömrü: Uzun yaşayan nesneler Gen1/Gen2'ye terfi eder; bu koleksiyonlar çok daha pahalıdır.

  • Bellek Parçalanması: Özellikle LOH (Large Object Heap) üzerindeki büyük nesneler parçalanmaya yol açar.

  • Önbellek İsabetsizliği: Heap'te dağınık nesneler, CPU önbelleğine sığmaz; erişim yavaşlar.

  • LOH Tahsisi: 85KB üzeri nesneler LOH'a gider ve varsayılan olarak sıkıştırılmaz; parçalanma riski artar.


4. Performans İyileştirme Stratejileri

A. Tahsis Sayısını Azaltın
En etkili strateji, gereksiz tahsislerden kaçınmaktır. Örneğin, döngü içinde string birleştirme yerine StringBuilder, küçük geçici buffer'lar için stackalloc veya ArrayPool<T> kullanın.

B. struct Kullanımını Bilinçli Yapın
Küçük, kısa ömürlü ve kopyalama maliyeti düşük veriler için struct (değer tipi) kullanmak, heap tahsisini ortadan kaldırır. Ancak struct'lar kopyalanır; büyük struct'lar (ör. 100+ byte) metot parametresi olarak geçirildiğinde kopyalama maliyeti artar. readonly struct ve in parametresi bu maliyeti azaltır.

C. Span<T> ve Memory<T> Kullanın
Dizilerin dilimlerine kopyalama yapmadan erişmek, hem tahsisi hem de GC baskısını azaltır. Span<T>, stack tabanlı işlemlerde sıfır tahsis sağlar.

D. ArrayPool<T> ve Object Pooling
Büyük ve sık kullanılan buffer'ları yeniden kullanmak, LOH baskısını ve GC koleksiyonlarını azaltır.

E. Kısa Ömürlü Nesneleri Gen0'da Tutun
GC, nesneleri generasyonlara ayırır. Kısa ömürlü nesneler Gen0'da toplanır ve hızlıdır. Uzun yaşayan nesneler Gen2'ye terfi eder ve toplanması pahalıdır. Bu nedenle, nesnelerin gereksiz yere uzun yaşamasını önleyin (ör. statik koleksiyonlarda biriktirmeyin).


5. Stack'in Sınırları ve Riskleri

Stack her ne kadar hızlı olsa da, sınırsız değildir:

  • Stack Overflow: Derin özyineleme (recursion) veya çok büyük stackalloc tahsisleri stack taşmasına yol açar ve uygulama çöker.

  • stackalloc Boyutu: Büyük buffer'lar için stackalloc kullanmak tehlikelidir; tipik olarak 1KB altı için uygundur.


6. En İyi Pratikler

Pratik Açıklama
Tahsis sayısını ölçün dotnet-counters veya BenchmarkDotNet ile GC ve allocation metriklerini izleyin.
Sıcak yolda tahsislerden kaçının Yüksek frekanslı kod yollarında heap tahsisi minimize edin.
struct'ı bilinçli kullanın Küçük veriler için; büyük struct'larda kopyalama maliyetine dikkat edin.
Pooling ve Span<T> kullanın Büyük buffer'larda ArrayPool, dilimlerde Span<T>.
Dogmayla değil, ölçümle karar verin "Stack her zaman hızlıdır" varsayımıyla değil, profiler verisiyle hareket edin.

Sonuç

Stack ve heap arasındaki performans farkı gerçektir, ancak "değer tipi stack'te, referans tipi heap'te" basitleştirmesi yanıltıcıdır. Bir değer tipi, closure, async state machine veya sınıf alanı içinde heap'e taşınabilir. Performans açısından belirleyici olan, tipin adı değil tahsis sayısı, nesne ömrü ve bellek düzenidir. Doğru strateji, gereksiz tahsislerden kaçınmak, pooling ve Span<T> gibi araçları kullanmak ve her kararı ölçümle doğrulamaktır. Unutmayın: En hızlı bellek, hiç tahsis edilmeyen bellektir; en iyi optimizasyon, ölçülmüş ve kanıtlanmış olandır.

Tüm yazılar Soru ya da yorumun mu var? Yaz