Recentemente, criei um aplicativo simples para testar a taxa de transferência de chamadas HTTP que pode ser gerada de maneira assíncrona versus uma abordagem multithread clássica.
O aplicativo é capaz de executar um número predefinido de chamadas HTTP e, no final, exibe o tempo total necessário para realizá-las. Durante meus testes, todas as chamadas HTTP foram feitas para o servidor IIS local e elas recuperaram um pequeno arquivo de texto (tamanho de 12 bytes).
A parte mais importante do código para a implementação assíncrona está listada abaixo:
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();
}
}
}
A parte mais importante da implementação de multithreading está listada abaixo:
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();
}
}
A execução dos testes revelou que a versão multithread era mais rápida. Demorou cerca de 0,6 segundos para concluir as solicitações de 10k, enquanto o assíncrono levou cerca de 2 segundos para concluir a mesma quantidade de carga. Isso foi uma surpresa, porque eu esperava que o assíncrono fosse mais rápido. Talvez tenha sido pelo fato de minhas chamadas HTTP serem muito rápidas. Em um cenário do mundo real, onde o servidor deve executar uma operação mais significativa e onde também deve haver alguma latência de rede, os resultados podem ser revertidos.
No entanto, o que realmente me preocupa é a maneira como o HttpClient se comporta quando a carga é aumentada. Como leva cerca de 2 segundos para entregar 10k mensagens, pensei que levaria cerca de 20 segundos para entregar 10 vezes o número de mensagens, mas a execução do teste mostrou que são necessários 50 segundos para entregar as 100k mensagens. Além disso, normalmente leva mais de 2 minutos para entregar 200k mensagens e, muitas vezes, alguns milhares delas (3-4k) falham com a seguinte exceção:
Não foi possível executar uma operação em um soquete porque o sistema não possuía espaço suficiente no buffer ou porque a fila estava cheia.
Eu verifiquei os logs e operações do IIS que falharam e nunca chegaram ao servidor. Eles falharam no cliente. Eu executei os testes em uma máquina Windows 7 com o intervalo padrão de portas efêmeras de 49152 a 65535. A execução do netstat mostrou que cerca de 5-6k portas estavam sendo usadas durante os testes; portanto, em teoria, deveria haver muito mais disponível. Se a falta de portas foi realmente a causa das exceções, significa que o netstat não relatou adequadamente a situação ou o HttClient usa apenas um número máximo de portas após o qual começa a lançar exceções.
Por outro lado, a abordagem multithread de gerar chamadas HTTP se comportou de maneira previsível. Levei cerca de 0,6 segundos para 10 mil mensagens, cerca de 5,5 segundos para 100 mil mensagens e, como esperado, cerca de 55 segundos para 1 milhão de mensagens. Nenhuma das mensagens falhou. Além disso, durante a execução, nunca usou mais de 55 MB de RAM (de acordo com o Gerenciador de Tarefas do Windows). A memória usada ao enviar mensagens de forma assíncrona cresceu proporcionalmente à carga. Ele usou cerca de 500 MB de RAM durante os testes de 200 mil mensagens.
Eu acho que existem duas razões principais para os resultados acima. O primeiro é que o HttpClient parece ser muito ganancioso ao criar novas conexões com o servidor. O alto número de portas usadas relatadas pelo netstat significa que provavelmente não se beneficia muito com o HTTP keep-alive.
A segunda é que o HttpClient não parece ter um mecanismo de limitação. De fato, esse parece ser um problema geral relacionado às operações assíncronas. Se você precisar executar um número muito grande de operações, todas serão iniciadas de uma só vez e, em seguida, suas continuações serão executadas conforme estiverem disponíveis. Em teoria, isso deve ser aceitável, porque nas operações assíncronas a carga está em sistemas externos, mas, como foi provado acima, não é totalmente o caso. Ter um grande número de solicitações iniciadas ao mesmo tempo aumentará o uso da memória e diminuirá a execução inteira.
Consegui obter melhores resultados, memória e tempo de execução, limitando o número máximo de solicitações assíncronas com um mecanismo de atraso simples, mas 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);
}
}
Seria realmente útil se o HttpClient incluísse um mecanismo para limitar o número de solicitações simultâneas. Ao usar a classe Task (que é baseada no pool de threads .Net), a otimização é alcançada automaticamente limitando o número de threads simultâneos.
Para uma visão geral completa, também criei uma versão do teste assíncrono com base no HttpWebRequest em vez do HttpClient e consegui obter resultados muito melhores. Para começar, ele permite definir um limite no número de conexões simultâneas (com ServicePointManager.DefaultConnectionLimit ou via config), o que significa que nunca ficou sem portas e nunca falhou em nenhuma solicitação (o HttpClient, por padrão, é baseado no HttpWebRequest , mas parece ignorar a configuração do limite de conexão).
A abordagem assíncrona HttpWebRequest ainda era 50 a 60% mais lenta que a abordagem multithreading, mas era previsível e confiável. A única desvantagem foi que ela utilizou uma quantidade enorme de memória sob grande carga. Por exemplo, ele precisava de cerca de 1,6 GB para enviar 1 milhão de solicitações. Limitando o número de solicitações simultâneas (como fiz acima para o HttpClient), consegui reduzir a memória usada para apenas 20 MB e obter um tempo de execução 10% mais lento que a abordagem de multithreading.
Após essa longa apresentação, minhas perguntas são: A classe HttpClient do .Net 4.5 é uma má escolha para aplicativos de carga intensiva? Existe alguma maneira de controlá-lo, o que deve corrigir os problemas mencionados acima? Que tal o sabor assíncrono do HttpWebRequest?
Atualização (obrigado @Stephen Cleary)
Como se vê, o HttpClient, assim como o HttpWebRequest (no qual se baseia por padrão), pode ter seu número de conexões simultâneas no mesmo host limitado ao ServicePointManager.DefaultConnectionLimit. O estranho é que, de acordo com o MSDN , o valor padrão para o limite de conexão é 2. Eu também verifiquei isso do meu lado usando o depurador que apontava que de fato 2 é o valor padrão. No entanto, parece que, a menos que você defina explicitamente um valor para ServicePointManager.DefaultConnectionLimit, o valor padrão será ignorado. Como não defini explicitamente um valor para ele durante meus testes HttpClient, pensei que fosse ignorado.
Após definir o ServicePointManager.DefaultConnectionLimit como 100 HttpClient, tornou-se confiável e previsível (o netstat confirma que apenas 100 portas são usadas). Ainda é mais lento que o assíncrono HttpWebRequest (em cerca de 40%), mas estranhamente, ele usa menos memória. Para o teste que envolve 1 milhão de solicitações, ele usou no máximo 550 MB, em comparação com 1,6 GB no HttpWebRequest assíncrono.
Portanto, enquanto o HttpClient em combinação ServicePointManager.DefaultConnectionLimit parece garantir confiabilidade (pelo menos no cenário em que todas as chamadas estão sendo feitas para o mesmo host), ainda parece que seu desempenho é impactado negativamente pela falta de um mecanismo de limitação adequado. Algo que limitaria o número simultâneo de solicitações a um valor configurável e colocaria o restante em uma fila o tornaria muito mais adequado para cenários de alta escalabilidade.
SemaphoreSlim
, como já mencionado, ou ActionBlock<T>
no TPL Dataflow.
HttpClient
deve respeitarServicePointManager.DefaultConnectionLimit
.