Manajemen memori dan pengumpulan sampah (GC) di ASP.NET Core

Oleh Sébastien Ros dan Rick Anderson

Manajemen memori kompleks, bahkan dalam kerangka kerja terkelola seperti .NET. Menganalisis dan memahami masalah memori bisa menjadi tantangan. Artikel ini:

  • Termotivasi oleh banyak kebocoran memori dan GC tidak bekerja masalah. Sebagian besar masalah ini disebabkan oleh tidak memahami cara kerja konsumsi memori di .NET Core, atau tidak memahami cara mengukurnya.
  • Menunjukkan penggunaan memori yang bermasalah, dan menyarankan pendekatan alternatif.

Cara kerja pengumpulan sampah (GC) di .NET Core

GC mengalokasikan segmen tumpukan di mana setiap segmen adalah rentang memori yang bersebelahan. Objek yang ditempatkan dalam tumpukan dikategorikan ke dalam salah satu dari 3 generasi: 0, 1, atau 2. Pembuatan menentukan frekuensi upaya GC untuk merilis memori pada objek terkelola yang tidak lagi direferensikan oleh aplikasi. Generasi bernomor yang lebih rendah lebih sering GC.

Objek dipindahkan dari satu generasi ke generasi lainnya berdasarkan masa pakainya. Karena objek hidup lebih lama, objek dipindahkan ke generasi yang lebih tinggi. Seperti disebutkan sebelumnya, generasi yang lebih tinggi jarang GC. Objek jangka pendek yang hidup selalu tetap berada di generasi 0. Misalnya, objek yang dirujuk selama masa pakai permintaan web berumur pendek. Singleton tingkat aplikasi umumnya bermigrasi ke generasi 2.

Saat aplikasi ASP.NET Core dimulai, GC:

  • Mencadangkan beberapa memori untuk segmen timbunan awal.
  • Menerapkan sebagian kecil memori saat runtime dimuat.

Alokasi memori sebelumnya dilakukan karena alasan performa. Keuntungan performa berasal dari segmen tumpuk dalam memori yang bersebelah.

GC. Mengumpulkan peringatan

Secara umum, aplikasi ASP.NET Core dalam produksi tidak boleh menggunakan GC. Kumpulkan secara eksplisit. Menginduksi pengumpulan sampah pada waktu sub-optimal dapat menurunkan performa secara signifikan.

GC. Kumpulkan berguna saat menyelidiki kebocoran memori. Panggilan GC.Collect() memicu siklus pengumpulan sampah pemblokiran yang mencoba merebut kembali semua objek yang tidak dapat diakses dari kode terkelola. Ini adalah cara yang berguna untuk memahami ukuran objek langsung yang dapat dijangkau dalam timbunan, dan melacak pertumbuhan ukuran memori dari waktu ke waktu.

Menganalisis penggunaan memori aplikasi

Alat khusus dapat membantu menganalisis penggunaan memori:

  • Menghitung referensi objek
  • Mengukur seberapa besar dampak GC terhadap penggunaan CPU
  • Mengukur ruang memori yang digunakan untuk setiap generasi

Gunakan alat berikut untuk menganalisis penggunaan memori:

Mendeteksi masalah memori

Task Manager dapat digunakan untuk mendapatkan gambaran tentang berapa banyak memori yang digunakan aplikasi ASP.NET. Nilai memori Task Manager:

  • Mewakili jumlah memori yang digunakan oleh proses ASP.NET.
  • Termasuk objek hidup aplikasi dan konsumen memori lainnya seperti penggunaan memori asli.

Jika nilai memori Task Manager meningkat tanpa batas waktu dan tidak pernah merata, aplikasi memiliki kebocoran memori. Bagian berikut menunjukkan dan menjelaskan beberapa pola penggunaan memori.

Contoh aplikasi penggunaan memori tampilan

Aplikasi sampel MemoryLeak tersedia di GitHub. Aplikasi MemoryLeak:

  • Termasuk pengontrol diagnostik yang mengumpulkan memori real-time dan data GC untuk aplikasi.
  • Memiliki halaman Indeks yang menampilkan memori dan data GC. Halaman Indeks disegarkan setiap detik.
  • Berisi pengontrol API yang menyediakan berbagai pola beban memori.
  • Namun, ini bukan alat yang didukung untuk menampilkan pola penggunaan memori aplikasi ASP.NET Core.

