Perangkap HttpClient di .NET

Melanjutkan serangkaian artikel tentang "jebakan" Saya tidak dapat mengabaikan System.Net.HttpClient, yang sangat sering digunakan dalam praktiknya, tetapi memiliki beberapa masalah serius yang mungkin tidak dapat segera terlihat.

Masalah yang cukup umum dalam pemrograman adalah bahwa pengembang hanya berfokus pada fungsionalitas komponen tertentu, sementara mengabaikan komponen non-fungsional yang sangat penting, yang dapat memengaruhi kinerja, skalabilitas, kemudahan pemulihan jika terjadi kegagalan, keamanan, dll. Misalnya, HttpClient yang sama tampaknya merupakan komponen dasar, tetapi ada beberapa pertanyaan: berapa banyak ia membuat koneksi paralel ke server, berapa lama mereka hidup, bagaimana akan berperilaku jika nama DNS yang diakses sebelumnya dialihkan ke alamat IP yang berbeda ? Mari kita coba jawab pertanyaan-pertanyaan ini di artikel.

  1. Koneksi bocor
  2. Batasi koneksi server bersamaan
  3. Koneksi berumur panjang dan caching DNS

Masalah pertama dengan HttpClient adalah kebocoran koneksi yang tidak jelas. Cukup sering, saya harus memenuhi kode di mana ia dibuat untuk mengeksekusi setiap permintaan:

public async Task<string> GetSomeText(Guid textId) { using (var client = new HttpClient()) { return await client.GetStringAsync($"http://someservice.com/api/v1/some-text/{textId}"); } } 

Sayangnya, pendekatan ini menyebabkan pemborosan sumber daya yang besar dan probabilitas tinggi untuk mendapatkan daftar koneksi terbuka yang melimpah. Untuk menunjukkan masalah dengan jelas, cukup menjalankan kode berikut:

 static void Main(string[] args) { for(int i = 0; i < 10; i++) { using (var client = new HttpClient()) { client.GetStringAsync("https://habr.com").Wait(); } } } 

Dan setelah selesai, lihat daftar koneksi terbuka via netstat:

 PS C: \ Development \ Exercises> netstat -n |  select-string -pattern "178.248.237.68"

   TCP 192.168.1.13:43684 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43685 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43686 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43687 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13:43689 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003690 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003691 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003692 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003693 178.248.237.68-00-0043 TIME_WAIT
   TCP 192.168.1.13-00-003695 178.248.237.68-00-0043 TIME_WAIT

Di sini, -n switch digunakan untuk mempercepat output, karena jika tidak netstat untuk setiap IP akan mencari nama domain, dan 178.248.237.68 akan mencari alamat IP habr.com pada saat penulisan ini.

Secara total, kita melihat bahwa meskipun menggunakan konstruk, dan meskipun program itu sepenuhnya selesai, koneksi ke server tetap "menggantung". Dan mereka akan menggantung selama ditunjukkan dalam kunci registri HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay.

Sebuah pertanyaan dapat segera muncul - bagaimana .NET Core berperilaku dalam kasus seperti itu? Apa yang ada di Windows, apa yang ada di Linux - persis sama, karena retensi koneksi seperti itu terjadi di tingkat sistem, dan bukan di tingkat aplikasi. Status TIME_WAIT adalah keadaan khusus soket setelah ditutup oleh aplikasi, dan ini diperlukan untuk memproses paket yang masih dapat melewati jaringan. Untuk Linux, durasi status ini ditentukan dalam detik di / proc / sys / net / ipv4 / tcp_fin_timeout, dan tentu saja itu dapat diubah jika perlu.

Masalah kedua HttpClient adalah batas koneksi konkuren yang tidak jelas ke server . Misalkan Anda menggunakan .NET Framework 4.7 yang dikenal, dengan bantuan yang Anda kembangkan layanan yang sangat dimuat, di mana ada panggilan ke layanan lain melalui HTTP. Masalah potensial dengan kebocoran koneksi telah diatasi, sehingga instance HttpClient yang sama digunakan untuk semua permintaan. Apa yang salah?

