O HttpClient assíncrono do .Net 4.5 é uma má escolha para aplicativos de carga intensiva?

130

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.

Florin Dumitrescu
fonte
4
HttpClientdeve respeitar ServicePointManager.DefaultConnectionLimit.
precisa
2
Suas observações parecem valer a pena investigar. Uma coisa está me incomodando: acho que é altamente artificial emitir milhares de E / S assíncronas ao mesmo tempo. Eu nunca faria isso na produção. O fato de você ser assíncrono não significa que você pode ficar louco consumindo vários recursos. (Microsofts amostras oficiais são um pouco enganosa a esse respeito também.)
usr
1
Não acelere com atrasos de tempo, no entanto. Acelere em um nível fixo de simultaneidade que você determina empiricamente. Uma solução simples seria o SemaphoreSlim.WaitAsync, embora isso também não seja adequado para quantidades arbitrariamente grandes de tarefas.
usr
1
@FlorinDumitrescu Para a otimização, você pode usar SemaphoreSlim, como já mencionado, ou ActionBlock<T>no TPL Dataflow.
svick
1
@ Rick, obrigado por suas sugestões. Não estou interessado em implementar manualmente um mecanismo para limitação de otimização / simultaneidade. Como mencionado, a implementação incluída na minha pergunta era apenas para testar e validar uma teoria. Não estou tentando melhorá-lo, pois não chegará à produção. O que me interessa é se a estrutura .Net oferece um mecanismo interno para limitar a simultaneidade de operações de E / S assíncronas (HttpClient incluído).
Florin Dumitrescu

Respostas:

64

Além dos testes mencionados na pergunta, criei recentemente alguns novos que envolvem muito menos chamadas HTTP (5000 em comparação a 1 milhão anteriormente), mas em solicitações que demoravam muito mais tempo para serem executadas (500 milissegundos em comparação com cerca de 1 milissegundo anteriormente). Os aplicativos testadores, o multithread síncrono (baseado no HttpWebRequest) e o I / O assíncrono (baseado no cliente HTTP) produziram resultados semelhantes: cerca de 10 segundos para executar usando cerca de 3% da CPU e 30 MB de memória. A única diferença entre os dois testadores era que o multithread usava 310 threads para executar, enquanto o assíncrono tinha apenas 22.

Como conclusão dos meus testes, as chamadas HTTP assíncronas não são a melhor opção ao lidar com solicitações muito rápidas. A razão por trás disso é que, ao executar uma tarefa que contém uma chamada de E / S assíncrona, o encadeamento no qual a tarefa é iniciada é encerrado assim que a chamada assíncrona é feita e o restante da tarefa é registrado como um retorno de chamada. Em seguida, quando a operação de E / S for concluída, o retorno de chamada será colocado na fila para execução no primeiro encadeamento disponível. Tudo isso cria uma sobrecarga, o que torna as operações de E / S rápidas mais eficientes quando executadas no encadeamento que as iniciou.

As chamadas HTTP assíncronas são uma boa opção ao lidar com operações de E / S longas ou potencialmente longas, porque não mantém nenhum encadeamento ocupado aguardando a conclusão das operações de E / S. Isso diminui o número geral de encadeamentos usados ​​por um aplicativo, permitindo que mais tempo de CPU seja gasto pelas operações ligadas à CPU. Além disso, em aplicativos que alocam apenas um número limitado de encadeamentos (como é o caso de aplicativos da Web), a E / S assíncrona evita o esgotamento de encadeamentos do conjunto de encadeamentos, o que pode ocorrer se a execução de chamadas de E / S for sincronizada.

Portanto, o HttpClient assíncrono não é um gargalo para aplicativos de carga intensiva. É que, por sua natureza, não é muito adequado para solicitações HTTP muito rápidas, mas é ideal para solicitações longas ou potencialmente longas, especialmente dentro de aplicativos que possuem apenas um número limitado de encadeamentos disponíveis. Além disso, é uma boa prática limitar a simultaneidade via ServicePointManager.DefaultConnectionLimit com um valor alto o suficiente para garantir um bom nível de paralelismo, mas baixo o suficiente para impedir o esgotamento efetivo da porta. Você pode encontrar mais detalhes sobre os testes e conclusões apresentados para esta pergunta aqui .

Florin Dumitrescu
fonte
3
Quão rápido é "muito rápido"? 1ms? 100ms? 1.000ms?
Tim P.
Estou usando algo como sua abordagem "assíncrona" para reproduzir uma carga em um servidor WebLogic implantado no Windows, mas estou obtendo um problema de depleção de portas efêmero, rapidamente. Não toquei no ServicePointManager.DefaultConnectionLimit e estou descartando e recriando tudo (HttpClient e resposta) em cada solicitação. Você tem alguma idéia do que pode estar fazendo com que as conexões permaneçam abertas e esgotem as portas?
Iravanchi
@TimP. para meus testes, como mencionado acima, "muito rápido" eram as solicitações que estavam demorando apenas 1 milissegundo para serem concluídas. No mundo real, isso sempre será subjetivo. Do meu ponto de vista, algo equivalente a uma pequena consulta em um banco de dados de rede local pode ser considerado rápido, enquanto algo equivalente a uma chamada de API pela Internet pode ser considerado lento ou potencialmente lento.
Florin Dumitrescu
1
@Iravanchi, nas abordagens "assíncronas", o envio de pedidos e o tratamento de respostas são realizados separadamente. Se você tiver muitas chamadas, todas as solicitações serão enviadas muito rapidamente e as respostas serão tratadas quando chegarem. Como você só pode descartar conexões depois que as respostas deles chegarem, um grande número de conexões simultâneas pode acumular e esgotar suas portas efêmeras. Você deve limitar o número máximo de conexões simultâneas usando ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu
1
@FlorinDumitrescu, eu também acrescentaria que as chamadas de rede são por natureza imprevisíveis. Coisas que são executadas em 10 ms 90% do tempo podem causar problemas de bloqueio quando esse recurso de rede está congestionado ou indisponível nos outros 10% do tempo.
Tim P.
27