Jalankan MemoryLeak. Memori yang dialokasikan perlahan meningkat sampai GC terjadi. Memori meningkat karena alat ini mengalokasikan objek kustom untuk mengambil data. Gambar berikut menunjukkan halaman Indeks MemoryLeak saat Gen 0 GC terjadi. Bagan menunjukkan 0 RPS (Permintaan per detik) karena tidak ada titik akhir API dari pengontrol API yang telah dipanggil.

Chart showing 0 Requests Per Second (RPS)

Bagan menampilkan dua nilai untuk penggunaan memori:

  • Dialokasikan: jumlah memori yang ditempati oleh objek terkelola
  • Set kerja: Kumpulan halaman di ruang alamat virtual proses yang saat ini tinggal dalam memori fisik. Set kerja yang ditampilkan adalah nilai yang sama dengan yang ditampilkan Task Manager.

Objek sementara

API berikut membuat instans String 10 KB dan mengembalikannya ke klien. Pada setiap permintaan, objek baru dialokasikan dalam memori dan ditulis ke respons. String disimpan sebagai karakter UTF-16 di .NET sehingga setiap karakter mengambil 2 byte dalam memori.

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

Grafik berikut dihasilkan dengan beban yang relatif kecil untuk menunjukkan bagaimana alokasi memori terpengaruh oleh GC.

Graph showing memory allocations for a relatively small load

Bagan sebelumnya menunjukkan:

  • RPS 4K (Permintaan per detik).
  • Koleksi GC Generasi 0 terjadi sekitar setiap dua detik.
  • Set kerja konstan pada sekitar 500 MB.
  • CPU adalah 12%.
  • Konsumsi dan rilis memori (melalui GC) stabil.

Bagan berikut diambil pada throughput maks yang dapat ditangani oleh mesin.

Chart showing max throughput

Bagan sebelumnya menunjukkan:

  • RP 22K
  • Koleksi GC Generasi 0 terjadi beberapa kali per detik.
  • Koleksi Generasi 1 dipicu karena aplikasi mengalokasikan lebih banyak memori per detik secara signifikan.
  • Set kerja konstan pada sekitar 500 MB.
  • CPU adalah 33%.
  • Konsumsi dan rilis memori (melalui GC) stabil.
  • CPU (33%) tidak terlalu banyak digunakan, oleh karena itu pengumpulan sampah dapat mengikuti jumlah alokasi yang tinggi.

Workstation GC vs. Server GC

.NET Garbage Collector memiliki dua mode berbeda:

  • Workstation GC: Dioptimalkan untuk desktop.
  • Server GC. GC default untuk aplikasi ASP.NET Core. Dioptimalkan untuk server.

Mode GC dapat diatur secara eksplisit dalam file proyek atau dalam runtimeconfig.json file aplikasi yang diterbitkan. Markup berikut menunjukkan pengaturan ServerGarbageCollection dalam file proyek:

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

Mengubah ServerGarbageCollection dalam file proyek mengharuskan aplikasi dibangun kembali.

Catatan: Pengumpulan sampah server tidak tersedia pada komputer dengan satu inti. Untuk informasi selengkapnya, lihat IsServerGC .

Gambar berikut menunjukkan profil memori di bawah RPS 5K menggunakan Workstation GC.

Chart showing memory profile for a Workstation GC

Perbedaan antara bagan ini dan versi server signifikan:

  • Set kerja turun dari 500 MB ke 70 MB.
  • GC melakukan koleksi generasi 0 beberapa kali per detik alih-alih setiap dua detik.
  • GC turun dari 300 MB menjadi 10 MB.

Pada lingkungan server web yang khas, penggunaan CPU lebih penting daripada memori, oleh karena itu Server GC lebih baik. Jika pemanfaatan memori tinggi dan penggunaan CPU relatif rendah, Workstation GC mungkin lebih berkinerja. Misalnya, kepadatan tinggi yang menghosting beberapa aplikasi web di mana memori langka.

GC menggunakan Docker dan kontainer kecil

Saat beberapa aplikasi kontainer berjalan di satu komputer, Workstation GC mungkin lebih berkinerja daripada Server GC. Untuk informasi selengkapnya, lihat Menjalankan dengan Server GC dalam Kontainer Kecil dan Berjalan dengan Server GC dalam Skenario Kontainer Kecil Bagian 1 - Batas Keras untuk Tumpukan GC.

Referensi objek persisten

GC tidak dapat membebaskan objek yang dirujuk. Objek yang dirujuk tetapi tidak lagi diperlukan mengakibatkan kebocoran memori. Jika aplikasi sering mengalokasikan objek dan gagal membebaskannya setelah tidak lagi diperlukan, penggunaan memori akan meningkat dari waktu ke waktu.

