ASP.NET Core'da bellek yönetimi ve çöp toplama (GC)

Sébastien Ros ve Rick Anderson tarafından

.NET gibi yönetilen bir çerçevede bile bellek yönetimi karmaşıktır. Bellek sorunlarını çözümlemek ve anlamak zor olabilir. Bu makalede:

  • Birçok bellek sızıntısı ve GC çalışmama sorunları tarafından motive edildi. Bu sorunların çoğu, .NET Core'da bellek tüketiminin nasıl çalıştığını anlamama veya nasıl ölçüldüğünü anlamama kaynaklıdır.
  • Sorunlu bellek kullanımını gösterir ve alternatif yaklaşımlar önerir.

.NET Core'da çöp toplama (GC) nasıl çalışır?

GC, her kesimin bitişik bir bellek aralığı olduğu yığın kesimlerini ayırır. Yığına yerleştirilen nesneler 3 nesilden birine ayrılır: 0, 1 veya 2. Oluşturma, GC'nin artık uygulama tarafından başvurulmayan yönetilen nesnelerde belleği serbest bırakmaya çalıştığı sıklığı belirler. Daha düşük numaralı nesiller GC'leri daha sıktır.

Nesneler yaşamlarına göre bir nesilden diğerine taşınır. Nesneler daha uzun süre yaşadığı için daha yüksek bir nesle taşınır. Daha önce belirtildiği gibi, daha yüksek nesiller GC'leri daha az sıktır. Kısa süreli nesneler her zaman 0. nesilde kalır. Örneğin, bir web isteğinin ömrü boyunca başvuruda bulunılan nesneler kısa sürelidir. Uygulama düzeyi tekiller genellikle 2. nesile geçirilmiştir.

bir ASP.NET Core uygulaması başlatıldığında GC:

  • İlk yığın segmentleri için biraz bellek ayırır.
  • Çalışma zamanı yüklendiğinde belleğin küçük bir bölümünü işler.

Önceki bellek ayırma işlemleri performans nedenleriyle gerçekleştirilir. Performans avantajı, bitişik bellekteki yığın kesimlerinden gelir.

GC. Uyarılar toplama

Genel olarak, üretimdeki ASP.NET Core uygulamaları GC kullanmamalıdır . Açıkça toplayın. Atık toplamaların en uygun olmayan zamanlarda çalıştırılması performansı önemli ölçüde düşürebilir.

GC. Toplama, bellek sızıntılarını araştırırken yararlıdır. Çağırma GC.Collect() , yönetilen koddan erişilemeyen tüm nesneleri geri almaya çalışan bir engelleyici çöp toplama döngüsünü tetikler. Yığındaki erişilebilir canlı nesnelerin boyutunu anlamak ve zaman içinde bellek boyutunun büyümesini izlemek için kullanışlı bir yoldur.

Bir uygulamanın bellek kullanımını analiz etme

Ayrılmış araçlar bellek kullanımını analiz etme konusunda yardımcı olabilir:

  • Nesne başvurularını sayma
  • GC'nin CPU kullanımı üzerindeki etkisini ölçme
  • Her nesil için kullanılan bellek alanını ölçme

Bellek kullanımını analiz etmek için aşağıdaki araçları kullanın:

Bellek sorunlarını algılama

Görev Yöneticisi, bir ASP.NET uygulamasının ne kadar bellek kullandığı hakkında fikir edinmek için kullanılabilir. Görev Yöneticisi bellek değeri:

  • ASP.NET işlemi tarafından kullanılan bellek miktarını temsil eder.
  • Uygulamanın canlı nesnelerini ve yerel bellek kullanımı gibi diğer bellek tüketicilerini içerir.

Görev Yöneticisi bellek değeri süresiz olarak artar ve hiçbir zaman düzleştirmezse, uygulamada bellek sızıntısı olur. Aşağıdaki bölümlerde çeşitli bellek kullanım desenleri gösterilmekte ve açıklanmaktadır.

