Comportamiento concurrente de HttpClient diferente cuando se ejecuta en Powershell que en Visual Studio


10

Estoy migrando millones de usuarios de AD local a Azure AD B2C usando la API de MS Graph para crear los usuarios en B2C. He escrito una aplicación de consola .Net Core 3.1 para realizar esta migración. Para acelerar las cosas, estoy haciendo llamadas concurrentes a la API Graph. Esto está funcionando muy bien, más o menos.

Durante el desarrollo, experimenté un rendimiento aceptable al ejecutar desde Visual Studio 2019, pero para la prueba, ejecuto desde la línea de comandos en Powershell 7. Desde Powershell, el rendimiento de las llamadas simultáneas al HttpClient es muy malo. Parece que hay un límite en la cantidad de llamadas simultáneas que HttpClient permite cuando se ejecuta desde Powershell, por lo que las llamadas en lotes concurrentes de más de 40 a 50 solicitudes comienzan a acumularse. Parece estar ejecutando 40 a 50 solicitudes concurrentes mientras bloquea el resto.

No estoy buscando ayuda con la programación asíncrona. Estoy buscando una forma de solucionar la diferencia entre el comportamiento en tiempo de ejecución de Visual Studio y el comportamiento en tiempo de ejecución de la línea de comandos de Powershell. La ejecución en modo de lanzamiento desde el botón de flecha verde de Visual Studio se comporta como se esperaba. Ejecutar desde la línea de comando no lo hace.

Lleno una lista de tareas con llamadas asíncronas y luego espero Task.WhenAll (tareas). Cada llamada toma entre 300 y 400 milisegundos. Cuando se ejecuta desde Visual Studio, funciona como se esperaba. Hago lotes concurrentes de 1000 llamadas y cada una se completa individualmente dentro del tiempo esperado. Todo el bloque de tareas tarda solo unos pocos milisegundos más que la llamada individual más larga.

El comportamiento cambia cuando ejecuto la misma compilación desde la línea de comandos de Powershell. Las primeras 40 a 50 llamadas toman los esperados 300 a 400 milisegundos, pero luego los tiempos de llamada individuales crecen hasta 20 segundos cada uno. Creo que las llamadas se están serializando, por lo que solo se ejecutan de 40 a 50 a la vez mientras los demás esperan.

Después de horas de prueba y error, pude reducirlo al HttpClient. Para aislar el problema, me burlé de las llamadas a HttpClient.SendAsync con un método que realiza Task.Delay (300) y devuelve un resultado simulado. En este caso, la ejecución desde la consola se comporta de manera idéntica a la ejecución desde Visual Studio.

Estoy usando IHttpClientFactory e incluso he intentado ajustar el límite de conexión en ServicePointManager.

Aquí está mi 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;
    }

Aquí está el 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;
    }
}

Aquí está el código que configura las tareas.

        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();

Así es como me burlé del 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();

Aquí hay métricas para usuarios de 10k B2C creados a través de GraphAPI utilizando 500 solicitudes simultáneas. Las primeras 500 solicitudes son más largas de lo normal porque se están creando las conexiones TCP.

Aquí hay un enlace a las métricas de ejecución de la consola .

Aquí hay un enlace a las métricas de ejecución de Visual Studio .

Los tiempos de bloqueo en las métricas de ejecución de VS son diferentes de lo que dije en esta publicación porque moví todo el acceso a archivos sincrónicos al final del proceso en un esfuerzo por aislar el código problemático tanto como sea posible para las ejecuciones de prueba.

El proyecto se compila utilizando .Net Core 3.1. Estoy usando Visual Studio 2019 16.4.5.


2
¿Ha revisado el estado de sus conexiones con la utilidad netstat después del primer lote? Podría proporcionar una idea de lo que está sucediendo después de completar las primeras tareas.
Pranav Negandhi

Si no termina resolviéndolo de esta manera (asíncrono de la solicitud HTTP), siempre podría usar llamadas HTTP de sincronización para cada usuario en un paralelismo consumidor / productor de ConcurrentQueue [objeto]. Recientemente hice esto por unos 200 millones de archivos en PowerShell.
thepip3r

1
@ thepip3r Acabo de volver a leer su recomendación y lo entendí esta vez. Lo tendré en mente.
Mark Lauter

1
No, digo, si quisieras usar PowerShell en lugar de c #: leeholmes.com/blog/2018/09/05/… .
thepip3r

1
@ thepip3r Acabo de leer la entrada del blog de Stephen Cleary. Debería estar bien.
Mark Lauter

Respuestas:


3

Dos cosas me vienen a la mente. La mayoría de Microsoft PowerShell se escribió en las versiones 1 y 2. Las versiones 1 y 2 tienen System.Threading.Thread.ApartmentState de MTA. En las versiones 3 a 5, el estado del apartamento cambió a STA por defecto.

El segundo pensamiento es que parece que están usando System.Threading.ThreadPool para administrar los hilos. ¿Qué tan grande es tu threadpool?

Si esos no resuelven el problema, comience a buscar en System.Threading.

Cuando leí tu pregunta pensé en este blog. https://devblogs.microsoft.com/oldnewthing/20170623-00/?p=96455

Un colega demostró con un programa de muestra que crea mil elementos de trabajo, cada uno de los cuales simula una llamada de red que tarda 500 ms en completarse. En la primera demostración, las llamadas de red estaban bloqueando las llamadas síncronas, y el programa de muestra limitó el grupo de subprocesos a diez subprocesos para que el efecto sea más evidente. Bajo esta configuración, los primeros elementos de trabajo se enviaron rápidamente a los subprocesos, pero luego la latencia comenzó a acumularse ya que no había más subprocesos disponibles para dar servicio a los nuevos elementos de trabajo, por lo que los elementos de trabajo restantes tuvieron que esperar más y más para que un subproceso estar disponible para atenderlo. La latencia promedio al inicio del elemento de trabajo fue de más de dos minutos.

Actualización 1: Ejecuté PowerShell 7.0 desde el menú de inicio y el estado del hilo era STA. ¿El estado del hilo es diferente en las dos versiones?

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

Actualización 2: deseo una mejor respuesta, pero tendrás que comparar los dos entornos hasta 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            

Actualización 3:

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

Además, cada instancia de HttpClient usa su propio grupo de conexiones, aislando sus solicitudes de las solicitudes ejecutadas por otras instancias de HttpClient.

Si una aplicación que usa HttpClient y clases relacionadas en el espacio de nombres Windows.Web.Http descarga grandes cantidades de datos (50 megabytes o más), entonces la aplicación debería transmitir esas descargas y no usar el almacenamiento en búfer predeterminado. Si se utiliza el almacenamiento en búfer predeterminado, el uso de la memoria del cliente se hará muy grande, lo que podría reducir el rendimiento.

Simplemente siga comparando los dos entornos y el problema debería destacarse

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

Cuando se ejecuta en Powershell 7.0 System.Threading.Thread.CurrentThread.GetApartmentState () devuelve MTA desde Program.Main ()
Mark Lauter

El grupo de subprocesos mínimo predeterminado era 12, intenté aumentar el tamaño mínimo del grupo a mi tamaño de lote (500 para probar). Esto no tuvo efecto en el comportamiento.
Mark Lauter

¿Cuántos hilos se generan en ambos entornos?
Aaron

Me preguntaba cuántos subprocesos tiene el 'HttpClient' porque está haciendo todo en el trabajo.
Aaron

¿Cuál es el estado del apartamento en ambas versiones?
Aaron
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.