Armadilhas do HttpClient no .NET

Continuando uma série de artigos sobre "armadilhas", não posso ignorar o System.Net.HttpClient, que é frequentemente usado na prática, mas há vários problemas sérios que podem não ser imediatamente visíveis.

Um problema bastante comum na programação é que os desenvolvedores se concentram apenas na funcionalidade de um componente em particular, ignorando completamente um componente não funcional muito importante, que pode afetar o desempenho, a escalabilidade, a facilidade de recuperação em caso de falhas, segurança etc. Por exemplo, o mesmo HttpClient é aparentemente um componente elementar, mas há várias perguntas: quanto ele cria conexões paralelas ao servidor, quanto tempo elas permanecem, como se comportará se o nome DNS que foi acessado anteriormente for alternado para um endereço IP diferente ? Vamos tentar responder a estas perguntas no artigo.

  1. Vazamento de conexão
  2. Limitar conexões simultâneas ao servidor
  3. Conexões de longa duração e cache DNS

O primeiro problema com o HttpClient é o vazamento de conexão não óbvio. Muitas vezes, eu precisava conhecer o código em que ele é criado para executar cada solicitação:

public async Task<string> GetSomeText(Guid textId) { using (var client = new HttpClient()) { return await client.GetStringAsync($"http://someservice.com/api/v1/some-text/{textId}"); } } 

Infelizmente, essa abordagem leva a um grande desperdício de recursos e a uma alta probabilidade de obter um estouro da lista de conexões abertas. Para mostrar claramente o problema, basta executar o seguinte código:

 static void Main(string[] args) { for(int i = 0; i < 10; i++) { using (var client = new HttpClient()) { client.GetStringAsync("https://habr.com").Wait(); } } } 

E, no final, veja a lista de conexões abertas via netstat:

 PS C: \ Desenvolvimento \ Exercícios> netstat -n |  padrão de cadeia de seleção "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

Aqui, a opção -n é usada para acelerar a saída, pois, caso contrário, o netstat para cada IP procurará o nome de domínio e 178.248.237.68 procurará o endereço IP do habr.com no momento da redação deste documento.

No total, vemos que, apesar da construção de uso e mesmo que o programa tenha sido completamente concluído, as conexões com o servidor permaneceram "paralisadas". E eles serão interrompidos pelo tempo indicado na chave do Registro HKEY_LOCAL_MACHINE \ SYSTEM \ CurrentControlSet \ Services \ Tcpip \ Parameters \ TcpTimedWaitDelay.

Uma pergunta pode surgir imediatamente - como o .NET Core se comporta nesses casos? O que está no Windows, o que está no Linux - exatamente o mesmo, porque essa retenção de conexão ocorre no nível do sistema e não no nível do aplicativo. O status TIME_WAIT é um estado especial do soquete depois de fechado pelo aplicativo, e isso é necessário para processar pacotes que ainda podem passar pela rede. Para o Linux, a duração desse estado é especificada em segundos em / proc / sys / net / ipv4 / tcp_fin_timeout e, é claro, pode ser alterada, se necessário.

O segundo problema do HttpClient é o limite não óbvio de conexões simultâneas com o servidor . Suponha que você use o familiar .NET Framework 4.7, com a ajuda do qual desenvolve um serviço altamente carregado, onde há chamadas para outros serviços via HTTP. O possível problema com vazamento de conexão foi resolvido, portanto, a mesma instância HttpClient é usada para todas as solicitações. O que poderia estar errado?

O problema pode ser facilmente visto executando o seguinte código:

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

O recurso especificado no link permite adiar a resposta do servidor pelo tempo especificado, neste caso - 5 segundos.

Como é fácil perceber após a execução do código acima - a cada 5 segundos, apenas 2 respostas chegam, embora 10 solicitações simultâneas tenham sido criadas. Isso se deve ao fato de que a interação com o HTTP em uma estrutura .NET normal, entre outras coisas, passa por uma classe especial System.Net.ServicePointManager que controla vários aspectos das conexões HTTP. Esta classe possui uma propriedade DefaultConnectionLimit que indica quantas conexões simultâneas podem ser criadas para cada domínio. E, portanto, historicamente, o valor padrão de uma propriedade é 2.