Örnek görüntüleme belleği kullanım uygulaması

MemoryLeak örnek uygulaması GitHub'da kullanılabilir. MemoryLeak uygulaması:

  • Uygulama için gerçek zamanlı bellek ve GC verileri toplayan bir tanılama denetleyicisi içerir.
  • Bellek ve GC verilerini görüntüleyen bir Dizin sayfası vardır. Dizin sayfası her saniye yenilenir.
  • Çeşitli bellek yükü desenleri sağlayan bir API denetleyicisi içerir.
  • Desteklenen bir araç değildir, ancak ASP.NET Core uygulamalarının bellek kullanım desenlerini görüntülemek için kullanılabilir.

MemoryLeak'i çalıştırın. Ayrılan bellek, GC gerçekleşene kadar yavaş artar. Araç verileri yakalamak için özel nesne ayırdığından bellek artar. Aşağıdaki görüntüde 0. Nesil GC oluştuğunda MemoryLeak Index sayfası gösterilmektedir. API denetleyicisinden hiçbir API uç noktası çağrılmadığından grafikte 0 RPS (saniyede istek sayısı) gösterilir.

Chart showing 0 Requests Per Second (RPS)

Grafikte bellek kullanımı için iki değer görüntülenir:

  • Ayrılan: Yönetilen nesneler tarafından kaplanan bellek miktarı
  • Çalışma kümesi: İşlemin sanal adres alanında bulunan ve şu anda fiziksel bellekte yerleşik olan sayfa kümesi. Gösterilen çalışma kümesi, Görev Yöneticisi'nin görüntülediği değerle aynıdır.

Geçici nesneler

Aşağıdaki API bir 10 KB Dize örneği oluşturur ve istemciye döndürür. Her istekte, bellekte yeni bir nesne ayrılır ve yanıta yazılır. Dizeler .NET'te UTF-16 karakter olarak depolanır, bu nedenle her karakter bellekte 2 bayt alır.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

Aşağıdaki grafik, bellek ayırmalarının GC'den nasıl etkilendiğini göstermek için içinde görece küçük bir yük ile oluşturulur.

Graph showing memory allocations for a relatively small load

Yukarıdaki grafik şunları gösterir:

  • 4K RPS (Saniye başına istek sayısı).
  • Nesil 0 GC koleksiyonları yaklaşık iki saniyede bir gerçekleşir.
  • Çalışma kümesi yaklaşık 500 MB sabittir.
  • CPU %12'dir.
  • Bellek tüketimi ve sürümü (GC aracılığıyla) kararlıdır.

Aşağıdaki grafik, makine tarafından işlenebilen maksimum aktarım hızıyla alınır.

Chart showing max throughput

Yukarıdaki grafik şunları gösterir:

  • 22K RPS
  • Nesil 0 GC koleksiyonları saniyede birkaç kez gerçekleşir.
  • Uygulama saniyede önemli ölçüde daha fazla bellek ayırdığından 1. nesil koleksiyonlar tetiklenir.
  • Çalışma kümesi yaklaşık 500 MB sabittir.
  • CPU %33'dür.
  • Bellek tüketimi ve sürümü (GC aracılığıyla) kararlıdır.
  • CPU (%33) fazla kullanılmaz, bu nedenle çöp toplama işlemi çok sayıda ayırmaya ayak uydurabilir.

İş istasyonu GC ve Sunucu GC karşılaştırması

.NET Çöp Toplayıcısı'nın iki farklı modu vardır:

  • İş istasyonu GC: Masaüstü için iyileştirildi.
  • Sunucu GC. ASP.NET Core uygulamaları için varsayılan GC. Sunucu için iyileştirilmiş.

GC modu proje dosyasında veya runtimeconfig.json yayımlanan uygulamanın dosyasında açıkça ayarlanabilir. Aşağıdaki işaretleme, proje dosyasındaki ayarı ServerGarbageCollection gösterir:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Proje dosyasında değişiklik ServerGarbageCollection yapmak için uygulamanın yeniden oluşturulması gerekir.

