Software · Aug 7, 2023 · 10 dk okuma
.NET Garbage Collector ve Memory Management: Uygulama Bellekte Nasıl Yaşar?
.NET uygulamalarında Stack, Heap, Garbage Collector, Generations ve Large Object Heap nasıl çalışır? Allocation, GC pressure, IDisposable ve memory leak arasındaki ilişkiyi gerçek bir dosya işleme senaryosuyla öğrenin.

.NET uygulamalarında memory management, yalnızca kullanılmayan nesnelerin Garbage Collector (GC) tarafından temizlenmesi değildir. Bir nesnenin nerede oluşturulduğu, ne kadar süre erişilebilir kaldığı, hangi generation içinde bulunduğu ve hangi kaynakları tuttuğu uygulamanın bellek davranışını belirler. GC bu sistemin merkezindedir, ancak bellek kullanımının tamamını yönetmez.
Bu ayrım özellikle uzun süre çalışan ASP.NET Core servislerinde, yoğun veri işleyen background worker uygulamalarında ve yüksek throughput gerektiren sistemlerde önem kazanır. Kod doğru sonuç üretebilir, exception fırlatmayabilir ve yine de gereksiz allocation nedeniyle CPU tüketimini artırabilir. Daha kötüsü, yönetilen nesnelerin arkasında açık dosyalar, socket bağlantıları veya beklenenden uzun yaşayan büyük buffer'lar bulunabilir.
Stack ve Heap ayrımı nesnenin türünden ibaret değildir
.NET uygulamasında bir method çağrıldığında runtime, o çağrının yürütülmesi için gerekli bilgileri yönetir. Stack, thread'e ait çağrı frame'lerinin tutulduğu bellek alanıdır. Method'un dönüş adresi, bazı local değişkenler ve geçici değerler burada bulunabilir. Method tamamlandığında ilgili stack frame kaldırılır; bunun için Garbage Collector'ın nesne toplaması gerekmez.
Managed Heap ise GC tarafından yönetilen nesnelerin genel olarak oluşturulduğu alandır. Heap üzerindeki bir nesnenin yaşam süresi, onu oluşturan method'un yaşam süresine bağlı olmak zorunda değildir. Nesneye erişilebilir bir reference bulunduğu sürece nesne yaşamaya devam edebilir.
Burada sık tekrarlanan bir açıklama teknik olarak eksiktir: Value type Stack'te, reference type Heap'te tutulur. Gerçek ayrım bu kadar basit değildir.
C# dilinde class ve record class reference type; struct, record struct ve enum ise value type kategorisindedir. Reference type değişkeni normalde nesnenin kendisini değil, nesneye ulaşmayı sağlayan reference değerini taşır. Value type değişkeni ise kendi değerini içerir.
Örneğin:
public struct Point
{
public int X;
public int Y;
}
public class Measurement
{
public Point Position;
public string Name = "";
}Measurement nesnesi Heap üzerinde oluşturulduğunda Position alanı da o nesnenin içinde bulunur. Ayrı bir Heap nesnesi olarak oluşturulması gerekmez. Buna karşılık Name alanı bir string nesnesine reference taşır.
Aynı şekilde bir method içindeki reference type değişkeni Stack üzerinde bir reference olarak bulunabilir; işaret ettiği nesne ise Heap üzerindedir. JIT compiler bazı değerleri register'larda tutabilir veya belirli nesnelerin allocation davranışını optimize edebilir. Bu nedenle C# kaynak kodundan bütün fiziksel bellek yerleşimini kesin olarak çıkarmak doğru değildir.
Value type kullanımı da tek başına Heap allocation yapılmayacağını garanti etmez. Bir struct, reference type nesnenin alanı olabilir, array içinde saklanabilir veya boxing nedeniyle Heap üzerinde yeni bir nesneye dönüşebilir.
int number = 42;
object boxed = number;Buradaki boxing işlemi, number değerini içeren bir nesne oluşturur. Özellikle sık çalışan döngülerde gereksiz boxing, allocation miktarını artırabilir.
Önemli karar, hangi türün daha hızlı olduğu değil, verinin nasıl temsil edildiği ve yaşam süresinin nasıl yönetildiğidir. Büyük bir struct değerini sürekli kopyalamak da küçük bir reference type nesnesi oluşturmaktan daha pahalı olabilir.
Garbage Collector erişilebilirliği izler, kullanım niyetini değil
.NET Garbage Collector, managed Heap üzerindeki nesnelerin yaşam döngüsünü yönetir. Bir nesne oluşturulduğunda runtime uygun bellek alanından yer ayırır. Bu işlem object allocation olarak adlandırılır.
Küçük nesneler için allocation çoğu durumda hızlıdır. Runtime, uygun koşullarda allocation pointer'ını ilerleterek yeni nesneye yer ayırabilir. Dolayısıyla her new ifadesinin işletim sisteminden yeni bellek istemesi veya pahalı bir sistem çağrısı yapması gerekmez.
Ancak hızlı allocation, sınırsız allocation anlamına gelmez. Oluşturulan nesneler arttıkça GC'nin yönetmesi gereken bellek miktarı da büyür.
GC'nin temel kavramı reachability, yani erişilebilirliktir. Runtime, GC root olarak kabul edilen başlangıç noktalarından hareket ederek hangi nesnelere ulaşılabildiğini belirler. Aktif stack ve register reference'ları, static alanlar ve GC handle'lar bu köklerin kaynakları arasındadır.
Bir nesneye herhangi bir GC root üzerinden ulaşılabiliyorsa nesne canlı kabul edilir. Uygulamanın o nesneyi artık kullanmayı planlamaması önemli değildir.
Örneğin bir static Dictionary<string, byte[]>, geçmiş request'lerin verilerini tutuyorsa dictionary içindeki array'ler erişilebilir kalır. İş mantığı açısından kullanılmayan bu veriler GC açısından hâlâ canlıdır.
Memory leak kavramının managed uygulamalardaki karşılığı çoğu zaman budur: Artık ihtiyaç duyulmayan nesnelerin reference zincirleri nedeniyle beklenenden uzun süre tutulması.
Bu durum unmanaged dillerdeki klasik bellek kaybından farklı mekanizmayla ortaya çıkar. Belleğin adresini kaybetmek yerine, gereksiz nesnelere ulaşabilen reference'ları koruruz.
Event subscription bunun yaygın örneklerinden biridir. Uzun yaşayan bir publisher, event handler üzerinden kısa yaşaması beklenen subscriber nesnesine reference tutabilir. Subscription kaldırılmadığında subscriber GC tarafından toplanamaz.
Benzer sorunlar sınırsız büyüyen cache'lerde, singleton servislerde tutulan request verilerinde, tamamlanmış işleri saklayan koleksiyonlarda ve yanlış lifetime ile kullanılan dependency injection bileşenlerinde görülür.
GC, nesnenin iş açısından gereksiz olduğunu anlayamaz. Yalnızca erişilebilir olup olmadığını değerlendirebilir.
Bu nedenle memory leak araştırmasının temel sorusu toplam bellek miktarı değil, beklenenden uzun yaşayan nesneyi hangi GC root'un tuttuğudur.
Generations ve Large Object Heap toplama maliyetini değiştirir
.NET GC, nesnelerin önemli bir bölümünün kısa yaşadığı gözleminden yararlanan generational bir tasarım kullanır. Bu yaklaşımda küçük nesnelerin çoğu önce Generation 0, yani Gen 0 içinde oluşturulur.
Gen 0 collection sırasında canlı kalan nesneler uygun koşullarda Gen 1'e taşınır. Daha uzun yaşayan nesneler zamanla Gen 2'ye ulaşabilir. Buradaki generation kavramı nesnenin işlevini değil, GC tarafından kaç toplama döngüsünden sağ çıktığını temsil eder.
Gen 0 collection genellikle daha dar bir alanı inceler. Gen 2 collection ise daha geniş ve pahalı bir çalışma gerektirebilir. Her collection bütün Heap'i temizlemez.
Bu tasarım kısa ömürlü nesneler için avantaj sağlar. Bir request sırasında oluşturulan geçici nesneler, sonraki collection öncesinde erişilemez duruma gelmişse yaşlı generation'lara taşınmadan temizlenebilir.
Sorun, kısa yaşaması gereken nesnelerin gereksiz yere uzun yaşamasıdır. Örneğin geçici bir buffer, uzun yaşayan bir koleksiyonda tutulursa Gen 2'ye kadar ilerleyebilir. Böylece başlangıçta ucuz görünen allocation, sonraki collection döngülerinde ek maliyet oluşturur.
Large Object Heap (LOH), büyük nesneler için ayrılmış özel bir Heap alanıdır. .NET'te yaklaşık 85.000 byte ve üzerindeki nesneler normalde LOH üzerinde allocate edilir. Bu eşik özellikle büyük array allocation'larında önemlidir.
byte[] buffer = new byte[100_000];Bu array normal koşullarda LOH üzerinde oluşturulur. LOH, Gen 2 collection ile birlikte ele alınır. Büyük nesnelerin sık oluşturulup bırakılması, memory usage dalgalanmalarına ve collection maliyetine katkıda bulunabilir.
LOH nesneleri varsayılan olarak her collection sırasında compact edilmez. Compaction, canlı nesnelerin taşınarak boş alanların birleştirilmesidir. LOH üzerinde uygun koşullarda veya açıkça talep edildiğinde compaction yapılabilir, ancak bunu her collection için varsaymak yanlıştır.
Ayrıca büyük nesnelerin taşınması maliyetli olabilir. Bu nedenle LOH davranışını yalnızca fragmentation üzerinden değerlendirmek yeterli değildir.
Bir başka ayrım da pinned object kavramıdır. Pinned nesne, GC'nin belirli bir süre boyunca taşıyamayacağı nesnedir. Native code ile çalışan uygulamalarda pinning gerekli olabilir, fakat uzun süreli pinning Heap compaction üzerinde kısıt oluşturabilir.
Modern .NET ayrıca belirli pinned allocation senaryoları için Pinned Object Heap (POH) sunar. Ancak POH, her büyük buffer probleminin otomatik çözümü değildir.
Bu noktada GC pressure kavramı devreye girer. GC pressure, uygulamanın allocation davranışının Garbage Collector üzerinde oluşturduğu çalışma yüküdür.
Yüksek GC pressure, yalnızca Heap'te çok bellek bulunması demek değildir. Bir uygulama düşük live memory ile çalışırken sürekli kısa ömürlü nesne oluşturarak yüksek allocation rate üretebilir. Başka bir uygulama ise büyük fakat kararlı bir Heap ile daha az collection gerçekleştirebilir.
Performance analizinde bu ayrım belirleyicidir. Toplam bellek tüketimi, allocation rate, collection sıklığı, pause süresi ve canlı nesnelerin generation dağılımı birlikte değerlendirilmelidir.
Bir dosya işleme worker'ında bellek maliyeti nasıl oluşur?
Bir ASP.NET Core uygulamasının yüklenen dosyaları işleyen background worker içerdiğini düşünelim. Worker, gelen dosyayı okuyor, içeriği doğruluyor, bir hash hesaplıyor ve sonucu kalıcı storage'a yazıyor.
İlk implementation bütün dosyayı belleğe alabilir:
byte[] content = await File.ReadAllBytesAsync(path);
byte[] hash = SHA256.HashData(content);Kod basittir ve küçük dosyalarda kabul edilebilir. Fakat ReadAllBytesAsync, dosyanın içeriğini taşıyan bir byte[] oluşturur. Dosya büyüdüğünde bu array LOH üzerinde yer alır.
Worker aynı anda birden fazla dosya işliyorsa peak memory usage, yalnızca tek dosyanın boyutuna bağlı değildir. Aynı anda canlı tutulan dosya içerikleri ve işlem sırasında oluşturulan diğer buffer'lar toplamı belirler.
Örneğin aynı anda işlenen 20 dosyanın her biri 10 MB ise yalnızca içerik array'leri yaklaşık 200 MB tutar. Bu hesap object overhead, ek buffer'lar ve diğer uygulama belleğini içermez.
Dosya işleme tamamlandığında array'ler erişilemez hale gelebilir. Ancak GC'nin bunları hemen toplaması garanti değildir. Managed Heap'in işletim sistemine ayrılmış belleği de collection sonrasında anında geri vermesi gerekmez.
Bu nedenle process memory grafiğinde yüksek bir değer görmek, tek başına memory leak kanıtı değildir.
Hash hesaplama işlemi için bütün dosyanın bellekte bulunması gerekmez. Streaming yaklaşımı dosyayı parça parça okuyabilir:
using var stream = File.OpenRead(path);
byte[] hash = await SHA256.HashDataAsync(stream);Burada dosyanın tamamını tutan büyük bir array oluşturulmaz. Hash algoritması veriyi stream üzerinden işler. Runtime ve kütüphane kendi geçici buffer'larını kullanabilir, ancak bellek gereksinimi dosyanın tamamını tek bir array'e yüklemekle aynı şekilde büyümez.
Bu örnekte using, GC'den farklı bir sorumluluğu yerine getirir. FileStream, işletim sistemi seviyesinde bir file handle tutar. Bu handle'ın ne zaman bırakılacağı managed nesnenin ne zaman toplanacağına bırakılmamalıdır.
IDisposable, bir nesnenin tuttuğu kaynakların deterministik biçimde serbest bırakılmasına yönelik contract'tır. using scope tamamlandığında Dispose() çağrılmasını sağlar. Scope exception nedeniyle sona erse bile bu çağrı gerçekleştirilir.
Asenkron cleanup gerektiren türlerde IAsyncDisposable ve await using kullanılabilir. Bunlar, DisposeAsync() üzerinden cleanup işleminin asenkron tamamlanmasını sağlar.
Önemli ayrım şudur: Dispose() çağrısı managed nesneyi Heap'ten silmez. Nesnenin sahip olduğu kaynakların bırakılması için tanımlanmış işlemi çalıştırır. Nesnenin managed belleği daha sonra GC tarafından geri kazanılır.
Bir FileStream için doğru disposal, dosya tanıtıcısının zamanında kapatılmasını sağlar. Fakat byte[] için Dispose() çağırmak mümkün değildir; array IDisposable uygulamaz ve onun belleği GC tarafından yönetilir.
Dosya işleme senaryosunda başka bir risk daha bulunur. Worker tamamlanan işlerin byte[] içeriklerini hata ayıklamak amacıyla sınırsız bir listede saklıyorsa streaming'e geçmek tek başına bütün problemi çözmez. Reference retention devam ettiği sürece ilgili nesneler canlı kalır.
Buffer reuse gerektiğinde ArrayPool<byte>.Shared değerlendirilebilir. Pool, uygun buffer'ların yeniden kullanılmasını sağlayarak tekrarlanan allocation miktarını azaltabilir. Ancak kiralanan buffer'ın iade edilmesi gerekir ve iade edilen array'in içeriğinin otomatik olarak temizlendiği varsayılmamalıdır.
Pool ayrıca kullanım disiplinini değiştirir. İade edilmiş buffer'a reference tutmak veya aynı buffer'ı eşzamanlı iki işte kullanmak veri bozulmasına yol açabilir. Bu nedenle pooling, basit allocation'ın yerine düşünmeden uygulanacak bir optimizasyon değildir.
Önce allocation davranışını ölçün, sonra değiştirmeye karar verin
.NET memory optimization çalışmalarında en sık gördüğüm yanlış yaklaşım, yüksek memory usage değerini doğrudan GC problemi olarak yorumlamaktır. Oysa yüksek kullanım, sağlıklı bir cache'in sonucu olabilir. Düşük kullanım da yüksek allocation rate nedeniyle oluşan CPU maliyetini gizleyebilir.
Başlangıç noktası ölçümdür. dotnet-counters ile allocation rate, GC heap size, collection sayıları ve ilgili runtime metrikleri incelenebilir. dotnet-gcdump ve dotnet-dump gibi araçlar ise canlı nesnelerin türlerini, miktarlarını ve reference ilişkilerini araştırmak için kullanılabilir.
Production ortamında dump toplamanın pause, disk alanı ve hassas veri açısından maliyetleri olabilir. Bu nedenle hangi aracın kullanılacağı uygulamanın çalışma koşullarına göre belirlenmelidir.
Bir optimizasyonun başarılı olması için yalnızca allocation miktarının düşmesi yeterli değildir. Request latency, throughput, CPU consumption ve peak memory usage üzerindeki etkisi de ölçülmelidir.
Örneğin her request'te küçük bir DTO nesnesi oluşturmak, tek başına problem değildir. Bu nesneleri ortadan kaldırmak için mutable object pool kurmak, thread safety ve yaşam döngüsü hatalarını artırabilir. Allocation maliyeti uygulamanın darboğazı değilse daha karmaşık çözüm performansı iyileştirmeyebilir.
Buna karşılık yüksek frekanslı serialization kodunda gereksiz string ve array allocation'ları yapılıyorsa Span<T>, Memory<T> veya uygun pooling stratejileri anlamlı olabilir. Span<T>, bellekteki bir bölgeyi yeni bir array oluşturmak zorunda kalmadan temsil edebilir. Fakat kullanım kuralları ve lifetime kısıtları vardır; her API sınırında doğrudan kullanılabilen genel bir çözüm değildir.
Benim tercih ettiğim sıra, önce gereksiz uzun yaşamayı ortadan kaldırmak, ardından büyük allocation'ları incelemek, son olarak kısa ömürlü küçük allocation'ları optimize etmektir. Bu sıra evrensel bir performans yasası değildir; fakat uzun yaşayan servislerde yanlış reference retention ve büyük buffer kullanımının etkisini erken görünür kılar.
GC'yi GC.Collect() ile manuel tetiklemek ise normal request işleme akışında çözüm olarak kullanılmamalıdır. Runtime'ın collection kararları Heap durumu ve sistem koşulları hakkında uygulama kodundan daha geniş bilgiye dayanır. Zorunlu collection gereksiz pause oluşturabilir ve canlı nesnelerin generation davranışını olumsuz etkileyebilir.
Bu tavsiyenin sınırı, ölçülmüş ve özel bir collection ihtiyacının bulunmasıdır. Örneğin uygulamanın bilinen bir çalışma evresi tamamlandıktan sonra, GC davranışı kontrollü biçimde değerlendirilebilir. Ancak böyle bir kararın profiling verisiyle desteklenmesi gerekir.
Son karar için tek bir soru yeterli değildir: Nesne neden oluşturuluyor, neden hâlâ canlı ve hangi kaynakları tutuyor? Bu üç sorunun cevapları farklı optimizasyonlara götürür. Gereksiz allocation azaltılır, gereksiz reference retention kaldırılır, unmanaged kaynaklar ise doğru zamanda dispose edilir.
.NET'te iyi memory management, GC'yi sürekli çalıştırmaya zorlamak değildir. Runtime'ın verimli yönetebileceği nesne yaşam süreleri oluşturmak ve runtime'ın yönetmediği kaynakların sahipliğini açık tutmaktır.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.