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à.
SemaphoreSlim
, come già accennato, o ActionBlock<T>
dal flusso di dati TPL.
HttpClient
dovrebbe rispettareServicePointManager.DefaultConnectionLimit
.