Masalahnya dapat dengan mudah dilihat dengan menjalankan kode berikut:

 static void Main(string[] args) { var client = new HttpClient(); var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

Sumber daya yang ditentukan dalam tautan memungkinkan Anda untuk menunda respons server untuk waktu yang ditentukan, dalam hal ini - 5 detik.

Karena mudah diketahui setelah mengeksekusi kode di atas - setiap 5 detik hanya 2 tanggapan yang tiba, meskipun 10 permintaan simultan dibuat. Hal ini disebabkan oleh kenyataan bahwa interaksi dengan HTTP dalam kerangka .NET reguler, antara lain, melewati System.Net.ServicePointManager kelas khusus yang mengontrol berbagai aspek koneksi HTTP. Kelas ini memiliki properti DefaultConnectionLimit yang menunjukkan berapa banyak koneksi konkuren yang dapat dibuat untuk setiap domain. Dan secara historis, nilai default sebuah properti adalah 2.

Jika Anda menambahkan contoh kode di atas di awal

 ServicePointManager.DefaultConnectionLimit = 5; 

maka eksekusi contoh akan terasa lebih cepat, karena permintaan akan dieksekusi dalam batch 5.

Dan sebelum beralih ke cara kerjanya di .NET Core, sedikit lagi harus dikatakan tentang ServicePointManager. Properti yang dibahas di atas menunjukkan jumlah koneksi default yang akan digunakan untuk koneksi berikutnya ke domain apa pun. Namun seiring dengan ini, dimungkinkan untuk mengontrol parameter untuk setiap nama domain secara individual dan ini dilakukan melalui kelas ServicePoint:

 var delayServicePoint = ServicePointManager.FindServicePoint(new Uri("http://slowwly.robertomurray.co.uk")); delayServicePoint.ConnectionLimit = 3; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.ConnectionLimit = 5; 

Setelah mengeksekusi kode ini, setiap interaksi dengan Habr melalui instance HttpClient yang sama akan menggunakan 5 koneksi simultan, dan 3 koneksi dengan situs "lambat".

Ada nuansa lain yang menarik di sini - batas jumlah koneksi untuk alamat lokal (localhost) adalah int.MaxValue secara default. Lihat saja hasil dari mengeksekusi kode ini tanpa terlebih dahulu menetapkan DefaultConnectionLimit:

 var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); Console.WriteLine(habrServicePoint.ConnectionLimit); var localServicePoint = ServicePointManager.FindServicePoint(new Uri("http://localhost")); Console.WriteLine(localServicePoint.ConnectionLimit); 

Sekarang mari kita beralih ke .NET Core. Meskipun ServicePointManager masih ada di namespace System.Net, itu tidak mempengaruhi perilaku HttpClient di .NET Core. Sebagai gantinya, parameter koneksi HTTP dapat dikontrol menggunakan HttpClientHandler (atau SocketsHttpHandler, yang akan kita bicarakan nanti):

 static void Main(string[] args) { var handler = new HttpClientHandler(); handler.MaxConnectionsPerServer = 2; var client = new HttpClient(handler); var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); Console.ReadLine(); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); } 

Contoh di atas akan berperilaku sama persis dengan contoh awal untuk .NET Framework biasa - untuk membuat hanya 2 koneksi pada suatu waktu. Tetapi jika Anda menghapus baris dengan set properti MaxConnectionsPerServer, jumlah koneksi simultan akan jauh lebih tinggi, karena secara default di .NET Core nilai properti ini adalah int.MaxValue.