Not: Tek çekirdekli makinelerde sunucu çöp toplama özelliği kullanılamaz . Daha fazla bilgi için bkz. IsServerGC.

Aşağıdaki görüntüde, İş İstasyonu GC'sini kullanan bir 5K RPS'nin altındaki bellek profili gösterilmektedir.

Chart showing memory profile for a Workstation GC

Bu grafik ile sunucu sürümü arasındaki farklar önemlidir:

  • Çalışma kümesi 500 MB'tan 70 MB'a düşer.
  • GC, 0. nesil koleksiyonları iki saniyede bir yerine saniyede birden çok kez yapar.
  • GC 300 MB'tan 10 MB'a düşer.

Tipik bir web sunucusu ortamında CPU kullanımı bellekten daha önemlidir, bu nedenle Sunucu GC daha iyidir. Bellek kullanımı yüksekse ve CPU kullanımı görece düşükse, İş İstasyonu GC daha yüksek performanslı olabilir. Örneğin, belleğin az olduğu çeşitli web uygulamalarını barındıran yüksek yoğunluklu.

Docker ve küçük kapsayıcıları kullanan GC

Bir makinede birden çok kapsayıcılı uygulama çalıştırıldığında İş İstasyonu GC, Sunucu GC'den daha yüksek performansa sahip olabilir. Daha fazla bilgi için bkz. Küçük Bir Kapsayıcıda Sunucu GC ile Çalıştırma ve Küçük Kapsayıcı Senaryosunda Sunucu GC ile Çalışma Bölüm 1 – GC Yığını için Sabit Sınır.

Kalıcı nesne başvuruları

GC, başvurulan nesneleri boşaltamaz. Başvuruda bulunan ancak artık gerekli olmayan nesneler bellek sızıntısına neden olur. Uygulama nesneleri sık sık ayırır ve artık gerekli olmadığında serbest bırakırsa, bellek kullanımı zaman içinde artar.

Aşağıdaki API bir 10 KB Dize örneği oluşturur ve istemciye döndürür. Önceki örnekteki fark, bu örneğe statik bir üye tarafından başvurulduğunu gösterir ve bu da koleksiyon için hiçbir zaman kullanılamadığı anlamına gelir.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

Önceki kod:

  • Tipik bir bellek sızıntısı örneğidir.
  • Sık yapılan çağrılarda, işlem bir OutOfMemory özel durumla kilitlenene kadar uygulama belleğinin artmasına neden olur.

Chart showing a memory leak

Yukarıdaki görüntüde:

  • Uç noktanın /api/staticstring yük testi bellekte doğrusal bir artışa neden olur.
  • GC, 2. nesil koleksiyonu çağırarak bellek baskısı arttıkça belleği boşaltmaya çalışır.
  • GC, sızdırılan belleği boşaltamaz. Ayrılan ve çalışma kümesi zaman ile artar.

Önbelleğe alma gibi bazı senaryolar, bellek baskısı bunları serbest bırakılmaya zorlayana kadar nesne başvurularının tutulmasını gerektirir. sınıfı WeakReference bu tür önbelleğe alma kodu için kullanılabilir. Bir WeakReference nesne bellek baskıları altında toplanır. varsayılan uygulaması IMemoryCache kullanır WeakReference.

Yerel bellek

Bazı .NET Core nesneleri yerel belleğe dayanır. Yerel bellek GC tarafından toplanamaz . Yerel bellek kullanan .NET nesnesinin yerel kod kullanarak boşaltması gerekir.

.NET, geliştiricilerin IDisposable yerel bellek yayınlamasına izin veren bir arabirim sağlar. Çağrılmasa Dispose bile, doğru uygulanan sınıflar sonlandırıcı çalıştığında çağırırDispose.

Aşağıdaki kodu göz önünde bulundurun:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider yönetilen bir sınıf olduğundan, isteğin sonunda herhangi bir örnek toplanır.

