Fatih Sultan Saruhan
~/yazilar/

Ben Bu Objeyi Sildim, Değil mi?

GC serisinin ilk bölümü. Bu seride Garbage Collector’ı teoriden çok projedeki etkileri üzerinden konuşacağız: DI container’ın, LINQ2DB sorgularımızın ve yazdığımız sıradan kodun memory’yi nasıl etkilediğini. Bugün işin temeliyle başlıyoruz: Bir objenin “işi bitti” demek ile “gerçekten gitti” demek neden aynı şey değil ve bu neden performansı ilgilendiriyor?

Check-out yaptın. Oda boşaldı mı?

Bir haftalık tatilin sonu. Resepsiyona gittin, anahtar kartı teslim ettin, check-out yaptın. Artık o odayla işin bitti.

Peki oda boşaldı mı?

Pek sayılmaz. Yatak hâlâ dağınık, çöp kutusu dolu. Odayı gerçekten boşaltacak olan sen değilsin, housekeeping. Ve housekeeping odaya senin “işim bitti” demenle değil, kendi kurallarıyla girer.

.NET’te de durum pek farklı değil. Hemen her projede buna benzer bir metod vardır:

csharp

public async Task<List<UserDto>> GetActiveUsersAsync()
{
    using var db = new AppDataConnection();
    var users = await db.GetTable<User>()
        .Where(x => x.IsActive)
        .ToListAsync();    return users
        .Select(x => new UserDto(x.Id, x.Name))
        .ToList();
}

Metod bittiğinde db.Dispose() çalışır ve database connection'ı bırakılır. Check-out yapıldı. Ama bu, objelerin memory'den silindiği anlamına gelmiyor. Aslında metodun sonunda her obje farklı bir durumda:

  • db connection'ını bıraktı, ama objenin kendisi hâlâ memory'de olabilir.

  • users listesi ve içindeki User'lara artık hiçbir yerden ulaşılamıyor. Toplanmayı bekliyorlar, ama ne zaman toplanacaklarına biz karar vermiyoruz.

  • DTO listesi çağırana döndüğü için yaşamaya devam ediyor.

Küçük bir sürpriz de var: DTO’yu oluştururken x.Name'i kopyalamadık, aynı string'in referansını verdik. Entity'ler gidebilir ama isimleri DTO'ların içinde yaşamaya devam eder. Bir objenin içindeki her şey, obje giderken onunla birlikte gitmek zorunda değil.

Tek bir metod, üç farklı kader. Hiçbirinde “sildim” diyebileceğimiz bir an yok.

“Bitti” sandığımız anlar

Gerçek projelerde obje silmek için bir şey yazmıyoruz. Ama bir objenin işinin bittiğini düşündüğümüz birkaç tipik an var ve hepsi aslında farklı şeyler:

Metod bitiyor. Local değişkenler artık objeye giden bir yol olmaktan çıkıyor. Başka kimse tutmuyorsa obje toplanmaya aday oluyor. Aday oluyor, silinmiyor.

Dispose() çağrılıyor. Obje elindeki dış kaynakları bırakıyor: connection, dosya, socket. Ama objenin kendisi memory'de duruyor. Check-out yapmak seni otelden ışınlamıyor.

DI scope’u kapanıyor. Container, o scope’ta oluşturduğu servisleri dispose ediyor ve onları tutmayı bırakıyor. Servisleri başka biri tutmuyorsa toplanmaya aday oluyorlar.

Bir collection’dan çıkarıyoruz. _cache.Remove(id) cache'in objeyi bırakması demek. Obje başka bir yerde de tutuluyorsa yaşamaya devam ediyor. List<T>.Clear() ise listeyi boşaltıyor ama arkadaki array'i küçültmüyor. Bir milyon elemanlık listeyi Clear() ile temizlediğinizde, bir milyon elemanlık yer hâlâ ayrılmış durumda. Gerçekten küçültmek istiyorsanız TrimExcess() çağırmak ya da listeyi yeniden oluşturmak gerekiyor.