Dan sekarang mari kita lihat masalah ketiga yang tidak jelas dengan pengaturan default, yang tidak kalah penting dari dua sebelumnya - koneksi berumur panjang dan caching DNS . Ketika membuat koneksi dengan server jarak jauh, nama domain pertama kali diselesaikan ke alamat IP yang sesuai, kemudian alamat yang diterima dimasukkan ke dalam cache selama beberapa waktu untuk mempercepat koneksi berikutnya. Selain itu, untuk menghemat sumber daya, paling sering koneksi tidak ditutup setelah setiap permintaan, tetapi tetap terbuka untuk waktu yang lama.

Bayangkan bahwa sistem yang kami kembangkan harus bekerja secara normal tanpa memaksa restart jika server yang berinteraksi dengannya telah berubah ke alamat IP yang berbeda. Misalnya, jika Anda beralih ke pusat data lain karena kegagalan yang sekarang. Bahkan jika koneksi permanen terputus karena kegagalan pada pusat data pertama (yang juga dapat terjadi dengan cepat), cache DNS tidak akan memungkinkan sistem kami untuk dengan cepat menanggapi perubahan seperti itu. Hal yang sama berlaku untuk panggilan ke alamat di mana penyeimbangan beban dilakukan melalui DNS round-robin.

Dalam kasus kerangka kerja β€œnormal” .NET, perilaku ini dapat dikontrol melalui ServicePointManager dan ServicePoint (semua parameter yang tercantum di bawah ini mengambil nilai dalam milidetik):

  • ServicePointManager.DnsRefreshTimeout - menunjukkan berapa lama alamat IP yang diterima untuk setiap nama domain akan di-cache, nilai standarnya adalah 2 menit (120000).
  • ServicePoint.ConnectionLeaseTimeout - Menunjukkan berapa lama koneksi dapat tetap terbuka. Secara default, tidak ada batasan waktu untuk koneksi, koneksi apa pun dapat ditahan untuk waktu yang lama, karena parameter ini -1. Mengaturnya ke 0 akan menyebabkan setiap koneksi ditutup segera setelah permintaan selesai.
  • ServicePoint.MaxIdleTime - Menunjukkan setelah berapa lama tidak aktif koneksi akan ditutup. Kelambanan berarti tidak ada transfer data melalui koneksi. Secara default, nilai parameter ini adalah 100 detik (100000).

Sekarang, untuk meningkatkan pemahaman tentang parameter-parameter ini, kami akan menggabungkan semuanya dalam satu skenario. Misalkan tidak ada yang mengubah DnsRefreshTimeout dan MaxIdleTime dan masing-masing 120 dan 100 detik. Dengan ini, ConnectionLeaseTimeout diatur ke 60 detik. Aplikasi hanya membangun satu koneksi, yang melaluinya mengirim permintaan setiap 10 detik.

Dengan pengaturan ini, koneksi akan ditutup setiap 60 detik (ConnectionLeaseTimeout), meskipun secara berkala mentransfer data. Penutupan dan penciptaan kembali akan dilakukan sedemikian rupa sehingga tidak mengganggu pelaksanaan permintaan yang benar - jika waktunya habis, dan saat ini permintaan masih dieksekusi, koneksi akan ditutup setelah permintaan selesai. Setiap kali koneksi dibuat ulang, alamat IP yang sesuai akan diambil dari cache terlebih dahulu, dan hanya jika resolusinya telah berakhir (120 detik), sistem akan mengirim permintaan ke server DNS.

Parameter MaxIdleTime tidak akan memainkan peran dalam skenario ini, karena koneksi belum menganggur selama lebih dari 10 detik.

