Apakah async HttpClient dari .Net 4.5 pilihan yang buruk untuk aplikasi beban intensif?

130

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.

Florin Dumitrescu
sumber
4
HttpClientharus menghormati ServicePointManager.DefaultConnectionLimit.
Stephen Cleary
2
Pengamatan Anda tampaknya layak untuk diselidiki. Satu hal yang mengganggu saya: saya pikir sangat sulit untuk mengeluarkan ribuan IOs async sekaligus. Saya tidak akan pernah melakukan ini dalam produksi. Fakta bahwa Anda async tidak berarti bahwa Anda bisa menjadi gila memakan berbagai sumber daya. (Sampel resmi Microsoft juga sedikit menyesatkan dalam hal itu.)
usr
1
Namun, jangan mencekik dengan penundaan waktu. Throttle pada tingkat konkurensi tetap yang Anda tentukan secara empiris. Solusi sederhana adalah SemaphoreSlim.WaitAsync meskipun itu juga tidak cocok untuk tugas-tugas besar dalam jumlah besar.
usr
1
@FlorinDumitrescu Untuk pembatasan, Anda dapat menggunakan SemaphoreSlim, seperti yang telah disebutkan, atau ActionBlock<T>dari TPL Dataflow.
svick
1
@vick, terima kasih atas saran Anda. Saya tidak tertarik menerapkan mekanisme pembatasan throttling / konkurensi secara manual. Seperti disebutkan, implementasi yang termasuk dalam pertanyaan saya hanya untuk pengujian dan untuk memvalidasi teori. Saya tidak berusaha memperbaikinya, karena tidak akan sampai ke produksi. Yang saya tertarik adalah jika .Net framework menawarkan mekanisme bawaan untuk membatasi konkurensi operasi IO async (termasuk HttpClient).
Florin Dumitrescu

Jawaban:

64

Selain tes yang disebutkan dalam pertanyaan, saya baru-baru ini membuat beberapa yang baru melibatkan panggilan HTTP jauh lebih sedikit (5000 dibandingkan dengan 1 juta sebelumnya) tetapi pada permintaan yang membutuhkan waktu lebih lama untuk mengeksekusi (500 milidetik dibandingkan dengan sekitar 1 milidetik sebelumnya). Kedua aplikasi penguji, yang multithread secara sinkron (berdasarkan HttpWebRequest) dan satu / tidak sinkron I / O (berdasarkan klien HTTP) menghasilkan hasil yang serupa: sekitar 10 detik untuk mengeksekusi menggunakan sekitar 3% dari CPU dan 30 MB memori. Satu-satunya perbedaan antara kedua penguji adalah bahwa yang multithreaded menggunakan 310 thread untuk mengeksekusi, sedangkan yang asynchronous hanya 22.

Sebagai kesimpulan untuk pengujian saya, panggilan HTTP asinkron bukan pilihan terbaik saat menangani permintaan yang sangat cepat. Alasan di balik itu adalah bahwa ketika menjalankan tugas yang berisi panggilan I / O asinkron, utas di mana tugas dimulai dihentikan segera setelah panggilan asinkron dibuat dan sisa tugas terdaftar sebagai panggilan balik. Kemudian, ketika operasi I / O selesai, callback diantri untuk dieksekusi pada utas pertama yang tersedia. Semua ini menciptakan overhead, yang membuat operasi I / O cepat menjadi lebih efisien ketika dijalankan pada utas yang memulai mereka.

Panggilan HTTP asinkron adalah pilihan yang baik ketika berhadapan dengan operasi I / O yang lama atau berpotensi lama karena tidak membuat utas sibuk menunggu operasi I / O selesai. Ini mengurangi jumlah keseluruhan utas yang digunakan oleh aplikasi yang memungkinkan lebih banyak waktu CPU untuk dihabiskan oleh operasi yang terikat CPU. Selain itu, pada aplikasi yang hanya mengalokasikan sejumlah thread (seperti halnya dengan aplikasi web), I / O asinkron mencegah penipisan thread pool thread, yang dapat terjadi jika melakukan panggilan I / O secara serempak.

