O async HttpClient da .Net 4.5 é uma má escolha para aplicativos de carga intensiva?

Recentemente, criei um aplicativo simples para testar a taxa de transferência de chamadas HTTP que pode ser gerada de maneira assíncrona em comparação com uma abordagem clássica multithread.

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 meu servidor IIS local e eles recuperaram um pequeno arquivo de texto (12 bytes de tamanho).

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 é 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 foi mais rápida. Demorou cerca de 0,6 segundos para completar 10k pedidos, enquanto o assíncrono levou cerca de 2 segundos para completar a mesma quantidade de carga. Isso foi um pouco surpreendente, porque eu esperava que o assíncrono fosse mais rápido. Talvez tenha sido por causa do fato de que minhas chamadas HTTP foram 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. Já que leva cerca de 2 segundos para entregar 10k mensagens, eu pensei que levaria cerca de 20 segundos para entregar 10 vezes o número de mensagens, mas a execução do teste mostrou que precisa de cerca de 50 segundos para entregar as 100k mensagens. Além disso, geralmente leva mais de 2 minutos para entregar 200k mensagens e, muitas vezes, alguns milhares deles (3-4k) falham com a seguinte exceção:

Uma operação em um soquete não pôde ser executada porque o sistema não tinha espaço suficiente no buffer ou porque uma fila estava cheia.

Eu chequei os logs do IIS e as operações que falharam nunca chegaram ao servidor. Eles falharam dentro do cliente. Executei os testes em uma máquina com 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 de fato a causa das exceções, significa que ou o netstat não relatou corretamente a situação ou o HttClient usa apenas um número máximo de portas após o qual ele começa a lançar exceções.

Em contraste, a abordagem multithread de gerar chamadas HTTP se comportou de maneira bastante previsível. Eu levei cerca de 0,6 segundos para 10k mensagens, cerca de 5,5 segundos para 100k mensagens e, como esperado, cerca de 55 segundos para 1 milhão de mensagens. Nenhuma das mensagens falhou. Além disso, enquanto correu, nunca usou mais de 55 MB de RAM (de acordo com o Windows Task Manager). A memória usada ao enviar mensagens de forma assíncrona cresceu proporcionalmente com a carga. Utilizou 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. A primeira é que o HttpClient parece ser muito ganancioso na criação de novas conexões com o servidor. O alto número de portas usadas relatadas pelo netstat significa que ele provavelmente não beneficia muito do keep-alive do HTTP.

A segunda é que o HttpClient não parece ter um mecanismo de otimização. Na verdade, este parece ser um problema geral relacionado a operações assíncronas. Se você precisar executar um número muito grande de operações, todas elas serão iniciadas de uma vez e, em seguida, suas continuações serão executadas à medida que estiverem disponíveis. Em teoria, isso deve ser ok, porque em operações assíncronas a carga está em sistemas externos, mas como provado acima, isso não é totalmente o caso. Ter um grande número de solicitações iniciadas de uma só vez aumentará o uso da memória e retardará 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 muito ú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 do .Net), a aceleração é obtida 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 baseado em HttpWebRequest em vez de HttpClient e consegui obter resultados muito melhores. Para começar, permite definir um limite no número de conexões simultâneas (com ServicePointManager.DefaultConnectionLimit ou via config), o que significa que ele nunca ficou sem portas e nunca falhou em nenhuma solicitação (HttpClient, por padrão, é baseado em HttpWebRequest , mas parece ignorar a configuração do limite de conexão).

A abordagem async HttpWebRequest ainda era cerca de 50 a 60% mais lenta que a multithreading, mas era previsível e confiável. A única desvantagem é que ele usava uma enorme quantidade de memória sob grande carga. Por exemplo, precisou 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 HttpClient), consegui reduzir a memória usada para apenas 20 MB e obter um tempo de execução apenas 10% mais lento que a abordagem de multithreading.

Após essa longa apresentação, minhas perguntas são: A classe HttpClient da .Net 4.5 é uma má escolha para aplicativos de carga intensiva? Existe alguma maneira de acelerá-lo, o que deve resolver os problemas que eu menciono? Como sobre o sabor assíncrono do HttpWebRequest?

Atualização (obrigado @Stephen Cleary)

Como se constata, o HttpClient, assim como o HttpWebRequest (no qual ele é baseado por padrão), pode ter seu número de conexões simultâneas no mesmo host limitado com o ServicePointManager.DefaultConnectionLimit. O estranho é que, de acordo comMSDN, o valor padrão para o limite de conexão é 2. Eu também verifiquei que do meu lado usando o depurador que apontou que, de fato, 2 é o valor padrão. No entanto, parece que, a menos que você defina explicitamente um valor como ServicePointManager.DefaultConnectionLimit, o valor padrão será ignorado. Como não defini explicitamente um valor para ele durante meus testes de HttpClient, achei que ele foi ignorado.

Depois de definir 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 async 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 um máximo de 550 MB, em comparação com 1,6 GB no HttpWebRequest async.

Portanto, embora HttpClient na combinação ServicePointManager.DefaultConnectionLimit pareça 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 é negativamente afetado pela falta de um mecanismo de otimizaçã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.

questionAnswers(3)

yourAnswerToTheQuestion