Comportamento simultâneo de HttpClient diferente ao executar no PowerShell e no Visual Studio

10

Estou migrando milhões de usuários do AD no local para o Azure AD B2C usando a API do MS Graph para criar os usuários no B2C. Eu escrevi um aplicativo de console .Net Core 3.1 para executar essa migração. Para acelerar, estou fazendo chamadas simultâneas para a API do Graph. Isso está funcionando muito bem - mais ou menos.

Durante o desenvolvimento, experimentei um desempenho aceitável durante a execução do Visual Studio 2019, mas, para teste, estou executando na linha de comando do Powershell 7. No Powershell, o desempenho de chamadas simultâneas para o HttpClient é muito ruim. Parece que há um limite para o número de chamadas simultâneas permitidas pelo HttpClient ao executar a partir do Powershell; portanto, as chamadas em lotes simultâneos com mais de 40 a 50 solicitações começam a se acumular. Parece estar executando de 40 a 50 solicitações simultâneas enquanto bloqueia o restante.

Não estou procurando ajuda com programação assíncrona. Estou procurando uma maneira de solucionar a diferença entre o comportamento em tempo de execução do Visual Studio e o comportamento em tempo de execução da linha de comando do Powershell. A execução no modo de liberação do botão de seta verde do Visual Studio se comporta conforme o esperado. A execução na linha de comando não.

Encho uma lista de tarefas com chamadas assíncronas e aguardo Task.WhenAll (tarefas). Cada chamada leva entre 300 e 400 milissegundos. Ao executar no Visual Studio, ele funciona conforme o esperado. Faço lotes simultâneos de 1000 chamadas e cada uma delas é concluída individualmente dentro do tempo esperado. Todo o bloco de tarefas leva apenas alguns milissegundos a mais que a chamada individual mais longa.

O comportamento muda quando executo a mesma compilação na linha de comando do Powershell. As primeiras 40 a 50 chamadas levam os esperados 300 a 400 milissegundos, mas os tempos de chamada individuais crescem até 20 segundos cada. Eu acho que as chamadas estão serializando, então apenas 40 a 50 estão sendo executadas de cada vez, enquanto os outros esperam.

Após horas de tentativa e erro, consegui reduzi-lo ao HttpClient. Para isolar o problema, zombei das chamadas para HttpClient.SendAsync com um método que executa Task.Delay (300) e retorna um resultado simulado. Nesse caso, a execução no console se comporta de forma idêntica à execução no Visual Studio.

Estou usando o IHttpClientFactory e até tentei ajustar o limite de conexão no ServicePointManager.

Aqui está o meu código de registro.

    public static IServiceCollection RegisterHttpClient(this IServiceCollection services, int batchSize)
    {
        ServicePointManager.DefaultConnectionLimit = batchSize;
        ServicePointManager.MaxServicePoints = batchSize;
        ServicePointManager.SetTcpKeepAlive(true, 1000, 5000);

        services.AddHttpClient(MSGraphRequestManager.HttpClientName, c =>
        {
            c.Timeout = TimeSpan.FromSeconds(360);
            c.DefaultRequestHeaders.Add("User-Agent", "xxxxxxxxxxxx");
        })
        .ConfigurePrimaryHttpMessageHandler(() => new DefaultHttpClientHandler(batchSize));

        return services;
    }

Aqui está o DefaultHttpClientHandler.

internal class DefaultHttpClientHandler : HttpClientHandler
{
    public DefaultHttpClientHandler(int maxConnections)
    {
        this.MaxConnectionsPerServer = maxConnections;
        this.UseProxy = false;
        this.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate;
    }
}

Aqui está o código que configura as tarefas.

        var timer = Stopwatch.StartNew();
        var tasks = new Task<(UpsertUserResult, TimeSpan)>[users.Length];
        for (var i = 0; i < users.Length; ++i)
        {
            tasks[i] = this.CreateUserAsync(users[i]);
        }

        var results = await Task.WhenAll(tasks);
        timer.Stop();

Aqui está como eu zombei do HttpClient.

        var httpClient = this.httpClientFactory.CreateClient(HttpClientName);
        #if use_http
            using var response = await httpClient.SendAsync(request);
        #else
            await Task.Delay(300);
            var graphUser = new User { Id = "mockid" };
            using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonConvert.SerializeObject(graphUser)) };
        #endif
        var responseContent = await response.Content.ReadAsStringAsync();

Aqui estão as métricas para 10k usuários B2C criados via GraphAPI usando 500 solicitações simultâneas. As primeiras 500 solicitações são mais longas que o normal porque as conexões TCP estão sendo criadas.

Aqui está um link para as métricas de execução do console .

Aqui está um link para as métricas de execução do Visual Studio .

Os tempos de bloqueio nas métricas de execução do VS são diferentes do que eu disse neste post porque movi todo o acesso ao arquivo síncrono para o final do processo, em um esforço para isolar o código problemático o máximo possível para as execuções de teste.

O projeto é compilado usando o .Net Core 3.1. Estou usando o Visual Studio 2019 16.4.5.