Jadi, async HttpClient bukanlah hambatan untuk aplikasi beban intensif. Hanya karena sifatnya itu tidak sangat cocok untuk permintaan HTTP yang sangat cepat, sebaliknya sangat ideal untuk permintaan yang lama atau berpotensi lama, terutama di dalam aplikasi yang hanya memiliki sejumlah utas yang tersedia. Juga, ini adalah praktik yang baik untuk membatasi konkurensi melalui ServicePointManager.DefaultConnectionLimit dengan nilai yang cukup tinggi untuk memastikan tingkat paralelisme yang baik, tetapi cukup rendah untuk mencegah penipisan porta sesaat. Anda dapat menemukan rincian lebih lanjut tentang tes dan kesimpulan yang disajikan untuk pertanyaan ini di sini .

Florin Dumitrescu
sumber
3
Seberapa cepat "sangat cepat"? 1 ms? 100 ms? 1.000 ms?
Tim P.
Saya menggunakan sesuatu seperti pendekatan "async" Anda untuk memutar ulang beban pada server web WebLogic yang digunakan pada Windows, tapi saya mendapatkan masalah deplesi port ephemical, agak cepat. Saya belum menyentuh ServicePointManager.DefaultConnectionLimit, dan saya membuang dan membuat kembali semuanya (HttpClient dan respons) pada setiap permintaan. Apakah Anda tahu apa yang menyebabkan koneksi tetap terbuka, dan menghabiskan port?
Iravanchi
@TimP. untuk pengujian saya, seperti yang disebutkan di atas, "sangat cepat" adalah permintaan yang hanya membutuhkan 1 milidetik untuk diselesaikan. Di dunia nyata ini akan selalu bersifat subjektif. Dari sudut pandang saya, sesuatu yang setara dengan kueri kecil pada basis data jaringan lokal, dapat dianggap cepat, sementara sesuatu yang setara dengan panggilan API melalui internet, dapat dianggap lambat atau berpotensi lambat.
Florin Dumitrescu
1
@Iravanchi, dalam pendekatan "async", pengiriman permintaan dan penanganan respons dilakukan secara terpisah. Jika Anda memiliki banyak panggilan, semua permintaan akan dikirim dengan sangat cepat dan respons akan ditangani ketika mereka tiba. Karena Anda hanya dapat membuang koneksi setelah responsnya tiba, sejumlah besar koneksi konkuren dapat menumpuk dan menghabiskan porta fana Anda. Anda harus membatasi jumlah maksimum koneksi konkuren menggunakan ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu
1
@FlorinDumitrescu, saya juga menambahkan bahwa panggilan jaringan pada dasarnya tidak dapat diprediksi. Hal-hal yang berjalan dalam 10ms, 90% dari waktu dapat menyebabkan masalah pemblokiran ketika sumber daya jaringan itu macet atau tidak tersedia 10% lainnya dari waktu.
Tim P.
27

Satu hal yang perlu dipertimbangkan yang mungkin mempengaruhi hasil Anda adalah bahwa dengan HttpWebRequest Anda tidak mendapatkan ResponseStream dan mengonsumsi aliran itu. Dengan HttpClient, secara default akan menyalin aliran jaringan ke aliran memori. Untuk menggunakan HttpClient dengan cara yang sama seperti saat ini Anda menggunakan HttpWebRquest, Anda harus melakukan

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

Hal lain adalah bahwa saya tidak begitu yakin apa bedanya, dari perspektif threading, Anda benar-benar menguji. Jika Anda menggali lebih dalam dari HttpClientHandler, ia hanya melakukan Task.Factory.StartNew untuk melakukan permintaan async. Perilaku threading didelegasikan ke konteks sinkronisasi dengan cara yang persis sama seperti contoh Anda dengan contoh HttpWebRequest dilakukan.

Tidak diragukan lagi, HttpClient menambahkan beberapa overhead sebagai standar menggunakan HttpWebRequest sebagai pustaka transportnya. Jadi, Anda akan selalu bisa mendapatkan performa yang lebih baik dengan HttpWebRequest secara langsung saat menggunakan HttpClientHandler. Manfaat yang dibawa HttpClient adalah dengan kelas standar seperti HttpResponseMessage, HttpRequestMessage, HttpContent dan semua header yang diketik dengan kuat. Dalam dirinya sendiri itu bukan optimasi perf.

Darrel Miller
sumber
(jawaban lama, tetapi) HttpClienttampaknya mudah digunakan dan saya pikir asinkron adalah cara untuk pergi, tetapi tampaknya ada banyak "tapi-dan-jika" di sekitar ini. Mungkin HttpClientharus ditulis ulang agar lebih intuitif untuk digunakan? Atau bahwa dokumentasi itu benar-benar menekankan hal-hal penting tentang cara menggunakannya secara efisien?
mortb
@mortb, Flurl.Http flurl.io lebih intuitif untuk menggunakan pembungkus HttpClient
Michael Freidgeim
1
@MichaelFreidgeim: Terima kasih, walaupun saya sudah belajar untuk hidup dengan HttpClient sekarang ...
mortb
17

