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:
- dotnet-trace: Dapat digunakan pada mesin produksi.
- Menganalisis penggunaan memori tanpa debugger Visual Studio
- Penggunaan memori profil di Visual Studio
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.
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.
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.
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.
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.
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 IMemoryCache WeakReference
.
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.
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:
Bagan berikut menunjukkan profil memori untuk memanggil /api/loh/84976
titik akhir, mengalokasikan hanya satu byte lagi:
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.
HttpClient
IDisposable
mengimplementasikan , 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:
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:
- Merangkum array yang dikumpulkan dalam objek sekali pakai.
- Daftarkan objek yang dikumpulkan dengan HttpContext.Response.RegisterForDispose.
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:
Perbedaan utama dialokasikan byte, dan sebagai konsekuensinya lebih sedikit koleksi generasi 0.
Sumber Daya Tambahan:
ASP.NET Core