API berikut membuat instans String 10 KB dan mengembalikannya ke klien. Perbedaan dengan contoh sebelumnya adalah bahwa instans ini dirujuk oleh anggota statis, yang berarti tidak pernah tersedia untuk koleksi.

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;
}

Kode sebelumnya:

  • Adalah contoh kebocoran memori yang khas.
  • Dengan panggilan yang sering, menyebabkan memori aplikasi meningkat hingga proses crash dengan OutOfMemory pengecualian.

Chart showing a memory leak

Pada gambar sebelumnya:

  • Pengujian /api/staticstring beban titik akhir menyebabkan peningkatan memori linier.
  • GC mencoba membebaskan memori saat tekanan memori tumbuh, dengan memanggil koleksi generasi 2.
  • GC tidak dapat membebaskan memori yang bocor. Alokasi dan set kerja meningkat dengan waktu.

Beberapa skenario, seperti penembolokan, mengharuskan referensi objek ditahan hingga tekanan memori memaksa mereka untuk dilepaskan. Kelas WeakReference dapat digunakan untuk jenis kode penembolokan ini. Objek WeakReference dikumpulkan di bawah tekanan memori. Implementasi default penggunaan IMemoryCacheWeakReference.

Memori asli

Beberapa objek .NET Core mengandalkan memori asli. Memori asli tidak dapat dikumpulkan oleh GC. Objek .NET yang menggunakan memori asli harus membebaskannya menggunakan kode asli.

.NET menyediakan IDisposable antarmuka untuk memungkinkan pengembang merilis memori asli. Bahkan jika Dispose tidak dipanggil, kelas yang diimplementasikan dengan benar memanggil Dispose saat finalizer berjalan.

Pertimbangkan gambar berikut:

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

PhysicalFileProvider adalah kelas terkelola, sehingga instans apa pun akan dikumpulkan di akhir permintaan.

Gambar berikut menunjukkan profil memori saat memanggil fileprovider API terus menerus.

Chart showing a native memory leak

Bagan sebelumnya menunjukkan masalah yang jelas dengan implementasi kelas ini, karena terus meningkatkan penggunaan memori. Ini adalah masalah yang diketahui yang sedang dilacak dalam masalah ini.

Kebocoran yang sama dapat terjadi dalam kode pengguna, dengan salah satu hal berikut:

  • Tidak merilis kelas dengan benar.
  • Lupa memanggil Dispose metode objek dependen yang harus dibuang.

Timbunan objek besar

Alokasi memori/siklus bebas yang sering dapat memfragmentasi memori, terutama ketika mengalokasikan gugus memori yang besar. Objek dialokasikan dalam blok memori yang berdekatan. Untuk mengurangi fragmentasi, ketika GC membebaskan memori, GC mencoba mendefragmentasinya. Proses ini disebut pemadatan. Pemadatan melibatkan objek bergerak. Memindahkan objek besar memberlakukan penalti performa. Untuk alasan ini, GC membuat zona memori khusus untuk objek besar , yang disebut tumpukan objek besar (LOH). Objek yang lebih besar dari 85.000 byte (sekitar 83 KB) adalah:

  • Ditempatkan di LOH.
  • Tidak dikompresi.
  • Dikumpulkan selama GC generasi 2.

Ketika LOH penuh, GC akan memicu koleksi generasi 2. Koleksi Generasi 2:

  • Secara inheren lambat.
  • Selain itu dikenakan biaya pemicu koleksi pada semua generasi lainnya.

Kode berikut segera memampatkan LOH:

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

Lihat LargeObjectHeapCompactionMode untuk informasi tentang memampatkan LOH.

Dalam kontainer yang menggunakan .NET Core 3.0 dan yang lebih baru, LOH secara otomatis dikompresi.

API berikut yang mengilustrasikan perilaku ini:

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

Bagan berikut menunjukkan profil memori memanggil /api/loh/84975 titik akhir, di bawah beban maksimum:

Chart showing memory profile of allocating bytes

Bagan berikut menunjukkan profil memori untuk memanggil /api/loh/84976 titik akhir, mengalokasikan hanya satu byte lagi:

Chart showing memory profile of allocating one more byte

Catatan: Struktur byte[] memiliki byte overhead. Itu sebabnya 84.976 byte memicu batas 85.000.

