Fortsetzung einer Reihe von Artikeln über "Fallstricke" Ich kann System.Net.HttpClient nicht ignorieren, das in der Praxis sehr häufig verwendet wird, aber es gibt einige schwerwiegende Probleme, die möglicherweise nicht sofort sichtbar sind.
Ein ziemlich häufiges Problem bei der Programmierung besteht darin, dass sich Entwickler nur auf die Funktionalität einer bestimmten Komponente konzentrieren und eine sehr wichtige nicht funktionierende Komponente vollständig ignorieren, was die Leistung, Skalierbarkeit, die einfache Wiederherstellung bei Fehlern, die Sicherheit usw. beeinträchtigen kann. Zum Beispiel ist derselbe HttpClient anscheinend eine elementare Komponente, aber es gibt mehrere Fragen: Wie oft werden parallele Verbindungen zum Server hergestellt, wie lange leben sie, wie verhält es sich, wenn der DNS-Name, auf den zuvor zugegriffen wurde, auf eine andere IP-Adresse umgeschaltet wird ? Versuchen wir, diese Fragen im Artikel zu beantworten.
- Verbindungsleck
- Begrenzen Sie gleichzeitige Serververbindungen
- Langlebige Verbindungen und DNS-Caching
Das erste Problem mit HttpClient ist das nicht offensichtliche
Verbindungsleck . Sehr oft musste ich Code treffen, in dem er erstellt wurde, um jede Anforderung auszuführen:
public async Task<string> GetSomeText(Guid textId) { using (var client = new HttpClient()) { return await client.GetStringAsync($"http://someservice.com/api/v1/some-text/{textId}"); } }
Leider führt dieser Ansatz zu einer großen Verschwendung von Ressourcen und einer hohen Wahrscheinlichkeit, dass die Liste der offenen Verbindungen überläuft. Um das Problem klar darzustellen, reicht es aus, den folgenden Code auszuführen:
static void Main(string[] args) { for(int i = 0; i < 10; i++) { using (var client = new HttpClient()) { client.GetStringAsync("https://habr.com").Wait(); } } }
Schauen Sie sich am Ende die Liste der offenen Verbindungen über netstat an:
PS C: \ Entwicklung \ Übungen> netstat -n | select-string -pattern "178.248.237.68"
TCP 192.168.1.13:43684 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13:43685 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13:43686 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13:43687 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13:43689 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13-00-003690 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13-00-003691 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13-00-003692 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13-00-003693 178.248.237.68-00-0043 TIME_WAIT
TCP 192.168.1.13-00-003695 178.248.237.68-00-0043 TIME_WAIT
Hier wird der Schalter -n verwendet, um die Ausgabe zu beschleunigen, da andernfalls netstat für jede IP nach dem Domänennamen sucht und 178.248.237.68 zum Zeitpunkt dieses Schreibens nach der habr.com-IP-Adresse sucht.Insgesamt sehen wir, dass trotz des using-Konstrukts und obwohl das Programm vollständig abgeschlossen war, die Verbindungen zum Server "hängen" blieben. Und sie bleiben so lange hängen, wie im Registrierungsschlüssel HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay angegeben.
Es kann sich sofort die Frage stellen, wie sich .NET Core in solchen Fällen verhält. Was ist unter Windows, was ist unter Linux - genau das gleiche, weil eine solche Verbindungsaufbewahrung auf Systemebene und nicht auf Anwendungsebene erfolgt. Der Status TIME_WAIT ist ein spezieller Status des Sockets, nachdem er von der Anwendung geschlossen wurde. Dies ist erforderlich, um Pakete zu verarbeiten, die noch über das Netzwerk übertragen werden können. Unter Linux wird die Dauer dieses Status in / proc / sys / net / ipv4 / tcp_fin_timeout in Sekunden angegeben und kann bei Bedarf natürlich geändert werden.
Das zweite Problem von HttpClient ist die nicht offensichtliche
Grenze gleichzeitiger Verbindungen zum Server . Angenommen, Sie verwenden das vertraute .NET Framework 4.7, mit dessen Hilfe Sie einen hoch geladenen Dienst entwickeln, bei dem andere Dienste über HTTP aufgerufen werden. Das potenzielle Problem mit Verbindungslecks wurde behoben, sodass für alle Anforderungen dieselbe HttpClient-Instanz verwendet wird. Was könnte falsch sein?
Das Problem kann leicht erkannt werden, indem der folgende Code ausgeführt wird:
static void Main(string[] args) { var client = new HttpClient(); var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); }
Mit der im Link angegebenen Ressource können Sie die Antwort des Servers um die angegebene Zeit verzögern, in diesem Fall um 5 Sekunden.Wie nach Ausführung des obigen Codes leicht zu bemerken ist, kommen alle 5 Sekunden nur 2 Antworten an, obwohl 10 gleichzeitige Anforderungen erstellt wurden. Dies liegt daran, dass die Interaktion mit HTTP in einem regulären .NET-Framework unter anderem eine spezielle Klasse System.Net.ServicePointManager durchläuft, die verschiedene Aspekte von HTTP-Verbindungen steuert. Diese Klasse verfügt über eine DefaultConnectionLimit-Eigenschaft, die angibt, wie viele gleichzeitige Verbindungen für jede Domäne erstellt werden können. Historisch gesehen ist der Standardwert einer Eigenschaft 2.
Wenn Sie das obige Codebeispiel ganz am Anfang hinzufügen
ServicePointManager.DefaultConnectionLimit = 5;
dann wird die Ausführung des Beispiels merklich beschleunigt, da die Anforderungen in Stapeln von 5 ausgeführt werden.
Bevor Sie fortfahren, wie dies in .NET Core funktioniert, sollten Sie noch etwas mehr über ServicePointManager sagen. Die oben beschriebene Eigenschaft gibt die Standardanzahl der Verbindungen an, die für nachfolgende Verbindungen zu einer beliebigen Domäne verwendet werden. Gleichzeitig ist es jedoch möglich, die Parameter für jeden Domänennamen einzeln zu steuern. Dies erfolgt über die ServicePoint-Klasse:
var delayServicePoint = ServicePointManager.FindServicePoint(new Uri("http://slowwly.robertomurray.co.uk")); delayServicePoint.ConnectionLimit = 3; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.ConnectionLimit = 5;
Nach dem Ausführen dieses Codes werden für jede Interaktion mit Habr über dieselbe HttpClient-Instanz 5 gleichzeitige Verbindungen und 3 Verbindungen mit der "slowwly" -Site verwendet.
Hier gibt es noch eine weitere interessante Nuance: Die maximale Anzahl von Verbindungen für lokale Adressen (localhost) ist standardmäßig int.MaxValue. Schauen Sie sich einfach die Ergebnisse der Ausführung dieses Codes an, ohne zuerst das DefaultConnectionLimit festzulegen:
var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); Console.WriteLine(habrServicePoint.ConnectionLimit); var localServicePoint = ServicePointManager.FindServicePoint(new Uri("http://localhost")); Console.WriteLine(localServicePoint.ConnectionLimit);
Fahren wir nun mit .NET Core fort. Obwohl ServicePointManager noch im System.Net-Namespace vorhanden ist, hat dies keine Auswirkungen auf das Verhalten von HttpClient in .NET Core. Stattdessen können die HTTP-Verbindungsparameter mit dem HttpClientHandler (oder SocketsHttpHandler, über den wir später sprechen werden) gesteuert werden:
static void Main(string[] args) { var handler = new HttpClientHandler(); handler.MaxConnectionsPerServer = 2; var client = new HttpClient(handler); var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(SendRequest(client, "http://slowwly.robertomurray.co.uk/delay/5000/url/https://habr.com")); } Task.WaitAll(tasks.ToArray()); Console.ReadLine(); } private static async Task SendRequest(HttpClient client, string url) { var response = await client.GetAsync(url); Console.WriteLine($"Received response {response.StatusCode} from {url}"); }
Das obige Beispiel verhält sich genauso wie das ursprüngliche Beispiel für das reguläre .NET Framework, um jeweils nur zwei Verbindungen herzustellen. Wenn Sie jedoch die Zeile mit dem Eigenschaftssatz MaxConnectionsPerServer entfernen, ist die Anzahl der gleichzeitigen Verbindungen viel höher, da der Wert dieser Eigenschaft in .NET Core standardmäßig int.MaxValue ist.
Und jetzt schauen wir uns das dritte nicht offensichtliche Problem mit den Standardeinstellungen an, das nicht weniger kritisch sein kann als die beiden vorherigen -
langlebige Verbindungen und DNS-Caching . Beim Herstellen einer Verbindung mit einem Remote-Server wird der Domänenname zuerst in die entsprechende IP-Adresse aufgelöst und anschließend die empfangene Adresse für einige Zeit in den Cache gestellt, um nachfolgende Verbindungen zu beschleunigen. Um Ressourcen zu sparen, wird die Verbindung häufig nicht nach jeder Anforderung geschlossen, sondern lange offen gehalten.
Stellen Sie sich vor, das von uns entwickelte System sollte normal funktionieren, ohne einen Neustart zu erzwingen, wenn der Server, mit dem es interagiert, auf eine andere IP-Adresse geändert wurde. Zum Beispiel, wenn Sie aufgrund eines Fehlers im aktuellen zu einem anderen Rechenzentrum wechseln. Selbst wenn eine permanente Verbindung aufgrund eines Fehlers im ersten Rechenzentrum unterbrochen wird (was auch schnell passieren kann), kann unser System im DNS-Cache nicht schnell auf eine solche Änderung reagieren. Gleiches gilt für Anrufe an die Adresse, an der der Lastausgleich über DNS-Round-Robin erfolgt.
Im Fall eines „normalen“ .NET-Frameworks kann dieses Verhalten über ServicePointManager und ServicePoint gesteuert werden (alle unten aufgeführten Parameter nehmen Werte in Millisekunden an):
- ServicePointManager.DnsRefreshTimeout - Gibt an, wie lange die empfangene IP-Adresse für jeden Domänennamen zwischengespeichert wird. Der Standardwert beträgt 2 Minuten (120000).
- ServicePoint.ConnectionLeaseTimeout - Gibt an, wie lange die Verbindung geöffnet bleiben kann. Standardmäßig gibt es keine zeitliche Begrenzung für Verbindungen. Jede Verbindung kann beliebig lange gehalten werden, da dieser Parameter -1 ist. Wenn Sie den Wert auf 0 setzen, wird jede Verbindung sofort nach Abschluss der Anforderung geschlossen.
- ServicePoint.MaxIdleTime - Gibt an, nach welchem Zeitpunkt der Inaktivität die Verbindung geschlossen wird. Untätigkeit bedeutet keine Datenübertragung über die Verbindung. Standardmäßig beträgt der Wert dieses Parameters 100 Sekunden (100000).
Um das Verständnis dieser Parameter zu verbessern, werden wir sie alle in einem Szenario kombinieren. Angenommen, niemand hat DnsRefreshTimeout und MaxIdleTime geändert und sie sind 120 bzw. 100 Sekunden. Damit wurde ConnectionLeaseTimeout auf 60 Sekunden gesetzt. Die Anwendung stellt nur eine Verbindung her, über die alle 10 Sekunden Anforderungen gesendet werden.
Mit diesen Einstellungen wird die Verbindung alle 60 Sekunden geschlossen (ConnectionLeaseTimeout), obwohl regelmäßig Daten übertragen werden. Das Schließen und Neuerstellen erfolgt so, dass die korrekte Ausführung von Anforderungen nicht beeinträchtigt wird. Wenn die Zeit abgelaufen ist und die Anforderung noch ausgeführt wird, wird die Verbindung nach Abschluss der Anforderung geschlossen. Jedes Mal, wenn eine Verbindung neu erstellt wird, wird zuerst die entsprechende IP-Adresse aus dem Cache entnommen. Erst wenn die Auflösung abgelaufen ist (120 Sekunden), sendet das System eine Anforderung an den DNS-Server.
Der Parameter MaxIdleTime spielt in diesem Szenario keine Rolle, da die Verbindung nicht länger als 10 Sekunden inaktiv war.
Das optimale Verhältnis dieser Parameter hängt stark von der spezifischen Situation und den nicht funktionalen Anforderungen ab:
- Wenn Sie nicht beabsichtigen, die IP-Adressen hinter dem Domänennamen, auf den Ihre Anwendung zugreift, transparent zu wechseln, und gleichzeitig die Kosten für Netzwerkverbindungen minimieren müssen, sind die Standardeinstellungen eine gute Option.
- Wenn bei Fehlern zwischen IP-Adressen gewechselt werden muss, können Sie DnsRefreshTimeout auf 0 und ConnectionLeaseTimeout auf den für Sie geeigneten nicht negativen Wert setzen. Welche davon speziell davon abhängt, wie schnell Sie zu einer anderen IP wechseln müssen. Natürlich möchten Sie die schnellstmögliche Reaktion auf einen Fehler haben, aber hier müssen Sie den optimalen Wert finden, der einerseits eine akzeptable Schaltzeit bietet, andererseits den Durchsatz und die Reaktionszeit des Systems nicht beeinträchtigt, indem Verbindungen häufig neu erstellt werden.
- Wenn Sie die schnellstmögliche Reaktion auf das Ändern der IP-Adresse benötigen, z. B. beim Ausgleichen über DNS-Round-Robin, können Sie versuchen, DnsRefreshTimeout und ConnectionLeaseTimeout auf 0 zu setzen. Dies ist jedoch äußerst verschwenderisch: Für jede Anforderung wird zuerst der DNS-Server abgefragt Die Verbindung zum Zielknoten wird wiederhergestellt.
- Es kann Situationen geben, in denen das Setzen von ConnectionLeaseTimeout auf 0 mit einem DnsRefreshTimeout ungleich Null nützlich sein kann, aber ich kann nicht sofort ein geeignetes Skript erstellen. Dies bedeutet logischerweise, dass für jede Anforderung Verbindungen neu erstellt werden, IP-Adressen jedoch nach Möglichkeit aus dem Cache entnommen werden.
Das Folgende ist ein Beispiel für Code, mit dem das Verhalten der oben beschriebenen Parameter beobachtet werden kann:
var client = new HttpClient(); ServicePointManager.DnsRefreshTimeout = 120000; var habrServicePoint = ServicePointManager.FindServicePoint(new Uri("https://habr.com")); habrServicePoint.MaxIdleTime = 100000; habrServicePoint.ConnectionLeaseTimeout = 60000; while (true) { client.GetAsync("https://habr.com").Wait(); Thread.Sleep(10000); }
Während das Testprogramm ausgeführt wird, können Sie netstat in einer Schleife über PowerShell ausführen, um die hergestellten Verbindungen zu überwachen.Es sollte sofort gesagt werden, wie die beschriebenen Parameter in .NET Core verwaltet werden. Einstellungen von ServicePointManager, wie im Fall von ConnectionLimit, funktionieren nicht. Core verfügt über einen speziellen Typ von HTTP-Handler, der zwei der drei oben beschriebenen Parameter implementiert - SocketsHttpHandler:
var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60);
Es gibt keinen Parameter, der die Caching-Zeit von DNS-Einträgen in .NET Core steuert. Testfälle zeigen, dass das Caching nicht funktioniert. Beim Erstellen einer neuen DNS-Verbindung wird die Auflösung erneut ausgeführt. Für den normalen Betrieb unter Bedingungen, unter denen der angeforderte Domänenname zwischen verschiedenen IP-Adressen wechseln kann, setzen Sie PooledConnectionLifetime einfach auf den gewünschten Wert.
Darüber hinaus muss gesagt werden, dass all diese Probleme von den Entwicklern von Microsoft nicht unbemerkt bleiben konnten. Daher wurde ab .NET Core 2.1 eine Factory von HTTP-Clients angezeigt, mit der einige von ihnen gelöst werden können -
https://docs.microsoft.com/en- us / dotnet / standard / microservices-architektur / implementieren-ausfallsichere-anwendungen / benutze-httpclientfactory-um-ausfallsichere-http-anfragen zu implementieren . Zusätzlich zur Verwaltung der Lebensdauer von Verbindungen bietet die neue Komponente die Möglichkeit, typisierte Clients zu erstellen, sowie einige andere nützliche Dinge. In diesem Artikel und den darin enthaltenen Links finden Sie genügend Informationen und Beispiele zur Verwendung von HttpClientFactory. Daher werde ich die damit verbundenen Details in diesem Artikel nicht berücksichtigen.