Async HttpClient di .Net 4.5 non è una buona scelta per le applicazioni a carico intensivo?


130

Di recente ho creato una semplice applicazione per testare il throughput delle chiamate HTTP che può essere generato in modo asincrono rispetto a un approccio multithread classico.

L'applicazione è in grado di eseguire un numero predefinito di chiamate HTTP e alla fine visualizza il tempo totale necessario per eseguirle. Durante i miei test, tutte le chiamate HTTP sono state fatte al mio server IIS locale e hanno recuperato un piccolo file di testo (12 byte di dimensione).

La parte più importante del codice per l'implementazione asincrona è elencata di seguito:

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

La parte più importante dell'implementazione del multithreading è elencata di seguito:

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

L'esecuzione dei test ha rivelato che la versione multithread era più veloce. Sono stati necessari circa 0,6 secondi per il completamento di 10k richieste, mentre quello asincrono ha richiesto circa 2 secondi per completare lo stesso carico. Questa è stata una sorpresa, perché mi aspettavo che quello asincrono fosse più veloce. Forse è stato per il fatto che le mie chiamate HTTP sono state molto veloci. In uno scenario del mondo reale, in cui il server dovrebbe eseguire un'operazione più significativa e dove dovrebbe esserci anche una certa latenza di rete, i risultati potrebbero essere invertiti.

Tuttavia, ciò che mi preoccupa davvero è il modo in cui HttpClient si comporta quando il carico viene aumentato. Dal momento che sono necessari circa 2 secondi per recapitare i messaggi 10k, ho pensato che sarebbero stati necessari circa 20 secondi per consegnare 10 volte il numero di messaggi, ma l'esecuzione del test ha dimostrato che occorrono circa 50 secondi per consegnare i messaggi 100k. Inoltre, di solito ci vogliono più di 2 minuti per consegnare 200k messaggi e spesso, alcune migliaia (3-4k) falliscono con la seguente eccezione:

Non è stato possibile eseguire un'operazione su un socket perché il sistema mancava di spazio sufficiente nel buffer o perché una coda era piena.

Ho controllato i registri IIS e le operazioni non riuscite non sono mai arrivate al server. Hanno fallito all'interno del client. Ho eseguito i test su una macchina Windows 7 con l'intervallo predefinito di porte effimere da 49152 a 65535. L'esecuzione di netstat ha mostrato che durante i test venivano utilizzate circa 5-6k porte, quindi in teoria ce ne sarebbero state molte altre disponibili. Se la mancanza di porte è stata effettivamente la causa delle eccezioni, significa che o netstat non ha segnalato correttamente la situazione o HttClient utilizza solo un numero massimo di porte dopo di che inizia a generare eccezioni.

Al contrario, l'approccio multithread per la generazione di chiamate HTTP si è comportato in modo molto prevedibile. Ho impiegato circa 0,6 secondi per i messaggi da 10k, circa 5,5 secondi per i messaggi da 100k e, come previsto, circa 55 secondi per 1 milione di messaggi. Nessuno dei messaggi ha avuto esito negativo. Inoltre, durante l'esecuzione, non ha mai utilizzato più di 55 MB di RAM (secondo Task Manager di Windows). La memoria utilizzata durante l'invio asincrono dei messaggi è cresciuta in modo proporzionale al carico. Ha usato circa 500 MB di RAM durante i test dei messaggi di 200k.

Penso che ci siano due ragioni principali per i risultati di cui sopra. Il primo è che HttpClient sembra essere molto avido nella creazione di nuove connessioni con il server. L'elevato numero di porte utilizzate riportate da netstat significa che probabilmente non beneficia molto del mantenimento HTTP.

