Saya baru-baru ini membuat aplikasi sederhana untuk menguji throughput panggilan HTTP yang dapat dihasilkan secara asinkron vs pendekatan multithreaded klasik.
Aplikasi ini mampu melakukan jumlah panggilan HTTP yang telah ditentukan dan pada akhirnya akan menampilkan total waktu yang diperlukan untuk melakukan panggilan HTTP. Selama pengujian saya, semua panggilan HTTP dilakukan ke server IIS lokal saya dan mereka mengambil file teks kecil (berukuran 12 byte).
Bagian terpenting dari kode untuk implementasi asinkron tercantum di bawah ini:
public async void TestAsync()
{
this.TestInit();
HttpClient httpClient = new HttpClient();
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
ProcessUrlAsync(httpClient);
}
}
private async void ProcessUrlAsync(HttpClient httpClient)
{
HttpResponseMessage httpResponse = null;
try
{
Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
httpResponse = await getTask;
Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}
finally
{
if(httpResponse != null) httpResponse.Dispose();
}
lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
}
Bagian terpenting dari implementasi multithreading tercantum di bawah ini:
public void TestParallel2()
{
this.TestInit();
ServicePointManager.DefaultConnectionLimit = 100;
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
Task.Run(() =>
{
try
{
this.PerformWebRequestGet();
Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}
lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
});
}
}
private void PerformWebRequestGet()
{
HttpWebRequest request = null;
HttpWebResponse response = null;
try
{
request = (HttpWebRequest)WebRequest.Create(URL);
request.Method = "GET";
request.KeepAlive = true;
response = (HttpWebResponse)request.GetResponse();
}
finally
{
if (response != null) response.Close();
}
}
Menjalankan tes mengungkapkan bahwa versi multithread lebih cepat. Butuh sekitar 0,6 detik untuk menyelesaikan permintaan 10rb, sedangkan async membutuhkan waktu sekitar 2 detik untuk menyelesaikan jumlah beban yang sama. Ini sedikit mengejutkan, karena saya mengharapkan yang async menjadi lebih cepat. Mungkin karena fakta bahwa panggilan HTTP saya sangat cepat. Dalam skenario dunia nyata, di mana server harus melakukan operasi yang lebih bermakna dan di mana juga harus ada latensi jaringan, hasilnya mungkin terbalik.
Namun, yang benar-benar memprihatinkan saya adalah cara HttpClient berperilaku ketika beban meningkat. Karena dibutuhkan sekitar 2 detik untuk mengirimkan 10k pesan, saya pikir akan membutuhkan waktu sekitar 20 detik untuk mengirim 10 kali jumlah pesan, tetapi menjalankan tes menunjukkan bahwa diperlukan sekitar 50 detik untuk mengirimkan 100k pesan. Selain itu, biasanya diperlukan lebih dari 2 menit untuk mengirim 200 ribu pesan dan seringkali, beberapa ribu di antaranya (3-4 ribu) gagal dengan pengecualian berikut:
Operasi pada soket tidak dapat dilakukan karena sistem tidak memiliki ruang buffer yang memadai atau karena antrian penuh.
Saya memeriksa log IIS dan operasi yang gagal tidak pernah sampai ke server. Mereka gagal di dalam klien. Saya menjalankan tes pada mesin Windows 7 dengan kisaran default port ephemeral dari 49152 hingga 65535. Menjalankan netstat menunjukkan bahwa sekitar 5-6k port sedang digunakan selama pengujian, jadi secara teori seharusnya ada lebih banyak tersedia. Jika kekurangan port memang menjadi penyebab pengecualian itu berarti bahwa netstat tidak melaporkan situasi dengan benar atau HttClient hanya menggunakan jumlah maksimum port setelah itu mulai melemparkan pengecualian.
Sebaliknya, pendekatan multithread menghasilkan panggilan HTTP berperilaku sangat dapat diprediksi. Saya mengambil sekitar 0,6 detik untuk pesan 10r, sekitar 5,5 detik untuk pesan 100r dan seperti yang diharapkan sekitar 55 detik untuk 1 juta pesan. Tidak ada pesan yang gagal. Lebih jauh lagi, saat dijalankan, tidak pernah menggunakan lebih dari 55 MB RAM (menurut Windows Task Manager). Memori yang digunakan saat mengirim pesan secara serempak tumbuh secara proporsional dengan beban. Ini digunakan sekitar 500 MB RAM selama tes pesan 200k.
Saya pikir ada dua alasan utama untuk hasil di atas. Yang pertama adalah bahwa HttpClient tampaknya sangat rakus dalam membuat koneksi baru dengan server. Banyaknya port yang digunakan yang dilaporkan oleh netstat berarti kemungkinan tidak banyak manfaatnya dari HTTP keep-live.
Yang kedua adalah bahwa HttpClient tampaknya tidak memiliki mekanisme pelambatan. Sebenarnya ini tampaknya menjadi masalah umum yang terkait dengan operasi async. Jika Anda perlu melakukan operasi dalam jumlah yang sangat besar, semuanya akan dimulai sekaligus dan kemudian kelanjutannya akan dieksekusi saat tersedia. Secara teori ini harus ok, karena dalam operasi async bebannya ada pada sistem eksternal tetapi sebagaimana dibuktikan di atas ini tidak sepenuhnya terjadi. Memulai sejumlah besar permintaan sekaligus akan meningkatkan penggunaan memori dan memperlambat seluruh eksekusi.
Saya berhasil memperoleh hasil, memori, dan waktu eksekusi yang lebih baik, dengan membatasi jumlah maksimum permintaan asinkron dengan mekanisme penundaan sederhana namun primitif:
public async void TestAsyncWithDelay()
{
this.TestInit();
HttpClient httpClient = new HttpClient();
for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
await Task.Delay(DELAY_TIME);
ProcessUrlAsyncWithReqCount(httpClient);
}
}
Akan sangat berguna jika HttpClient menyertakan mekanisme untuk membatasi jumlah permintaan bersamaan. Saat menggunakan kelas Task (yang didasarkan pada .Net thread pool) pelambatan secara otomatis dicapai dengan membatasi jumlah utas bersamaan.
Untuk gambaran umum yang lengkap, saya juga telah membuat versi tes async berdasarkan HttpWebRequest daripada HttpClient dan berhasil mendapatkan hasil yang jauh lebih baik. Sebagai permulaan, ini memungkinkan pengaturan batas jumlah koneksi bersamaan (dengan ServicePointManager.DefaultConnectionLimit atau melalui config), yang berarti tidak pernah kehabisan port dan tidak pernah gagal pada permintaan apa pun (HttpClient, secara default, didasarkan pada HttpWebRequest , tetapi tampaknya mengabaikan pengaturan batas koneksi).
Pendekatan HttpWebRequest async masih sekitar 50 - 60% lebih lambat daripada yang multithreading, tetapi itu dapat diprediksi dan dapat diandalkan. Satu-satunya downside ke itu adalah bahwa ia menggunakan sejumlah besar memori di bawah beban besar. Misalnya diperlukan sekitar 1,6 GB untuk mengirim 1 juta permintaan. Dengan membatasi jumlah permintaan bersamaan (seperti yang saya lakukan di atas untuk HttpClient) saya berhasil mengurangi memori yang digunakan menjadi hanya 20 MB dan mendapatkan waktu eksekusi 10% lebih lambat daripada pendekatan multithreading.
Setelah presentasi panjang ini, pertanyaan saya adalah: Apakah kelas HttpClient dari .Net 4.5 pilihan yang buruk untuk aplikasi beban intensif? Apakah ada cara untuk melambatkannya, yang seharusnya memperbaiki masalah yang saya sebutkan? Bagaimana dengan rasa async dari HttpWebRequest?
Perbarui (terima kasih @Stephen Cleary)
Ternyata, HttpClient, seperti halnya HttpWebRequest (yang menjadi dasarnya), dapat memiliki jumlah koneksi bersamaan pada host yang sama terbatas dengan ServicePointManager.DefaultConnectionLimit. Yang aneh adalah bahwa menurut MSDN , nilai default untuk batas koneksi adalah 2. Saya juga memeriksa bahwa di sisi saya menggunakan debugger yang menunjuk bahwa memang 2 adalah nilai default. Namun, tampaknya kecuali jika secara eksplisit menetapkan nilai ke ServicePointManager.DefaultConnectionLimit, nilai default akan diabaikan. Karena saya tidak secara eksplisit menetapkan nilai untuk itu selama tes HttpClient saya, saya pikir itu diabaikan.
Setelah mengatur ServicePointManager.DefaultConnectionLimit ke 100 HttpClient menjadi andal dan dapat diprediksi (netstat mengonfirmasi bahwa hanya 100 port yang digunakan). Masih lebih lambat dari async HttpWebRequest (sekitar 40%), tetapi anehnya, ia menggunakan lebih sedikit memori. Untuk pengujian yang melibatkan 1 juta permintaan, digunakan maksimum 550 MB, dibandingkan dengan 1,6 GB di HttpWebRequest async.
Jadi, sementara HttpClient dalam kombinasi ServicePointManager.DefaultConnectionLimit tampaknya memastikan keandalan (setidaknya untuk skenario di mana semua panggilan dilakukan terhadap host yang sama), sepertinya kinerjanya dipengaruhi secara negatif oleh kurangnya mekanisme pelambatan yang tepat. Sesuatu yang akan membatasi jumlah permintaan bersamaan ke nilai yang dapat dikonfigurasi dan menempatkan sisanya dalam antrian akan membuatnya jauh lebih cocok untuk skenario skalabilitas tinggi.
SemaphoreSlim
, seperti yang telah disebutkan, atau ActionBlock<T>
dari TPL Dataflow.
HttpClient
harus menghormatiServicePointManager.DefaultConnectionLimit
.