Ich habe kürzlich eine einfache Anwendung zum Testen des HTTP-Anrufdurchsatzes erstellt, die asynchron im Vergleich zu einem klassischen Multithread-Ansatz generiert werden kann.
Die Anwendung kann eine vordefinierte Anzahl von HTTP-Aufrufen ausführen und zeigt am Ende die Gesamtzeit an, die für deren Ausführung erforderlich ist. Während meiner Tests wurden alle HTTP-Aufrufe an meinen lokalen IIS-Server gesendet und eine kleine Textdatei (12 Byte groß) abgerufen.
Der wichtigste Teil des Codes für die asynchrone Implementierung ist unten aufgeführt:
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();
}
}
}
Der wichtigste Teil der Multithreading-Implementierung ist unten aufgeführt:
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();
}
}
Das Ausführen der Tests ergab, dass die Multithread-Version schneller war. Es dauerte ungefähr 0,6 Sekunden, um für 10.000 Anfragen fertig zu sein, während die asynchrone Anfrage ungefähr 2 Sekunden dauerte, um für die gleiche Menge an Last fertig zu sein. Dies war eine kleine Überraschung, da ich erwartet hatte, dass die asynchrone schneller sein würde. Vielleicht lag es daran, dass meine HTTP-Anrufe sehr schnell waren. In einem realen Szenario, in dem der Server eine aussagekräftigere Operation ausführen sollte und in dem auch eine gewisse Netzwerklatenz auftreten sollte, können die Ergebnisse umgekehrt werden.
Was mich jedoch wirklich beschäftigt, ist das Verhalten von HttpClient, wenn die Last erhöht wird. Da die Zustellung von 10.000 Nachrichten ungefähr 2 Sekunden dauert, dachte ich, dass die Zustellung der 10-fachen Anzahl von Nachrichten ungefähr 20 Sekunden dauern würde, aber das Ausführen des Tests ergab, dass die Zustellung der 100.000 Nachrichten ungefähr 50 Sekunden dauert. Darüber hinaus dauert die Zustellung von 200.000 Nachrichten in der Regel länger als 2 Minuten, und häufig schlagen einige Tausend Nachrichten (3-4.000) mit der folgenden Ausnahme fehl:
Eine Operation an einem Socket konnte nicht ausgeführt werden, weil dem System nicht genügend Pufferplatz zur Verfügung stand oder weil eine Warteschlange voll war.
Ich habe die fehlgeschlagenen IIS-Protokolle und -Vorgänge überprüft und bin nie auf den Server gelangt. Sie sind innerhalb des Clients fehlgeschlagen. Ich habe die Tests auf einem Windows 7-Computer mit dem Standardbereich für kurzlebige Ports von 49152 bis 65535 ausgeführt. Das Ausführen von netstat hat gezeigt, dass während der Tests etwa 5-6.000 Ports verwendet wurden, sodass theoretisch viel mehr verfügbar sein sollten. Wenn das Fehlen von Ports tatsächlich die Ursache für die Ausnahmen war, bedeutet dies, dass entweder netstat die Situation nicht ordnungsgemäß gemeldet hat oder HttClient nur eine maximale Anzahl von Ports verwendet, nach denen Ausnahmen ausgelöst werden.
Im Gegensatz dazu verhielt sich der Multithread-Ansatz zum Generieren von HTTP-Aufrufen sehr vorhersehbar. Ich brauchte ungefähr 0,6 Sekunden für 10.000 Nachrichten, ungefähr 5,5 Sekunden für 100.000 Nachrichten und wie erwartet ungefähr 55 Sekunden für 1 Million Nachrichten. Keine der Nachrichten ist fehlgeschlagen. Während der Ausführung wurden nie mehr als 55 MB RAM verwendet (laut Windows Task-Manager). Der Speicher, der beim asynchronen Senden von Nachrichten verwendet wurde, wuchs proportional zur Last. Bei den 200.000 Nachrichtentests wurden rund 500 MB RAM verwendet.
Ich denke, es gibt zwei Hauptgründe für die obigen Ergebnisse. Der erste ist, dass HttpClient sehr gierig zu sein scheint, wenn es darum geht, neue Verbindungen mit dem Server herzustellen. Die hohe Anzahl der von netstat gemeldeten verwendeten Ports bedeutet, dass HTTP Keep-Alive wahrscheinlich nicht viel davon profitiert.
Das zweite ist, dass HttpClient keinen Drosselungsmechanismus zu haben scheint. Tatsächlich scheint dies ein allgemeines Problem im Zusammenhang mit asynchronen Operationen zu sein. Wenn Sie eine sehr große Anzahl von Vorgängen ausführen müssen, werden alle gleichzeitig gestartet und ihre Fortsetzungen werden ausgeführt, sobald sie verfügbar sind. Theoretisch sollte dies in Ordnung sein, da bei asynchronen Vorgängen externe Systeme belastet werden. Wie oben gezeigt, ist dies jedoch nicht ganz der Fall. Wenn eine große Anzahl von Anforderungen gleichzeitig gestartet wird, erhöht sich die Speichernutzung und die gesamte Ausführung wird verlangsamt.
Ich habe es geschafft, bessere Ergebnisse in Bezug auf Speicher und Ausführungszeit zu erzielen, indem ich die maximale Anzahl asynchroner Anforderungen mit einem einfachen, aber primitiven Verzögerungsmechanismus begrenzt habe:
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);
}
}
Es wäre sehr nützlich, wenn HttpClient einen Mechanismus zum Begrenzen der Anzahl gleichzeitiger Anforderungen enthalten würde. Bei Verwendung der Task-Klasse (die auf dem .NET-Thread-Pool basiert) wird die Drosselung automatisch erreicht, indem die Anzahl der gleichzeitigen Threads begrenzt wird.
Für eine vollständige Übersicht habe ich auch eine Version des asynchronen Tests erstellt, die auf HttpWebRequest anstelle von HttpClient basiert, und es geschafft, viel bessere Ergebnisse zu erzielen. Zunächst können Sie die Anzahl der gleichzeitigen Verbindungen (mit ServicePointManager.DefaultConnectionLimit oder über config) begrenzen. Dies bedeutet, dass die Ports nie ausgehen und bei keiner Anforderung fehlschlagen (HttpClient basiert standardmäßig auf HttpWebRequest) , aber es scheint die Einstellung des Verbindungslimits zu ignorieren).
Der asynchrone HttpWebRequest-Ansatz war immer noch etwa 50 bis 60% langsamer als der Multithreading-Ansatz, aber vorhersehbar und zuverlässig. Der einzige Nachteil war, dass es unter großer Last eine große Menge an Speicher verbrauchte. Zum Beispiel wurden rund 1,6 GB für das Senden von 1 Million Anfragen benötigt. Durch die Begrenzung der Anzahl gleichzeitiger Anforderungen (wie oben für HttpClient) konnte ich den verwendeten Speicher auf nur 20 MB reduzieren und eine Ausführungszeit erzielen, die nur 10% langsamer ist als beim Multithreading-Ansatz.
Nach dieser langen Präsentation lauten meine Fragen: Ist die HttpClient-Klasse aus .Net 4.5 eine schlechte Wahl für Anwendungen mit intensiver Last? Gibt es eine Möglichkeit, es zu drosseln, um die von mir erwähnten Probleme zu beheben? Wie wäre es mit der asynchronen Variante von HttpWebRequest?
Update (danke @Stephen Cleary)
Wie sich herausstellt, kann HttpClient genau wie HttpWebRequest (auf dem es standardmäßig basiert) die Anzahl der gleichzeitigen Verbindungen auf demselben Host mit ServicePointManager.DefaultConnectionLimit begrenzen. Das Seltsame ist, dass laut MSDN der Standardwert für das Verbindungslimit 2 ist. Ich habe dies auch auf meiner Seite mit dem Debugger überprüft, der darauf hinwies, dass tatsächlich 2 der Standardwert ist. Es scheint jedoch, dass der Standardwert ignoriert wird, wenn nicht explizit ein Wert für ServicePointManager.DefaultConnectionLimit festgelegt wird. Da ich bei meinen HttpClient-Tests keinen expliziten Wert dafür festgelegt habe, dachte ich, dass dieser ignoriert wurde.
Nach dem Setzen von ServicePointManager.DefaultConnectionLimit auf 100 wurde HttpClient zuverlässig und vorhersehbar (netstat bestätigt, dass nur 100 Ports verwendet werden). Es ist immer noch langsamer als asynchrones HttpWebRequest (um etwa 40%), aber seltsamerweise benötigt es weniger Speicher. Für den Test mit 1 Million Anforderungen wurden maximal 550 MB verwendet, verglichen mit 1,6 GB in der asynchronen HttpWebRequest.
Obwohl HttpClient in Kombination mit ServicePointManager.DefaultConnectionLimit die Zuverlässigkeit zu gewährleisten scheint (zumindest für das Szenario, in dem alle Anrufe an denselben Host getätigt werden), scheint die Leistung durch das Fehlen eines geeigneten Drosselungsmechanismus negativ beeinflusst zu werden. Etwas, das die gleichzeitige Anzahl von Anforderungen auf einen konfigurierbaren Wert begrenzt und den Rest in eine Warteschlange stellt, würde es für Szenarien mit hoher Skalierbarkeit viel besser geeignet machen.
SemaphoreSlim
, wie bereits erwähnt, oder ActionBlock<T>
aus TPL Dataflow verwenden.
HttpClient
sollte respektierenServicePointManager.DefaultConnectionLimit
.