Aşağıdaki görüntüde, API sürekli çağrılırken bellek profili gösterilmektedir fileprovider .

Chart showing a native memory leak

Yukarıdaki grafikte, bellek kullanımını artırmaya devam eden bu sınıfın uygulanmasıyla ilgili belirgin bir sorun gösterilmektedir. Bu, bu sorunda izlenen bilinen bir sorundur.

Kullanıcı kodunda aşağıdakilerden biri tarafından aynı sızıntı oluşabilir:

  • Sınıfı doğru şekilde serbest bırakılmıyor.
  • Atılması gereken bağımlı nesnelerin yöntemini çağırmayı Dispose unutma.

Büyük nesne yığını

Sık bellek ayırma/boş döngüler, özellikle büyük bellek öbekleri ayrılırken belleği parçalayabilir. Nesneler bitişik bellek bloklarında ayrılır. Parçalanmayı azaltmak için GC belleği boşalttığında birleştirmeye çalışır. Bu işleme sıkıştırma denir. Sıkıştırma, nesneleri taşımayı içerir. Büyük nesnelerin taşınması bir performans cezasına neden olabilir. Bu nedenle GC, büyük nesneler için büyük nesne yığını (LOH) olarak adlandırılan özel bir bellek bölgesi oluşturur. 85.000 bayttan (yaklaşık 83 KB) büyük nesneler şunlardır:

  • LOH'a yerleştirildi.
  • Sıkıştırılmaz.
  • 2. nesil GC'ler sırasında toplanır.

LOH dolduğunda GC, 2. nesil bir koleksiyonu tetikler. 2. Nesil koleksiyonlar:

  • Doğal olarak yavaşlar.
  • Ayrıca, bir koleksiyonu diğer tüm nesillerde tetikleme maliyeti de vardır.

Aşağıdaki kod LOH'yi hemen sıkıştırıyor:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

LOH'yi sıkıştırma hakkında bilgi için bkz LargeObjectHeapCompactionMode .

.NET Core 3.0 ve üzerini kullanan kapsayıcılarda LOH otomatik olarak sıkıştırılır.

Bu davranışı gösteren aşağıdaki API:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

Aşağıdaki grafik, maksimum yük altında uç noktayı çağırmanın /api/loh/84975 bellek profilini gösterir:

Chart showing memory profile of allocating bytes

Aşağıdaki grafikte, yalnızca bir bayt daha ayırarak uç noktayı çağırmanın /api/loh/84976 bellek profili gösterilmektedir:

Chart showing memory profile of allocating one more byte

Not: Yapıda byte[] ek yük baytları vardır. Bu nedenle 84.976 bayt 85.000 sınırını tetikler.

Yukarıdaki iki grafiği karşılaştırma:

  • Çalışma kümesi, yaklaşık 450 MB olan her iki senaryo için de benzerdir.
  • LOH istekleri altında (84.975 bayt) çoğunlukla 0. nesil koleksiyonlar gösterilir.
  • Over LOH istekleri, 2. nesil sabit koleksiyonlar oluşturur. 2. nesil koleksiyonlar pahalıdır. Daha fazla CPU gerekir ve aktarım hızı neredeyse %50 düşer.

Geçici büyük nesneler özellikle sorunludur çünkü 2. nesil GC'lere neden olurlar.

En yüksek performans için büyük nesne kullanımı en aza indirilmelidir. Mümkünse büyük nesneleri ayırın. Örneğin, ASP.NET Core'daki Yanıt Önbelleğe Alma ara yazılımı önbellek girdilerini 85.000 bayttan küçük bloklara böler.

Aşağıdaki bağlantılarda nesneleri LOH sınırının altında tutmaya yönelik ASP.NET Core yaklaşımı gösterilmektedir:

Daha fazla bilgi için bkz.

HttpClient