Hiçbiri objeyi silmiyor. Hepsi bir şeyi bırakıyor. Silme kararını veren tek bir mekanizma var.

Housekeeping’in tek kuralı

Garbage Collector, bir objenin işimize yarayıp yaramadığını umursamaz. Tek bir soru sorar:

Bu objeye hâlâ ulaşabiliyor muyum?

Housekeeping bir odayı temizlemeden önce rezervasyon defterine bakar. Defterde kayıtlı bir misafirin odasına dokunmaz. GC’nin de bir rezervasyon defteri var: GC Root’lar. Çalışmakta olan metodlardaki değişkenler, static alanlar ve runtime’ın tuttuğu bazı özel referanslar bu başlangıç noktalarıdır.

GC bu noktalardan başlar ve referansları takip eder:

Root
  |
  A
  |
  B

Root’tan A’ya, A’dan B’ye ulaşılabiliyorsa ikisi de yaşar.

Peki şöyle bir durumda?

A <----> B

A ve B birbirini tutuyor ama dışarıdan onlara ulaşan hiçbir root yok.

İkisi birbirine “Sen buradasın, ben de buradayım” diyebilir.

GC de gayet sakin bir şekilde “İkinizi de kimse istemiyor” deyip ikisini birden toplar.

Yani asıl soru bir referansın olup olmadığı değil, o objeye bir root’tan ulaşılıp ulaşılamadığı. GC çöp aramaz. Önce yaşayanları bulur, geriye kalana bakar.

Housekeeping bedava değil

Buraya kadar anlattıklarımız “ilginç ama beni ne ilgilendirir?” sorusunu doğurabilir. Cevap şu: GC’nin her çalışması bir maliyettir.

GC çalışırken CPU harcar ve bazı durumlarda uygulamanın thread’lerini kısa bir süreliğine durdurur. Housekeeping koridoru temizlerken misafirlerin geçişini bekletmesi gibi. Tek seferde fark edilmez. Ama otel ne kadar dağınıksa temizlik o kadar sık yapılır ve bekleme süreleri toplanınca response sürelerine yansımaya başlar.

İki basit kural var:

  • Ne kadar çok obje üretirsen, GC o kadar sık çalışır. Kısa ömürlü objeler ucuzdur, ama bedava değildir.

  • Ne kadar çok objeyi uzun süre tutarsan, her GC o kadar pahalı olur. GC’nin işi büyük ölçüde yaşayanları bulmaktır. Yaşayan ne kadar çoksa iş o kadar büyür.

Bunu kendi makinende görmek için şu kodu çalıştırabilirsin:

csharp

var before = GC.CollectionCount(0);
for (int i = 0; i < 1_000_000; i++)
{
    var dto = new UserDto(i, $"User {i}");
}var after = GC.CollectionCount(0);
Console.WriteLine($"Bu döngü sırasında GC {after - before} kez çalıştı.");

Sayı makineden makineye değişir, ama sıfır olmayacak. Hiçbir işe yaramayan bir döngü bile housekeeping’i defalarca çağırdı. Şimdi bunu saniyede yüzlerce request alan bir API’de düşün.

Bavul emanette, havlu şezlongda

Projelerde memory ile ilgili iki farklı problemle karşılaşırız ve belirtileri birbirine karıştırılabilir.

Bavul emanette. Tatilin son günü bavulunu resepsiyona emanete bıraktın ve almayı unuttun. Otel onu atmaz, çünkü emanet defterinde adın yazıyor. Uygulamada da bir obje ile işimiz bitmiştir ama bir static alan, bir Singleton, bir cache ya da unutulmuş bir event subscription onu hâlâ tutuyordur. Sen “artık kullanmıyorum” dersin, GC “ama şu arkadaş hâlâ tutuyor” der. Memory yavaş yavaş şişer. .NET’teki memory leak’in çoğu zaman anlamı budur.