Se você adicionar o exemplo de código acima no início

 ServicePointManager.DefaultConnectionLimit = 5; 

a execução do exemplo acelerará notavelmente, pois as solicitações serão executadas em lotes de 5.

E antes de seguir como isso funciona no .NET Core, um pouco mais deve ser dito sobre o ServicePointManager. A propriedade discutida acima indica o número padrão de conexões que serão usadas nas conexões subseqüentes a qualquer domínio. Mas junto com isso, é possível controlar os parâmetros para cada nome de domínio individualmente e isso é feito através da classe ServicePoint:

 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; 

Após a execução desse código, qualquer interação com o Habr através da mesma instância HttpClient utilizará 5 conexões simultâneas e 3 conexões com o site "slowwly".

Há outra nuance interessante aqui - o limite no número de conexões para endereços locais (localhost) é int.MaxValue por padrão. Veja os resultados da execução desse código sem primeiro definir o DefaultConnectionLimit:

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

Agora vamos para o .NET Core. Embora o ServicePointManager ainda exista no espaço para nome System.Net, ele não afeta o comportamento do HttpClient no .NET Core. Em vez disso, os parâmetros de conexão HTTP podem ser controlados usando o HttpClientHandler (ou SocketsHttpHandler, sobre o qual falaremos mais adiante):

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

O exemplo acima se comportará exatamente da mesma forma que o exemplo inicial do .NET Framework comum - para estabelecer apenas duas conexões por vez. Mas se você remover a linha com o conjunto de propriedades MaxConnectionsPerServer, o número de conexões simultâneas será muito maior, pois, por padrão no .NET Core, o valor dessa propriedade é int.MaxValue.

E agora vamos examinar o terceiro problema não óbvio com as configurações padrão, que podem não ser menos críticas que as duas anteriores - conexões de longa duração e cache de DNS . Ao estabelecer uma conexão com um servidor remoto, o nome do domínio é resolvido primeiro para o endereço IP correspondente e, em seguida, o endereço recebido é colocado no cache por algum tempo para acelerar as conexões subseqüentes. Além disso, para economizar recursos, na maioria das vezes a conexão não é fechada após cada solicitação, mas mantida aberta por um longo tempo.

Imagine que o sistema que estamos desenvolvendo funcione normalmente sem forçar a reinicialização se o servidor com o qual ele interage mudou para um endereço IP diferente. Por exemplo, se você alternar para outro data center devido a uma falha no atual. Mesmo se uma conexão permanente for interrompida devido a uma falha no primeiro data center (o que também pode acontecer rapidamente), o cache DNS não permitirá que o sistema responda rapidamente a essa alteração. O mesmo vale para chamadas para o endereço em que o balanceamento de carga é feito por meio de round-robin DNS.

No caso de uma estrutura .NET "normal", esse comportamento pode ser controlado pelo ServicePointManager e ServicePoint (todos os parâmetros listados abaixo assumem valores em milissegundos):

  • ServicePointManager.DnsRefreshTimeout - indica quanto tempo o endereço IP recebido para cada nome de domínio será armazenado em cache; o valor padrão é 2 minutos (120000).
  • ServicePoint.ConnectionLeaseTimeout - indica quanto tempo a conexão pode ser mantida aberta. Por padrão, não há limite de tempo para as conexões; qualquer conexão pode ser mantida por um tempo arbitrariamente longo, pois esse parâmetro é -1. Configurá-lo como 0 fará com que cada conexão seja fechada imediatamente após a solicitação ser concluída.
  • ServicePoint.MaxIdleTime - Indica após que período de inatividade a conexão será fechada. Inação significa que não há transferência de dados através da conexão. Por padrão, o valor desse parâmetro é 100 segundos (100000).

Agora, para melhorar a compreensão desses parâmetros, combinaremos todos eles em um cenário. Suponha que ninguém alterou DnsRefreshTimeout e MaxIdleTime e sejam 120 e 100 segundos, respectivamente. Com isso, ConnectionLeaseTimeout foi definido para 60 segundos. O aplicativo estabelece apenas uma conexão, através da qual envia solicitações a cada 10 segundos.