Mark Lauter
fonte
2
Você revisou o estado de suas conexões com o utilitário netstat após o primeiro lote? Pode fornecer algumas dicas sobre o que está acontecendo após as primeiras tarefas serem concluídas.
Pranav Negandhi 16/03
Se você não resolvê-lo dessa maneira (assíncrona a solicitação HTTP), sempre poderá usar chamadas HTTP de sincronização para cada usuário em um paralelismo de consumidor / produtor ConcurrentQueue [objeto]. Recentemente, fiz isso em cerca de 200 milhões de arquivos no PowerShell.
thepip3r 16/03
11
@ thepip3r Acabei de reler seu elogio e entendi desta vez. Vou manter isso em mente.
Mark Lauter
11
Não, estou dizendo, se você quisesse usar o PowerShell em vez de c #: leeholmes.com/blog/2018/09/05/05 .
thepip3r 16/03
11
@ thepip3r Basta ler a entrada do blog de Stephen Cleary. Eu deveria ser bom.
Mark Lauter

Respostas:

3

Duas coisas vem a mente. A maioria dos microsoft powershell foi escrita nas versões 1 e 2. As versões 1 e 2 têm System.Threading.Thread.ApartmentState do MTA. Nas versões 3 a 5, o estado do apartamento foi alterado para STA por padrão.

O segundo pensamento é que parece que eles estão usando System.Threading.ThreadPool para gerenciar os threads. Qual é o tamanho do seu pool de threads?

Se isso não resolver o problema, comece a cavar em System.Threading.

Quando li sua pergunta, pensei neste blog. https://devblogs.microsoft.com/oldnewthing/20170623-00/?p=96455

Um colega demonstrou com um programa de amostra que cria mil itens de trabalho, cada um dos quais simula uma chamada de rede que leva 500 ms para ser concluída. Na primeira demonstração, as chamadas de rede estavam bloqueando as chamadas síncronas e o programa de amostra limitou o pool de threads a dez threads, a fim de tornar o efeito mais aparente. Sob essa configuração, os primeiros itens de trabalho foram despachados rapidamente para os threads, mas a latência começou a aumentar, pois não havia mais threads disponíveis para atender a novos itens de trabalho; portanto, os itens de trabalho restantes tiveram que esperar mais e mais tempo para que um thread fosse fique disponível para repará-lo. A latência média para o início do item de trabalho foi superior a dois minutos.

Atualização 1: executei o PowerShell 7.0 no menu Iniciar e o estado do thread era STA. O estado do encadeamento é diferente nas duas versões?

PS C:\Program Files\PowerShell\7>  [System.Threading.Thread]::CurrentThread

ManagedThreadId    : 12
IsAlive            : True
IsBackground       : False
IsThreadPoolThread : False
Priority           : Normal
ThreadState        : Running
CurrentCulture     : en-US
CurrentUICulture   : en-US
ExecutionContext   : System.Threading.ExecutionContext
Name               : Pipeline Execution Thread
ApartmentState     : STA

Atualização 2: Desejo uma resposta melhor, mas você terá que comparar os dois ambientes até que algo se destaque.

PS C:\Windows\system32> [System.Net.ServicePointManager].GetProperties() | select name

Name                               
----                               
SecurityProtocol                   
MaxServicePoints                   
DefaultConnectionLimit             
MaxServicePointIdleTime            
UseNagleAlgorithm                  
Expect100Continue                  
EnableDnsRoundRobin                
DnsRefreshTimeout                  
CertificatePolicy                  
ServerCertificateValidationCallback
ReusePort                          
CheckCertificateRevocationList     
EncryptionPolicy            

Atualização 3:

https://docs.microsoft.com/en-us/uwp/api/windows.web.http.httpclient

Além disso, toda instância HttpClient usa seu próprio conjunto de conexões, isolando suas solicitações de solicitações executadas por outras instâncias HttpClient.

Se um aplicativo usando HttpClient e classes relacionadas no espaço para nome Windows.Web.Http baixar grandes quantidades de dados (50 megabytes ou mais), o aplicativo deverá transmitir esses downloads e não usar o buffer padrão. Se o buffer padrão for usado, o uso da memória do cliente será muito grande, resultando potencialmente em desempenho reduzido.

Continue comparando os dois ambientes e o problema deve se destacar

Add-Type -AssemblyName System.Net.Http
$client = New-Object -TypeName System.Net.Http.Httpclient
$client | format-list *

DefaultRequestHeaders        : {}
BaseAddress                  : 
Timeout                      : 00:01:40
MaxResponseContentBufferSize : 2147483647
Aaron
fonte
Ao executar no Powershell 7.0 System.Threading.Thread.CurrentThread.GetApartmentState () retorna o MTA de dentro de Program.Main ()
Mark Lauter
O conjunto mínimo de encadeamentos padrão era 12, tentei aumentar o tamanho mínimo do conjunto para o tamanho do meu lote (500 para teste). Isso não teve efeito no comportamento.
Mark Lauter
Quantos encadeamentos são gerados nos dois ambientes?
Aaron
Eu queria saber quantos tópicos o 'HttpClient' tem, porque está fazendo tudo no trabalho.
Aaron
Qual é o estado do apartamento nas duas versões?
Aaron