Yanlış kullanmak HttpClient kaynak sızıntısına neden olabilir. Veritabanı bağlantıları, yuvalar, dosya tanıtıcıları gibi sistem kaynakları:

  • Bellekten daha az.
  • Sızdırıldığında bellekten daha sorunludur.

Deneyimli .NET geliştiricileri, uygulayan IDisposablenesneler üzerinde çağrıda Dispose bulunduğunu bilir. Uygulayan IDisposable nesnelerin atılmaması genellikle belleğin sızdırılmasına veya sistem kaynaklarının sızdırılmasına neden olur.

HttpClientIDisposableuygular, ancak her çağrıda atılmamalıdır. Bunun yerine yeniden HttpClient kullanılmalıdır.

Aşağıdaki uç nokta her istekte yeni HttpClient bir örnek oluşturur ve atar:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

Yük altında aşağıdaki hata iletileri günlüğe kaydedilir:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

Örnekler atılmış olsa HttpClient da, gerçek ağ bağlantısının işletim sistemi tarafından serbest bırakılması biraz zaman alır. Sürekli yeni bağlantılar oluşturularak bağlantı noktalarının tükenmesi oluşur. Her istemci bağlantısı kendi istemci bağlantı noktası gerektirir.

Bağlantı noktası tükenmesini önlemenin bir yolu aynı HttpClient örneği yeniden kullanmaktır:

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

Uygulama HttpClient durduğunda örnek serbest bırakılır. Bu örnekte her tek kullanımlık kaynağın her kullanımdan sonra atılmaması gerektiği gösterilmektedir.

Örneğin ömrünü işlemenin daha iyi bir HttpClient yolu için aşağıdakilere bakın:

Nesne havuzu oluşturma

Önceki örnekte, örneğin tüm istekler tarafından nasıl HttpClient statik hale getirilebileceği ve yeniden kullanılabilmesi gösterildi. Yeniden kullanma, kaynakların tükenmesini önler.

Nesne havuzu oluşturma:

  • Yeniden kullanım desenini kullanır.
  • Oluşturması pahalı nesneler için tasarlanmıştır.

Havuz, iş parçacıkları arasında ayrılabilen ve serbest bırakılabilen önceden başlatılmış nesneler koleksiyonudur. Havuzlar sınırlar, önceden tanımlanmış boyutlar veya büyüme oranı gibi ayırma kurallarını tanımlayabilir.

Microsoft.Extensions.ObjectPool NuGet paketi, bu tür havuzları yönetmeye yardımcı olan sınıflar içerir.

Aşağıdaki API uç noktası, her istekte rastgele sayılarla dolu bir byte arabelleğin örneğini oluşturur:

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

Aşağıdaki grafik, yukarıdaki API'yi orta düzeyde yükle çağırmayı görüntüler:

Chart showing calls to API with moderate load

Yukarıdaki grafikte, 0. nesil koleksiyonlar saniyede yaklaşık bir kez gerçekleşir.

Önceki kod, ArrayPool<T> kullanılarak arabellek havuza byte alınarak iyileştirilebilir. Statik bir örnek istekler arasında yeniden kullanılır.

Bu yaklaşımdan farklı olan, API'den havuza alınan bir nesne döndürülür. Bu da şu anlama gelir:

  • yönteminden döner dönmez nesne denetiminizden çıkar.
  • Nesneyi serbest bırakamazsınız.

Nesnenin atılması ayarlamak için:

  • Havuza alınan diziyi tek kullanımlık bir nesnede kapsülleyin.
  • Havuza alınan nesneyi HttpContext.Response.RegisterForDispose ile kaydedin.

RegisterForDispose yalnızca HTTP isteği tamamlandığında serbest bırakılabilmesi için hedef nesne üzerinde çağrıyla Dispose ilgilenir.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

Havuza alınan olmayan sürümle aynı yükün uygulanması aşağıdaki grafikte sonuçlanır:

Chart showing fewer allocations

Ana fark, ayrılan bayt sayısıdır ve sonuç olarak çok daha az 0. nesil koleksiyondur.

Ek kaynaklar