Uma coisa a considerar que pode estar afetando seus resultados é que, com o HttpWebRequest, você não está obtendo o ResponseStream e consumindo esse fluxo. Com o HttpClient, por padrão, ele copia o fluxo da rede em um fluxo de memória. Para usar o HttpClient da mesma maneira que você está usando o HttpWebRquest, você precisa fazer

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

A outra coisa é que não tenho muita certeza de qual é a diferença real, de uma perspectiva de segmentação, na verdade você está testando. Se você se aprofundar no HttpClientHandler, ele simplesmente executará Task.Factory.StartNew para executar uma solicitação assíncrona. O comportamento de encadeamento é delegado ao contexto de sincronização exatamente da mesma maneira que o seu exemplo com o exemplo HttpWebRequest.

Sem dúvida, o HttpClient adiciona alguma sobrecarga, por padrão, ele usa o HttpWebRequest como sua biblioteca de transporte. Assim, você sempre poderá obter um melhor desempenho diretamente com um HttpWebRequest ao usar o HttpClientHandler. Os benefícios que o HttpClient traz são as classes padrão, como HttpResponseMessage, HttpRequestMessage, HttpContent e todos os cabeçalhos fortemente tipados. Por si só, não é uma otimização de desempenho.

Darrel Miller
fonte
(resposta antiga, mas) HttpClientparece fácil de usar e eu pensei que era o caminho a seguir, mas parece haver muitos "buts e ifs" em torno disso. Talvez o HttpClientdeva ser reescrito para que seja mais intuitivo de usar? Ou que a documentação estava realmente enfatizando as coisas importantes sobre como usá-la com mais eficiência?
mortb
@mortb, Flurl.Http flurl.io é uma forma mais intuitiva de uso invólucro de HttpClient
Michael Freidgeim
1
@ MichaelFreidgeim: Obrigado, embora eu já tenha aprendido a conviver com o HttpClient ... #
mortb
17

Embora isso não responda diretamente à parte 'assíncrona' da pergunta do OP, isso soluciona um erro na implementação que ele está usando.

Se você deseja que seu aplicativo seja dimensionado, evite usar HttpClients com base em instância. A diferença é enorme! Dependendo da carga, você verá números de desempenho muito diferentes. O HttpClient foi projetado para ser reutilizado em solicitações. Isso foi confirmado pelos caras da equipe da BCL que o escreveram.

Um projeto recente que tive foi ajudar um varejista de computadores on-line muito grande e conhecido a expandir o tráfego da Black Friday / feriado para alguns novos sistemas. Encontramos alguns problemas de desempenho relacionados ao uso do HttpClient. Como ele é implementado IDisposable, os desenvolvedores fizeram o que você normalmente faria criando uma instância e colocando-a dentro de uma using()instrução. Depois que começamos a testar o aplicativo, o aplicativo ficou de joelhos - sim, o servidor não apenas o aplicativo. O motivo é que todas as instâncias do HttpClient abrem uma porta de conclusão de E / S no servidor. Devido à finalização não determinística do GC e ao fato de você estar trabalhando com recursos de computador que abrangem vários camadas OSI , o fechamento das portas de rede pode demorar um pouco. De fato, o próprio sistema operacional Windowspode levar até 20 segundos para fechar uma porta (por Microsoft). Estávamos abrindo portas mais rapidamente do que podiam ser fechadas - a exaustão da porta do servidor que aumentou a CPU a 100%. Minha correção foi alterar o HttpClient para uma instância estática que resolveu o problema. Sim, é um recurso descartável, mas qualquer sobrecarga é amplamente compensada pela diferença de desempenho. Convido você a fazer alguns testes de carga para ver como seu aplicativo se comporta.

Também respondido no link abaixo:

Qual é a sobrecarga de criar um novo HttpClient por chamada em um cliente WebAPI?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client

Dave Black
fonte
Encontrei exatamente o mesmo problema ao criar exaustão da porta TCP no cliente. A solução foi conceder a instância HttpClient por longos períodos em que as chamadas iterativas estavam sendo feitas, não criar e descartar para cada chamada. A conclusão a que cheguei foi "Só porque implementa Dispose, isso não significa que é barato descartá-lo".
PhillipH
portanto, se o HttpClient é estático e preciso alterar um cabeçalho na próxima solicitação, o que isso faz com a primeira solicitação? Existe algum dano em alterar o HttpClient, pois é estático - como emitir um HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Por exemplo, se eu tenho usuários autenticados por tokens, esses tokens precisam ser adicionados como cabeçalhos na solicitação à API, dos quais tokens diferentes. Ter o HttpClient como estático e alterar esse cabeçalho no HttpClient não teria efeitos adversos?
crizzwald
Se você precisar usar membros da instância HttpClient, como cabeçalhos / cookies, etc., não deverá usar um HttpClient estático. Caso contrário, os dados da sua instância (cabeçalhos, cookies) seriam os mesmos para todas as solicitações - certamente NÃO o que você deseja.
Dave Black
como esse é o caso ... como você evitaria o que está descrevendo acima em sua postagem - contra carga? balanceador de carga e jogar mais servidores nele?
crizzwald
@crizzwald - No meu post, observei a solução usada. Use uma instância estática do HttpClient. Se você precisar usar cabeçalho / cookies em um HttpClient, eu usaria uma alternativa.
Dave Black