Rasio optimal dari parameter-parameter ini sangat tergantung pada situasi spesifik dan persyaratan non-fungsional:

  • Jika Anda tidak berniat untuk berpindah alamat IP secara transparan di belakang nama domain yang diakses aplikasi Anda, dan pada saat yang sama Anda perlu meminimalkan biaya koneksi jaringan, maka pengaturan default terlihat seperti pilihan yang baik.
  • Jika ada kebutuhan untuk beralih antara alamat IP jika terjadi kegagalan, Anda dapat mengatur DnsRefreshTimeout ke 0, dan ConnectionLeaseTimeout ke nilai non-negatif yang cocok untuk Anda. Yang mana secara spesifik tergantung pada seberapa cepat Anda perlu beralih ke IP lain. Jelas, Anda ingin memiliki respons tercepat yang mungkin terhadap kegagalan, tetapi di sini Anda perlu menemukan nilai optimal, yang, di satu sisi, memberikan waktu switching yang dapat diterima, di sisi lain, tidak menurunkan throughput sistem dan waktu respons dengan menghubungkan kembali terlalu sering.
  • Jika Anda memerlukan reaksi secepat mungkin untuk mengubah alamat IP, misalnya, seperti dalam hal menyeimbangkan melalui DNS round-robin, Anda dapat mencoba mengatur DnsRefreshTimeout dan ConnectionLeaseTimeout ke 0, tetapi ini akan sangat boros: untuk setiap permintaan, server DNS akan disurvei terlebih dahulu, setelah itu Koneksi ke node target akan dibangun kembali.
  • Mungkin ada situasi di mana pengaturan ConnectionLeaseTimeout ke 0 dengan DnsRefreshTimeout yang tidak nol dapat berguna, tetapi saya tidak dapat segera membuat skrip yang sesuai. Secara logis, ini berarti bahwa untuk setiap permintaan, koneksi akan dibuat lagi, tetapi alamat IP akan diambil dari cache bila memungkinkan.

Berikut ini adalah contoh kode yang dapat digunakan untuk mengamati perilaku parameter yang dijelaskan di atas:
 var client = new HttpClient(); ServicePointManager.DnsRefreshTimeout = 120000; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.MaxIdleTime = 100000; habrServicePoint.ConnectionLeaseTimeout = 60000; while (true) { client.GetAsync("https://habr.com").Wait(); Thread.Sleep(10000); } 

Saat program pengujian sedang berjalan, Anda dapat menjalankan netstat melalui PowerShell dalam satu lingkaran untuk memantau koneksi yang dibuatnya.

Segera harus dikatakan bagaimana mengelola parameter yang dijelaskan dalam .NET Core. Pengaturan dari ServicePointManager, seperti dalam kasus ConnectionLimit, tidak akan berfungsi. Core memiliki tipe khusus HTTP handler yang mengimplementasikan dua dari tiga parameter yang dijelaskan di atas - SocketsHttpHandler:

 var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60); // ConnectionLeaseTimeout handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(100); // MaxIdleTime var client = new HttpClient(handler); 

Tidak ada parameter yang mengontrol waktu caching catatan DNS di .NET Core. Kasus uji menunjukkan bahwa caching tidak berfungsi - saat membuat koneksi DNS baru, resolusi dilakukan lagi, jadi untuk operasi normal dalam kondisi ketika nama domain yang diminta dapat beralih di antara alamat IP yang berbeda, cukup atur PooledConnectionLifetime ke nilai yang diinginkan.

Selain semuanya, harus dikatakan bahwa semua masalah ini tidak dapat diketahui oleh pengembang dari Microsoft, dan oleh karena itu, dimulai dengan .NET Core 2.1, muncul pabrik klien HTTP yang memungkinkan penyelesaian beberapa di antaranya - https://docs.microsoft.com/en- us / dotnet / standard / microservices-architecture / implement-resilient-apps / use-httpclientfactory-factory-to-implement-resilient-http-request . Selain itu, selain mengelola masa pakai koneksi, komponen baru memberikan peluang untuk membuat klien yang diketik, serta beberapa hal berguna lainnya. Dalam artikel ini dan tautan darinya terdapat cukup informasi dan contoh tentang penggunaan HttpClientFactory, oleh karena itu, saya tidak akan mempertimbangkan rincian yang terkait dengannya di artikel ini.

Source: https://habr.com/ru/post/id424873/


All Articles