Meskipun ini tidak secara langsung menjawab bagian 'async' dari pertanyaan OP, ini mengatasi kesalahan dalam implementasi yang ia gunakan.

Jika Anda ingin skala aplikasi Anda, hindari menggunakan HttpClients berbasis instance. Perbedaannya adalah BESAR! Tergantung pada bebannya, Anda akan melihat angka kinerja yang sangat berbeda. HttpClient dirancang untuk digunakan kembali di seluruh permintaan. Ini dikonfirmasi oleh orang-orang di tim BCL yang menulisnya.

Sebuah proyek baru-baru ini yang saya miliki adalah untuk membantu pengecer komputer online yang sangat besar dan terkenal untuk meningkatkan traffic Black Friday / holiday untuk beberapa sistem baru. Kami mengalami beberapa masalah kinerja seputar penggunaan HttpClient. Sejak diterapkan IDisposable, para devs melakukan apa yang biasanya Anda lakukan dengan membuat sebuah instance dan menempatkannya di dalam sebuah using()pernyataan. Setelah kami mulai memuat pengujian, aplikasi membuat server bertekuk lutut - ya, server bukan hanya aplikasi. Alasannya adalah bahwa setiap instance dari HttpClient membuka Port Penyelesaian I / O di server. Karena finalisasi GC yang non-deterministik dan fakta bahwa Anda bekerja dengan sumber daya komputer yang menjangkau beberapa lapisan OSI , menutup port jaringan dapat memakan waktu cukup lama. Bahkan OS Windows sendiridapat memakan waktu hingga 20 detik untuk menutup port (per Microsoft). Kami membuka port lebih cepat daripada yang bisa ditutup - kelelahan port server yang memalu CPU hingga 100%. Perbaikan saya adalah mengubah HttpClient menjadi instance statis yang menyelesaikan masalah. Ya, ini adalah sumber daya sekali pakai, tetapi setiap overhead jauh lebih besar daripada perbedaan dalam kinerja. Saya mendorong Anda untuk melakukan beberapa pengujian beban untuk melihat bagaimana perilaku aplikasi Anda.

Juga dijawab di tautan di bawah ini:

Apa overhead untuk membuat HttpClient baru per panggilan dalam klien WebAPI?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client

Dave Black
sumber
Saya telah menemukan masalah yang sama persis membuat kelelahan port TCP pada klien. Solusinya adalah dengan menyewa instance HttpClient untuk periode waktu yang lama di mana panggilan berulang dilakukan, bukan membuat dan membuang untuk setiap panggilan. Kesimpulan yang saya capai adalah "Hanya karena menerapkan Buang, itu tidak berarti murah untuk Buang itu".
PhillipH
jadi jika HttpClient statis, dan saya perlu mengubah header pada permintaan berikutnya, apa hubungannya dengan permintaan pertama? Apakah ada salahnya mengubah HttpClient karena statis - seperti mengeluarkan HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Misalnya, jika saya memiliki pengguna yang mengautentikasi melalui token, token tersebut perlu ditambahkan sebagai tajuk atas permintaan ke API, yang merupakan token yang berbeda. Tidakkah menjadikan HttpClient sebagai statis dan kemudian mengubah tajuk ini pada HttpClient memiliki dampak buruk?
crizzwald
Jika Anda perlu menggunakan anggota instance HttpClient seperti header / cookie, dll. Anda tidak boleh menggunakan HttpClient statis. Kalau tidak, data instan Anda (header, cookie) akan sama untuk setiap permintaan - tentu BUKAN apa yang Anda inginkan.
Dave Black
karena ini masalahnya ... bagaimana Anda mencegah apa yang Anda gambarkan di atas di pos Anda - terhadap muatan? memuat penyeimbang dan melemparkan lebih banyak server ke dalamnya?
crizzwald
@crizzwald - Dalam posting saya, saya mencatat solusi yang digunakan. Gunakan contoh statis HttpClient. Jika Anda perlu menggunakan header / cookie pada HttpClient, saya akan mencari untuk menggunakan alternatif.
Dave Black