Membandingkan dua bagan sebelumnya:

  • Set kerja mirip untuk kedua skenario, sekitar 450 MB.
  • Di bawah permintaan LOH (84.975 byte) menunjukkan sebagian besar koleksi generasi 0.
  • Permintaan LOH yang lebih banyak menghasilkan koleksi generasi 2 yang konstan. Koleksi Generasi 2 mahal. Lebih banyak CPU diperlukan dan throughput turun hampir 50%.

Objek besar sementara sangat bermasalah karena menyebabkan GC gen2.

Untuk performa maksimum, penggunaan objek besar harus diminimalkan. Jika memungkinkan, pisahkan objek besar. Misalnya, middleware Penembolokan Respons di ASP.NET Core membagi entri cache menjadi blok kurang dari 85.000 byte.

Tautan berikut menunjukkan pendekatan ASP.NET Core untuk menyimpan objek di bawah batas LOH:

Untuk informasi selengkapnya, lihat:

HttpClient

Salah menggunakan HttpClient dapat mengakibatkan kebocoran sumber daya. Sumber daya sistem, seperti koneksi database, soket, handel file, dll.:

  • Lebih langka daripada memori.
  • Lebih bermasalah ketika bocor daripada memori.

Pengembang .NET berpengalaman tahu untuk memanggil Dispose objek yang mengimplementasikan IDisposable. Tidak membuang objek yang mengimplementasikan IDisposable biasanya menghasilkan memori bocor atau sumber daya sistem yang bocor.

HttpClientIDisposablemengimplementasikan , tetapi tidak boleh dibuang pada setiap pemanggilan. Sebaliknya, HttpClient harus digunakan kembali.

Titik akhir berikut membuat dan membuang instans baru HttpClient pada setiap permintaan:

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

Di bawah beban, pesan kesalahan berikut dicatat:

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)

Meskipun HttpClient instans dibuang, koneksi jaringan aktual membutuhkan waktu untuk dirilis oleh sistem operasi. Dengan terus membuat koneksi baru, kelelahan port terjadi. Setiap koneksi klien memerlukan port kliennya sendiri.

Salah satu cara untuk mencegah kelelahan port adalah dengan menggunakan kembali instans yang sama HttpClient :

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;
}

HttpClient Instans dirilis saat aplikasi berhenti. Contoh ini menunjukkan bahwa tidak setiap sumber daya sekali pakai harus dibuang setelah setiap penggunaan.

Lihat berikut ini untuk cara yang lebih baik untuk menangani masa HttpClient pakai instans:

Pengumpulan objek

Contoh sebelumnya menunjukkan bagaimana HttpClient instans dapat dibuat statis dan digunakan kembali oleh semua permintaan. Penggunaan kembali mencegah kehabisan sumber daya.

Pengumpulan objek:

  • Menggunakan pola penggunaan kembali.
  • Dirancang untuk objek yang mahal untuk dibuat.

Kumpulan adalah kumpulan objek prainisialisasi yang dapat dipesan dan dirilis di seluruh utas. Kumpulan dapat menentukan aturan alokasi seperti batas, ukuran yang telah ditentukan sebelumnya, atau tingkat pertumbuhan.

Paket NuGet Microsoft.Extensions.ObjectPool berisi kelas yang membantu mengelola kumpulan tersebut.

Titik akhir API berikut membuat instans byte buffer yang diisi dengan angka acak pada setiap permintaan:

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

            return array;
        }

Tampilan bagan berikut memanggil API sebelumnya dengan beban sedang:

Chart showing calls to API with moderate load

Pada bagan sebelumnya, koleksi generasi 0 terjadi sekitar sekali per detik.

Kode sebelumnya dapat dioptimalkan dengan mengumpulkan byte buffer dengan menggunakan ArrayPool<T>. Instans statis digunakan kembali di seluruh permintaan.

Apa yang berbeda dengan pendekatan ini adalah bahwa objek yang dikumpulkan dikembalikan dari API. Itu berarti:

  • Objek berada di luar kendali Anda segera setelah Anda kembali dari metode .
  • Anda tidak dapat melepaskan objek.

Untuk menyiapkan pembuangan objek:

RegisterForDispose akan mengurus panggilan Dispose pada objek target sehingga hanya dirilis ketika permintaan HTTP selesai.

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;
}

Menerapkan beban yang sama seperti versi yang tidak dikumpulkan menghasilkan bagan berikut:

Chart showing fewer allocations

Perbedaan utama dialokasikan byte, dan sebagai konsekuensinya lebih sedikit koleksi generasi 0.

Sumber Daya Tambahan: