HttpClient asincrono da .Net 4.5 è una scelta sbagliata per le applicazioni di carico intenso?

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 dimensioni).

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 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  { 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. Ci sono voluti circa 0,6 secondi per completare le richieste di 10k, mentre quello asincrono ha richiesto circa 2 secondi per la stessa quantità di carico. Questa è stata una sorpresa, perché mi aspettavo che la versione async fosse più veloce. Forse era a causa del fatto che le mie chiamate HTTP erano molto veloci. In uno scenario reale, in cui il server dovrebbe eseguire un’operazione più significativa e dove dovrebbe esserci anche qualche latenza di rete, i risultati potrebbero essere invertiti.

Tuttavia, ciò che mi preoccupa veramente è il modo in cui HttpClient si comporta quando il carico viene aumentato. Poiché impiega circa 2 secondi per recapitare 10k messaggi, ho pensato che sarebbe occorso circa 20 secondi per fornire un numero di messaggi pari a 10 volte, ma l’esecuzione del test ha dimostrato che sono necessari circa 50 secondi per recapitare i messaggi 100k. Inoltre, in genere ci vogliono più di 2 minuti per recapitare 200k messaggi e spesso, alcune migliaia (3-4k) falliscono con la seguente eccezione:

Non è stato ansible eseguire un’operazione su un socket perché il sistema non disponeva di spazio sufficiente del buffer o perché una coda era piena.

Ho controllato i log di IIS e le operazioni che non sono riuscite non sono mai arrivate sul server. Hanno fallito nel cliente. 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 utilizzati circa 5-6k di porte, quindi in teoria ce ne sarebbero stati molti altri disponibili. Se la mancanza di porte era effettivamente la causa delle eccezioni, significa che 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 di generare chiamate HTTP si comportava in modo molto prevedibile. Ho impiegato circa 0,6 secondi per i messaggi a 10k, circa 5,5 secondi per i messaggi a 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 il Task Manager di Windows). La memoria utilizzata durante l’invio di messaggi in modo asincrono è cresciuta proporzionalmente al carico. Ha utilizzato circa 500 MB di RAM durante i test dei 200k messaggi.

Penso che ci siano due ragioni principali per i risultati di cui sopra. Il primo è che HttpClient sembra essere molto avido nel creare nuove connessioni con il server. L’elevato numero di porte utilizzate segnalate da netstat significa che probabilmente non beneficia molto di HTTP keep-alive.

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

Sono riuscito a ottenere risultati migliori, la memoria e il tempo di esecuzione, 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 = 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 class Task (che si basa sul pool di thread .Net), la limitazione viene automaticamente ottenuta limitando il numero di thread simultanei.

Per una panoramica completa, ho anche creato una versione del test asincrono basata su HttpWebRequest piuttosto che su HttpClient e sono riuscito a ottenere risultati molto migliori. Per cominciare, consente di impostare un limite sul numero di connessioni simultanee (con ServicePointManager.DefaultConnectionLimit o via config), il che significa che non ha mai esaurito le porte e non ha mai fallito su nessuna richiesta (HttpClient, per impostazione predefinita, è basato 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 svantaggio era che utilizzava un’enorme quantità di memoria sotto un grande carico. Ad esempio, era necessario circa 1,6 GB per inviare 1 milione di richieste. 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 solo del 10% più lento rispetto all’approccio multithreading.

Dopo questa lunga presentazione, le mie domande sono: La class HttpClient di .Net 4.5 è una ctriggers scelta per le applicazioni di carico intensivo? C’è un modo per ridurlo, che dovrebbe risolvere i problemi che ho menzionato? Che ne dici del sapore asincrono di HttpWebRequest?

Aggiornamento (grazie @Stephen Cleary)

Come risulta, HttpClient, proprio come HttpWebRequest (su cui è basato 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 di default per il limite di connessione è 2. Ho anche controllato che da parte mia ho usato il debugger che indicava che effettivamente 2 è il valore predefinito. Tuttavia, sembra che a meno che non si imposti esplicitamente un valore su ServicePointManager.DefaultConnectionLimit, il valore predefinito verrà ignorato. Dal momento che non ho impostato esplicitamente un valore 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 asincrona (di circa il 40%), ma stranamente, utilizza meno memoria. Per il test che ha coinvolto 1 milione di richieste, è stato utilizzato un massimo di 550 MB, rispetto a 1,6 GB dell’async HttpWebRequest.

Pertanto, 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 ancora che le sue prestazioni siano influenzate negativamente dalla mancanza di un meccanismo di limitazione appropriato. Qualcosa che limiti il ​​numero simultaneo di richieste a un valore configurabile e metta il resto in una coda lo renderebbe molto più adatto a scenari di alta scalabilità.

Oltre ai test menzionati nella domanda, di recente ne ho creati di nuovi che comportano un numero molto inferiore di chiamate HTTP (5000 rispetto a 1 milione in precedenza) ma su richieste che richiedevano molto più tempo per l’esecuzione (500 millisecondi rispetto a circa 1 millisecondo in precedenza). Entrambe le applicazioni tester, quella sincrona multithread (basata su HttpWebRequest) e quella I / O asincrona (basata su client HTTP) hanno prodotto risultati simili: circa 10 secondi da eseguire utilizzando circa il 3% della CPU e 30 MB di memoria. L’unica differenza tra i due tester era che il multithreaded usava 310 thread da eseguire, mentre quello asincrono solo 22. Quindi, in un’applicazione che avrebbe combinato sia le operazioni di I / O che quelle legate alla CPU, la versione asincrona avrebbe prodotto risultati migliori perché ci sarebbe stato più tempo di CPU disponibile per i thread che eseguono le operazioni della CPU, che sono quelli che ne hanno effettivamente bisogno (i thread in attesa di operazioni I / O per completare sono solo sprechi).

A conclusione dei miei test, le chiamate HTTP asincrone non sono l’opzione migliore quando si tratta di richieste molto veloci. Il motivo dietro è 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 eseguita la chiamata asincrona e il resto dell’attività viene registrato come callback. Quindi, al termine dell’operazione I / O, la richiamata viene accodata 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 gestiscono operazioni di I / O lunghe o potenzialmente lunghe perché non mantiene occupati i thread in attesa del completamento delle operazioni di I / O. Ciò riduce il numero complessivo di thread utilizzati da un’applicazione che consente di dedicare più tempo alla CPU per le operazioni associate alla CPU. Inoltre, nelle applicazioni che assegnano solo un numero limitato di thread (come nel caso delle applicazioni Web), l’I / O asincrono impedisce l’esaurimento del thread del pool di thread, cosa che può verificarsi se si eseguono chiamate I / O in modo sincrono.

Quindi, async HttpClient non è un collo di bottiglia per le applicazioni di carico intensivo. È solo che per sua natura non è molto adatto per richieste HTTP molto veloci, ma è ideale per 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 sufficientemente basso da impedire l’esaurimento della porta effimero. Puoi trovare maggiori dettagli sui test e conclusioni presentate per questa domanda qui .

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

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

L’altra cosa è che non sono veramente sicuro di quale sia la vera differenza, da una prospettiva di threading, in realtà stai testando. Se si scava nelle profondità di HttpClientHandler, semplicemente esegue Task.Factory.StartNew per eseguire una richiesta asincrona. Il comportamento del thread è delegato al contesto di sincronizzazione esattamente nello stesso modo in cui viene eseguito l’esempio con l’esempio HttpWebRequest.

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

Anche se questo non risponde direttamente alla parte ‘asincrona’ della domanda dell’OP, questo risolve un errore nell’implementazione che sta usando.

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

Un recente progetto che ho avuto è stato quello di aiutare un rivenditore di computer online molto grande e famoso scalare per il traffico di Black Friday / vacanze per alcuni nuovi sistemi. Abbiamo riscontrato alcuni problemi di prestazioni relativi all’uso di HttpClient. Dal momento che implementa IDisposable , gli sviluppatori hanno fatto ciò che avresti normalmente fatto creando un’istanza e inserendola all’interno di un’istruzione using() . Una volta iniziato il test del carico, l’app ha messo il server in ginocchio – 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, il sistema operativo Windows stesso può richiedere fino a 20 secondi per chiudere una porta (per Microsoft). Stavamo aprendo le porte più velocemente di quanto avrebbero potuto essere chiuse: esaurimento delle porte del server che martellava la CPU al 100%. La mia soluzione era di cambiare HttpClient in un’istanza statica che risolvesse il problema. Sì, è una risorsa usa e getta, ma qualsiasi overhead è ampiamente superato dalla differenza di prestazioni. Ti incoraggio a eseguire alcuni test di carico per vedere come si comporta la tua app.

Anche risposto al link sottostante:

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