.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
awaitsonrası 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
stackalloctahsisleri stack taşmasına yol açar ve uygulama çöker. -
stackallocBoyutu: Büyük buffer'lar içinstackallockullanmak 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.