Neste último mês fiquei investigando incidentes relacionados a alto consumo de memória por parte das nossas aplicações. Consegui encontrar alguns problemas e quero compartilhar com vocês, vamos começar com a utilização do HttpClient, esse cara pode ser um vilão se não for bem utilizado. As APIs da Wiz têm uma característica de chamar outras APIs e me atrevo a dizer que é mais consumo de API do que utilização de banco de dados. Nesse artigo quero explicar como você pode otimizar o desempenho do HttpClient ao lidar com dados como cargas úteis JSON no HttpResponseMessage.
Teoria
O que eu não sabia era que por padrão, na maioria dos casos, ao usar HttpClient e seus metodos (GetAsync, PostAsync e SendAsync), todo o corpo da resposta é lido em um buffer de memória antes que o método seja concluído. Nesse ponto, a conexão TCP, usada para a solicitação fica inativa e estará disponível para reutilização para outra solicitação, um ponto aqui é que estou falando do dotnet core 3.1, se for falar do dotnet 5, existem opções melhores como é o caso da utilização do protocolo http2, aí a história é outra.
Na maioria dos casos esse comportamento é aceitável, já que evita o uso da conexão tcp pelo período mínimo de tempo necessário. Mas... em casos que não temos memória sobressalente, aí entramos no caminho infeliz da história pois essa abordagem padrão introduz alguma sobrecarga de memória. Já que a resposta da API, vou generalizar aqui, o JSON é armazenado em buffer usando um MemoryStream, podemos acessar esse buffer pela classe HttpResponseMessage. Dependendo do tamanho da carga de resposta, isso pode significar que armazenamos em buffer uma grande quantidade de dados na memória.
Solução
O que eu não sabia era que existe uma sobrecarga desses métodos esperando um enum HttpCompletionOption, esse enum possui 2 valores o padrão é o ResponseContentRead, esse aí informa para o HttpClient que é para ler o corpo do JSON e colocar em memória mesmo que a nossa aplicação não vai usar esse objeto, sim isso é possível. O segundo valor é o que iremos começar a utilizar aqui na Wiz chamado ResponseHeadersRead, esse cara indica para o HttpClient quando os cabeçalhos de resposta forem totalmente lidos. O corpo da resposta pode não ser totalmente recebido neste momento.
O principal benefício é o desempenho. Ao usar esta opção, evitamos o buffer MemoryStream intermediário, em vez de obter o conteúdo diretamente do fluxo exposto no Socket. Isso evita alocações desnecessárias, o que é uma meta em situações altamente otimizadas.
Aqui vai um exemplo, quero serializar uma lista de livros apenas quando receber um status code 200. Se eu receber um 500 vou lançar uma exception e não preciso do conteúdo da API que estou consumindo.
A forma de utilizar esse enum é bem simples, olha como fica:
_httpClient.GetAsync("http://openlibrary.org/search.json?q=tdd", HttpCompletionOption.ResponseHeadersRead);
O normal é analisar o conteúdo de alguma forma. Vou mostrar um código de como podemos escrever nossa chamada para isso.
using var response = await _httpClient.GetAsync("http://openlibrary.org/search.json?q=tdd", HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
if (response.Content is object)
{
var stream = await response.Content.ReadAsStreamAsync();
var data = await JsonSerializer.DeserializeAsync<Search>(stream);
// do something with the data or return it
}
Usamos o EnsureSuccessStatusCode para garantir que o status code recebido é um 2xx. Em caso afirmativo, verificamos se há conteúdo disponível na resposta. Agora podemos acessar o fluxo do conteúdo de resposta usando ReadAsStreamAsync.
O problema dessa abordagem é que assumimos mais responsabilidade em relação aos recursos do sistema, uma vez que a conexão com o servidor remoto fica presa até decidirmos que terminaremos com o conteúdo. A maneira como sinalizamos isso é descartando o HttpResponseMessage, que então libera a conexão para ser usada para outras solicitações. Por isso não esquecer do using.
using var response = await _httpClient.GetAsync
Outra forma de garantir isso é usar o try/finaly, veja um exemplo:
var response = await _httpClient.GetAsync("http://openlibrary.org/search.json?q=tdd", HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
Search data = null;
try
{
if (response.Content is object)
{
var stream = await response.Content.ReadAsStreamAsync();
data = await JsonSerializer.DeserializeAsync<Search>(stream);
}
}
finally
{
response.Dispose();
}
if (data is object)
{
// intensive and slow processing of books list. We don't want this to delay releasing the connection.
}
Estudo de caso
Fiz um programa de teste para ter um benchmark da performance do que citei nesse artigo. Essa analise é feita tanto no Windows como no Linux, usando o dotnet core 3.1. Vocês podem acessar aqui.
Observe a coluna Allocated, usando um sistema Windows com o método WithHttpCompletionOption chegamos a uma performance de 26,87% em comparação com WithoutHttpCompletionOption que representa 73,13% do consumo de memória.
Agora usando um sistema Linux com o método WithHttpCompletionOption chegamos a uma performance de 28,62% em comparação com o método WithoutHttpCompletionOption que representa 71,38% do consumo de memória.
Windows
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
.NET Core SDK=3.1.404
[Host] : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
Server : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
ServerForce : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
Workstation : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
WorkstationForce : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
IterationCount=15 LaunchCount=2 WarmupCount=10
Method | Job | Force | Server | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|---|
WithoutHttpCompletionOption | Server | False | True | 477.6 ms | 65.79 ms | 96.43 ms | 515.4 ms | - | - | - | 404.48 KB |
WithHttpCompletionOption | Server | False | True | 673.4 ms | 73.56 ms | 103.12 ms | 723.6 ms | - | - | - | 162.16 KB |
WithGetStreamAsync | Server | False | True | 703.2 ms | 149.15 ms | 213.91 ms | 662.3 ms | - | - | - | 162.66 KB |
WithoutHttpCompletionOption | ServerForce | True | True | 414.7 ms | 65.04 ms | 93.27 ms | 363.7 ms | - | - | - | 394.98 KB |
WithHttpCompletionOption | ServerForce | True | True | 642.0 ms | 81.61 ms | 106.12 ms | 642.8 ms | - | - | - | 163.63 KB |
WithGetStreamAsync | ServerForce | True | True | 707.8 ms | 97.39 ms | 139.68 ms | 727.4 ms | - | - | - | 162.81 KB |
WithoutHttpCompletionOption | Workstation | True | False | 659.7 ms | 96.77 ms | 132.46 ms | 643.2 ms | - | - | - | 394.62 KB |
WithHttpCompletionOption | Workstation | True | False | 530.2 ms | 9.43 ms | 12.59 ms | 528.1 ms | - | - | - | 162.35 KB |
WithGetStreamAsync | Workstation | True | False | 439.2 ms | 65.54 ms | 98.10 ms | 452.9 ms | - | - | - | 161.59 KB |
WithoutHttpCompletionOption | WorkstationForce | False | False | 499.3 ms | 52.65 ms | 77.17 ms | 517.6 ms | - | - | - | 394.89 KB |
WithHttpCompletionOption | WorkstationForce | False | False | 626.4 ms | 69.05 ms | 94.52 ms | 600.1 ms | - | - | - | 162.98 KB |
WithGetStreamAsync | WorkstationForce | False | False | 508.5 ms | 57.64 ms | 82.67 ms | 524.8 ms | - | - | - | 162.39 KB |
Linux
BenchmarkDotNet=v0.12.1, OS=ubuntu 20.04
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 2 logical cores and 1 physical core
.NET Core SDK=3.1.405
[Host] : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
Server : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
ServerForce : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
Workstation : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
WorkstationForce : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
IterationCount=15 LaunchCount=2 WarmupCount=10
Method | Job | Force | Server | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|---|
WithoutHttpCompletionOption | Server | False | True | 426.4 ms | 73.53 ms | 100.64 ms | 409.1 ms | - | - | - | 389120 B |
WithHttpCompletionOption | Server | False | True | 360.1 ms | 53.91 ms | 77.31 ms | 331.1 ms | - | - | - | 152472 B |
WithGetStreamAsync | Server | False | True | 328.8 ms | 12.37 ms | 17.74 ms | 323.7 ms | - | - | - | 149624 B |
WithoutHttpCompletionOption | ServerForce | True | True | 421.0 ms | 71.88 ms | 103.09 ms | 426.8 ms | - | - | - | 406960 B |
WithHttpCompletionOption | ServerForce | True | True | 515.3 ms | 71.17 ms | 104.32 ms | 520.8 ms | - | - | - | 149936 B |
WithGetStreamAsync | ServerForce | True | True | 525.4 ms | 142.11 ms | 203.81 ms | 515.6 ms | - | - | - | 150136 B |
WithoutHttpCompletionOption | Workstation | True | False | NA | NA | NA | NA | - | - | - | - |
WithHttpCompletionOption | Workstation | True | False | 523.6 ms | 94.41 ms | 138.38 ms | 515.3 ms | - | - | - | 149520 B |
WithGetStreamAsync | Workstation | True | False | 582.9 ms | 288.63 ms | 404.62 ms | 354.0 ms | - | - | - | 150072 B |
WithoutHttpCompletionOption | WorkstationForce | False | False | 523.4 ms | 44.34 ms | 62.16 ms | 527.0 ms | - | - | - | 389112 B |
WithHttpCompletionOption | WorkstationForce | False | False | 523.0 ms | 11.63 ms | 16.68 ms | 518.3 ms | - | - | - | 150680 B |
WithGetStreamAsync | WorkstationForce | False | False | 427.7 ms | 78.15 ms | 104.33 ms | 497.9 ms | - | - | - | 151472 B |
Top comments (1)
Parabéns Juscelio pela abordagem, realmente faz muita diferença considerar a forma como é tratado o buffer de memória na resposta da requisição do httpClient, aprendi muito com esse artigo, abraço...