Havlu şezlongda. Havuz başında sabah havlusunu şezlonga atıp öğlene kadar ortadan kaybolan misafiri hepimiz tanıyoruz. Şezlong boş ama kimse oturamıyor. Dispose çağırmayı unutmak da böyledir. Connection'lar pool'a zamanında dönmez ve bir gün "max pool size was reached" hatasıyla karşılaşırsın. GC burada seni kurtarmaz, çünkü GC memory'ye bakar, şezlonglara değil.

Ayrım basit: Memory şişiyorsa birileri objeleri tutuyordur. Connection’lar tükeniyorsa birileri dispose etmiyordur. İkisi farklı problemler ve farklı yerlere bakmayı gerektirir.

Ne zaman bakmalıyım?

Her gün “GC ne yapıyor?” diye kontrol etmen gerekmiyor. Ama şu belirtilerden birini görüyorsan GC şüpheliler listesine girmeli: Memory kullanımı sürekli artıyorsa, yoğun anlarda response süreleri anlamsız şekilde uzuyorsa ya da CPU’nun önemli bir kısmı GC’ye gidiyorsa.

Yüksek memory kullanımının tek başına “leak var” demek olmadığını da unutmamak gerek. Uygulama gerçekten çok veri işliyor olabilir. Kalabalık bir otel her zaman kötü yönetilen bir otel değildir.

Mesela database’den 500.000 kayıt çeken bir işlem yavaşladığında suçu database’e atmak çok kolay. Ama sorgu hızlıca bitmiş, sonrasında yüz binlerce obje oluşturulmuş, map edilmiş, listelere eklenmiş ve GC bu yükle boğuşuyor olabilir.

Database hızlıdır.

Kod da “çalışıyordur”.

Ama tatil biraz pahalıya patlamıştır.

Nereden başlamalı?

GC’yi hiç düşünmediysen ve projende performans istiyorsan, sırayla şunları yap:

  1. Önce ölç. dotnet-counters aracıyla çalışan uygulamanın heap boyutunu, GC sayılarını, GC'de geçen süreyi ve allocation hızını izleyebilirsin. Tahmin yerine sayıyla konuşmak her şeyi değiştirir. Bu aracı 3. bölümde detaylı kullanacağız.

  2. Problemi ayır. Memory zamanla sürekli artıyor mu (bavul emanette), connection’lar mı tükeniyor (havlu şezlongda), yoksa sadece yoğun anlarda çok mu obje üretiliyor? Her birinin çözümü farklı.

  3. Kolay kazançları al. IDisposable olan her şeyi using ile kullan. Unutulan dispose'ları yakalamak için CA2000 analyzer'ını aç (genelde varsayılan olarak kapalı, .editorconfig üzerinden açılıyor). Büyük listeleri çekmeden önce "bunların hepsine aynı anda ihtiyacım var mı?" diye sor.

  4. Derinleşmek istersen oku. Microsoft Learn’deki “Fundamentals of garbage collection” dokümanı iyi bir başlangıç. Daha da derine inmek istersen Konrad Kokosa’nın Pro .NET Memory Management kitabı bu konunun başvuru kaynağı.

Kapanış

Metodun bitmesi, Dispose, scope'un kapanması, collection'dan çıkarmak. Hepsi bir şeyi bırakıyor ama hiçbiri objeyi silmiyor. Bir obje ancak ona giden bütün yollar kapandığında gider. Ve o gidene kadar her obje, housekeeping'in iş yükünün bir parçası.

Çünkü bazen problem oluşturduğumuz obje değildir.

Bazen problem, onun gitmesine izin vermeyen şeydir.

Peki o objeyi kim tutuyor?

Bir Singleton mı? Bir cache mi? Bir static alan mı? Yoksa DI container’ın verdiği scope sandığımızdan çok daha uzun mu?

Bir sonraki bölümde DI container’a ve LINQ2DB sorgularımıza bakacağız ve housekeeping’in kapıya gelip “Bu oda hâlâ dolu görünüyor” demesinin sebeplerini tek tek bulacağız.