Com essas configurações, a conexão será fechada a cada 60 segundos (ConnectionLeaseTimeout), mesmo que transfira dados periodicamente. O fechamento e a recriação ocorrerão de forma a não interferir na execução correta das solicitações - se o tempo acabar e no momento em que a solicitação ainda estiver sendo executada, a conexão será fechada após a conclusão da solicitação. Cada vez que uma conexão é recriada, o endereço IP correspondente será retirado do cache primeiro e somente se sua resolução expirar (120 segundos), o sistema enviará uma solicitação ao servidor DNS.

O parâmetro MaxIdleTime não desempenhará um papel nesse cenário, pois a conexão não fica ociosa por mais de 10 segundos.

A proporção ideal desses parâmetros depende muito da situação específica e dos requisitos não funcionais:

  • Se você não pretende alternar de maneira transparente os endereços IP atrás do nome de domínio que seu aplicativo acessa e, ao mesmo tempo, você precisa minimizar o custo das conexões de rede, as configurações padrão parecem uma boa opção.
  • Se houver necessidade de alternar entre os endereços IP no caso de uma falha, você pode definir DnsRefreshTimeout como 0 e ConnectionLeaseTimeout com o valor não negativo que melhor lhe convier. Qual deles depende especificamente da rapidez com que você precisa mudar para outro IP. Obviamente, você deseja ter a resposta mais rápida possível a uma falha, mas aqui você precisa encontrar o valor ideal, que, por um lado, fornece um tempo de comutação aceitável, por outro lado, não prejudica o rendimento e o tempo de resposta do sistema, recriando conexões com freqüência.
  • Se você precisar da reação mais rápida possível para alterar o endereço IP, por exemplo, como no caso de balanceamento via round-robin DNS, tente definir DnsRefreshTimeout e ConnectionLeaseTimeout como 0, mas isso será extremamente inútil: para cada solicitação, o servidor DNS será pesquisado primeiro e, em seguida, A conexão com o nó de destino será restabelecida.
  • Pode haver situações em que definir ConnectionLeaseTimeout como 0 com um DnsRefreshTimeout diferente de zero pode ser útil, mas não consigo criar um script apropriado imediatamente. Logicamente, isso significa que, para cada solicitação, as conexões serão criadas novamente, mas os endereços IP serão retirados do cache sempre que possível.

A seguir, é apresentado um exemplo de código que pode ser usado para observar o comportamento dos parâmetros descritos acima:
 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); } 

Enquanto o programa de teste está em execução, você pode executar o netstat através do PowerShell em um loop para monitorar as conexões que ele estabelece.

Imediatamente, deve-se dizer como gerenciar os parâmetros descritos no .NET Core. As configurações do ServicePointManager, como no caso de ConnectionLimit, não funcionarão. O Core possui um tipo especial de manipulador HTTP que implementa dois dos três parâmetros descritos acima - SocketsHttpHandler:

 var handler = new SocketsHttpHandler(); handler.PooledConnectionLifetime = TimeSpan.FromSeconds(60); // ConnectionLeaseTimeout handler.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(100); // MaxIdleTime var client = new HttpClient(handler); 

Não há parâmetro que controla o tempo de armazenamento em cache dos registros DNS no .NET Core. Os casos de teste mostram que o cache não funciona - ao criar uma nova conexão DNS, a resolução é executada novamente; portanto, para operação normal sob condições em que o nome de domínio solicitado pode alternar entre endereços IP diferentes, basta definir PooledConnectionLifetime para o valor desejado.

Além de tudo, é preciso dizer que todos esses problemas não poderiam ter sido despercebidos pelos desenvolvedores da Microsoft e, portanto, a partir do .NET Core 2.1, apareceu uma fábrica de clientes HTTP que permite resolver alguns deles - https://docs.microsoft.com/en- us / dotnet / standard / microservices-architecture / implement-resilient-applications / use-httpclientfactory-to-implement-resilient-http-orders . Além disso, além de gerenciar o tempo de vida das conexões, o novo componente oferece oportunidades para a criação de clientes digitados, além de outras coisas úteis. Neste artigo e nos links dele, há informações e exemplos suficientes sobre o uso do HttpClientFactory, portanto, não considerarei os detalhes associados a ele neste artigo.

Source: https://habr.com/ru/post/pt424873/


All Articles