Il secondo è che HttpClient non sembra avere un meccanismo di limitazione. In effetti questo sembra essere un problema generale legato alle operazioni asincrone. Se è necessario eseguire un numero molto elevato di operazioni, verranno avviate tutte contemporaneamente e le loro continuazioni verranno eseguite non appena disponibili. In teoria questo dovrebbe andare bene, perché nelle operazioni asincrone il carico è su sistemi esterni ma, come dimostrato sopra, non è del tutto vero. Avere un gran numero di richieste avviate contemporaneamente aumenterà l'utilizzo della memoria e rallenterà l'intera esecuzione.

Sono riuscito a ottenere risultati migliori, memoria e tempi di esecuzione saggi, limitando il numero massimo di richieste asincrone con un meccanismo di ritardo semplice ma primitivo:

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

Sarebbe davvero utile se HttpClient includesse un meccanismo per limitare il numero di richieste simultanee. Quando si utilizza la classe Task (che si basa sul pool di thread .Net), la limitazione viene ottenuta automaticamente limitando il numero di thread simultanei.

Per una panoramica completa, ho anche creato una versione del test asincrono basato su HttpWebRequest anziché su HttpClient e sono riuscito a ottenere risultati molto migliori. Per cominciare, consente di impostare un limite al numero di connessioni simultanee (con ServicePointManager.DefaultConnectionLimit o tramite config), il che significa che non ha mai esaurito le porte e non ha mai fallito su qualsiasi richiesta (HttpClient, per impostazione predefinita, si basa su HttpWebRequest , ma sembra ignorare l'impostazione del limite di connessione).

L'approccio asincrono HttpWebRequest era ancora circa il 50 - 60% più lento di quello multithreading, ma era prevedibile e affidabile. L'unico aspetto negativo era che utilizzava un'enorme quantità di memoria sotto carico pesante. Ad esempio, per inviare 1 milione di richieste erano necessari circa 1,6 GB. Limitando il numero di richieste simultanee (come ho fatto sopra per HttpClient) sono riuscito a ridurre la memoria utilizzata a soli 20 MB e ottenere un tempo di esecuzione più lento del 10% rispetto all'approccio multithreading.

Dopo questa lunga presentazione, le mie domande sono: la classe HttpClient di .Net 4.5 non è una buona scelta per le applicazioni a carico intensivo? C'è un modo per limitarlo, che dovrebbe risolvere i problemi di cui parlo? Che ne dici del sapore asincrono di HttpWebRequest?

Aggiornamento (grazie @Stephen Cleary)

A quanto pare, HttpClient, proprio come HttpWebRequest (su cui si basa per impostazione predefinita), può avere il suo numero di connessioni simultanee sullo stesso host limitato con ServicePointManager.DefaultConnectionLimit. La cosa strana è che secondo MSDN , il valore predefinito per il limite di connessione è 2. Ho anche verificato che dalla mia parte usando il debugger che ha indicato che effettivamente 2 è il valore predefinito. Tuttavia, sembra che se non si imposta esplicitamente un valore su ServicePointManager.DefaultConnectionLimit, il valore predefinito verrà ignorato. Dato che non ho impostato esplicitamente un valore per questo durante i miei test HttpClient, ho pensato che fosse ignorato.

Dopo aver impostato ServicePointManager.DefaultConnectionLimit su 100 HttpClient è diventato affidabile e prevedibile (netstat conferma che vengono utilizzate solo 100 porte). È ancora più lento di HttpWebRequest asincrono (di circa il 40%), ma stranamente usa meno memoria. Per il test che prevede 1 milione di richieste, ha utilizzato un massimo di 550 MB, rispetto a 1,6 GB nell'httpWebRequest asincrono.

Quindi, mentre HttpClient in combinazione ServicePointManager.DefaultConnectionLimit sembra garantire l'affidabilità (almeno per lo scenario in cui tutte le chiamate vengono effettuate verso lo stesso host), sembra comunque che le sue prestazioni siano influenzate negativamente dalla mancanza di un adeguato meccanismo di limitazione. Qualcosa che limiterebbe il numero simultaneo di richieste a un valore configurabile e inserisca il resto in una coda lo renderebbe molto più adatto a scenari ad alta scalabilità.


4
HttpClientdovrebbe rispettare ServicePointManager.DefaultConnectionLimit.
Stephen Cleary,

2
Le tue osservazioni sembrano degne di essere investigate. Una cosa però mi dà fastidio: penso che sia fortemente inventato emettere contemporaneamente migliaia di IO asincroni. Non lo farei mai in produzione. Il fatto che tu sia asincrono non significa che puoi impazzire consumando varie risorse. (I campioni ufficiali di Microsofts sono un po 'fuorvianti anche in questo senso.)
usr

1
Tuttavia, non limitare i ritardi. Accelerare a un livello di concorrenza fisso che si determina empiricamente. Una soluzione semplice sarebbe SemaphoreSlim.WaitAsync sebbene ciò non sarebbe adatto anche per quantità arbitrariamente grandi di compiti.
usr

1
@FlorinDumitrescu Per la limitazione, è possibile utilizzare SemaphoreSlim, come già accennato, o ActionBlock<T>dal flusso di dati TPL.
svick,

1
@svick, grazie per i tuoi suggerimenti. Non mi interessa implementare manualmente un meccanismo per limitare / limitazione della concorrenza. Come accennato, l'implementazione inclusa nella mia domanda era solo per testare e validare una teoria. Non sto cercando di migliorarlo, dal momento che non arriverà alla produzione. Quello che mi interessa è se il framework .Net offre un meccanismo incorporato per limitare la concorrenza di operazioni di I / O asincrone (incluso HttpClient).
Florin Dumitrescu,

Risposte:


64

Oltre ai test citati nella domanda, ne ho recentemente creati alcuni nuovi che coinvolgono molte meno chiamate HTTP (5000 rispetto a 1 milione in precedenza) ma su richieste che richiedevano molto più tempo per essere eseguite (500 millisecondi rispetto a circa 1 millisecondo in precedenza). Entrambe le applicazioni tester, quella multithread sincrona (basata su HttpWebRequest) e quella I / O asincrona (basata sul client HTTP) hanno prodotto risultati simili: circa 10 secondi per l'esecuzione utilizzando circa il 3% della CPU e 30 MB di memoria. L'unica differenza tra i due tester era che quello multithread utilizzava 310 thread per l'esecuzione, mentre quello asincrono solo 22.

Come conclusione dei miei test, le chiamate HTTP asincrone non sono l'opzione migliore quando si tratta di richieste molto veloci. Il motivo dietro ciò è che quando si esegue un'attività che contiene una chiamata I / O asincrona, il thread su cui viene avviata l'attività viene chiuso non appena viene effettuata la chiamata asincrona e il resto dell'attività viene registrato come richiamata. Quindi, al termine dell'operazione I / O, il callback viene messo in coda per l'esecuzione sul primo thread disponibile. Tutto ciò crea un sovraccarico, che rende le operazioni di I / O veloci più efficienti quando eseguite sul thread che le ha avviate.

Le chiamate HTTP asincrone sono una buona opzione quando si hanno a che fare con operazioni di I / O lunghe o potenzialmente lunghe perché non tengono occupati i thread in attesa del completamento delle operazioni di I / O. Ciò riduce il numero complessivo di thread utilizzati da un'applicazione consentendo più tempo alla CPU da dedicare alle operazioni legate alla CPU. Inoltre, sulle applicazioni che allocano solo un numero limitato di thread (come nel caso delle applicazioni Web), l'I / O asincrono impedisce l'esaurimento dei thread del pool di thread, che può verificarsi se si eseguono chiamate I / O in modo sincrono.

Pertanto, HttpClient asincrono non rappresenta un collo di bottiglia per le applicazioni a carico intensivo. È solo che per sua natura non è molto adatto per richieste HTTP molto veloci, invece è ideale per richieste lunghe o potenzialmente lunghe, specialmente all'interno di applicazioni che hanno solo un numero limitato di thread disponibili. Inoltre, è buona norma limitare la concorrenza tramite ServicePointManager.DefaultConnectionLimit con un valore sufficientemente elevato da garantire un buon livello di parallelismo, ma abbastanza basso da impedire l'esaurimento effimero delle porte. Puoi trovare maggiori dettagli sui test e le conclusioni presentate per questa domanda qui .


3
Quanto è veloce "molto veloce"? 1ms? 100ms? 1,000ms?
Tim P.

Sto usando qualcosa come il tuo approccio "asincrono" per riprodurre un carico su un server Web WebLogic distribuito su Windows, ma sto riscontrando un problema di esaurimento delle porte ephemical, piuttosto rapidamente. Non ho toccato ServicePointManager.DefaultConnectionLimit e sto eliminando e ricreando tutto (HttpClient e risposta) su ogni richiesta. Hai idea di cosa potrebbe causare l'apertura delle connessioni e l'esaurimento delle porte?
Iravanchi,

@TimP. per i miei test, come accennato in precedenza, "molto velocemente" erano le richieste che richiedevano solo 1 millisecondo per essere completate. Nel mondo reale questo sarà sempre soggettivo. Dal mio punto di vista, qualcosa di equivalente a una piccola query su un database di rete locale può essere considerato veloce, mentre qualcosa di equivalente a una chiamata API su Internet può essere considerato lento o potenzialmente lento.
Florin Dumitrescu,

1
@Iravanchi, in approcci "asincroni", l'invio della richiesta e la gestione della risposta vengono eseguiti separatamente. Se hai molte chiamate, tutte le richieste verranno inviate molto velocemente e le risposte verranno gestite al loro arrivo. Dal momento che è possibile disporre le connessioni solo dopo che sono arrivate le loro risposte, un gran numero di connessioni simultanee può accumulare e esaurire le porte effimere. È necessario limitare il numero massimo di connessioni simultanee utilizzando ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu,

1
@FlorinDumitrescu, aggiungerei anche che le chiamate di rete sono per natura imprevedibili. Le cose che funzionano nel 10% delle volte il 90% delle volte possono causare problemi di blocco quando la risorsa di rete è congestionata o non disponibile l'altro 10% delle volte.
Tim P.

27

Una cosa da considerare che potrebbe influire sui risultati è che con HttpWebRequest non si ottiene ResponseStream e si consuma quel flusso. Con HttpClient, per impostazione predefinita copierà il flusso di rete in un flusso di memoria. Per utilizzare HttpClient nello stesso modo in cui stai utilizzando HttpWebRquest, dovrai farlo

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

L'altra cosa è che non sono davvero sicuro di quale sia la vera differenza, dal punto di vista del threading, in realtà stai testando. Se scavi nelle profondità di HttpClientHandler, esegue semplicemente Task.Factory.StartNew per eseguire una richiesta asincrona. Il comportamento di threading viene delegato al contesto di sincronizzazione esattamente allo stesso modo dell'esempio con HttpWebRequest.

Indubbiamente, HttpClient aggiunge un certo sovraccarico poiché per impostazione predefinita utilizza HttpWebRequest come libreria di trasporto. Quindi sarai sempre in grado di ottenere una migliore perf direttamente con un HttpWebRequest mentre usi HttpClientHandler. I vantaggi offerti da HttpClient sono con le classi standard come HttpResponseMessage, HttpRequestMessage, HttpContent e tutte le intestazioni fortemente tipizzate. Di per sé non è un'ottimizzazione perfetta.


(vecchia risposta, ma) HttpClientsembra facile da usare e ho pensato che la strada da percorrere fosse asincrona, ma sembra che ci siano molti "ma e se" attorno a questo. Forse il HttpClientdovrebbe essere riscritto in modo che sarebbe più intuitivo da usare? O che la documentazione stava davvero sottolineando le cose importanti su come usarla nel modo più efficiente?
mortb

@mortb, Flurl.Http flurl.io è un wrapper più intuitivo da utilizzare di HttpClient
Michael Freidgeim,

1
@MichaelFreidgeim: Grazie, anche se ormai ho imparato a convivere con HttpClient ...
Mortb

17

Sebbene ciò non risponda direttamente alla parte "asincrona" della domanda del PO, ciò risolve un errore nell'implementazione che sta utilizzando.

Se si desidera ridimensionare l'applicazione, evitare di utilizzare HttpClients basati su istanza. La differenza è ENORME! A seconda del carico, vedrai numeri di prestazioni molto diversi. HttpClient è stato progettato per essere riutilizzato tra le richieste. Ciò è stato confermato dai ragazzi del team BCL che lo hanno scritto.

Un recente progetto che ho avuto è stato quello di aiutare un grande e noto rivenditore di computer online a scalare il traffico del Black Friday / festivo per alcuni nuovi sistemi. Abbiamo riscontrato alcuni problemi di prestazioni relativi all'uso di HttpClient. Dal momento che implementa IDisposable, gli sviluppatori hanno fatto quello che avresti fatto normalmente creando un'istanza e inserendola all'interno di using()un'istruzione. Una volta iniziato il test del carico, l'app ha messo in ginocchio il server: sì, il server non è solo l'app. Il motivo è che ogni istanza di HttpClient apre una porta di completamento I / O sul server. A causa della finalizzazione non deterministica di GC e del fatto che si sta lavorando con risorse di computer che si estendono su più livelli OSI , la chiusura delle porte di rete può richiedere del tempo. In effetti stesso sistema operativo Windowspossono essere necessari fino a 20 secondi per chiudere una porta (per Microsoft). Stavamo aprendo le porte più velocemente di quanto potessero essere chiuse - esaurimento delle porte del server che ha portato la CPU al 100%. La mia soluzione era quella di cambiare HttpClient in un'istanza statica che risolvesse il problema. Sì, è una risorsa usa e getta, ma qualsiasi sovraccarico è ampiamente compensato dalla differenza di prestazioni. Ti incoraggio a fare alcuni test di carico per vedere come si comporta la tua app.

Risposto anche al seguente link:

Qual è l'overhead della creazione di un nuovo HttpClient per chiamata in un client WebAPI?

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


Ho riscontrato esattamente lo stesso problema creando l'esaurimento della porta TCP sul client. La soluzione consisteva nel noleggiare l'istanza di HttpClient per lunghi periodi di tempo in cui venivano effettuate chiamate iterative, non creare e smaltire per ogni chiamata. La conclusione che ho raggiunto è stata "Solo perché implementa Dispose, ciò non significa che sia economico smaltirlo".
PhillipH,

quindi se HttpClient è statico e devo modificare un'intestazione sulla richiesta successiva, che cosa fa per la prima richiesta? C'è qualche danno nel cambiare HttpClient poiché è statico - come l'emissione di un HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Ad esempio, se ho utenti che eseguono l'autenticazione tramite token, questi token devono essere aggiunti come intestazioni nella richiesta all'API, di cui sono token diversi. Avere HttpClient come statico e quindi cambiare questa intestazione su HttpClient avrebbe effetti negativi?
Crizzwald,

Se è necessario utilizzare membri dell'istanza di HttpClient come intestazioni / cookie, ecc. Non utilizzare un HttpClient statico. Altrimenti, i dati dell'istanza (intestazioni, cookie) sarebbero gli stessi per ogni richiesta, sicuramente NON quello che desideri.
Dave Black,

dal momento che questo è il caso ... come potresti impedire ciò che stai descrivendo sopra nel tuo post - a carico? bilanciamento del carico e lanciare più server su di esso?
Crizzwald,

@crizzwald - Nel mio post ho notato la soluzione utilizzata. Utilizzare un'istanza statica di HttpClient. Se hai bisogno di usare intestazione / cookie su un HttpClient, cercherei di usare un'alternativa.
Dave Black,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.